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: {