From b83ec9a982653c8e2ae2e84da35b4d2708f0ff14 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 09:46:59 -0400 Subject: [PATCH] refactor(mobile): extract the runtime-state and text-scaling slice The document's declaration block, where almost everything it shares is declared, with the query-reply and surface-swap groups interpolated inside it. Three modules: the two declarations that come before the groups, the text scaling, and the viewport transform with the scroll indicator. Seven more names stop being externals. Two things this slice forced. The scope-declaration rule now counts each declarator of one `var`, because `var panX = 0, panY = 0` becomes two assignments onto the scope. It has its own acceptance case in the instrument's test. The two halves are compared against their own text rather than as one joined program. The declaration the slice opens with is shadowed by a parameter inside one of the interpolated groups, and printing the baseline as one program renames that parameter; qualifying the outer name removes the shadow, so the rename has nothing to correspond to. Splitting the slice on the group constants compares like with like, and those groups have their own tests. Build-time constants are now substituted textually rather than through an esbuild `define`: a `define` whose value is an object or an array is injected as a helper binding instead of being inlined. Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38, rebindings 25, braced bodies 13, unbound catches 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../build-terminal-document-script.mjs | 19 ++- mobile/src/terminal/document/cell-geometry.ts | 2 +- .../terminal/document/document-constants.ts | 8 + .../terminal/document/document-externals.ts | 21 --- .../src/terminal/document/document-scope.ts | 22 ++- mobile/src/terminal/document/fit-scale.ts | 4 +- .../src/terminal/document/message-bridge.ts | 9 +- .../terminal/document/mouse-report-cell.ts | 2 +- .../document/normal-buffer-smooth-scroll.ts | 2 +- mobile/src/terminal/document/reflow.ts | 3 +- .../terminal/document/runtime-state.test.ts | 62 ++++++++ .../src/terminal/document/term-observers.ts | 2 +- ...minal-document-equivalence.test-support.ts | 17 ++- .../terminal-document-equivalence.test.ts | 7 + .../src/terminal/document/terminal-handle.ts | 10 ++ mobile/src/terminal/document/terminal-init.ts | 3 +- mobile/src/terminal/document/text-scaling.ts | 94 ++++++++++++ .../terminal/document/viewport-transform.ts | 143 ++++++++++++++++++ .../src/terminal/document/webgl-recovery.ts | 2 +- mobile/src/terminal/document/wheel-scroll.ts | 7 +- 20 files changed, 386 insertions(+), 53 deletions(-) create mode 100644 mobile/src/terminal/document/runtime-state.test.ts create mode 100644 mobile/src/terminal/document/terminal-handle.ts create mode 100644 mobile/src/terminal/document/text-scaling.ts create mode 100644 mobile/src/terminal/document/viewport-transform.ts diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs index b92271e3e30..093a6e88870 100644 --- a/mobile/scripts/build-terminal-document-script.mjs +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -34,11 +34,12 @@ const constantsPath = path.join( let substitutions = null /** - * `document-constants.ts` as esbuild `define` entries. + * `document-constants.ts` as the literal text each name stands for. * - * Substitution happens after the import lines are dropped, when the names are free again; while the - * import is still there esbuild sees a bound name and leaves it alone, which is the correct thing - * for the page and the wrong thing for the document. + * Substitution happens after the import lines are dropped, when the names are free again, and it is + * textual rather than an esbuild `define` because a `define` whose value is an object or an array + * is injected as a helper binding instead of being inlined, which is not what the document carries. + * The names are exported for this purpose only and none of them appears inside a string. */ async function documentConstantSubstitutions() { if (substitutions === null) { @@ -114,13 +115,15 @@ export async function emitTerminalDocumentModule(modulePath) { } kept.push(line.startsWith('export ') ? line.slice('export '.length) : line) } - const define = await documentConstantSubstitutions() - const substituted = await esbuild.transform(kept.join('\n'), { + let text = kept.join('\n') + for (const [name, literal] of Object.entries(await documentConstantSubstitutions())) { + text = text.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), literal) + } + const substituted = await esbuild.transform(text, { loader: 'js', format: 'esm', target: 'chrome74', - minify: false, - define + minify: false }) const body = substituted.code.trim() return body diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts index f4591c8d5c1..5bfc6956285 100644 --- a/mobile/src/terminal/document/cell-geometry.ts +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -1,5 +1,5 @@ import { getCellHeight } from './fit-scale' -import { getCellWidth, getTotalScale } from './document-externals' +import { getCellWidth, getTotalScale } from './viewport-transform' import { scope } from './document-scope' export function cellToViewportPx(col: number, absRow: number) { diff --git a/mobile/src/terminal/document/document-constants.ts b/mobile/src/terminal/document/document-constants.ts index 9a686cbca51..730bccf519c 100644 --- a/mobile/src/terminal/document/document-constants.ts +++ b/mobile/src/terminal/document/document-constants.ts @@ -1,4 +1,6 @@ import { colors } from '../../theme/mobile-theme' +import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' +import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme' import { MOBILE_TERMINAL_CARET_OPTIONS } from '../terminal-webview-html/theme' import { TERMINAL_FILE_URL_REGEX_SOURCE, @@ -34,3 +36,9 @@ export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle + +/** The text-scale presets, as the document's own array literal. */ +export const terminalTextScalePresets = [...TERMINAL_TEXT_SCALES] + +/** The built-in theme, as the document's own object literal. */ +export const terminalDefaultTheme = DEFAULT_TERMINAL_THEME diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 26c4c844668..1aa93159c23 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -9,24 +9,12 @@ * This file only shrinks. When the last group moves it is deleted. */ -/** `smooth-scroll-and-cell-geometry`: one character cell's width in surface pixels. */ -export declare function getCellWidth(): number - -/** `terminal-fit-scale`: the fit scale times the user's pinch scale. */ -export declare function getTotalScale(): number - /** `runtime-state-and-text-scaling`: posts one message to the native host. */ export declare function notify(message: Record): void /** `terminal-init-and-write`: whether xterm is showing the alternate screen buffer. */ export declare function isAlternateBufferActive(): boolean -/** `smooth-scroll-and-cell-geometry`: shows or hides the scroll indicator. */ -export declare function updateScrollIndicator(visible: boolean): void - -/** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */ -export declare function flog(name: string, detail: Record): void - /** `mouse-report-and-scroll-routing`: whether scrolling should reach the TUI as input. */ export declare function shouldRouteScrollToTerminalInput(): boolean @@ -77,12 +65,3 @@ export declare function handleMsg(msg: unknown): void /** `terminal-init-and-write`: reports an engine failure to the host. */ export declare function reportEngineError(summary: string, cause: unknown, fatal: unknown): void - -/** `smooth-scroll-and-cell-geometry`: writes the pan and scale onto the surface transform. */ -export declare function updateTransform(): void - -/** `runtime-state-and-text-scaling`: the scale that fits the grid to the viewport. */ -export declare function computeFitScale(): number - -/** `runtime-state-and-text-scaling`: the font size in pixels for a text-scale preset. */ -export declare function fontPxForScale(scale: number): number diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index e5faf9bba66..80314077ae7 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -1,3 +1,4 @@ +import { terminalTextScalePresets } from './document-constants' import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme' import type { TerminalDocumentThemeMessage } from './terminal-theme' /** @@ -29,7 +30,7 @@ export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: str /** The xterm internals the OSC 8 lookup walks. */ export type TerminalDocumentCore = { - _renderService?: { dimensions?: { css: { cell: { height?: number } } } } + _renderService?: { dimensions?: { css: { cell: { height?: number; width?: number } } } } _oscLinkService?: TerminalOscLinkService _inputHandler?: { _oscLinkService?: TerminalOscLinkService } } @@ -77,13 +78,14 @@ export type TerminalDocumentTheme = Record export type TerminalDocumentTerminalOptions = { theme: TerminalDocumentTheme minimumContrastRatio: number + fontSize: number } export type TerminalDocumentTerminal = { readonly cols: number readonly rows: number readonly buffer: { readonly active: TerminalDocumentBuffer } - readonly options: TerminalDocumentTerminalOptions + options: TerminalDocumentTerminalOptions write: (data: string, callback?: () => void) => void open: (element: HTMLElement) => void scrollToLine: (line: number) => void @@ -155,6 +157,16 @@ export type TerminalDocumentScope = { sgrMouseMode: boolean /** `runtime-state`: whether the TUI asked for SGR pixel (1016) mouse reports. */ sgrMousePixelsMode: boolean + /** `text-scaling`: the scroll indicator's hide timer. */ + scrollIndicatorHideTimer: ReturnType | null + /** `text-scaling`: the narrowest grid a text-scale change will fit to. */ + MIN_FIT_COLS: number + /** `text-scaling`: the smallest text-scale preset. */ + MIN_TEXT_SCALE: number + /** `text-scaling`: the largest text-scale preset. */ + MAX_TEXT_SCALE: number + /** `viewport-transform`: host message ids already handled, to drop repeats. */ + handledMessageIds: string[] /** `runtime-state`: the text scale the user picked, as a preset index. */ currentTextScale: number /** `runtime-state`: the font stack xterm renders with. */ @@ -288,6 +300,7 @@ export type TerminalDocumentWebglAddon = { * A factory rather than a shared literal so a second document — a test, or a page that remounts — * starts from its own state instead of inheriting what the last one left. */ +const textScalePresets = terminalTextScalePresets const statusDot = String.fromCharCode(0x23fa) const textPresentationSelector = String.fromCharCode(0xfe0e) const emojiPresentationSelector = String.fromCharCode(0xfe0f) @@ -322,6 +335,11 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { trackedMouseTrackingMode: 'none', sgrMouseMode: false, sgrMousePixelsMode: false, + scrollIndicatorHideTimer: null, + MIN_FIT_COLS: 20, + MIN_TEXT_SCALE: textScalePresets[0], + MAX_TEXT_SCALE: textScalePresets[textScalePresets.length - 1], + handledMessageIds: [], currentTextScale: 1, terminalFontFamily: '', firstDataPending: true, diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts index 3e5ea407bea..87ddc61be60 100644 --- a/mobile/src/terminal/document/fit-scale.ts +++ b/mobile/src/terminal/document/fit-scale.ts @@ -1,11 +1,11 @@ +import { repositionOverlay } from './document-externals' import { computeFitScale, flog, getCellWidth, getTotalScale, - repositionOverlay, updateTransform -} from './document-externals' +} from './viewport-transform' import { scope } from './document-scope' export function getCellHeight() { diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index 784ac56186e..34997a0bc62 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -1,11 +1,6 @@ import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' -import { - handleMsg, - notify, - reportEngineError, - repositionOverlay, - updateTransform -} from './document-externals' +import { handleMsg, notify, reportEngineError, repositionOverlay } from './document-externals' +import { updateTransform } from './viewport-transform' import { scope } from './document-scope' declare global { diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts index 1c820bbcdda..ca4bbaec3ed 100644 --- a/mobile/src/terminal/document/mouse-report-cell.ts +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -1,5 +1,5 @@ import { getCellHeight } from './fit-scale' -import { getCellWidth, getTotalScale } from './document-externals' +import { getCellWidth, getTotalScale } from './viewport-transform' import { scope } from './document-scope' /** Where a viewport point lands in the terminal's cell grid, for an xterm mouse report. */ diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index fb52e9d18e1..e1ee6dd63e3 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,5 +1,5 @@ import { getCellHeight } from './fit-scale' -import { getTotalScale, updateScrollIndicator } from './document-externals' +import { getTotalScale, updateScrollIndicator } from './viewport-transform' import { scope } from './document-scope' export function clampNormalScrollLines(lines: number) { diff --git a/mobile/src/terminal/document/reflow.ts b/mobile/src/terminal/document/reflow.ts index 2bddff32966..c1299a3c00a 100644 --- a/mobile/src/terminal/document/reflow.ts +++ b/mobile/src/terminal/document/reflow.ts @@ -1,5 +1,6 @@ import { applyFitScale } from './fit-scale' -import { isAlternateBufferActive, updateScrollIndicator } from './document-externals' +import { isAlternateBufferActive } from './document-externals' +import { updateScrollIndicator } from './viewport-transform' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { scope } from './document-scope' diff --git a/mobile/src/terminal/document/runtime-state.test.ts b/mobile/src/terminal/document/runtime-state.test.ts new file mode 100644 index 00000000000..96444c6f2a7 --- /dev/null +++ b/mobile/src/terminal/document/runtime-state.test.ts @@ -0,0 +1,62 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected' +import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injected' +import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from '../terminal-webview-html/runtime-state-and-text-scaling' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +/** + * This slice wraps two already-extracted groups, and the declaration it opens with is shadowed by + * a parameter inside one of them. Printed as one program the shadow has to be renamed, so the two + * halves are compared against their own text: the slice split on the groups it interpolates, which + * have their own tests either side of this one. + */ +function sliceOn(text: string, marker: string): [string, string] { + const at = text.indexOf(marker) + expect(at).toBeGreaterThan(-1) + return [text.slice(0, at), text.slice(at + marker.length)] +} + +const emit = (relative: string) => + emitTerminalDocumentModule(fileURLToPath(new URL(relative, import.meta.url))) + +describe('the runtime-state and text-scaling slice', () => { + it('emits the declaration it opens with', async () => { + const [head] = sliceOn(TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, TERMINAL_QUERY_REPLY_JS) + expect(compareTerminalDocumentScripts(head, await emit('./terminal-handle.ts'), 'scope')) + .toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 0, + scopeFieldDeclarations: 2, + rebindings: 0, + bracedBodies: 0, + unboundCatches: 0, + numberProperties: 0, + shorthandProperties: 0 + } + }) + }) + + it('emits everything after the groups it interpolates', async () => { + const [, rest] = sliceOn(TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, TERMINAL_QUERY_REPLY_JS) + const [between, tail] = sliceOn(rest, TERMINAL_SURFACE_SWAP_JS) + expect(between.trim()).toBe('') + const emitted = [await emit('./text-scaling.ts'), await emit('./viewport-transform.ts')] + .join('\n') + expect(compareTerminalDocumentScripts(tail, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 31, + // Where the document declares almost everything it shares. + scopeFieldDeclarations: 38, + rebindings: 25, + bracedBodies: 13, + unboundCatches: 1, + numberProperties: 0, + shorthandProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts index fe1adf538b7..2e7cd4f12b3 100644 --- a/mobile/src/terminal/document/term-observers.ts +++ b/mobile/src/terminal/document/term-observers.ts @@ -1,5 +1,5 @@ import { afterWritesDrained, disposeTermObservers } from './write-queue' -import { updateScrollIndicator } from './document-externals' +import { updateScrollIndicator } from './viewport-transform' import { scope } from './document-scope' import { logFeedAndEvict } from './selection-state-and-eviction' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts index 10abea2b582..98153d49347 100644 --- a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts +++ b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts @@ -35,7 +35,10 @@ import { transformSync } from 'esbuild' export type TerminalDocumentNormalisations = { /** `name` became `.name`; the declaration stayed where it was. */ readonly qualifiedReferences: number - /** `var name` became `.name`; the declaration moved onto the scope object. */ + /** + * `var name` became `.name`; the declaration moved onto the scope object. A `var` + * with several declarators counts once per declarator, because each becomes its own assignment. + */ readonly scopeFieldDeclarations: number /** `var` became `const` or `let`, the binding staying local to the emitted script. */ readonly rebindings: number @@ -217,6 +220,18 @@ export function compareTerminalDocumentScripts( right += 3 continue } + // `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second + // declaration, which is written as its own assignment. + if ( + expected.label === ',' && + before[left + 1] !== undefined && + isQualified(after, right, before[left + 1], qualifier) + ) { + scopeFieldDeclarations += 1 + left += 2 + right += 3 + continue + } if (expected.label === 'var' && isBlockScopedKeyword(actual)) { rebindings += 1 lastMatched = actual diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test.ts b/mobile/src/terminal/document/terminal-document-equivalence.test.ts index 3538266e18f..3dabc01f7e6 100644 --- a/mobile/src/terminal/document/terminal-document-equivalence.test.ts +++ b/mobile/src/terminal/document/terminal-document-equivalence.test.ts @@ -121,6 +121,13 @@ describe('terminal document script equivalence', () => { ).toEqual({ ...NONE, shorthandProperties: 1 }) }) + it('counts each declarator of one var that moved onto the scope', () => { + expect(normalisationsOf('var a = 1, b = 2;', 'scope.a = 1; scope.b = 2;')).toEqual({ + ...NONE, + scopeFieldDeclarations: 2 + }) + }) + it('refuses a changed literal', () => { expect(normalisationsOf('var a = 1;', 'var a = 2')).toBe( 'token 3: expected num 1, generated num 2' diff --git a/mobile/src/terminal/document/terminal-handle.ts b/mobile/src/terminal/document/terminal-handle.ts new file mode 100644 index 00000000000..01ff73f278c --- /dev/null +++ b/mobile/src/terminal/document/terminal-handle.ts @@ -0,0 +1,10 @@ +import { scope } from './document-scope' + +/** + * The two fields the document declares before the query-reply bridge that follows it. + * + * They are one module because the emitted document puts them on one line, ahead of an injected + * group; nothing else joins them. + */ +scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 +scope.term = null diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index d0f44f8e29b..8afc1227c25 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -5,7 +5,8 @@ import { terminalCursorStyle, terminalShowCursorImmediately } from './document-constants' -import { fontPxForScale, notify } from './document-externals' +import { notify } from './document-externals' +import { fontPxForScale } from './text-scaling' import { scope, type TerminalDocumentTerminal, diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts new file mode 100644 index 00000000000..88db2df62f1 --- /dev/null +++ b/mobile/src/terminal/document/text-scaling.ts @@ -0,0 +1,94 @@ +import { terminalTextScalePresets } from './document-constants' +import { scope } from './document-scope' +import { applyFitScale, getCellHeight } from './fit-scale' +import { getCellWidth } from './viewport-transform' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' + +export const scrollIndicator = document.getElementById('scroll-indicator') +export const scrollThumb = document.getElementById('scroll-thumb') +scope.scrollIndicatorHideTimer = null +scope.writeQueue = [] +scope.writeQueueHead = 0 +scope.writesDraining = false +scope.afterDrainCallbacks = [] +scope.termObserverDisposables = [] +scope.ready = false +// Why: init() flips ready false on every re-init (live width reflow included) +// while the old surface stays visible; a document-scoped latch drives the +// fatal/non-fatal decision so a transient reflow cannot blank a live terminal. +scope.everReady = false +scope.currentScale = 1 +// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a +// gesture only; it resets to 1 on release. The persistent "text size" is the +// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it +// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures +// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the +// new width. A finished pinch snaps to the nearest preset and reports it to RN. +scope.userScale = 1 +const BASE_FONT_PX = 13 +const MIN_FONT_PX = 6 +scope.MIN_FIT_COLS = 20 +scope.currentTextScale = 1 +const TEXT_SCALE_PRESETS = terminalTextScalePresets +scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0] +scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1] +export function snapToTextScalePreset(value: number) { + let best = TEXT_SCALE_PRESETS[0], + bestDelta = Infinity + for (let i = 0; i < TEXT_SCALE_PRESETS.length; i++) { + const delta = Math.abs(TEXT_SCALE_PRESETS[i] - value) + if (delta < bestDelta) { + bestDelta = delta + best = TEXT_SCALE_PRESETS[i] + } + } + return best +} +export function fontPxForScale(scale: number) { + return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)) +} +export function isIOSWebView() { + if (/iP(ad|hone|od)/.test(navigator.userAgent)) { + return true + } + return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 +} +// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can +// fall to a non-monospace face; lead with the ui-monospace generic to avoid that. +const TERMINAL_FONT_FALLBACKS = + '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace' +scope.terminalFontFamily = + (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS +// Why: change the real font size, then resize the grid to fit the viewport at +// the new cell metrics so the text shows at its true size immediately. RN's +// refit (measure → updateViewport) then makes the server reflow the PTY to the +// same column count so the shell rewraps. cell metrics update on the frame +// after fontSize changes, so the resize/fit is deferred one rAF. +export function applyTextScale(scale: number) { + scope.currentTextScale = scale + if (!scope.term) { + return + } + const px = fontPxForScale(scale) + if (scope.term.options.fontSize === px) { + return + } + scope.term.options.fontSize = px + requestAnimationFrame(function () { + if (!scope.term) { + return + } + const cellW = getCellWidth() + const cellH = getCellHeight() + if (cellW > 0 && cellH > 0) { + const cols = Math.floor(window.innerWidth / cellW) + if (cols < scope.MIN_FIT_COLS) { + return + } + const rows = Math.max(8, Math.floor(window.innerHeight / cellH)) + scope.term.resize(cols, rows) + emitKeyboardAvoidanceMetrics() + } + applyFitScale('text-scale') + }) +} diff --git a/mobile/src/terminal/document/viewport-transform.ts b/mobile/src/terminal/document/viewport-transform.ts new file mode 100644 index 00000000000..347897e3caf --- /dev/null +++ b/mobile/src/terminal/document/viewport-transform.ts @@ -0,0 +1,143 @@ +import { terminalDefaultTheme } from './document-constants' +import { repositionOverlay, shouldRouteScrollToTerminalInput } from './document-externals' +import { scope } from './document-scope' +import { scrollIndicator, scrollThumb } from './text-scaling' + +declare global { + interface Window { + ReactNativeWebView?: { postMessage: (message: string) => void } + } +} + +scope.panX = 0 +scope.panY = 0 +scope.smoothScrollOffsetY = 0 +scope.pendingNormalScrollDeltaY = 0 +scope.normalScrollFrameId = null +scope.initRows = 24 +scope.terminalGeneration = 0 +scope.defaultTheme = terminalDefaultTheme +scope.terminalThemeInput = null +scope.terminalTheme = scope.defaultTheme +scope.terminalMinimumContrastRatio = 3 +scope.webglAddon = null +scope.webglRecoveryTimer = null +scope.activeAltScreenSnapshot = false +scope.trackedMouseTrackingMode = 'none' +scope.sgrMouseMode = false +scope.sgrMousePixelsMode = false +scope.initialOscLinks = [] +scope.initialOscLinkRowOffset = 0 +scope.initialOscLinkEvictionReady = false +scope.mouseModeScanTail = '' +scope.handledMessageIds = [] +// Why: after init() the initial scrollback applyFitScale may have run +// against an empty buffer (or one without the widest line yet). Re-fit +// once when the first live data chunk arrives so a wider line that pushes +// scrollWidth past the previously-measured value gets re-scaled to fit. +scope.firstDataPending = false + +// Diagnostic logger — bridges WebView console.log to RN via postMessage. +// Tag with [fit] so it's easy to filter in the Expo/Metro logs. +export function flog(tag: string, payload: Record) { + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'log', + tag: '[fit]' + tag, + payload: payload + }) + ) + } + } catch {} +} + +export function getCellWidth() { + if (!scope.term || !scope.term._core) { + return 0 + } + const core = scope.term._core + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.width || 0 + } + return 0 +} + +// Why: width measurement strategy. +// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses +// to lay out and is independent of buffer content. It's the "logical +// width" of the terminal grid. +// 2. Fall back to term.element.scrollWidth — the actual rendered DOM +// width — only when cellWidth isn't available yet (renderer not +// initialized). This is content-dependent (reflects widest row), +// but better than nothing. +// 3. If both are 0, return 1 (no scale change). The retry loop in +// applyFitScale will keep trying until one is positive. +export function computeFitScale() { + if (!scope.term) { + return 1 + } + const cellW = getCellWidth() + const termWidth = + cellW > 0 ? cellW * scope.term.cols : scope.term.element ? scope.term.element.scrollWidth : 0 + if (termWidth <= 0) { + return 1 + } + const vpWidth = window.innerWidth + return Math.min(1, vpWidth / termWidth) +} + +export function getTotalScale() { + return scope.currentScale * scope.userScale +} + +export function updateTransform() { + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the document shell ships the surface element. + scope.surface!.style.transform = + 'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale() + ')' + updateScrollIndicator(false) + if (scope.selMode === 'select') { + repositionOverlay() + } +} + +export function updateScrollIndicator(reveal: boolean) { + if ( + !scrollIndicator || + !scrollThumb || + !scope.term || + !scope.term.buffer || + !scope.term.buffer.active + ) { + return + } + const buffer = scope.term.buffer.active + const maxViewportY = buffer.baseY || 0 + if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { + scrollIndicator.classList.remove('visible') + return + } + const trackHeight = Math.max(0, window.innerHeight - 8) + const totalRows = maxViewportY + (scope.term.rows || 0) + if (trackHeight <= 0 || totalRows <= 0) { + return + } + const thumbHeight = Math.max(24, (trackHeight * (scope.term.rows || 0)) / totalRows) + const maxTop = Math.max(0, trackHeight - thumbHeight) + const top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0 + scrollThumb.style.height = thumbHeight + 'px' + scrollThumb.style.transform = 'translateY(' + top + 'px)' + if (!reveal) { + return + } + scrollIndicator.classList.add('visible') + if (scope.scrollIndicatorHideTimer) { + clearTimeout(scope.scrollIndicatorHideTimer) + } + scope.scrollIndicatorHideTimer = setTimeout(function () { + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the guard above returned when it was absent. + scrollIndicator!.classList.remove('visible') + scope.scrollIndicatorHideTimer = null + }, 550) +} diff --git a/mobile/src/terminal/document/webgl-recovery.ts b/mobile/src/terminal/document/webgl-recovery.ts index 1a5a67f5ed3..fd6ed70c582 100644 --- a/mobile/src/terminal/document/webgl-recovery.ts +++ b/mobile/src/terminal/document/webgl-recovery.ts @@ -1,4 +1,4 @@ -import { flog } from './document-externals' +import { flog } from './viewport-transform' import { applyTerminalTheme } from './terminal-theme' import { scope, type TerminalDocumentWebglAddon } from './document-scope' diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 161ed7e3230..4de56f73378 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,9 +1,6 @@ import { getCellHeight } from './fit-scale' -import { - getTotalScale, - routeScrollLines, - shouldRouteScrollToTerminalInput -} from './document-externals' +import { routeScrollLines, shouldRouteScrollToTerminalInput } from './document-externals' +import { getTotalScale } from './viewport-transform' import { dispatcherShouldBlockSurface } from './tap-dispatch' import { enqueueNormalBufferScrollDelta,