mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c532f32227 | ||
|
|
31f9657679 | ||
|
|
a25a8ac676 | ||
|
|
c08657d22a |
@@ -5,6 +5,7 @@ import {
|
|||||||
resolveTerminalGridMetricsMeasurement,
|
resolveTerminalGridMetricsMeasurement,
|
||||||
resolveMeasuredTerminalCellMetrics,
|
resolveMeasuredTerminalCellMetrics,
|
||||||
resolveTerminalCursorOffset,
|
resolveTerminalCursorOffset,
|
||||||
|
resolveTerminalTextLetterSpacing,
|
||||||
} from "./terminal-grid-metrics";
|
} from "./terminal-grid-metrics";
|
||||||
|
|
||||||
describe("native 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", () => {
|
it("emits measured metrics once until the measurement changes", () => {
|
||||||
const measured = { cellWidth: 7.2, cellHeight: 16 };
|
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(
|
export function resolveTerminalGridMetricsMeasurement(
|
||||||
previous: TerminalGridCellMetrics | null,
|
previous: TerminalGridCellMetrics | null,
|
||||||
next: TerminalGridCellMetrics,
|
next: TerminalGridCellMetrics,
|
||||||
|
|||||||
@@ -25,14 +25,18 @@ import {
|
|||||||
resolveTerminalCustomGlyphCellTransform,
|
resolveTerminalCustomGlyphCellTransform,
|
||||||
resolveTerminalGridMetricsMeasurement,
|
resolveTerminalGridMetricsMeasurement,
|
||||||
resolveTerminalCursorOffset,
|
resolveTerminalCursorOffset,
|
||||||
|
resolveTerminalTextLetterSpacing,
|
||||||
type TerminalGridCellMetrics,
|
type TerminalGridCellMetrics,
|
||||||
} from "./terminal-grid-metrics";
|
} from "./terminal-grid-metrics";
|
||||||
import {
|
import {
|
||||||
buildRows,
|
buildRows,
|
||||||
|
TERMINAL_WIDE_GLYPH_CLASSES,
|
||||||
type TerminalCustomGlyphCell,
|
type TerminalCustomGlyphCell,
|
||||||
type TerminalCustomGlyphRun,
|
type TerminalCustomGlyphRun,
|
||||||
type TerminalRowModel,
|
type TerminalRowModel,
|
||||||
type TerminalRun,
|
type TerminalRun,
|
||||||
|
type TerminalTextRun,
|
||||||
|
type TerminalWideGlyphClass,
|
||||||
} from "./terminal-row-model";
|
} from "./terminal-row-model";
|
||||||
import type { TerminalSelectionRange } from "./terminal-selection";
|
import type { TerminalSelectionRange } from "./terminal-selection";
|
||||||
|
|
||||||
@@ -40,12 +44,17 @@ const MEASURE_TEXT = "mmmmmmmmmm";
|
|||||||
const DEFAULT_FONT_SIZE = 12;
|
const DEFAULT_FONT_SIZE = 12;
|
||||||
const INITIAL_CELL_WIDTH_RATIO = 0.62;
|
const INITIAL_CELL_WIDTH_RATIO = 0.62;
|
||||||
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
|
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
|
||||||
|
// Keep native line layout clear of the cell boundary: rounding and trailing letter spacing can
|
||||||
|
// otherwise wrap a short run. The run box clips ordinary text; isolated fallback glyphs can spill.
|
||||||
|
const TEXT_LAYOUT_OVERFLOW_CELLS = 2;
|
||||||
|
|
||||||
interface CellMetrics {
|
interface CellMetrics {
|
||||||
cellWidth: number;
|
cellWidth: number;
|
||||||
cellHeight: number;
|
cellHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalWideGlyphStyles = Readonly<Record<TerminalWideGlyphClass, TextStyle>>;
|
||||||
|
|
||||||
interface TerminalGridViewport {
|
interface TerminalGridViewport {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -57,6 +66,8 @@ interface TerminalGridRowProps {
|
|||||||
cellHeight: number;
|
cellHeight: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
letterSpacing: number;
|
||||||
|
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||||
styleEpoch: string;
|
styleEpoch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ interface TerminalGridRunProps {
|
|||||||
cellWidth: number;
|
cellWidth: number;
|
||||||
cellHeight: number;
|
cellHeight: number;
|
||||||
textStyle: StyleProp<TextStyle>;
|
textStyle: StyleProp<TextStyle>;
|
||||||
|
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalGridViewProps {
|
export interface TerminalGridViewProps {
|
||||||
@@ -99,21 +111,55 @@ function resolveVisibleCols(input: {
|
|||||||
return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth)));
|
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>>(
|
const runStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [
|
() => [
|
||||||
styles.run,
|
isolated ? styles.isolatedRun : styles.run,
|
||||||
{
|
{
|
||||||
backgroundColor: run.style.backgroundColor,
|
backgroundColor: run.style.backgroundColor,
|
||||||
height: cellHeight,
|
height: cellHeight,
|
||||||
width: run.cellCount * cellWidth,
|
width: run.cellCount * cellWidth,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
|
[cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor],
|
||||||
);
|
);
|
||||||
const runTextStyle = useMemo<StyleProp<TextStyle>>(
|
const runTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() => [textStyle, run.style],
|
() => [
|
||||||
[run.style, textStyle],
|
textStyle,
|
||||||
|
run.style,
|
||||||
|
styles.runText,
|
||||||
|
{ width: (run.cellCount + TEXT_LAYOUT_OVERFLOW_CELLS) * cellWidth },
|
||||||
|
],
|
||||||
|
[cellWidth, run.cellCount, run.style, textStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,8 +167,8 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
|
|||||||
{run.renderKind === "custom-glyph" ? (
|
{run.renderKind === "custom-glyph" ? (
|
||||||
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
|
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
|
||||||
) : (
|
) : (
|
||||||
<Text numberOfLines={1} style={runTextStyle}>
|
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
|
||||||
{run.text}
|
{renderTerminalTextSegments(run, wideGlyphStyles)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -225,7 +271,8 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
|
|||||||
previous.run === next.run &&
|
previous.run === next.run &&
|
||||||
previous.cellWidth === next.cellWidth &&
|
previous.cellWidth === next.cellWidth &&
|
||||||
previous.cellHeight === next.cellHeight &&
|
previous.cellHeight === next.cellHeight &&
|
||||||
previous.textStyle === next.textStyle
|
previous.textStyle === next.textStyle &&
|
||||||
|
previous.wideGlyphStyles === next.wideGlyphStyles
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +282,8 @@ function TerminalGridRow({
|
|||||||
cellHeight,
|
cellHeight,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
letterSpacing,
|
||||||
|
wideGlyphStyles,
|
||||||
}: TerminalGridRowProps) {
|
}: TerminalGridRowProps) {
|
||||||
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [styles.row, { height: cellHeight }],
|
() => [styles.row, { height: cellHeight }],
|
||||||
@@ -248,9 +297,10 @@ function TerminalGridRow({
|
|||||||
lineHeight: cellHeight,
|
lineHeight: cellHeight,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
letterSpacing,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[cellHeight, fontFamily, fontSize],
|
[cellHeight, fontFamily, fontSize, letterSpacing],
|
||||||
);
|
);
|
||||||
const accessibilityLabel = useMemo(
|
const accessibilityLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -275,6 +325,7 @@ function TerminalGridRow({
|
|||||||
cellWidth={cellWidth}
|
cellWidth={cellWidth}
|
||||||
cellHeight={cellHeight}
|
cellHeight={cellHeight}
|
||||||
textStyle={textStyle}
|
textStyle={textStyle}
|
||||||
|
wideGlyphStyles={wideGlyphStyles}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -288,10 +339,38 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
|
|||||||
previous.cellHeight === next.cellHeight &&
|
previous.cellHeight === next.cellHeight &&
|
||||||
previous.fontFamily === next.fontFamily &&
|
previous.fontFamily === next.fontFamily &&
|
||||||
previous.fontSize === next.fontSize &&
|
previous.fontSize === next.fontSize &&
|
||||||
|
previous.letterSpacing === next.letterSpacing &&
|
||||||
|
previous.wideGlyphStyles === next.wideGlyphStyles &&
|
||||||
previous.styleEpoch === next.styleEpoch
|
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({
|
export function TerminalGridView({
|
||||||
state,
|
state,
|
||||||
xtermTheme = DEFAULT_TERMINAL_THEME,
|
xtermTheme = DEFAULT_TERMINAL_THEME,
|
||||||
@@ -303,6 +382,10 @@ export function TerminalGridView({
|
|||||||
}: TerminalGridViewProps) {
|
}: TerminalGridViewProps) {
|
||||||
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
|
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
|
||||||
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
|
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 [viewport, setViewport] = useState<TerminalGridViewport | null>(null);
|
||||||
const resolvedFontFamily = useMemo(
|
const resolvedFontFamily = useMemo(
|
||||||
() => resolveNativeTerminalFontFamily(fontFamily),
|
() => resolveNativeTerminalFontFamily(fontFamily),
|
||||||
@@ -381,6 +464,14 @@ export function TerminalGridView({
|
|||||||
measureTextLength: MEASURE_TEXT.length,
|
measureTextLength: MEASURE_TEXT.length,
|
||||||
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
||||||
});
|
});
|
||||||
|
setLetterSpacing(
|
||||||
|
resolveTerminalTextLetterSpacing({
|
||||||
|
measuredTextWidth: event.nativeEvent.layout.width,
|
||||||
|
measureTextLength: MEASURE_TEXT.length,
|
||||||
|
cellWidth: nextMetrics.cellWidth,
|
||||||
|
cellsPerGlyph: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
||||||
measuredMetricsRef.current,
|
measuredMetricsRef.current,
|
||||||
nextMetrics,
|
nextMetrics,
|
||||||
@@ -395,6 +486,35 @@ export function TerminalGridView({
|
|||||||
[onCellMetricsChange],
|
[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 handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const { width, height } = event.nativeEvent.layout;
|
const { width, height } = event.nativeEvent.layout;
|
||||||
setViewport((current) => {
|
setViewport((current) => {
|
||||||
@@ -411,6 +531,15 @@ export function TerminalGridView({
|
|||||||
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
|
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
|
||||||
{MEASURE_TEXT}
|
{MEASURE_TEXT}
|
||||||
</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) => (
|
{rows.map((row) => (
|
||||||
<MemoTerminalGridRow
|
<MemoTerminalGridRow
|
||||||
key={row.index}
|
key={row.index}
|
||||||
@@ -419,6 +548,8 @@ export function TerminalGridView({
|
|||||||
cellHeight={metrics.cellHeight}
|
cellHeight={metrics.cellHeight}
|
||||||
fontFamily={resolvedFontFamily}
|
fontFamily={resolvedFontFamily}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
|
letterSpacing={letterSpacing}
|
||||||
|
wideGlyphStyles={wideGlyphStyles}
|
||||||
styleEpoch={resolver.themeKey}
|
styleEpoch={resolver.themeKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -441,6 +572,17 @@ const styles = StyleSheet.create({
|
|||||||
run: {
|
run: {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
isolatedRun: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
// The run box owns cell dimensions and background. Text gets extra layout room without
|
||||||
|
// changing those dimensions; only isolated fallback glyphs paint beyond the run box.
|
||||||
|
runText: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
left: 0,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
customGlyphRun: {
|
customGlyphRun: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import React from "react";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
|
||||||
|
import { TerminalGridView } from "./terminal-grid-view.native";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("native terminal grid text layout", () => {
|
||||||
|
it("gives short runs between accented glyphs layout room without widening their cells", async () => {
|
||||||
|
const terminal = createNativeHeadlessTerminal({ rows: 1, cols: 30 });
|
||||||
|
try {
|
||||||
|
await terminal.write("café naïve|END");
|
||||||
|
const view = render(<TerminalGridView state={terminal.getViewportState()} />);
|
||||||
|
const text = view.getByText(" na", { normalizer: (value) => value });
|
||||||
|
const run = text.parentElement!;
|
||||||
|
const accentRun = view.getByText("é").parentElement!;
|
||||||
|
const cellWidth = parseFloat(getComputedStyle(accentRun).width);
|
||||||
|
expect(parseFloat(getComputedStyle(run).width)).toBe(3 * cellWidth);
|
||||||
|
|
||||||
|
// Native Text wraps or ellipsizes when rounding or trailing letter spacing puts this
|
||||||
|
// leading-space run just past its layout width. Layout has room; the cell box clips paint.
|
||||||
|
expect(parseFloat(getComputedStyle(text).width)).toBeGreaterThan(3 * cellWidth);
|
||||||
|
expect(getComputedStyle(text).position).toBe("absolute");
|
||||||
|
expect(getComputedStyle(run).overflowX).toBe("hidden");
|
||||||
|
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe("café naïve|END");
|
||||||
|
} finally {
|
||||||
|
terminal.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,95 @@ describe("terminal row model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([
|
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;
|
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 {
|
export interface TerminalTextRun extends TerminalRunBase {
|
||||||
renderKind: "text";
|
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 {
|
export interface TerminalCustomGlyphRun extends TerminalRunBase {
|
||||||
@@ -78,6 +110,43 @@ function terminalCharWidth(char: string): number {
|
|||||||
return isWide ? 2 : 1;
|
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 {
|
function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean {
|
||||||
if (terminalCharWidth(char) < 2) {
|
if (terminalCharWidth(char) < 2) {
|
||||||
return false;
|
return false;
|
||||||
@@ -107,15 +176,23 @@ function appendRun(input: {
|
|||||||
col: number;
|
col: number;
|
||||||
}): void {
|
}): void {
|
||||||
const renderKind = input.customGlyph ? "custom-glyph" : "text";
|
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];
|
const previousRun = input.runs[input.runs.length - 1];
|
||||||
if (
|
if (
|
||||||
|
!isolated &&
|
||||||
previousRun &&
|
previousRun &&
|
||||||
previousRun.styleKey === input.styleKey &&
|
previousRun.styleKey === input.styleKey &&
|
||||||
previousRun.renderKind === renderKind
|
previousRun.renderKind === renderKind &&
|
||||||
|
!(previousRun.renderKind === "text" && previousRun.isolated)
|
||||||
) {
|
) {
|
||||||
const offset = previousRun.cellCount;
|
const offset = previousRun.cellCount;
|
||||||
previousRun.text += input.text;
|
previousRun.text += input.text;
|
||||||
previousRun.cellCount += input.cellCount;
|
previousRun.cellCount += input.cellCount;
|
||||||
|
if (previousRun.renderKind === "text") {
|
||||||
|
appendSegment(previousRun.segments, input.text, wideGlyphClass);
|
||||||
|
}
|
||||||
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
|
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
|
||||||
previousRun.glyphs.push({
|
previousRun.glyphs.push({
|
||||||
key: `${input.col}:${input.text}`,
|
key: `${input.col}:${input.text}`,
|
||||||
@@ -142,7 +219,12 @@ function appendRun(input: {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.runs.push({ ...baseRun, renderKind: "text" });
|
input.runs.push({
|
||||||
|
...baseRun,
|
||||||
|
renderKind: "text",
|
||||||
|
isolated,
|
||||||
|
segments: [{ text: input.text, wideGlyphClass }],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellIntersectsSelection(input: {
|
function cellIntersectsSelection(input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user