fix(app): stabilize native terminal rendering
Scale custom glyph paths in measured cell coordinates so box-drawing runs fill their cells on Android. Debounce daemon resize claims at the terminal pane boundary so keyboard layout animation sends only the settled dimensions.
This commit is contained in:
@@ -53,6 +53,10 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||||
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
|
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
|
||||||
|
import {
|
||||||
|
createTerminalResizeDebouncer,
|
||||||
|
type TerminalResizeRequest,
|
||||||
|
} from "./terminal-resize-debouncer";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +89,7 @@ interface TerminalPaneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
|
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
ctrl: "Ctrl",
|
ctrl: "Ctrl",
|
||||||
@@ -759,52 +764,70 @@ export function TerminalPane({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalResize = useStableEvent(
|
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
|
||||||
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
|
const nextSize = { rows: input.rows, cols: input.cols };
|
||||||
const { rows, cols } = input;
|
const claim = resolveTerminalResizeClaim({
|
||||||
if (rows <= 0 || cols <= 0) {
|
size: nextSize,
|
||||||
return;
|
previousSentSize: lastSentTerminalSizeRef.current,
|
||||||
}
|
shouldClaim: input.shouldClaim,
|
||||||
const normalizedRows = Math.floor(rows);
|
forceClaim: input.forceClaim ?? false,
|
||||||
const normalizedCols = Math.floor(cols);
|
supportsTerminalSizeOwnership,
|
||||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
readiness: {
|
||||||
measuredTerminalSizeRef.current = nextSize;
|
isWorkspaceFocused: isTerminalActive,
|
||||||
const claim = resolveTerminalResizeClaim({
|
isPaneFocused,
|
||||||
size: nextSize,
|
isAppActivelyVisible,
|
||||||
previousSentSize: lastSentTerminalSizeRef.current,
|
isClientReady: client !== null,
|
||||||
shouldClaim: input.shouldClaim,
|
isConnected,
|
||||||
forceClaim: input.forceClaim ?? false,
|
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||||
supportsTerminalSizeOwnership,
|
},
|
||||||
readiness: {
|
});
|
||||||
isWorkspaceFocused: isTerminalActive,
|
let sent = false;
|
||||||
isPaneFocused,
|
if (client && terminalId && claim.shouldSend) {
|
||||||
isAppActivelyVisible,
|
lastSentTerminalSizeRef.current = nextSize;
|
||||||
isClientReady: client !== null,
|
client.sendTerminalInput(terminalId, {
|
||||||
isConnected,
|
type: "resize",
|
||||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
rows: input.rows,
|
||||||
},
|
cols: input.cols,
|
||||||
|
intent: claim.intent,
|
||||||
});
|
});
|
||||||
let sent = false;
|
sent = true;
|
||||||
if (client && terminalId && claim.shouldSend) {
|
}
|
||||||
lastSentTerminalSizeRef.current = nextSize;
|
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
|
||||||
client.sendTerminalInput(terminalId, {
|
if (requestedKey && claim.intent === "claim") {
|
||||||
type: "resize",
|
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
|
||||||
rows: normalizedRows,
|
key: requestedKey,
|
||||||
cols: normalizedCols,
|
sent,
|
||||||
intent: claim.intent,
|
});
|
||||||
});
|
}
|
||||||
sent = true;
|
});
|
||||||
}
|
|
||||||
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
|
const terminalResizeDebouncer = useMemo(
|
||||||
if (requestedKey && claim.intent === "claim") {
|
() =>
|
||||||
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
|
createTerminalResizeDebouncer({
|
||||||
key: requestedKey,
|
delayMs: TERMINAL_RESIZE_DEBOUNCE_MS,
|
||||||
sent,
|
emit: sendTerminalResize,
|
||||||
});
|
}),
|
||||||
}
|
[sendTerminalResize],
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => terminalResizeDebouncer.cancel(),
|
||||||
|
[terminalResizeDebouncer, terminalStreamKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
|
||||||
|
if (input.rows <= 0 || input.cols <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextResize = {
|
||||||
|
...input,
|
||||||
|
rows: Math.floor(input.rows),
|
||||||
|
cols: Math.floor(input.cols),
|
||||||
|
};
|
||||||
|
measuredTerminalSizeRef.current = { rows: nextResize.rows, cols: nextResize.cols };
|
||||||
|
terminalResizeDebouncer.schedule(nextResize);
|
||||||
|
});
|
||||||
|
|
||||||
const handleTerminalKey = useCallback(
|
const handleTerminalKey = useCallback(
|
||||||
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||||
sendTerminalKey(input);
|
sendTerminalKey(input);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTerminalResizeDebouncer } from "./terminal-resize-debouncer";
|
||||||
|
|
||||||
|
describe("terminal resize debouncer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits only the latest size after resize callbacks settle", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const emitted: Array<{
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
shouldClaim: boolean;
|
||||||
|
forceClaim?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const debouncer = createTerminalResizeDebouncer({
|
||||||
|
delayMs: 100,
|
||||||
|
emit: (resize) => emitted.push(resize),
|
||||||
|
});
|
||||||
|
|
||||||
|
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
|
||||||
|
vi.advanceTimersByTime(99);
|
||||||
|
|
||||||
|
expect(emitted).toEqual([]);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
|
||||||
|
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a claim while taking the latest dimensions", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const emitted: Array<{
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
shouldClaim: boolean;
|
||||||
|
forceClaim?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const debouncer = createTerminalResizeDebouncer({
|
||||||
|
delayMs: 100,
|
||||||
|
emit: (resize) => emitted.push(resize),
|
||||||
|
});
|
||||||
|
|
||||||
|
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: true, forceClaim: true });
|
||||||
|
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: true, forceClaim: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending resize", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const emitted: unknown[] = [];
|
||||||
|
const debouncer = createTerminalResizeDebouncer({
|
||||||
|
delayMs: 100,
|
||||||
|
emit: (resize) => emitted.push(resize),
|
||||||
|
});
|
||||||
|
|
||||||
|
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
|
||||||
|
debouncer.cancel();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(emitted).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface TerminalResizeRequest {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
shouldClaim: boolean;
|
||||||
|
forceClaim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTerminalResizeDebouncer(input: {
|
||||||
|
delayMs: number;
|
||||||
|
emit: (resize: TerminalResizeRequest) => void;
|
||||||
|
}): {
|
||||||
|
schedule: (resize: TerminalResizeRequest) => void;
|
||||||
|
cancel: () => void;
|
||||||
|
} {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pending: TerminalResizeRequest | null = null;
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule: (resize) => {
|
||||||
|
pending = {
|
||||||
|
...resize,
|
||||||
|
shouldClaim: (pending?.shouldClaim ?? false) || resize.shouldClaim,
|
||||||
|
forceClaim: (pending?.forceClaim ?? false) || resize.forceClaim || undefined,
|
||||||
|
};
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
const resizeToEmit = pending;
|
||||||
|
pending = null;
|
||||||
|
if (resizeToEmit) {
|
||||||
|
input.emit(resizeToEmit);
|
||||||
|
}
|
||||||
|
}, input.delayMs);
|
||||||
|
},
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveTerminalCustomGlyphCellTransform,
|
||||||
resolveTerminalGridMetricsMeasurement,
|
resolveTerminalGridMetricsMeasurement,
|
||||||
resolveMeasuredTerminalCellMetrics,
|
resolveMeasuredTerminalCellMetrics,
|
||||||
resolveTerminalCursorOffset,
|
resolveTerminalCursorOffset,
|
||||||
} from "./terminal-grid-metrics";
|
} from "./terminal-grid-metrics";
|
||||||
|
|
||||||
describe("native terminal grid metrics", () => {
|
describe("native terminal grid metrics", () => {
|
||||||
|
it("places each normalized custom glyph in run viewport coordinates", () => {
|
||||||
|
expect(
|
||||||
|
resolveTerminalCustomGlyphCellTransform({
|
||||||
|
cellOffset: 57,
|
||||||
|
cellWidth: 7.4,
|
||||||
|
cellHeight: 16.2,
|
||||||
|
}),
|
||||||
|
).toBe("matrix(7.4 0 0 16.2 421.8 0)");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves fractional measured cell width for horizontal layout", () => {
|
it("preserves fractional measured cell width for horizontal layout", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveMeasuredTerminalCellMetrics({
|
resolveMeasuredTerminalCellMetrics({
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ export interface TerminalCursorOffset {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalCustomGlyphCellTransformInput {
|
||||||
|
cellOffset: number;
|
||||||
|
cellWidth: number;
|
||||||
|
cellHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTerminalCustomGlyphCellTransform(
|
||||||
|
input: TerminalCustomGlyphCellTransformInput,
|
||||||
|
): string {
|
||||||
|
const translateX = input.cellOffset * input.cellWidth;
|
||||||
|
return `matrix(${input.cellWidth} 0 0 ${input.cellHeight} ${translateX} 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMeasuredTerminalCellMetrics(
|
export function resolveMeasuredTerminalCellMetrics(
|
||||||
input: TerminalGridCellMetricsInput,
|
input: TerminalGridCellMetricsInput,
|
||||||
): TerminalGridCellMetrics {
|
): TerminalGridCellMetrics {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { resolveNativeTerminalFontFamily } from "./font.native";
|
|||||||
import type { TerminalViewportState } from "./headless-terminal-state";
|
import type { TerminalViewportState } from "./headless-terminal-state";
|
||||||
import {
|
import {
|
||||||
resolveMeasuredTerminalCellMetrics,
|
resolveMeasuredTerminalCellMetrics,
|
||||||
|
resolveTerminalCustomGlyphCellTransform,
|
||||||
resolveTerminalGridMetricsMeasurement,
|
resolveTerminalGridMetricsMeasurement,
|
||||||
resolveTerminalCursorOffset,
|
resolveTerminalCursorOffset,
|
||||||
type TerminalGridCellMetrics,
|
type TerminalGridCellMetrics,
|
||||||
@@ -192,7 +193,7 @@ function TerminalGridCustomGlyphRun({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
style={styles.customGlyphSvg}
|
style={styles.customGlyphSvg}
|
||||||
viewBox={`0 0 ${run.cellCount} 1`}
|
viewBox={`0 0 ${run.cellCount * cellWidth} ${cellHeight}`}
|
||||||
width={run.cellCount * cellWidth}
|
width={run.cellCount * cellWidth}
|
||||||
>
|
>
|
||||||
<G fill="none" stroke={run.foregroundColor}>
|
<G fill="none" stroke={run.foregroundColor}>
|
||||||
@@ -204,7 +205,11 @@ function TerminalGridCustomGlyphRun({
|
|||||||
strokeLinecap="butt"
|
strokeLinecap="butt"
|
||||||
strokeLinejoin="miter"
|
strokeLinejoin="miter"
|
||||||
strokeWidth={cell.glyph.strokeWidth}
|
strokeWidth={cell.glyph.strokeWidth}
|
||||||
transform={`translate(${cell.offset} 0)`}
|
transform={resolveTerminalCustomGlyphCellTransform({
|
||||||
|
cellOffset: cell.offset,
|
||||||
|
cellWidth,
|
||||||
|
cellHeight,
|
||||||
|
})}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user