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,