From c08657d22af6e76d737110836ad44ba5c70838be Mon Sep 17 00:00:00 2001 From: lucas Date: Thu, 1 Oct 2026 11:20:42 +0900 Subject: [PATCH] Keep native terminal text on its cells so the cursor stays after Korean input The native grid renderer draws each style run as one Text, so glyphs advance by their font's natural width while the cursor sits on the cell grid. - Hangul, Han and kana fall back to a system font narrower than two cells (10.38pt vs 14.67pt at size 12), so every wide glyph pulled the rest of the run left. Ten syllables left the cursor about six columns past the text. Each script's advance is now measured once and its segment is letter-spaced to exactly two cells per glyph, without extra views. - Cell width is snapped to the pixel grid (7.225pt -> 7.333pt), so a 49 column ASCII line also ended about 0.7 cell short of the cursor. ASCII text now gets the matching letter spacing. - Other non-ASCII glyphs (symbols, emoji, jamo) have no known advance, so they get their own box and may spill into the next cell instead of shifting the run. --- .../terminal-grid-metrics.test.ts | 34 ++++ .../native-renderer/terminal-grid-metrics.ts | 15 ++ .../terminal-grid-view.native.tsx | 160 +++++++++++++++++- .../terminal-row-model.test.ts | 90 +++++++++- .../native-renderer/terminal-row-model.ts | 86 +++++++++- 5 files changed, 374 insertions(+), 11 deletions(-) diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts index 0c4d7bc25f..0a315491cb 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts @@ -5,6 +5,7 @@ import { resolveTerminalGridMetricsMeasurement, resolveMeasuredTerminalCellMetrics, resolveTerminalCursorOffset, + resolveTerminalTextLetterSpacing, } from "./terminal-grid-metrics"; describe("native terminal grid metrics", () => { @@ -63,6 +64,39 @@ describe("native terminal grid metrics", () => { }); }); + it.each([1, 2, 2.75, 3, 3.5])( + "spaces font glyphs so text ends on the pixel-snapped cell grid at density %s", + (density) => { + const measurement = { + measuredTextWidth: 72.246, + measuredTextHeight: 16.2, + measureTextLength: 10, + roundToNearestPixel: (value: number) => Math.round(value * density) / density, + }; + const { cellWidth } = resolveMeasuredTerminalCellMetrics(measurement); + const letterSpacing = resolveTerminalTextLetterSpacing({ + ...measurement, + cellWidth, + cellsPerGlyph: 1, + }); + const glyphAdvance = measurement.measuredTextWidth / measurement.measureTextLength; + + expect(80 * (glyphAdvance + letterSpacing)).toBeCloseTo(80 * cellWidth, 10); + }, + ); + + it("spaces wide fallback glyphs to exactly two cells each", () => { + const cellWidth = 22 / 3; + const letterSpacing = resolveTerminalTextLetterSpacing({ + measuredTextWidth: 103.8, + measureTextLength: 10, + cellWidth, + cellsPerGlyph: 2, + }); + + expect(24 * (10.38 + letterSpacing)).toBeCloseTo(48 * cellWidth, 10); + }); + it("emits measured metrics once until the measurement changes", () => { const measured = { cellWidth: 7.2, cellHeight: 16 }; diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts index cb14111bf2..b64930ad8e 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts @@ -44,6 +44,21 @@ export function resolveMeasuredTerminalCellMetrics( }; } +export interface TerminalTextLetterSpacingInput { + measuredTextWidth: number; + measureTextLength: number; + cellWidth: number; + cellsPerGlyph: 1 | 2; +} + +// Cells are snapped to the pixel grid but glyphs advance by their font's natural width, so a run +// of N glyphs ends N * (cells * cellWidth - advance) away from its cells and the cursor. The +// letter spacing makes up that difference per glyph. +export function resolveTerminalTextLetterSpacing(input: TerminalTextLetterSpacingInput): number { + const textLength = Math.max(1, input.measureTextLength); + return input.cellsPerGlyph * input.cellWidth - input.measuredTextWidth / textLength; +} + export function resolveTerminalGridMetricsMeasurement( previous: TerminalGridCellMetrics | null, next: TerminalGridCellMetrics, 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..99307f8606 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 @@ -25,14 +25,18 @@ import { resolveTerminalCustomGlyphCellTransform, resolveTerminalGridMetricsMeasurement, resolveTerminalCursorOffset, + resolveTerminalTextLetterSpacing, type TerminalGridCellMetrics, } from "./terminal-grid-metrics"; import { buildRows, + TERMINAL_WIDE_GLYPH_CLASSES, type TerminalCustomGlyphCell, type TerminalCustomGlyphRun, type TerminalRowModel, type TerminalRun, + type TerminalTextRun, + type TerminalWideGlyphClass, } from "./terminal-row-model"; import type { TerminalSelectionRange } from "./terminal-selection"; @@ -40,12 +44,16 @@ const MEASURE_TEXT = "mmmmmmmmmm"; const DEFAULT_FONT_SIZE = 12; const INITIAL_CELL_WIDTH_RATIO = 0.62; const INITIAL_CELL_HEIGHT_RATIO = 1.35; +// Room a fallback glyph (emoji, symbols) gets to spill past its cells instead of being clipped. +const ISOLATED_GLYPH_OVERFLOW_CELLS = 2; interface CellMetrics { cellWidth: number; cellHeight: number; } +type TerminalWideGlyphStyles = Readonly>; + interface TerminalGridViewport { width: number; height: number; @@ -57,6 +65,8 @@ interface TerminalGridRowProps { cellHeight: number; fontFamily?: string; fontSize: number; + letterSpacing: number; + wideGlyphStyles: TerminalWideGlyphStyles; styleEpoch: string; } @@ -65,6 +75,7 @@ interface TerminalGridRunProps { cellWidth: number; cellHeight: number; textStyle: StyleProp; + wideGlyphStyles: TerminalWideGlyphStyles; } export interface TerminalGridViewProps { @@ -99,21 +110,58 @@ function resolveVisibleCols(input: { return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth))); } -function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) { +function renderTerminalTextSegments( + run: TerminalTextRun, + wideGlyphStyles: TerminalWideGlyphStyles, +) { + if (!run.segments.some((segment) => segment.wideGlyphClass)) { + return run.text; + } + let offset = 0; + return run.segments.map((segment) => { + const key = `${offset}:${segment.wideGlyphClass ?? "font"}`; + offset += segment.text.length; + return ( + + {segment.text} + + ); + }); +} + +function TerminalGridRun({ + run, + cellWidth, + cellHeight, + textStyle, + wideGlyphStyles, +}: TerminalGridRunProps) { + const isolated = run.renderKind === "text" && run.isolated; const runStyle = useMemo>( () => [ - styles.run, + isolated ? styles.isolatedRun : styles.run, { backgroundColor: run.style.backgroundColor, height: cellHeight, width: run.cellCount * cellWidth, }, ], - [cellHeight, cellWidth, run.cellCount, run.style.backgroundColor], + [cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor], ); const runTextStyle = useMemo>( - () => [textStyle, run.style], - [run.style, textStyle], + () => + isolated + ? [ + textStyle, + run.style, + styles.isolatedText, + { width: (run.cellCount + ISOLATED_GLYPH_OVERFLOW_CELLS) * cellWidth }, + ] + : [textStyle, run.style], + [cellWidth, isolated, run.cellCount, run.style, textStyle], ); return ( @@ -122,7 +170,7 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid ) : ( - {run.text} + {renderTerminalTextSegments(run, wideGlyphStyles)} )} @@ -225,7 +273,8 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => { previous.run === next.run && previous.cellWidth === next.cellWidth && previous.cellHeight === next.cellHeight && - previous.textStyle === next.textStyle + previous.textStyle === next.textStyle && + previous.wideGlyphStyles === next.wideGlyphStyles ); }); @@ -235,6 +284,8 @@ function TerminalGridRow({ cellHeight, fontFamily, fontSize, + letterSpacing, + wideGlyphStyles, }: TerminalGridRowProps) { const rowStyle = useMemo>( () => [styles.row, { height: cellHeight }], @@ -248,9 +299,10 @@ function TerminalGridRow({ lineHeight: cellHeight, fontFamily, fontSize, + letterSpacing, }, ], - [cellHeight, fontFamily, fontSize], + [cellHeight, fontFamily, fontSize, letterSpacing], ); const accessibilityLabel = useMemo( () => @@ -275,6 +327,7 @@ function TerminalGridRow({ cellWidth={cellWidth} cellHeight={cellHeight} textStyle={textStyle} + wideGlyphStyles={wideGlyphStyles} /> ))} @@ -288,10 +341,38 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => { previous.cellHeight === next.cellHeight && previous.fontFamily === next.fontFamily && previous.fontSize === next.fontSize && + previous.letterSpacing === next.letterSpacing && + previous.wideGlyphStyles === next.wideGlyphStyles && previous.styleEpoch === next.styleEpoch ); }); +interface TerminalWideGlyphMeasureProps { + glyphClass: TerminalWideGlyphClass; + sample: string; + style: StyleProp; + onMeasure: (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => void; +} + +// Measures one wide script's advance in the terminal font's fallback, like MEASURE_TEXT does for +// the terminal font itself. +function TerminalWideGlyphMeasure({ + glyphClass, + sample, + style, + onMeasure, +}: TerminalWideGlyphMeasureProps) { + const handleLayout = useCallback( + (event: LayoutChangeEvent) => onMeasure(glyphClass, event.nativeEvent.layout.width), + [glyphClass, onMeasure], + ); + return ( + + {sample.repeat(MEASURE_TEXT.length)} + + ); +} + export function TerminalGridView({ state, xtermTheme = DEFAULT_TERMINAL_THEME, @@ -303,6 +384,10 @@ export function TerminalGridView({ }: TerminalGridViewProps) { const [metrics, setMetrics] = useState(() => estimateCellMetrics(fontSize)); const measuredMetricsRef = useRef(null); + const [letterSpacing, setLetterSpacing] = useState(0); + const [wideGlyphWidths, setWideGlyphWidths] = useState< + Partial> + >({}); const [viewport, setViewport] = useState(null); const resolvedFontFamily = useMemo( () => resolveNativeTerminalFontFamily(fontFamily), @@ -381,6 +466,14 @@ export function TerminalGridView({ measureTextLength: MEASURE_TEXT.length, roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value), }); + setLetterSpacing( + resolveTerminalTextLetterSpacing({ + measuredTextWidth: event.nativeEvent.layout.width, + measureTextLength: MEASURE_TEXT.length, + cellWidth: nextMetrics.cellWidth, + cellsPerGlyph: 1, + }), + ); const changedMetrics = resolveTerminalGridMetricsMeasurement( measuredMetricsRef.current, nextMetrics, @@ -395,6 +488,35 @@ export function TerminalGridView({ [onCellMetricsChange], ); + const handleWideGlyphMeasure = useCallback( + (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => { + setWideGlyphWidths((current) => + current[glyphClass] === measuredTextWidth + ? current + : { ...current, [glyphClass]: measuredTextWidth }, + ); + }, + [], + ); + const wideGlyphStyles = useMemo(() => { + const entries = TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => { + const measuredTextWidth = wideGlyphWidths[spec.name]; + const spacingStyle: TextStyle = { + letterSpacing: + measuredTextWidth === undefined + ? 0 + : resolveTerminalTextLetterSpacing({ + measuredTextWidth, + measureTextLength: MEASURE_TEXT.length, + cellWidth: metrics.cellWidth, + cellsPerGlyph: 2, + }), + }; + return [spec.name, spacingStyle] as const; + }); + return Object.fromEntries(entries) as TerminalWideGlyphStyles; + }, [metrics.cellWidth, wideGlyphWidths]); + const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setViewport((current) => { @@ -411,6 +533,15 @@ export function TerminalGridView({ {MEASURE_TEXT} + {TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => ( + + ))} {rows.map((row) => ( ))} @@ -441,6 +574,17 @@ const styles = StyleSheet.create({ run: { overflow: "hidden", }, + isolatedRun: { + overflow: "visible", + }, + // The run box keeps the cell width and paints the background; the wider text only draws the + // glyph, so a fallback glyph wider than its cells spills over the next cell like in a terminal. + isolatedText: { + backgroundColor: "transparent", + left: 0, + position: "absolute", + top: 0, + }, customGlyphRun: { flex: 1, position: "relative", diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts index b19922b7a9..dbc192200c 100644 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts @@ -37,7 +37,95 @@ describe("terminal row model", () => { }); expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ - { text: "A界", cellCount: 3 }, + { text: "A", cellCount: 2 }, + { text: "界", cellCount: 1 }, + ]); + }); + + test("keeps wide scripts in the run as segments the renderer spaces to two cells", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + + const rows = buildRows({ + grid: [ + [ + cell("한", { width: 2 }), + cell(" ", { width: 0 }), + cell("글", { width: 2 }), + cell(" ", { width: 0 }), + cell(" ", { width: 1 }), + cell("中", { width: 2 }), + cell(" ", { width: 0 }), + cell("あ", { width: 2 }), + cell(" ", { width: 0 }), + cell("o", { width: 1 }), + cell("k", { width: 1 }), + ], + ], + resolver, + }); + + expect( + rows[0].runs.map((run) => ({ + text: run.text, + cellCount: run.cellCount, + segments: run.renderKind === "text" ? run.segments : null, + })), + ).toEqual([ + { + text: "한글 中あok", + cellCount: 11, + segments: [ + { text: "한글", wideGlyphClass: "hangul" }, + { text: " ", wideGlyphClass: null }, + { text: "中", wideGlyphClass: "han" }, + { text: "あ", wideGlyphClass: "kana" }, + { text: "ok", wideGlyphClass: null }, + ], + }, + ]); + }); + + test("gives wide glyphs outside the spaced scripts their own box", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + + const [row] = buildRows({ + grid: [ + [ + cell("ㄱ", { width: 2 }), + cell(" ", { width: 0 }), + cell("A", { width: 2 }), + cell(" ", { width: 0 }), + cell("가", { width: 2 }), + cell(" ", { width: 0 }), + ], + ], + resolver, + }); + + expect( + row.runs.map((run) => [run.text, run.cellCount, run.renderKind === "text" && run.isolated]), + ).toEqual([ + ["ㄱ", 2, true], + ["A", 2, true], + ["가", 2, false], + ]); + }); + + test("gives non-ASCII narrow glyphs their own cell", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + + const [row] = buildRows({ + grid: [ + ["⏺", " ", "e\u0301", " ", "d", "o", "n", "e"].map((char) => cell(char, { width: 1 })), + ], + resolver, + }); + + expect(row.runs.map((run) => [run.text, run.cellCount])).toEqual([ + ["⏺", 1], + [" ", 1], + ["e\u0301", 1], + [" done", 5], ]); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.ts index a65f39fd44..fdd71b2b2e 100644 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.ts +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.ts @@ -27,8 +27,40 @@ interface TerminalRunBase { foregroundColor: string; } +export type TerminalWideGlyphClass = "hangul" | "han" | "kana"; + +interface TerminalWideGlyphClassSpec { + name: TerminalWideGlyphClass; + sample: string; + ranges: ReadonlyArray; +} + +// Wide scripts whose glyphs share one advance within a font. The renderer measures each sample +// once and letter-spaces the script so every glyph advances exactly two cells. +export const TERMINAL_WIDE_GLYPH_CLASSES: ReadonlyArray = [ + { name: "hangul", sample: "가", ranges: [[0xac00, 0xd7a3]] }, + { + name: "han", + sample: "中", + ranges: [ + [0x3400, 0x4dbf], + [0x4e00, 0x9fff], + ], + }, + { name: "kana", sample: "あ", ranges: [[0x3041, 0x30ff]] }, +]; + +export interface TerminalTextSegment { + text: string; + /** Null for terminal-font text, which advances one cell per glyph. */ + wideGlyphClass: TerminalWideGlyphClass | null; +} + export interface TerminalTextRun extends TerminalRunBase { renderKind: "text"; + /** Holds one glyph whose fallback-font advance is unknown, drawn in its own box. */ + isolated: boolean; + segments: TerminalTextSegment[]; } export interface TerminalCustomGlyphRun extends TerminalRunBase { @@ -78,6 +110,43 @@ function terminalCharWidth(char: string): number { return isWide ? 2 : 1; } +// Text runs render with the font's natural advances. Printable ASCII comes from the terminal font +// and the spaced wide scripts get a measured letter spacing, so both stay on the cell grid. Any +// other glyph (symbols, emoji, jamo) falls back to a font with an unknown advance; inside a run it +// would shift every glyph after it and pull the text away from the cursor. +function resolveGlyphSpacing( + char: string, + cellCount: number, +): TerminalWideGlyphClass | null | "isolated" { + if (char.length !== 1) { + return "isolated"; + } + const code = char.charCodeAt(0); + if (cellCount === 1 && code >= 0x20 && code <= 0x7e) { + return null; + } + if (cellCount !== 2) { + return "isolated"; + } + const glyphClass = TERMINAL_WIDE_GLYPH_CLASSES.find((spec) => + spec.ranges.some(([start, end]) => code >= start && code <= end), + ); + return glyphClass?.name ?? "isolated"; +} + +function appendSegment( + segments: TerminalTextSegment[], + text: string, + wideGlyphClass: TerminalWideGlyphClass | null, +): void { + const last = segments[segments.length - 1]; + if (last && last.wideGlyphClass === wideGlyphClass) { + last.text += text; + return; + } + segments.push({ text, wideGlyphClass }); +} + function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean { if (terminalCharWidth(char) < 2) { return false; @@ -107,15 +176,23 @@ function appendRun(input: { col: number; }): void { const renderKind = input.customGlyph ? "custom-glyph" : "text"; + const spacing = input.customGlyph ? null : resolveGlyphSpacing(input.text, input.cellCount); + const isolated = spacing === "isolated"; + const wideGlyphClass = spacing === "isolated" ? null : spacing; const previousRun = input.runs[input.runs.length - 1]; if ( + !isolated && previousRun && previousRun.styleKey === input.styleKey && - previousRun.renderKind === renderKind + previousRun.renderKind === renderKind && + !(previousRun.renderKind === "text" && previousRun.isolated) ) { const offset = previousRun.cellCount; previousRun.text += input.text; previousRun.cellCount += input.cellCount; + if (previousRun.renderKind === "text") { + appendSegment(previousRun.segments, input.text, wideGlyphClass); + } if (previousRun.renderKind === "custom-glyph" && input.customGlyph) { previousRun.glyphs.push({ key: `${input.col}:${input.text}`, @@ -142,7 +219,12 @@ function appendRun(input: { }); return; } - input.runs.push({ ...baseRun, renderKind: "text" }); + input.runs.push({ + ...baseRun, + renderKind: "text", + isolated, + segments: [{ text: input.text, wideGlyphClass }], + }); } function cellIntersectsSelection(input: {