From d96fb7f96c846a18744b3b61e7eb8ac3b1bbd0ce Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:30:30 +0000 Subject: [PATCH] fix(app): keep short styled terminal runs complete Give native text layout rounding room inside the existing clipping cell boxes. Matching glyph advance alone still lets one-line layout replace short styled segments with ellipses. Co-authored-by: lucas --- .../terminal-grid-view.browser.test.tsx | 61 ++++++++++++++++++- .../terminal-grid-view.native.tsx | 19 +++++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.browser.test.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.browser.test.tsx index 031757e572..c0371da791 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.browser.test.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.browser.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { TerminalViewportState } from "./headless-terminal-state"; import { TerminalGridView } from "./terminal-grid-view.native"; +const fixtureStyle = { width: 1000 }; const output = "#".repeat(49); const state: TerminalViewportState = { rows: 1, @@ -16,20 +17,74 @@ const state: TerminalViewportState = { cursor: { row: 0, col: 48, hidden: true }, }; -afterEach(cleanup); +const styledRuns = ["a", "bc", "defg", "hijkl"]; +const styledState: TerminalViewportState = { + ...state, + cols: 12, + grid: [ + styledRuns.flatMap((text, index) => + [...text].map((char) => ({ char, fg: index + 1, fgMode: 1 })), + ), + ], +}; + +const measuredCellWidths: number[] = []; +function onCellMetricsChange(metrics: { cellWidth: number }) { + measuredCellWidths.push(metrics.cellWidth); +} + +afterEach(() => { + cleanup(); + measuredCellWidths.length = 0; +}); describe("native terminal full-width text", () => { + it.each([9, 11, 12, 14, 18])( + "keeps short colored runs complete despite native layout rounding at %s px", + async (fontSize) => { + const view = render( + , + ); + for (const runText of styledRuns) { + const text = view.getByText(runText); + const run = text.parentElement!; + await expect.poll(() => run.clientWidth).toBeGreaterThan(0); + // Native one-line layout rounds independently of the cell box. Leave text + // layout at least one physical pixel of slack, while the cell box clips paint. + await expect + .poll(() => text.getBoundingClientRect().width - run.getBoundingClientRect().width) + .toBeGreaterThanOrEqual(1 / window.devicePixelRatio); + expect(getComputedStyle(run).overflowX).toBe("hidden"); + const range = document.createRange(); + range.selectNodeContents(text); + expect(range.getBoundingClientRect().width).toBeLessThanOrEqual( + text.getBoundingClientRect().width, + ); + } + expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe( + styledRuns.join(""), + ); + }, + ); it.each([9, 11, 12, 14, 18])( "fits every character in a full-width ASCII row at %s px", async (fontSize) => { - const view = render(); + const view = render( + , + ); + await expect.poll(() => measuredCellWidths.at(-1) ?? 0).toBeGreaterThan(0); const text = view.getByText(output); await expect.poll(() => text.clientWidth).toBeGreaterThan(0); // The native Text adapter truncates overflowing one-line content. Compare the actual // font layout with the available width, rather than asserting a particular CSS policy. await expect.poll(() => text.scrollWidth - text.clientWidth).toBeLessThanOrEqual(1); const run = text.parentElement!; - expect(run.getBoundingClientRect().width).toBeCloseTo(text.getBoundingClientRect().width, 1); + expect(run.getBoundingClientRect().width).toBeCloseTo(49 * measuredCellWidths.at(-1)!, 1); expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(output); }, ); diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx index 8a55f90f32..169d425205 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -115,8 +115,15 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid [cellHeight, cellWidth, run.cellCount, run.style.backgroundColor], ); const runTextStyle = useMemo>( - () => [textStyle, run.style], - [run.style, textStyle], + () => [ + textStyle, + run.style, + styles.runText, + // Native text layout rounds independently of the cell box. One extra cell + // prevents rounding from truncating a run that otherwise fits its cells. + { minWidth: (run.cellCount + 1) * cellWidth }, + ], + [cellWidth, run.cellCount, run.style, textStyle], ); return ( @@ -124,7 +131,7 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid {run.renderKind === "custom-glyph" ? ( ) : ( - + {run.text} )} @@ -458,6 +465,12 @@ const styles = StyleSheet.create({ run: { overflow: "hidden", }, + runText: { + backgroundColor: "transparent", + position: "absolute", + left: 0, + top: 0, + }, customGlyphRun: { flex: 1, position: "relative",