diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts index 92d0bf960b2..f4591c8d5c1 100644 --- a/mobile/src/terminal/document/cell-geometry.ts +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -1,4 +1,5 @@ -import { getCellHeight, getCellWidth, getTotalScale } from './document-externals' +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './document-externals' import { scope } from './document-scope' export function cellToViewportPx(col: number, absRow: number) { diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index e9d66918a63..728b258e975 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -12,9 +12,6 @@ /** `smooth-scroll-and-cell-geometry`: one character cell's width in surface pixels. */ export declare function getCellWidth(): number -/** `smooth-scroll-and-cell-geometry`: one character cell's height in surface pixels. */ -export declare function getCellHeight(): number - /** `terminal-fit-scale`: the fit scale times the user's pinch scale. */ export declare function getTotalScale(): number @@ -24,9 +21,6 @@ export declare function notify(message: Record): void /** `terminal-init-and-write`: whether xterm is showing the alternate screen buffer. */ export declare function isAlternateBufferActive(): boolean -/** `terminal-fit-scale`: recomputes the fit scale and applies it, naming why. */ -export declare function applyFitScale(reason: string): void - /** `smooth-scroll-and-cell-geometry`: shows or hides the scroll indicator. */ export declare function updateScrollIndicator(visible: boolean): void @@ -84,11 +78,8 @@ 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 -/** `terminal-init-and-write`: re-fits the row count to the current viewport. */ -export declare function adjustRowsForViewport(): void - -/** `smooth-scroll-and-cell-geometry`: clamps the pan offsets to the scaled surface. */ -export declare function clampPan(): 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 diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index cc6b47e64d3..9c20f83c026 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -29,6 +29,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 } } } } _oscLinkService?: TerminalOscLinkService _inputHandler?: { _oscLinkService?: TerminalOscLinkService } } @@ -84,6 +85,7 @@ export type TerminalDocumentTerminal = { readonly buffer: { readonly active: TerminalDocumentBuffer } readonly options: TerminalDocumentTerminalOptions write: (data: string, callback?: () => void) => void + readonly element?: HTMLElement readonly _core?: TerminalDocumentCore readonly modes?: { bracketedPasteMode?: boolean } onLineFeed?: (listener: () => void) => TerminalDocumentDisposable @@ -143,6 +145,10 @@ export type TerminalDocumentScope = { sgrMouseMode: boolean /** `runtime-state`: whether the TUI asked for SGR pixel (1016) mouse reports. */ sgrMousePixelsMode: boolean + /** `terminal-fit-scale`: the fit scale the document committed. */ + currentScale: number + /** `runtime-state`: the pinch zoom the user applied on top of the fit scale. */ + userScale: number /** `runtime-state`: Claude's record dot, which iOS WebKit would otherwise promote to emoji. */ CLAUDE_STATUS_DOT: string /** `runtime-state`: the variation selector that forces the text glyph. */ @@ -298,6 +304,8 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { trackedMouseTrackingMode: 'none', sgrMouseMode: false, sgrMousePixelsMode: false, + currentScale: 1, + userScale: 1, CLAUDE_STATUS_DOT: statusDot, TEXT_PRESENTATION_SELECTOR: textPresentationSelector, EMOJI_PRESENTATION_SELECTOR: emojiPresentationSelector, diff --git a/mobile/src/terminal/document/fit-scale.test.ts b/mobile/src/terminal/document/fit-scale.test.ts new file mode 100644 index 00000000000..a9701972669 --- /dev/null +++ b/mobile/src/terminal/document/fit-scale.test.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_HTML_FIT_SCALE } from '../terminal-webview-html/terminal-fit-scale' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +/** The slice opens with the already-extracted theme group, so its text is two emissions joined. */ +const modulePaths = ['./terminal-theme.ts', './fit-scale.ts'].map((relative) => + fileURLToPath(new URL(relative, import.meta.url)) +) + +describe('the terminal fit-scale slice', () => { + it('emits the script the document carries, modulo the six normalisations', async () => { + const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n') + expect(compareTerminalDocumentScripts(TERMINAL_HTML_FIT_SCALE, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 47, + scopeFieldDeclarations: 0, + rebindings: 47, + bracedBodies: 20, + unboundCatches: 0, + numberProperties: 9, + shorthandProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts new file mode 100644 index 00000000000..3e5ea407bea --- /dev/null +++ b/mobile/src/terminal/document/fit-scale.ts @@ -0,0 +1,146 @@ +import { + computeFitScale, + flog, + getCellWidth, + getTotalScale, + repositionOverlay, + updateTransform +} from './document-externals' +import { scope } from './document-scope' + +export function getCellHeight() { + if (!scope.term || !scope.term._core) { + return 15 + } + const core = scope.term._core + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15 + } + return 15 +} + +// Why: clamp pan so the terminal content always covers the viewport +// when zoomed in. When content is smaller than viewport in a +// dimension, pin to top-left (no floating in the middle). +export function clampPan() { + if (!scope.term || !scope.term.element) { + return + } + const ts = getTotalScale() + const cw = scope.term.element.scrollWidth * ts + const ch = scope.term.element.scrollHeight * ts + const vpW = window.innerWidth + const vpH = window.innerHeight + if (cw > vpW) { + scope.panX = Math.min(0, Math.max(vpW - cw, scope.panX)) + } else { + scope.panX = 0 + } + if (ch > vpH) { + scope.panY = Math.min(0, Math.max(vpH - ch, scope.panY)) + } else { + scope.panY = 0 + } +} + +// Why: intentional no-op. Mobile replays a live PTY snapshot then applies +// live cursor-relative chunks from that same PTY; resizing only the WebView +// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or +// overlap. Kept as a no-op so its call sites stay legible. +export function adjustRowsForViewport() {} + +// Why: cold-start fit. After init() opens xterm, the renderer needs +// several frames before cell dimensions are computed. Reading too early +// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM +// not laid out), and computeFitScale returns 1 → no zoom. +// +// Gate: cellWidth × cols is the canonical "logical width" of the grid +// and reflects xterm's layout decision, independent of buffer content. +// We commit when cellWidth becomes positive (renderer ready). Fallback: +// if cellWidth never becomes available, gate on stable positive +// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) +// so a backgrounded WebView never spins forever. +const FIT_RETRY_MAX_FRAMES = 60 +let fitRetryToken = 0 +export function applyFitScale(reason: string) { + if (!scope.term || !scope.term.element) { + return + } + const token = ++fitRetryToken + let attempts = 0 + let lastScrollWidth = -1 + function attempt() { + if (token !== fitRetryToken) { + return + } + if (!scope.term || !scope.term.element) { + return + } + attempts++ + const cellW = getCellWidth() + if (cellW > 0 && scope.term.cols > 0) { + commitFitScale(reason, attempts, 'cellW') + return + } + const w = scope.term.element.scrollWidth + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW') + return + } + lastScrollWidth = w + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: scope.term.cols + }) + commitFitScale(reason, attempts, 'timeout') + return + } + requestAnimationFrame(attempt) + } + requestAnimationFrame(attempt) +} + +export function commitFitScale(reason: string, attempts: number, gate: string) { + if (!scope.term || !scope.term.element) { + return + } + const preSnapScale = computeFitScale() + scope.currentScale = preSnapScale + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (scope.currentScale >= 0.95) { + scope.currentScale = 1 + } + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + scope.smoothScrollOffsetY = 0 + updateTransform() + adjustRowsForViewport() + + const cellW = getCellWidth() + const sw = scope.term.element.scrollWidth + const vpW = window.innerWidth + const expectedW = cellW * scope.term.cols + const suspect = scope.currentScale === 1 && scope.term.cols > 0 && expectedW > vpW + 1 // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: scope.currentScale, + cellW: cellW, + cols: scope.term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }) + } + repositionOverlay() +} diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index df94439e32f..784ac56186e 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -1,7 +1,5 @@ +import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' import { - adjustRowsForViewport, - applyFitScale, - clampPan, handleMsg, notify, reportEngineError, diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts index 6e8218888a9..1c820bbcdda 100644 --- a/mobile/src/terminal/document/mouse-report-cell.ts +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -1,4 +1,5 @@ -import { getCellHeight, getCellWidth, getTotalScale } from './document-externals' +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './document-externals' 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 b692d22fc70..fb52e9d18e1 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,4 +1,5 @@ -import { getCellHeight, getTotalScale, updateScrollIndicator } from './document-externals' +import { getCellHeight } from './fit-scale' +import { getTotalScale, updateScrollIndicator } from './document-externals' 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 12cc07746ac..2bddff32966 100644 --- a/mobile/src/terminal/document/reflow.ts +++ b/mobile/src/terminal/document/reflow.ts @@ -1,4 +1,5 @@ -import { applyFitScale, isAlternateBufferActive, updateScrollIndicator } from './document-externals' +import { applyFitScale } from './fit-scale' +import { isAlternateBufferActive, updateScrollIndicator } from './document-externals' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { scope } from './document-scope' diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 8f58dced3f2..161ed7e3230 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,5 +1,5 @@ +import { getCellHeight } from './fit-scale' import { - getCellHeight, getTotalScale, routeScrollLines, shouldRouteScrollToTerminalInput