Merge commit 'a25a8ac676855c3f379d4f7ac89c0e6e42db5493' into bot/terminal-spacing-0e346cd5

This commit is contained in:
paseo-bot[bot]
2026-10-24 16:30:49 +00:00
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: {