mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d96fb7f96c | ||
|
|
1a8523abd6 |
@@ -0,0 +1,91 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { TerminalViewportState } from "./headless-terminal-state";
|
||||||
|
import { TerminalGridView } from "./terminal-grid-view.native";
|
||||||
|
|
||||||
|
const fixtureStyle = { width: 1000 };
|
||||||
|
const output = "#".repeat(49);
|
||||||
|
const state: TerminalViewportState = {
|
||||||
|
rows: 1,
|
||||||
|
cols: 49,
|
||||||
|
firstRow: 0,
|
||||||
|
oldestRow: 0,
|
||||||
|
newestRow: 0,
|
||||||
|
grid: [[...output].map((char) => ({ char }))],
|
||||||
|
cursor: { row: 0, col: 48, hidden: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const styledRuns = ["a", "bc", "defg", "hijkl"];
|
||||||
|
const styledState: TerminalViewportState = {
|
||||||
|
...state,
|
||||||
|
cols: 12,
|
||||||
|
grid: [
|
||||||
|
styledRuns.flatMap((text, index) =>
|
||||||
|
[...text].map((char) => ({ char, fg: index + 1, fgMode: 1 })),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const measuredCellWidths: number[] = [];
|
||||||
|
function onCellMetricsChange(metrics: { cellWidth: number }) {
|
||||||
|
measuredCellWidths.push(metrics.cellWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
measuredCellWidths.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native terminal full-width text", () => {
|
||||||
|
it.each([9, 11, 12, 14, 18])(
|
||||||
|
"keeps short colored runs complete despite native layout rounding at %s px",
|
||||||
|
async (fontSize) => {
|
||||||
|
const view = render(
|
||||||
|
<TerminalGridView state={styledState} fontSize={fontSize} style={fixtureStyle} />,
|
||||||
|
);
|
||||||
|
for (const runText of styledRuns) {
|
||||||
|
const text = view.getByText(runText);
|
||||||
|
const run = text.parentElement!;
|
||||||
|
await expect.poll(() => run.clientWidth).toBeGreaterThan(0);
|
||||||
|
// Native one-line layout rounds independently of the cell box. Leave text
|
||||||
|
// layout at least one physical pixel of slack, while the cell box clips paint.
|
||||||
|
await expect
|
||||||
|
.poll(() => text.getBoundingClientRect().width - run.getBoundingClientRect().width)
|
||||||
|
.toBeGreaterThanOrEqual(1 / window.devicePixelRatio);
|
||||||
|
expect(getComputedStyle(run).overflowX).toBe("hidden");
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(text);
|
||||||
|
expect(range.getBoundingClientRect().width).toBeLessThanOrEqual(
|
||||||
|
text.getBoundingClientRect().width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(
|
||||||
|
styledRuns.join(""),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([9, 11, 12, 14, 18])(
|
||||||
|
"fits every character in a full-width ASCII row at %s px",
|
||||||
|
async (fontSize) => {
|
||||||
|
const view = render(
|
||||||
|
<TerminalGridView
|
||||||
|
state={state}
|
||||||
|
style={fixtureStyle}
|
||||||
|
fontSize={fontSize}
|
||||||
|
onCellMetricsChange={onCellMetricsChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await expect.poll(() => measuredCellWidths.at(-1) ?? 0).toBeGreaterThan(0);
|
||||||
|
const text = view.getByText(output);
|
||||||
|
await expect.poll(() => text.clientWidth).toBeGreaterThan(0);
|
||||||
|
// The native Text adapter truncates overflowing one-line content. Compare the actual
|
||||||
|
// font layout with the available width, rather than asserting a particular CSS policy.
|
||||||
|
await expect.poll(() => text.scrollWidth - text.clientWidth).toBeLessThanOrEqual(1);
|
||||||
|
const run = text.parentElement!;
|
||||||
|
expect(run.getBoundingClientRect().width).toBeCloseTo(49 * measuredCellWidths.at(-1)!, 1);
|
||||||
|
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(output);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ const INITIAL_CELL_HEIGHT_RATIO = 1.35;
|
|||||||
interface CellMetrics {
|
interface CellMetrics {
|
||||||
cellWidth: number;
|
cellWidth: number;
|
||||||
cellHeight: number;
|
cellHeight: number;
|
||||||
|
letterSpacing: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalGridViewport {
|
interface TerminalGridViewport {
|
||||||
@@ -57,6 +58,7 @@ interface TerminalGridRowProps {
|
|||||||
cellHeight: number;
|
cellHeight: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
letterSpacing: number;
|
||||||
styleEpoch: string;
|
styleEpoch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ function estimateCellMetrics(fontSize: number): CellMetrics {
|
|||||||
return {
|
return {
|
||||||
cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO),
|
cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO),
|
||||||
cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO),
|
cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO),
|
||||||
|
letterSpacing: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +115,15 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
|
|||||||
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
|
[cellHeight, cellWidth, 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,
|
||||||
|
// Native text layout rounds independently of the cell box. One extra cell
|
||||||
|
// prevents rounding from truncating a run that otherwise fits its cells.
|
||||||
|
{ minWidth: (run.cellCount + 1) * cellWidth },
|
||||||
|
],
|
||||||
|
[cellWidth, run.cellCount, run.style, textStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,7 +131,7 @@ 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}
|
{run.text}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -235,6 +245,7 @@ function TerminalGridRow({
|
|||||||
cellHeight,
|
cellHeight,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
letterSpacing,
|
||||||
}: TerminalGridRowProps) {
|
}: TerminalGridRowProps) {
|
||||||
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [styles.row, { height: cellHeight }],
|
() => [styles.row, { height: cellHeight }],
|
||||||
@@ -248,9 +259,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(
|
||||||
() =>
|
() =>
|
||||||
@@ -288,6 +300,7 @@ 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.styleEpoch === next.styleEpoch
|
previous.styleEpoch === next.styleEpoch
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -381,6 +394,17 @@ export function TerminalGridView({
|
|||||||
measureTextLength: MEASURE_TEXT.length,
|
measureTextLength: MEASURE_TEXT.length,
|
||||||
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
||||||
});
|
});
|
||||||
|
// Grid cells are snapped to physical pixels. Match the font's natural advance to that
|
||||||
|
// cell width so a full row still fits instead of triggering native Text truncation.
|
||||||
|
const letterSpacing =
|
||||||
|
nextMetrics.cellWidth - event.nativeEvent.layout.width / MEASURE_TEXT.length;
|
||||||
|
setMetrics((current) =>
|
||||||
|
current.cellWidth === nextMetrics.cellWidth &&
|
||||||
|
current.cellHeight === nextMetrics.cellHeight &&
|
||||||
|
current.letterSpacing === letterSpacing
|
||||||
|
? current
|
||||||
|
: { ...nextMetrics, letterSpacing },
|
||||||
|
);
|
||||||
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
||||||
measuredMetricsRef.current,
|
measuredMetricsRef.current,
|
||||||
nextMetrics,
|
nextMetrics,
|
||||||
@@ -389,7 +413,6 @@ export function TerminalGridView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
measuredMetricsRef.current = changedMetrics;
|
measuredMetricsRef.current = changedMetrics;
|
||||||
setMetrics(changedMetrics);
|
|
||||||
onCellMetricsChange?.(changedMetrics);
|
onCellMetricsChange?.(changedMetrics);
|
||||||
},
|
},
|
||||||
[onCellMetricsChange],
|
[onCellMetricsChange],
|
||||||
@@ -419,6 +442,7 @@ export function TerminalGridView({
|
|||||||
cellHeight={metrics.cellHeight}
|
cellHeight={metrics.cellHeight}
|
||||||
fontFamily={resolvedFontFamily}
|
fontFamily={resolvedFontFamily}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
|
letterSpacing={metrics.letterSpacing}
|
||||||
styleEpoch={resolver.themeKey}
|
styleEpoch={resolver.themeKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -441,6 +465,12 @@ const styles = StyleSheet.create({
|
|||||||
run: {
|
run: {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
runText: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
customGlyphRun: {
|
customGlyphRun: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
Reference in New Issue
Block a user