refactor(mobile): extract the terminal fit-scale slice into a module

The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.

Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 09:39:18 -04:00
parent 2bce0883be
commit 422940ef93
10 changed files with 195 additions and 20 deletions
@@ -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) {
@@ -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<string, unknown>): 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
@@ -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,
@@ -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
}
})
})
})
+146
View File
@@ -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()
}
@@ -1,7 +1,5 @@
import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
import {
adjustRowsForViewport,
applyFitScale,
clampPan,
handleMsg,
notify,
reportEngineError,
@@ -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. */
@@ -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) {
+2 -1
View File
@@ -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'
+1 -1
View File
@@ -1,5 +1,5 @@
import { getCellHeight } from './fit-scale'
import {
getCellHeight,
getTotalScale,
routeScrollLines,
shouldRouteScrollToTerminalInput