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:
Mohamed Boudra
2026-08-04 18:43:46 +02:00
parent dac255cb99
commit adf508a1c8
6 changed files with 212 additions and 45 deletions
+66 -43
View File
@@ -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,