Skip to content

fix(datagrid): overlay the inline cell editor exactly on the drawn cell - #2579

Closed
shreeve wants to merge 1 commit into
TableProApp:mainfrom
shreeve:fix/datagrid-inline-editor-alignment
Closed

fix(datagrid): overlay the inline cell editor exactly on the drawn cell#2579
shreeve wants to merge 1 commit into
TableProApp:mainfrom
shreeve:fix/datagrid-inline-editor-alignment

Conversation

@shreeve

@shreeve shreeve commented Aug 29, 2026

Copy link
Copy Markdown

Problem

Opening an inline edit on a data-grid cell visibly shifted the value it was editing. The editor overlay was taller than the row at every row-height setting (spilling over the row below), its text sat at the top of the overlay while the drawn cell centers its baseline in the row, and its glyphs started 1pt right of the drawn text. Entering edit mode should not move the glyphs at all.

Root cause

Two unrelated geometry owners for the same glyphs. The CoreText renderer (DataGridCellRenderer) draws at x = minX + DataGridMetrics.cellHorizontalInset with the baseline centered in the row. The overlay (CellOverlayBase) sized itself from boundingRectForFont.height + 12, anchored at the cell top, with an NSTextView left on its default 5pt lineFragmentPadding and zero textContainerInset. Nothing shared a constant, so height, baseline and x inset all disagreed.

Measured on macOS 15 (mono 12pt, 28pt row): overlay 30.33pt tall in a 28pt row, first baseline at 12.0 (top-aligned) vs 18.54 drawn, glyph x 5 vs 4.

Fix

One geometry owner, DataGridCellTextGeometry, consumed by both sides:

  • baselineY(rowHeight:font:): the centered-baseline formula, floored to a whole point. Measured at 1x and 2x backing: TextKit floors rendered baselines to integral points at every scale while CTLineDraw honors fractions, so the drawn side adopts the floor (moves at most 0.5pt, once, uniformly) and the editor lands on it exactly.
  • textContainerTopInset(rowHeight:font:): baselineY - defaultBaselineOffset(for: font), computed from a detached NSLayoutManager. Reading the text view's own layoutManager would downgrade it to TextKit 1 and revert the no-wrap overlay layout (Data grid breaks with many columns: flickering, columns stop rendering, horizontal scroll lags behind viewport #2381), so neither the code nor the tests touch it; the parity test measures through textLayoutManager.

A single-line value's overlay is now exactly the cell rect, with the vertical scroller and elasticity off (a grid font taller than the row would otherwise scroll its own descenders). A value that breaks into lines still grows, capped at 120pt, with its height derived from the same geometry (textContainerInset is symmetric, so the content pays the top inset twice). Line breaks are counted the way TextKit lays them out (LF, lone CR, NEL, U+2028, U+2029, CRLF as one), and an edit that becomes multiline after opening (Option+Return, paste) reframes the overlay and re-arms the scroller. The border slimmed from 2pt to a 1px stroke; a layer border paints over edge pixels and cannot displace glyphs. The read-only CellOverlayViewer shares all of it.

Glyph-origin parity was probe-verified at delta 0.000 across three fonts before implementation, and frameOfCell(atColumn:row:) vs rect(ofColumn:) was measured to agree on x for every editable column.

Tests

DataGridCellTextGeometryTests (new):

  • Baseline parity measured through the overlay's real TextKit 2 layout (NSTextLineFragment.glyphOrigin), all four row heights x three fonts, including a font taller than the 20pt row.
  • Single-line overlay frame == cell frame at every row height.
  • Multiline height pinned against a raw NSLayoutManager and the written-out formula, so a drift in the shared geometry fails the test instead of being copied into the expectation.
  • Line-break counting parity with TextKit, and the rect-source agreement pin.

All suites owning the touched types pass (128 tests): DataGridCellTextGeometryTests, CellOverlayTextLayoutTests, CellOverlayAppearanceTests, DataGridCellAccessoryAppearanceTests, KeyHandlingTableViewOverlayTests, TableViewCoordinatorLayoutTests, ValueFontTests.

The open-editor-and-type flow itself is not UI-automated: the overlay editor requires a live connection and grid focus, which does not run deterministically on the CI runner (the drawn grid publishes cells XCUITest refuses to click; see the accessibility invariant). The geometry it exercises is covered by the unit suites above.

Before / After

Before: opening an edit on the status cell of row 4 shows a bordered box 1.5 rows tall covering the top of row 5, with the value shifted up and left.
After: the value does not move; the only change is a 1px focus-colored stroke around the cell.

Screenshots to be attached as a comment.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T21:29:29.967014Z ac94299 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@datlechin

Copy link
Copy Markdown
Member

Thanks for the write-up and for measuring before you wrote code. The bug is real: the overlay was taller than the row, it top-aligned its text, and its glyphs started 1pt right of the drawn ones. Opening an edit should not move the value.

We are not taking this change. Three reasons, heaviest first.

1. It moves the drawn text of every cell to accommodate the editor

baselineY floors the centered baseline, so DataGridCellRenderer now draws every value in the app off center by whatever the fraction was. The PR body puts that at "at most 0.5pt, once, uniformly".

Measured against the default grid font, System Mono 13pt, which is what all four shipped themes set (TablePro/Resources/Themes/tablepro.default-light.json):

row height centered baseline .rounded(.down) shift .rounded() shift
20 14.9131 14 0.9131 15 0.0869
24 16.9131 16 0.9131 17 0.0869
28 18.9131 18 0.9131 19 0.0869
32 20.9131 20 0.9131 21 0.0869

Nearly a full point, not half of one, and it lands on the default configuration rather than an edge case. It also varies by font (0.1543 for Menlo 12pt, 0.5352 for System Mono 12pt, 0.9131 for System Mono 13pt), so "uniformly" holds within a font and not across the setting.

The editor does not need .rounded(.down) for any of this. textContainerTopInset is baselineY - defaultBaselineOffset(for:), so it absorbs whatever integer you pick: .rounded() gives byte-identical parity and leaves the drawn text ten times closer to centered. Changing what 100% of the grid renders to serve the one cell being edited is the wrong direction, and choosing the worse of two integers that both work is not a trade we can carry.

2. It makes the "you are editing" affordance fainter than the cursor it replaces

DataGridCellAppearance.resolve sets isCursorVisible = state.isFocused && !hasOverlay, so the drawn cell cursor is suppressed for the whole time the overlay is up. That cursor is DataGridCellRenderer.drawFocusRing at lineWidth = 2, and its own comment says why it exists: "A mounted cell got this from AppKit's exterior focus ring; a drawn cell has no view to hang one on, so it is drawn to the same shape." The overlay border was 2pt to match it.

This PR takes the overlay to 1pt and leaves the cursor at 2pt, so committing to an edit makes the indicator thinner than merely putting the cursor on the cell. That reads backwards, and it is the only signal left: CellOverlayEditor sets textView.focusRingType = .none, so there is no system ring behind it. A 1px hairline of keyboardFocusIndicatorColor does not follow Increase Contrast or the Full Keyboard Access focus appearance, both of which drive the ring the view opted out of, and it carries the state on color alone.

The stated end result does not hold either. "The only change is a 1px focus-colored stroke" is true for glyph position and not for the cell. The overlay paints NSColor.textBackgroundColor and .labelColor over a cell the renderer drew from the theme palette: on a selected row the value goes from alternateSelectedControlTextColor on the selection fill to labelColor on the system text background, and on a modified cell modifiedColumnTint disappears the moment the editor opens. The cell still visibly changes, so the geometry work does not buy the result it is aimed at.

3. The load-bearing constant is an undocumented equality between the two TextKit engines

textContainerTopInset positions a TextKit 2 text view with NSLayoutManager.defaultBaselineOffset(for:), which is TextKit 1. The PR knows this and pins it with a test that reads NSTextLineFragment.glyphOrigin. That test passes on the macOS it runs on and says nothing about the next one, and nothing documents that the engines agree. When they stop agreeing, the symptom is a quietly misaligned editor, not a build failure.

Two smaller instances of the same habit: lineBreakCount hand-rolls Unicode line-break classification that NSString line and paragraph enumeration performs by definition, and the multiline height is a hand formula (lineCount * defaultLineHeight + 2 * inset) where NSTextLayoutManager.usageBoundsForTextContainer is the measurement. A static let NSLayoutManager held as shared state to answer both questions is a third.

What we would take

A narrow PR for the two parts nothing else explains away:

  • lineFragmentPadding set to DataGridMetrics.cellHorizontalInset instead of AppKit's default 5. That is the whole 1pt horizontal shift, one line, no side effects.
  • overlayFrame giving a single-line value the cell rect instead of boundingRectForFont.height + 12. Reading valueFont rather than dataGridFonts.regular is right too, per the two-font-domain rule in CLAUDE.md.

For the vertical alignment we want the editor to meet the drawn baseline, not the renderer to move to meet the editor. Keep the centered fractional baseline in DataGridCellRenderer and put the whole difference in the text view's inset. If TextKit genuinely cannot be placed on a fractional baseline, then round to nearest, and write down in the code why the drawn text is quantized at all, because that is the part a future reader will want.

Closing rather than running a long review thread on a change we would end up rewriting. The measurement work here is good and the bug stays on our list. Thanks for taking the time.

@datlechin datlechin closed this Aug 31, 2026
@shreeve

shreeve commented Aug 31, 2026

Copy link
Copy Markdown
Author

Thanks for your review. I stopped using TablePro and I built a new DuckDB viewer in Rust with GPUI called DuckTable at:

https://github.com/shreeve/ducktable

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants