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 TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
|
||||
import {
|
||||
createTerminalResizeDebouncer,
|
||||
type TerminalResizeRequest,
|
||||
} from "./terminal-resize-debouncer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
@@ -85,6 +89,7 @@ interface TerminalPaneProps {
|
||||
}
|
||||
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -759,52 +764,70 @@ export function TerminalPane({
|
||||
],
|
||||
);
|
||||
|
||||
const handleTerminalResize = useStableEvent(
|
||||
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
|
||||
const { rows, cols } = input;
|
||||
if (rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
||||
measuredTerminalSizeRef.current = nextSize;
|
||||
const claim = resolveTerminalResizeClaim({
|
||||
size: nextSize,
|
||||
previousSentSize: lastSentTerminalSizeRef.current,
|
||||
shouldClaim: input.shouldClaim,
|
||||
forceClaim: input.forceClaim ?? false,
|
||||
supportsTerminalSizeOwnership,
|
||||
readiness: {
|
||||
isWorkspaceFocused: isTerminalActive,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
},
|
||||
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
|
||||
const nextSize = { rows: input.rows, cols: input.cols };
|
||||
const claim = resolveTerminalResizeClaim({
|
||||
size: nextSize,
|
||||
previousSentSize: lastSentTerminalSizeRef.current,
|
||||
shouldClaim: input.shouldClaim,
|
||||
forceClaim: input.forceClaim ?? false,
|
||||
supportsTerminalSizeOwnership,
|
||||
readiness: {
|
||||
isWorkspaceFocused: isTerminalActive,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
},
|
||||
});
|
||||
let sent = false;
|
||||
if (client && terminalId && claim.shouldSend) {
|
||||
lastSentTerminalSizeRef.current = nextSize;
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: input.rows,
|
||||
cols: input.cols,
|
||||
intent: claim.intent,
|
||||
});
|
||||
let sent = false;
|
||||
if (client && terminalId && claim.shouldSend) {
|
||||
lastSentTerminalSizeRef.current = nextSize;
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
intent: claim.intent,
|
||||
});
|
||||
sent = true;
|
||||
}
|
||||
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
|
||||
if (requestedKey && claim.intent === "claim") {
|
||||
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
|
||||
key: requestedKey,
|
||||
sent,
|
||||
});
|
||||
}
|
||||
},
|
||||
sent = true;
|
||||
}
|
||||
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
|
||||
if (requestedKey && claim.intent === "claim") {
|
||||
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
|
||||
key: requestedKey,
|
||||
sent,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const terminalResizeDebouncer = useMemo(
|
||||
() =>
|
||||
createTerminalResizeDebouncer({
|
||||
delayMs: TERMINAL_RESIZE_DEBOUNCE_MS,
|
||||
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(
|
||||
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||
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 {
|
||||
resolveTerminalCustomGlyphCellTransform,
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveMeasuredTerminalCellMetrics,
|
||||
resolveTerminalCursorOffset,
|
||||
} from "./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", () => {
|
||||
expect(
|
||||
resolveMeasuredTerminalCellMetrics({
|
||||
|
||||
@@ -21,6 +21,19 @@ export interface TerminalCursorOffset {
|
||||
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(
|
||||
input: TerminalGridCellMetricsInput,
|
||||
): TerminalGridCellMetrics {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resolveNativeTerminalFontFamily } from "./font.native";
|
||||
import type { TerminalViewportState } from "./headless-terminal-state";
|
||||
import {
|
||||
resolveMeasuredTerminalCellMetrics,
|
||||
resolveTerminalCustomGlyphCellTransform,
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveTerminalCursorOffset,
|
||||
type TerminalGridCellMetrics,
|
||||
@@ -192,7 +193,7 @@ function TerminalGridCustomGlyphRun({
|
||||
pointerEvents="none"
|
||||
preserveAspectRatio="none"
|
||||
style={styles.customGlyphSvg}
|
||||
viewBox={`0 0 ${run.cellCount} 1`}
|
||||
viewBox={`0 0 ${run.cellCount * cellWidth} ${cellHeight}`}
|
||||
width={run.cellCount * cellWidth}
|
||||
>
|
||||
<G fill="none" stroke={run.foregroundColor}>
|
||||
@@ -204,7 +205,11 @@ function TerminalGridCustomGlyphRun({
|
||||
strokeLinecap="butt"
|
||||
strokeLinejoin="miter"
|
||||
strokeWidth={cell.glyph.strokeWidth}
|
||||
transform={`translate(${cell.offset} 0)`}
|
||||
transform={resolveTerminalCustomGlyphCellTransform({
|
||||
cellOffset: cell.offset,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
})}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null,
|
||||
|
||||
Reference in New Issue
Block a user