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.
This commit is contained in:
lucas
2026-10-01 11:20:42 +09:00
parent e0a0cf990d
commit c08657d22a
5 changed files with 374 additions and 11 deletions
@@ -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 };
@@ -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,
@@ -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<Record<TerminalWideGlyphClass, TextStyle>>;
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<TextStyle>;
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 (
<Text
key={key}
style={segment.wideGlyphClass ? wideGlyphStyles[segment.wideGlyphClass] : undefined}
>
{segment.text}
</Text>
);
});
}
function TerminalGridRun({
run,
cellWidth,
cellHeight,
textStyle,
wideGlyphStyles,
}: TerminalGridRunProps) {
const isolated = run.renderKind === "text" && run.isolated;
const runStyle = useMemo<StyleProp<ViewStyle>>(
() => [
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<StyleProp<TextStyle>>(
() => [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
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
{renderTerminalTextSegments(run, wideGlyphStyles)}
</Text>
)}
</View>
@@ -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<StyleProp<ViewStyle>>(
() => [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}
/>
))}
</View>
@@ -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<TextStyle>;
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 (
<Text onLayout={handleLayout} pointerEvents="none" style={style}>
{sample.repeat(MEASURE_TEXT.length)}
</Text>
);
}
export function TerminalGridView({
state,
xtermTheme = DEFAULT_TERMINAL_THEME,
@@ -303,6 +384,10 @@ export function TerminalGridView({
}: TerminalGridViewProps) {
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
const [letterSpacing, setLetterSpacing] = useState(0);
const [wideGlyphWidths, setWideGlyphWidths] = useState<
Partial<Record<TerminalWideGlyphClass, number>>
>({});
const [viewport, setViewport] = useState<TerminalGridViewport | null>(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({
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
{MEASURE_TEXT}
</Text>
{TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => (
<TerminalWideGlyphMeasure
key={spec.name}
glyphClass={spec.name}
sample={spec.sample}
style={measureStyle}
onMeasure={handleWideGlyphMeasure}
/>
))}
{rows.map((row) => (
<MemoTerminalGridRow
key={row.index}
@@ -419,6 +550,8 @@ export function TerminalGridView({
cellHeight={metrics.cellHeight}
fontFamily={resolvedFontFamily}
fontSize={fontSize}
letterSpacing={letterSpacing}
wideGlyphStyles={wideGlyphStyles}
styleEpoch={resolver.themeKey}
/>
))}
@@ -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",
@@ -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],
]);
});
@@ -27,8 +27,40 @@ interface TerminalRunBase {
foregroundColor: string;
}
export type TerminalWideGlyphClass = "hangul" | "han" | "kana";
interface TerminalWideGlyphClassSpec {
name: TerminalWideGlyphClass;
sample: string;
ranges: ReadonlyArray<readonly [number, number]>;
}
// 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<TerminalWideGlyphClassSpec> = [
{ 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: {