Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlucas d96fb7f96c 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 <lucas@withvus.ai>
2026-10-24 22:30:30 +00:00
paseo-bot[bot]andlucas 1a8523abd6 fix(app): keep full-width terminal rows from ending in ellipses
Match the measured font advance to the pixel-snapped cells. Otherwise the text overflows its fixed run box and native one-line layout replaces the final characters with an ellipsis.

Co-authored-by: lucas <lucas@withvus.ai>
2026-10-24 21:14:38 +00:00
2 changed files with 126 additions and 5 deletions
@@ -0,0 +1,91 @@
import React from "react";
import { cleanup, render } from "@testing-library/react";
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,
cols: 49,
firstRow: 0,
oldestRow: 0,
newestRow: 0,
grid: [[...output].map((char) => ({ char }))],
cursor: { row: 0, col: 48, hidden: true },
};
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(
<TerminalGridView state={styledState} fontSize={fontSize} style={fixtureStyle} />,
);
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(
<TerminalGridView
state={state}
style={fixtureStyle}
fontSize={fontSize}
onCellMetricsChange={onCellMetricsChange}
/>,
);
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(49 * measuredCellWidths.at(-1)!, 1);
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(output);
},
);
});
@@ -44,6 +44,7 @@ const INITIAL_CELL_HEIGHT_RATIO = 1.35;
interface CellMetrics {
cellWidth: number;
cellHeight: number;
letterSpacing: number;
}
interface TerminalGridViewport {
@@ -57,6 +58,7 @@ interface TerminalGridRowProps {
cellHeight: number;
fontFamily?: string;
fontSize: number;
letterSpacing: number;
styleEpoch: string;
}
@@ -81,6 +83,7 @@ function estimateCellMetrics(fontSize: number): CellMetrics {
return {
cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO),
cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO),
letterSpacing: 0,
};
}
@@ -112,8 +115,15 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
);
const runTextStyle = useMemo<StyleProp<TextStyle>>(
() => [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 (
@@ -121,7 +131,7 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
{run.text}
</Text>
)}
@@ -235,6 +245,7 @@ function TerminalGridRow({
cellHeight,
fontFamily,
fontSize,
letterSpacing,
}: TerminalGridRowProps) {
const rowStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.row, { height: cellHeight }],
@@ -248,9 +259,10 @@ function TerminalGridRow({
lineHeight: cellHeight,
fontFamily,
fontSize,
letterSpacing,
},
],
[cellHeight, fontFamily, fontSize],
[cellHeight, fontFamily, fontSize, letterSpacing],
);
const accessibilityLabel = useMemo(
() =>
@@ -288,6 +300,7 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
previous.cellHeight === next.cellHeight &&
previous.fontFamily === next.fontFamily &&
previous.fontSize === next.fontSize &&
previous.letterSpacing === next.letterSpacing &&
previous.styleEpoch === next.styleEpoch
);
});
@@ -381,6 +394,17 @@ export function TerminalGridView({
measureTextLength: MEASURE_TEXT.length,
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
});
// Grid cells are snapped to physical pixels. Match the font's natural advance to that
// cell width so a full row still fits instead of triggering native Text truncation.
const letterSpacing =
nextMetrics.cellWidth - event.nativeEvent.layout.width / MEASURE_TEXT.length;
setMetrics((current) =>
current.cellWidth === nextMetrics.cellWidth &&
current.cellHeight === nextMetrics.cellHeight &&
current.letterSpacing === letterSpacing
? current
: { ...nextMetrics, letterSpacing },
);
const changedMetrics = resolveTerminalGridMetricsMeasurement(
measuredMetricsRef.current,
nextMetrics,
@@ -389,7 +413,6 @@ export function TerminalGridView({
return;
}
measuredMetricsRef.current = changedMetrics;
setMetrics(changedMetrics);
onCellMetricsChange?.(changedMetrics);
},
[onCellMetricsChange],
@@ -419,6 +442,7 @@ export function TerminalGridView({
cellHeight={metrics.cellHeight}
fontFamily={resolvedFontFamily}
fontSize={fontSize}
letterSpacing={metrics.letterSpacing}
styleEpoch={resolver.themeKey}
/>
))}
@@ -441,6 +465,12 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
runText: {
backgroundColor: "transparent",
position: "absolute",
left: 0,
top: 0,
},
customGlyphRun: {
flex: 1,
position: "relative",