From adf508a1c859a5e2875154c727ad26d82deca4a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 18:43:46 +0200 Subject: [PATCH] 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. --- packages/app/src/components/terminal-pane.tsx | 109 +++++++++++------- .../terminal-resize-debouncer.test.ts | 68 +++++++++++ .../components/terminal-resize-debouncer.ts | 47 ++++++++ .../terminal-grid-metrics.test.ts | 11 ++ .../native-renderer/terminal-grid-metrics.ts | 13 +++ .../terminal-grid-view.native.tsx | 9 +- 6 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 packages/app/src/components/terminal-resize-debouncer.test.ts create mode 100644 packages/app/src/components/terminal-resize-debouncer.ts diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index fd5dd6992..428f92ba0 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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); diff --git a/packages/app/src/components/terminal-resize-debouncer.test.ts b/packages/app/src/components/terminal-resize-debouncer.test.ts new file mode 100644 index 000000000..bf93e27af --- /dev/null +++ b/packages/app/src/components/terminal-resize-debouncer.test.ts @@ -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([]); + }); +}); diff --git a/packages/app/src/components/terminal-resize-debouncer.ts b/packages/app/src/components/terminal-resize-debouncer.ts new file mode 100644 index 000000000..81a0af8c5 --- /dev/null +++ b/packages/app/src/components/terminal-resize-debouncer.ts @@ -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 | 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, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts index 32e7fd774..0c4d7bc25 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts @@ -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({ diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts index 5a8a1b0b3..cb14111bf 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts @@ -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 { diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx index 9b81c5146..04f82c101 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -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} > @@ -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,