Center glyphs vertically when lineSpacing > 1 - #585
Conversation
2c8204f to
20e2b12
Compare
|
I like the idea in principle, but this is not in sync with the caretView, we recently had to address that divergence, should be easy to test if you use a text editor and go to that line, the cursor is likely out of place, This also does not cover metal. Lastly, double-height characters might need to be addressed - check glyphSlotFit for details. |
20e2b12 to
7cedf85
Compare
|
Thanks for the review — all three points addressed, by restructuring rather than patching each site. I introduced a shared On your specific points:
Also rebased onto current main. At |
|
Thanks, let me check it again. |
lineSpacing scales the cell height in computeFontDimensions(), but the baseline stayed pinned at ceil(descent + leading) from the cell bottom, so all the added height piled up above the text and lines looked bottom-heavy at the larger values (1.4-1.6). Introduce CellGeometry.baselineOffset, one definition that splits the extra height evenly above and below the glyph (the half-leading model iTerm2 and WezTerm use), and make every place that computed its own ceil(descent + leading) read it instead: - drawTerminalContents, via SnapshotRenderContext.baselineOffset - CaretView.drawCursor, so the glyph drawn inside the caret stays on the same baseline as the text under it - MetalTerminalRenderer buildDrawDataPass and buildCursorDrawData (which no longer takes lineDescent/lineLeading: it already receives the SnapshotRenderContext those came from) - GlyphSlotFit.calculate and TerminalView.glyphSlotFit, whose baselineFromBottom has to agree with the draw-time offset or scaled wide glyphs stop being centered on it - quickLook's definition popover, which anchors on the word's baseline - the GlyphMetricsParityTests helper, which had its own copy of the formula and so compared the scaled path against a baseline the reference path no longer used CellGeometry is a plain non-isolated namespace rather than a member of TerminalView because the snapshot renderers do this arithmetic on their own threads, from the fonts and cell dimensions captured for the frame. The classic ceil(descent + leading) term is kept intact and the glyph's share of the extra is added as a whole number of points on top, instead of rounding the sum. That keeps the baseline on the pixel grid the old offset landed on, and makes lineSpacing == 1 exact rather than approximate: a cell within two points of the font's natural height — which pixel-grid snapping alone can produce on a fractional backing scale — yields the old offset unchanged. CellBaselineTests pins both halves: the offset is identical to ceil(descent + leading) at lineSpacing == 1 across eight font/size combinations, it rises in whole points without pushing the ascender out of the cell, and at 1.2/1.4/1.6/2.0 the space under the descender and over the ascender agree to within a rounding step. 990 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7cedf85 to
5fe78a5
Compare
|
Rebased onto current Three things worth flagging: The formula had spread from 4 copies to 8. Two are new code: It's a non-isolated namespace, not a
So the split is now Added |
What
When
lineSpacing > 1, vertically center the glyph within the (now taller) cell, splitting the extra space above and below — instead of leaving it all above the text.Rebased onto current
main(cb3c863), after thenew-iowork landed. Single commit.Why
lineSpacingscales the cell height incomputeFontDimensions():but every renderer positioned the baseline with the un-scaled offset:
Since the row is drawn from the bottom up, the glyph baseline stays pinned to the bottom of the cell and all the added spacing ends up above the text. At larger values (e.g. 1.4–1.6) the lines look bottom-heavy. Splitting the extra evenly matches how iTerm2 and WezTerm distribute their line spacing (and the CSS half-leading model).
Change
One definition,
CellGeometry.baselineOffset, that every path reads:It is a plain non-isolated namespace rather than a
TerminalViewmember because the snapshot renderers do this arithmetic off the main actor — hanging it on the (now@MainActor) view fails strict-concurrency checking withsending 'normalFont' risks causing data races.SnapshotRenderContext.baselineOffsetandTerminalView.baselineOffsetare thin accessors over it, so a frame's text, caret, and scaled glyphs are guaranteed to share one baseline.Every place that computed its own
ceil(descent + leading)now reads it. There were eight, up from four when this PR was first opened:drawTerminalContentsSnapshotRenderContext.baselineOffsetCaretView.drawCursorMetalTerminalRenderer.buildDrawDataPassMetalTerminalRenderer.buildCursorDrawDatalineDescent/lineLeadingparameters dropped — it already receives theSnapshotRenderContextthey were derived fromGlyphSlotFit.calculatebaselineFromBottommust agree with the draw-time offset or scaled wide glyphs stop being centered on itTerminalView.glyphSlotFit(font:glyph:columnWidth:policy:)quickLook's definition popoverlineSpacing > 1GlyphMetricsParityTests'scaledFithelperOn rounding
The classic
ceil(descent + leading)term is kept intact and the glyph's share of the extra is added as a whole number of points on top, rather than rounding the sum (ceil(descent + leading + extra/2), which is what the previous revision did).Two reasons. It keeps the baseline on the same pixel grid the old offset landed on. And it makes
lineSpacing == 1exact rather than approximate:cellHeightis snapped to the pixel grid, so on a fractional backing scale it can sit a point or two above the font's natural height even at the default, and rounding the sum would then quietly move the baseline for someone who never touchedlineSpacing.The parity test below is what caught this: with Menlo 16pt (natural line 19pt) and its synthetic 20pt cell,
extrais exactly 1.0 andceil(3.77 + 0.5)pushed the baseline up a full point.The test that had its own copy
GlyphMetricsParityTests.scaledFithardcodedceil(CTFontGetDescent(normalFont) + CTFontGetLeading(normalFont))as itsbaselineFromBottom, while the reference side calledGlyphSlotFit.calculate(font:...). Once the two disagreed the test failed by 41pt on the 100×100 synthetic cell. Both sides now derive the baseline fromCellGeometry, which is what the test set out to compare in the first place — the scaled pixel-space path against the point-space reference, not two different baselines.Verification
swift buildclean, 990 tests pass (Xcode 26 / Swift 6.3, macOS).New
CellBaselineTestspins both halves of the invariant:baselineOffsetis equal to the oldceil(descent + leading)across eight font/size combinations (Monaco, Menlo, SF Mono, Courier; 10–24pt, including a fractional 13.5pt), and for any cell within two points of the natural line height.extrafrom 0 to 40pt in 0.5pt steps, the offset always lands on an integer point, never decreases, and never pushes the ascender out of the cell.lineSpacing1.2 / 1.4 / 1.6 / 2.0, the space under the descender and the space over the ascender agree to within one rounding step.Also verified previously, and unchanged by the rebase: at
lineSpacing == 1.6the caret-drawn glyph sits on the same baseline as the surrounding text, and DECDHL double-height rows join cleanly at 1.5 (both halves shift by the same pre-scale offset and the scale pivots sit on the shared row boundary).