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 new file mode 100644 index 0000000000..031757e572 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.browser.test.tsx @@ -0,0 +1,36 @@ +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 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 }, +}; + +afterEach(cleanup); + +describe("native terminal full-width text", () => { + 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 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(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 04f82c1011..8a55f90f32 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 @@ -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, }; } @@ -235,6 +238,7 @@ function TerminalGridRow({ cellHeight, fontFamily, fontSize, + letterSpacing, }: TerminalGridRowProps) { const rowStyle = useMemo>( () => [styles.row, { height: cellHeight }], @@ -248,9 +252,10 @@ function TerminalGridRow({ lineHeight: cellHeight, fontFamily, fontSize, + letterSpacing, }, ], - [cellHeight, fontFamily, fontSize], + [cellHeight, fontFamily, fontSize, letterSpacing], ); const accessibilityLabel = useMemo( () => @@ -288,6 +293,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 +387,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 +406,6 @@ export function TerminalGridView({ return; } measuredMetricsRef.current = changedMetrics; - setMetrics(changedMetrics); onCellMetricsChange?.(changedMetrics); }, [onCellMetricsChange], @@ -419,6 +435,7 @@ export function TerminalGridView({ cellHeight={metrics.cellHeight} fontFamily={resolvedFontFamily} fontSize={fontSize} + letterSpacing={metrics.letterSpacing} styleEpoch={resolver.themeKey} /> ))}