diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index bece528b51d..f7bc75f4515 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -384,3 +384,76 @@ export async function createBundleServer({ outDir, cspHeader, transformChunk }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { server, origin: `http://127.0.0.1:${String(server.address().port)}` } } + +/** + * A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror` + * that belongs to someone else. + * + * Reading `null` three times would pass on a terminal that assigned `null` over a real handler, + * which is the failure this seam exists to prevent. The sentinel is identity-checked in the page + * rather than marshalled out of it — a function does not survive `evaluate` — and it returns + * false so the browser still reports the error normally. + */ +export function installPageErrorSentinel() { + globalThis.__orcaSentinelCalls = [] + const sentinel = (message) => { + globalThis.__orcaSentinelCalls.push(String(message)) + return false + } + globalThis.__orcaSentinel = sentinel + window.onerror = sentinel +} + +/** + * Every animation frame and timer, tagged with the mount that scheduled it. + * + * Installed before the bundle loads, so the document's own scheduling goes through it. The test + * bumps `mount` at dispose; a callback that was scheduled under the previous number and still runs + * is a frame or timer of the first mount firing into the second, which is the whole finding. React + * schedules its work on the microtask queue rather than on frames, and xterm's frames belong to the + * terminal being disposed, so what this records is the document's. + */ +export function installSchedulerRecorder() { + globalThis.__orcaScheduler = { mount: 0, watching: false, pending: [], leaked: [] } + const state = globalThis.__orcaScheduler + const wrap = (schedule, kind) => + function (callback, ...rest) { + if (!state.watching || typeof callback !== 'function') { + return schedule(callback, ...rest) + } + // The line that called this, which is the script the work belongs to. Line 0 is the error's + // own header and line 1 is this wrapper. + const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim() + const entry = { kind, caller, mount: state.mount } + state.pending.push(entry) + return schedule( + (...args) => { + const at = state.pending.indexOf(entry) + if (at !== -1) { + state.pending.splice(at, 1) + } + if (entry.mount !== state.mount) { + state.leaked.push(`${kind} from ${caller}`) + } + return callback(...args) + }, + ...rest + ) + } + globalThis.requestAnimationFrame = wrap( + globalThis.requestAnimationFrame.bind(globalThis), + 'frame' + ) + globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer') + globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval') +} + +/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */ +export function installCspViolationRecorder() { + globalThis.__orcaCspViolations = [] + document.addEventListener('securitypolicyviolation', (event) => { + globalThis.__orcaCspViolations.push( + `${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}` + ) + }) +} diff --git a/config/scripts/mobile-web-app-terminal-render.test.mjs b/config/scripts/mobile-web-app-terminal-render.test.mjs index 5e925d1f37e..50ccab0bb18 100644 --- a/config/scripts/mobile-web-app-terminal-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-render.test.mjs @@ -9,6 +9,9 @@ import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { createBundleServer, + installCspViolationRecorder, + installPageErrorSentinel, + installSchedulerRecorder, installShellDouble, readBridgeFaultGrant, readBridgeProtocolVersion, @@ -172,35 +175,6 @@ export default function ProbeLayout() { } ` -/** - * A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror` - * that belongs to someone else. - * - * Reading `null` three times would pass on a terminal that assigned `null` over a real handler, - * which is the failure this seam exists to prevent. The sentinel is identity-checked in the page - * rather than marshalled out of it — a function does not survive `evaluate` — and it returns - * false so the browser still reports the error normally. - */ -function installPageErrorSentinel() { - globalThis.__orcaSentinelCalls = [] - const sentinel = (message) => { - globalThis.__orcaSentinelCalls.push(String(message)) - return false - } - globalThis.__orcaSentinel = sentinel - window.onerror = sentinel -} - -/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */ -function installCspViolationRecorder() { - globalThis.__orcaCspViolations = [] - document.addEventListener('securitypolicyviolation', (event) => { - globalThis.__orcaCspViolations.push( - `${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}` - ) - }) -} - const bundles = mobileWebAppDependenciesPresent() const describeRender = bundles ? describe : describe.skip @@ -256,9 +230,15 @@ afterAll(async () => { } }) -async function openPage(pathname, { errorSentinel = false, beforeNavigate } = {}) { +async function openPage( + pathname, + { errorSentinel = false, scheduler = false, beforeNavigate } = {} +) { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) await beforeNavigate?.(page) + if (scheduler) { + await page.addInitScript(installSchedulerRecorder) + } await page.addInitScript(installCspViolationRecorder) if (errorSentinel) { await page.addInitScript(installPageErrorSentinel) @@ -600,6 +580,115 @@ describeRender( await page.close() }, 300_000) + it('still reports runtime errors after a first mount spent the non-fatal budget', async () => { + // Ruling 21's finding, end to end. `reportEngineError` caps non-fatal notifies at five so a + // per-frame thrower cannot flood the host. That counter is the document's, not the mount's: + // a first terminal that spends it leaves the second one mute, reporting nothing however it + // fails, while every other signal — readiness, paint, selection — says the terminal is fine. + const { page } = await openTerminal() + await openProbeTerminal(page) + await page.evaluate(() => { + for (let index = 0; index < 6; index++) { + setTimeout(() => { + throw new Error(`orca-budget-burn-${String(index)}`) + }, 0) + } + }) + await page.waitForFunction( + () => + globalThis.__orcaTerminalEngineErrors.filter((entry) => + entry.includes('orca-budget-burn') + ).length >= 5, + { timeout: 30_000, polling: 100 } + ) + + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + + await page.evaluate(() => { + globalThis.__orcaTerminalEngineErrors = [] + setTimeout(() => { + throw new Error('orca-second-mount-error') + }, 0) + }) + await page.waitForFunction( + () => + globalThis.__orcaTerminalEngineErrors.some((entry) => + entry.includes('orca-second-mount-error') + ), + { timeout: 30_000, polling: 100 } + ) + await page.close() + }, 300_000) + + it('cancels its frames and timers, so none of the first mount runs into the second', async () => { + // The other half of the same rule. A frame or timer the first terminal scheduled has no + // owner after dispose, and on the second mount it acts on the terminal that replaced it — + // refitting a grid nobody resized, scrolling a buffer nobody touched. + // The document is its own chunk, and the point is what *it* scheduled: xterm's renderer + // schedules frames of its own that a disposed terminal simply ignores, and the browser + // cannot unschedule those. So the chunk is identified on the wire, by a literal only + // `host-notify` carries, and a leak is a callback that chunk scheduled. + let documentChunk = null + const { page } = await openPage(PROBE_ROUTE, { + scheduler: true, + beforeNavigate: async (opened) => { + await opened.route('**/*.js', async (route) => { + const response = await route.fetch() + const body = await response.text() + if (body.includes('terminal runtime error')) { + documentChunk = new URL(route.request().url()).pathname + } + await route.fulfill({ response, body }) + }) + } + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) + // One task, so the frame cannot run before the dispose that should take it back. A resize + // refits synchronously — the mount arms that listener itself — and the refit asks for a + // frame on the spot. React unmounts after this task, so at dispose the frame is owed. + await page.evaluate(() => { + globalThis.__orcaScheduler.watching = true + globalThis.dispatchEvent(new Event('resize')) + globalThis.__orcaScheduler.pendingAtDispose = globalThis.__orcaScheduler.pending.length + globalThis.__orcaScheduler.mount += 1 + globalThis.__orcaTerminalProbe.setMounted(false) + }) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + // Long enough for any frame or timer of the first mount to have fired if it survived. + await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000))) + const scheduler = await page.evaluate(() => globalThis.__orcaScheduler) + // The precondition: there was something to leak. A run where the refit asked for no frame + // would agree with the empty list below for the wrong reason. + expect(scheduler.pendingAtDispose).toBeGreaterThan(0) + expect(scheduler.leaked.filter((entry) => entry.includes(documentChunk))).toEqual([]) + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.close() + }, 300_000) + it('measures a fit through the handle and records what beforeinput reports', async () => { const { page } = await openTerminal() await openProbeTerminal(page) diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs index bf8572d05c7..fb095738eaa 100644 --- a/mobile/scripts/build-terminal-document-script.mjs +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -5,8 +5,10 @@ import { importTypeScriptModule } from './import-typescript-module.mjs' import { TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_RESET_CALL, TERMINAL_DOCUMENT_SCOPE_MODULE, - terminalDocumentStartFunctionName + terminalDocumentStartFunctionName, + terminalDocumentStopFunctionName } from './terminal-document-module-order.mjs' /** @@ -169,15 +171,24 @@ export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join( * how every module in this directory writes it. */ export async function terminalDocumentStartCalls(moduleNames) { - const calls = [] + return await declaredFunctions(moduleNames, terminalDocumentStartFunctionName) +} + +/** The stop functions, in module order. The page runs them in reverse; the WebView never stops. */ +export async function terminalDocumentStopCalls(moduleNames) { + return await declaredFunctions(moduleNames, terminalDocumentStopFunctionName) +} + +async function declaredFunctions(moduleNames, nameFor) { + const found = [] for (const name of moduleNames) { const source = await readFile(path.join(documentDirectory, `${name}.ts`), 'utf8') - const startName = terminalDocumentStartFunctionName(name) - if (new RegExp(`^export function ${startName}\\(\\) \\{$`, 'm').test(source)) { - calls.push(startName) + const declared = nameFor(name) + if (new RegExp(`^export function ${declared}\\(\\) \\{$`, 'm').test(source)) { + found.push(declared) } } - return calls + return found } /** @@ -198,10 +209,12 @@ export async function buildTerminalDocumentScript() { for (const name of order) { emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`))) } - // Ruling 20: the modules above only declare. Every element read, listener and reporter install - // they used to do at parse time is in a start function, and this is where they run — once here, - // per mount on the page, in the one order both hosts share. - const calls = (await terminalDocumentStartCalls(order)).map((name) => `${INDENT}${name}();`) + // Rulings 20 and 21: the modules above only declare. The scope's reset comes first, so the + // state every module reads is the state a fresh parse has; then every element read, listener + // and reporter install runs, once here and per mount on the page, in the order both hosts share. + const calls = [TERMINAL_DOCUMENT_RESET_CALL, ...(await terminalDocumentStartCalls(order))].map( + (name) => `${INDENT}${name}();` + ) return `(function() {\n${emitted.join('\n')}\n${calls.join('\n')}\n})();` } diff --git a/mobile/scripts/terminal-document-module-order.mjs b/mobile/scripts/terminal-document-module-order.mjs index 20b447d5175..207f36371b0 100644 --- a/mobile/scripts/terminal-document-module-order.mjs +++ b/mobile/scripts/terminal-document-module-order.mjs @@ -16,7 +16,6 @@ export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope' export const TERMINAL_DOCUMENT_MODULE_ORDER = [ 'runtime-constants', - 'terminal-handle', 'query-reply', 'surface-swap', 'text-scaling', @@ -70,3 +69,16 @@ export function terminalDocumentStartFunctionName(moduleName) { .join('') ) } + +/** The per-module stop function's name, by the same convention (ruling 21). */ +export function terminalDocumentStopFunctionName(moduleName) { + return terminalDocumentStartFunctionName(moduleName).replace(/^start/, 'stop') +} + +/** + * The scope's reset, called ahead of every start (ruling 21). + * + * Module top level holds no mutable state, so a second mount's state comes from here and nowhere + * else. The WebView runs it once at parse, where it restores what the factory just built. + */ +export const TERMINAL_DOCUMENT_RESET_CALL = 'resetTerminalDocumentScope' diff --git a/mobile/src/terminal/document/document-module-order.test.ts b/mobile/src/terminal/document/document-module-order.test.ts index f92f05ceb14..921e6e7aa0b 100644 --- a/mobile/src/terminal/document/document-module-order.test.ts +++ b/mobile/src/terminal/document/document-module-order.test.ts @@ -1,7 +1,10 @@ import { readdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { + buildTerminalDocumentScript, + emitTerminalDocumentModule +} from '../../../scripts/build-terminal-document-script.mjs' import { TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, TERMINAL_DOCUMENT_MODULE_ORDER, @@ -71,11 +74,25 @@ describe('the document module order', () => { expect(emitted).toBe('') }) - it('emits the host seams ahead of the scope, whose defaults are those five functions', () => { - // Order, not just membership: `createTerminalDocumentScope()` runs as the script is parsed and - // reads the five by name, so a seams module emitted after it would throw on the first line of - // the document. The generator's own list is asserted in its test; this is the reason. + it('emits the host seams ahead of the scope, whose defaults are those five functions', async () => { + // Order in the emitted document, not membership in a list: `createTerminalDocumentScope()` + // runs as the script is parsed and reads the five by name, so a seams module emitted after it + // would throw on the document's first line. Non-membership cannot see that — it is satisfied + // by any arrangement — so the two texts are located in the document the generator produces. expect(TERMINAL_DOCUMENT_MODULE_ORDER).not.toContain(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE) expect(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE).not.toBe(TERMINAL_DOCUMENT_SCOPE_MODULE) + + const script = await buildTerminalDocumentScript() + const emittedAt = async (name: string) => { + const text = await emitTerminalDocumentModule( + fileURLToPath(new URL(`./${name}.ts`, import.meta.url)) + ) + const at = script.indexOf(text) + expect(at, `${name} is not in the emitted document`).toBeGreaterThanOrEqual(0) + return at + } + expect(await emittedAt(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)).toBeLessThan( + await emittedAt(TERMINAL_DOCUMENT_SCOPE_MODULE) + ) }) }) diff --git a/mobile/src/terminal/document/document-parse-time-effects.test.ts b/mobile/src/terminal/document/document-parse-time-effects.test.ts index 7ecc43c497a..0078f9b5793 100644 --- a/mobile/src/terminal/document/document-parse-time-effects.test.ts +++ b/mobile/src/terminal/document/document-parse-time-effects.test.ts @@ -8,7 +8,7 @@ import { } from '../../../scripts/terminal-document-module-order.mjs' /** - * Ruling 20: no module in the document does work as it is parsed. + * Rulings 20 and 21: no module in the document does work as it is parsed, and none owns state. * * ES module bodies run once per page. Inside the WebView that was invisible — the script is * parsed once per document and the document is the page — but the web component mounts these same @@ -20,6 +20,12 @@ import { * emitted module may declare; none may run. What used to run lives in that module's start * function, which both hosts call — the generated script once at the foot of the document, the * page once per mount. + * + * Ruling 21 is the same argument about state rather than about effects. A module-level `let` + * survives a mount just as a module body does, so the second terminal inherited a spent error + * budget, the first terminal's committed surface and its momentum loop. Every mutable binding + * therefore lives on the scope, which the start sequence resets first, and module top level holds + * constants, functions and types only. */ const EMITTED = [ TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, @@ -125,6 +131,25 @@ function readsTheDocument(node: unknown): boolean { return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value)) } +/** Every top-level `let` or `var`: state the module owns, which a second mount would inherit. */ +function mutableBindingsIn(name: string, source: string): string[] { + const { program } = parseSync(`${name}.ts`, source, { lang: 'ts' }) + const found: string[] = [] + for (const statement of program.body) { + const declaration = + statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement + if (declaration.type !== 'VariableDeclaration' || declaration.kind === 'const') { + continue + } + found.push(`${name}: ${source.slice(declaration.start, declaration.end).split('\n')[0]}`) + } + return found +} + +function mutableBindings(name: string): string[] { + return mutableBindingsIn(name, moduleSource(name)) +} + function parseTimeEffects(name: string): string[] { const source = moduleSource(name) const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' }) @@ -172,6 +197,17 @@ describe('the document modules at parse time', () => { expect(topLevel.some(readsTheDocument)).toBe(false) }) + it('own no mutable state: no top-level let or var outside the scope', () => { + expect(EMITTED.filter((name) => name !== BUILDS_THE_SCOPE).flatMap(mutableBindings)).toEqual([]) + }) + + it('would report a planted one, so the empty list above is a measurement', () => { + // The precondition for the case above, run against the same reader: a module body with a + // `let` in it is the exact shape the rule refuses, and the reader has to say so. + const planted = `import { scope } from './document-scope'\nlet spent = 0\nexport function n() {\n spent++\n return scope.term\n}\n` + expect(mutableBindingsIn('planted', planted)).toEqual(['planted: let spent = 0']) + }) + it('would report one, so the empty list above is a measurement', () => { // The precondition. A walk that matched nothing would agree with an empty expectation just as // happily, so the same reader is aimed at a module that does have a top-level effect: this @@ -185,13 +221,16 @@ describe('the document modules at parse time', () => { expect(running.length).toBeGreaterThan(0) }) - it('still start: every module that had an effect exports the function holding it', () => { + it('still start and stop: the functions holding what was moved out are exported', () => { // The other half. Moving an effect out is only correct if something calls it, and the caller // is pinned by `page-document-module-order.test.ts` against the generator's own sequence; - // this holds the shape of the name so that sequence can be derived rather than listed. - const withStart = EMITTED.filter((name) => - /^export function start[A-Za-z]+\(\) \{$/m.test(moduleSource(name)) - ) - expect(withStart.length).toBe(14) + // this holds the shape of the names so that sequence can be derived rather than listed. + const declaring = (keyword: string) => + EMITTED.filter((name) => + new RegExp(`^export function ${keyword}[A-Za-z]+\\(\\) \\{$`, 'm').test(moduleSource(name)) + ) + expect(declaring('start').length).toBe(10) + // Ruling 21: a module that schedules a frame, a timer or a retry owes an undo for it. + expect(declaring('stop').length).toBe(9) }) }) diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 5f34c1c2421..22d8ab324a8 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -14,6 +14,9 @@ import type { TerminalDocumentWebglAddon, TerminalInitialOscLink } from './document-terminal-shape' +import type { TerminalMouseGesture } from './mouse-click-drag' +import type { TerminalTouchState } from './surface-touch-gestures' +import type { TerminalTouchDispatch } from './tap-dispatch' import type { TerminalDocumentThemeMessage } from './terminal-theme' // Re-exported so every module that reads the scope keeps naming one import for both: the split is @@ -42,8 +45,8 @@ export type * from './document-terminal-shape' * The table grows one group at a time as C7.1 extracts them; a field arrives with its group. */ -export type TerminalDocumentScope = { - /** `terminal-handle`: the live xterm terminal, or null before the first init. */ +export type TerminalDocumentState = { + /** `terminal-init`: the live xterm terminal, or null before the first init. */ term: TerminalDocumentTerminal | null /** `viewport-transform`: the surface's pan offset, in viewport pixels. */ panX: number @@ -188,6 +191,40 @@ export type TerminalDocumentScope = { surface: HTMLElement | null /** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */ pendingTerm: TerminalDocumentTerminal | null + /** `surface-swap`: the terminal the committed surface is showing. */ + committedTerm: TerminalDocumentTerminal | null + /** `surface-swap`: the surface the committed terminal is mounted on. */ + committedSurface: HTMLElement | null + /** `surface-swap`: the hidden replacement surface, until it commits. */ + pendingSurface: HTMLElement | null + /** `text-scaling`: the scroll indicator's track and its thumb. */ + scrollIndicator: HTMLElement | null + scrollThumb: HTMLElement | null + /** `query-reply`: whether the host asked for terminal data replies. */ + terminalDataRepliesEnabled: boolean + /** `selection-state-and-eviction`: rows written since the terminal opened. */ + linesEverWritten: number + /** `host-notify`: non-fatal reports already sent, against the flood cap. */ + nonFatalErrorNotifies: number + /** `host-notify`: undoes the host's reporter install, or null before one. */ + uninstallErrorReporter: (() => void) | null + /** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */ + fitRetryToken: number + /** `mouse-click-drag`: the mouse gesture in progress, or null. */ + mouseGesture: TerminalMouseGesture | null + /** `tap-dispatch`: what the document-level dispatcher has latched onto. */ + touchDispatch: TerminalTouchDispatch + /** `surface-touch-gestures`: the surface touch, its velocity and its momentum frame. */ + touchGesture: TerminalTouchState + /** Every animation frame the document has asked for and not yet run. */ + scheduledFrames: number[] +} + +/** + * The five host seams, kept out of the state above because they are the one thing a reset must + * not touch: the page sets them once per mount, before the start sequence runs. + */ +export type TerminalDocumentHostSeams = { /** `host-notify`, `viewport-transform`: where a message for the host goes. */ postToHost: (message: Record) => void /** `terminal-init`: builds the xterm terminal. */ @@ -200,6 +237,9 @@ export type TerminalDocumentScope = { installErrorReporter: (report: TerminalDocumentErrorReporter) => () => void } +/** The document's whole scope: its state, and the seams to whatever is hosting it. */ +export type TerminalDocumentScope = TerminalDocumentState & TerminalDocumentHostSeams + /** The live selection; only the dragged handle is read outside the overlay slice. */ export type TerminalDocumentSelection = { anchor: { row: number; col: number } @@ -236,7 +276,7 @@ const statusDot = String.fromCharCode(0x23fa) const textPresentationSelector = String.fromCharCode(0xfe0e) const emojiPresentationSelector = String.fromCharCode(0xfe0f) -export function createTerminalDocumentScope(): TerminalDocumentScope { +function createTerminalDocumentState(): TerminalDocumentState { return { term: null, panX: 0, @@ -273,7 +313,7 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { handledMessageIds: [], currentTextScale: 1, terminalFontFamily: '', - firstDataPending: true, + firstDataPending: false, activeAltScreenSnapshot: false, currentScale: 1, userScale: 1, @@ -320,6 +360,43 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { wheelAccumDeltaY: 0, surface: null, pendingTerm: null, + committedTerm: null, + committedSurface: null, + pendingSurface: null, + scrollIndicator: null, + scrollThumb: null, + terminalDataRepliesEnabled: false, + linesEverWritten: 0, + nonFatalErrorNotifies: 0, + uninstallErrorReporter: null, + fitRetryToken: 0, + mouseGesture: null, + touchDispatch: { + mode: 'idle', + touchId: null, + touchIds: null, + longPressFingerInsideOverlay: false + }, + scheduledFrames: [], + touchGesture: { + lastX: 0, + lastY: 0, + lastTime: 0, + velY: 0, + accumDelta: 0, + momentumId: null, + isPinching: false, + pinchDist: 0, + pinchScale: 0, + pinchSurfX: 0, + pinchSurfY: 0 + } + } +} + +/** The seams' defaults: the window reads and writes the document already did. */ +function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams { + return { postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, @@ -328,5 +405,59 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { } } +export function createTerminalDocumentScope(): TerminalDocumentScope { + return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() } +} + +/** + * The scope back at the state a freshly parsed document has (ruling 21). + * + * The page mounts these modules more than once and an ES module body runs once per page, so this + * is what makes a second mount a second document: the start sequence calls it first, and on the + * WebView it runs once at parse, where it changes nothing. The seams are left alone — the page + * sets them before the sequence runs, and they belong to the host rather than to the terminal. + * + * Two counters carry forward instead of resetting, because they are what a stale callback is + * tested against: a frame scheduled by the mount that just went away compares its captured number + * with the one here, and a reset to zero would make the old number match again. + */ +export function resetTerminalDocumentScope() { + const generations = { + terminalGeneration: scope.terminalGeneration + 1, + fitRetryToken: scope.fitRetryToken + 1 + } + Object.assign(scope, createTerminalDocumentState(), generations) +} + /** The document's own scope. The generator emits this declaration at the top of the script. */ export const scope: TerminalDocumentScope = createTerminalDocumentScope() + +/** + * An animation frame the document can take back (ruling 21). + * + * A generation guard makes a stale frame *do* nothing; it still runs, and inside a WebView that + * is the same thing. On the page it is not: the mount that scheduled the frame may be gone and + * the next one already up, and a callback that reads the scope reads the new mount's. Every frame + * the document asks for is registered here so `cancelDocumentFrames` can take the pending ones + * back, which is what the page's dispose does. The id is dropped as the frame runs, so the list + * holds only what is still owed. + */ +export function scheduleDocumentFrame(callback: FrameRequestCallback) { + const id = requestAnimationFrame(function (time) { + const at = scope.scheduledFrames.indexOf(id) + if (at !== -1) { + scope.scheduledFrames.splice(at, 1) + } + callback(time) + }) + scope.scheduledFrames.push(id) + return id +} + +/** Takes back every frame the document is still owed. */ +export function cancelDocumentFrames() { + for (const id of scope.scheduledFrames) { + cancelAnimationFrame(id) + } + scope.scheduledFrames = [] +} diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts index 9f9138ed88a..0f264c69d7e 100644 --- a/mobile/src/terminal/document/fit-scale.ts +++ b/mobile/src/terminal/document/fit-scale.ts @@ -6,7 +6,7 @@ import { getTotalScale, updateTransform } from './viewport-transform' -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' export function getCellHeight() { if (!scope.term || !scope.term._core) { @@ -61,16 +61,15 @@ export function adjustRowsForViewport() {} // 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 + const token = ++scope.fitRetryToken let attempts = 0 let lastScrollWidth = -1 function attempt() { - if (token !== fitRetryToken) { + if (token !== scope.fitRetryToken) { return } if (!scope.term || !scope.term.element) { @@ -99,9 +98,9 @@ export function applyFitScale(reason: string) { commitFitScale(reason, attempts, 'timeout') return } - requestAnimationFrame(attempt) + scheduleDocumentFrame(attempt) } - requestAnimationFrame(attempt) + scheduleDocumentFrame(attempt) } export function commitFitScale(reason: string, attempts: number, gate: string) { @@ -144,3 +143,11 @@ export function commitFitScale(reason: string, attempts: number, gate: string) { } repositionOverlay() } + +/** + * Ruling 21: the retry loop is abandoned by bumping the token it compares itself against, which is + * how it already abandons a superseded attempt. + */ +export function stopFitScale() { + scope.fitRetryToken++ +} diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index b03bca2cead..d570a72a84e 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -1,4 +1,4 @@ -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' import { applyFitScale } from './fit-scale' import { notify } from './host-notify' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' @@ -49,7 +49,12 @@ export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: n } if (notReady || cellWidth <= 0 || cellHeight <= 0) { if (retriesLeft > 0) { - requestAnimationFrame(function () { + // Ruling 21: a retry that outlives its mount would answer the next mount's measure. + const gen = scope.terminalGeneration + scheduleDocumentFrame(function () { + if (gen !== scope.terminalGeneration) { + return + } measureFitDimensions(containerHeightPx, retriesLeft - 1) }) return diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts index 0701a6786b5..853c7a0cadf 100644 --- a/mobile/src/terminal/document/host-notify.ts +++ b/mobile/src/terminal/document/host-notify.ts @@ -44,15 +44,13 @@ export function chromeVersionText() { return match ? 'Chrome ' + match[1] : 'Chrome version unknown' } -let nonFatalErrorNotifies = 0 - export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) { const isFatal = fatal === undefined ? !scope.everReady : !!fatal if (!isFatal) { // Why: a constructed-but-degraded engine can throw per frame; cap // non-fatal notifies so RN isn't flooded. Fatal reports always emit. - nonFatalErrorNotifies++ - if (nonFatalErrorNotifies > 5) { + scope.nonFatalErrorNotifies++ + if (scope.nonFatalErrorNotifies > 5) { return } } @@ -72,10 +70,8 @@ export function reportEngineError(context: string, err: TerminalEngineError, fat }) } -let uninstallErrorReporter: (() => void) | null = null - export function startHostNotify() { - uninstallErrorReporter = scope.installErrorReporter(function ( + scope.uninstallErrorReporter = scope.installErrorReporter(function ( msg: string | (Event & { message?: unknown }), source, line, @@ -90,8 +86,8 @@ export function startHostNotify() { } export function stopHostNotify() { - if (uninstallErrorReporter) { - uninstallErrorReporter() - uninstallErrorReporter = null + if (scope.uninstallErrorReporter) { + scope.uninstallErrorReporter() + scope.uninstallErrorReporter = null } } diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts index 021c9260350..fde043316ea 100644 --- a/mobile/src/terminal/document/mode-mirroring.ts +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -37,13 +37,3 @@ export function emitModesIfChanged() { }) } } - -export function startModeMirroring() { - scope.lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - } -} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index aed11e99f5f..611bce805e3 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -20,8 +20,6 @@ export type TerminalMouseGesture = { dismissedSelection: boolean } -let mouseGesture: TerminalMouseGesture | null = null - // One report per transition, built with the same encoding ladder as // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' // when the mode does not report this transition (x10 has no release, only @@ -81,8 +79,8 @@ export function mouseReportCellKey(clientX: number, clientY: number) { } export function abandonMouseGesture() { - const gesture = mouseGesture - mouseGesture = null + const gesture = scope.mouseGesture + scope.mouseGesture = null if (!gesture) { return } @@ -141,7 +139,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { } // Why: a pointerup lost outside the WebView must not leave the previous // gesture latched (tracking press with no release) when the next one lands. - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture() } // Why: mouse pointers have no implicit capture; without it a drag that @@ -151,7 +149,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.setPointerCapture(e.pointerId) } } catch {} - mouseGesture = { + scope.mouseGesture = { startX: e.clientX, startY: e.clientY, lastX: e.clientX, @@ -165,7 +163,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { // Why: touch parity — pressing outside the pill dismisses the current // selection; the same press may still start a new drag selection. cancelSelect() - mouseGesture.dismissedSelection = true + scope.mouseGesture.dismissedSelection = true } }, true @@ -174,7 +172,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'pointermove', function (e) { - const gesture = mouseGesture + const gesture = scope.mouseGesture if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') { return } @@ -220,11 +218,11 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'pointerup', function (e) { - const gesture = mouseGesture + const gesture = scope.mouseGesture if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) { return } - mouseGesture = null + scope.mouseGesture = null if (gesture.mode === 'cancelled' || !scope.term) { return } @@ -274,7 +272,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'touchstart', function () { - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture() } }, diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index e1ee6dd63e3..6d737fe1581 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,6 +1,6 @@ import { getCellHeight } from './fit-scale' import { getTotalScale, updateScrollIndicator } from './viewport-transform' -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' export function clampNormalScrollLines(lines: number) { if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) { @@ -77,7 +77,7 @@ export function enqueueNormalBufferScrollDelta(deltaY: number) { // Why: dense terminal rows are expensive to repaint. Coalesce touchmove // deltas into one xterm row-scroll per frame instead of repainting from // the input event stream. - scope.normalScrollFrameId = requestAnimationFrame(function () { + scope.normalScrollFrameId = scheduleDocumentFrame(function () { scope.normalScrollFrameId = null const delta = scope.pendingNormalScrollDeltaY scope.pendingNormalScrollDeltaY = 0 @@ -100,3 +100,8 @@ export function resetSmoothScrollOffset() { scope.smoothScrollOffsetY = 0 updateScrollIndicator(false) } + +/** Ruling 21: the smooth-scroll frame, which would otherwise scroll the next mount's buffer. */ +export function stopNormalBufferSmoothScroll() { + resetSmoothScrollOffset() +} diff --git a/mobile/src/terminal/document/page-document-modules.ts b/mobile/src/terminal/document/page-document-modules.ts index eb4093686de..ad27a11cbdd 100644 --- a/mobile/src/terminal/document/page-document-modules.ts +++ b/mobile/src/terminal/document/page-document-modules.ts @@ -1,93 +1,96 @@ /** - * The document's modules, and the one sequence that starts them. + * The document's modules, and the two sequences that start and stop them. * * The document is one function scope, not a dependency graph: `runtime-constants` takes the * surface, `surface-swap` captures the surface it was handed, and `selection-state-and-eviction` - * takes the overlay elements. Ruling 20 moved each of those out of the module body and into a - * start function, so importing this file does nothing on its own; the order below is still the - * order, because the start calls follow it and the WebView's generated script emits the same - * sequence at the foot of the document. + * takes the overlay elements. Ruling 20 moved those out of the module bodies into start + * functions; ruling 21 moved every mutable binding onto the scope, so what is left at module top + * level is constants, functions and types. Importing this file therefore does nothing at all. * * That is what makes a second mount a second terminal. ES module bodies run once per page, so a - * remount re-imports nothing; it calls `startPageDocumentModules` again, and every element read - * and listener install happens against the markup the host has just replanted. + * remount re-imports nothing: it resets the scope, then runs the same start sequence the WebView's + * generated script runs once at parse, against the markup the host has just replanted. * * `message-bridge` is deliberately absent (ruling 19). It installs `window`/`document` `message` * listeners, and on the page those frames belong to the shell: the document would read a bridge * envelope as a terminal command. The component calls `handleMsg` instead, and re-arms the one * other thing that module does, the window-resize refit. * - * `page-document-module-order.test.ts` holds this list against + * `page-document-module-order.test.ts` holds these lists against * `scripts/terminal-document-module-order.mjs`, so the page and the WebView cannot run different * programs and a reordering edit cannot pass unread. */ import './document-host-seams' -import './document-scope' +import { cancelDocumentFrames, resetTerminalDocumentScope } from './document-scope' import { startRuntimeConstants } from './runtime-constants' -import { startTerminalHandle } from './terminal-handle' import './query-reply' import { startSurfaceSwap } from './surface-swap' import { startTextScaling } from './text-scaling' -import { startViewportTransform } from './viewport-transform' +import { stopViewportTransform } from './viewport-transform' import './terminal-theme' -import './fit-scale' +import { stopFitScale } from './fit-scale' import './mouse-mode-decset-scan' import './write-queue' import { startWebglRecovery, stopWebglRecovery } from './webgl-recovery' -import './terminal-init' +import { stopTerminalInit } from './terminal-init' import './reflow' import { startHostNotify, stopHostNotify } from './host-notify' import './host-message-router' import { startSelectionStateAndEviction } from './selection-state-and-eviction' -import { startModeMirroring } from './mode-mirroring' +import './mode-mirroring' import './keyboard-avoidance-metrics' import './term-observers' import './viewport-cell' import './mouse-report-cell' import './mouse-input-encoding' -import './normal-buffer-smooth-scroll' +import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll' import './cell-geometry' import './path-tap' import './url-tap' import './osc-link-tap' import './surface-tap' import './selection-range' -import './selection-overlay' +import { stopSelectionOverlay } from './selection-overlay' import { startTapDispatch, stopTapDispatch } from './tap-dispatch' -import { startWheelScroll } from './wheel-scroll' +import './wheel-scroll' import './mouse-click-drag' import { startSelectionMenuButtons } from './selection-menu-buttons' -import { startSurfaceTouchGestures } from './surface-touch-gestures' +import { startSurfaceTouchGestures, stopSurfaceTouchGestures } from './surface-touch-gestures' /** - * Every module's start function, in module order: what the WebView's document runs once as its - * script is parsed, run here once per mount. + * The scope's reset and every module's start function, in module order: what the WebView's + * document runs once as its script is parsed, run here once per mount. */ export function startPageDocumentModules() { + resetTerminalDocumentScope() startRuntimeConstants() - startTerminalHandle() startSurfaceSwap() startTextScaling() - startViewportTransform() startWebglRecovery() startHostNotify() startSelectionStateAndEviction() - startModeMirroring() startTapDispatch() - startWheelScroll() startSelectionMenuButtons() startSurfaceTouchGestures() } /** - * The undo, in reverse. Only the three modules that reach past the host element need one: every - * other listener is on the surface or the menu buttons, which the host replaces wholesale, and - * every other start writes a scope field the next start overwrites. + * The undo, in reverse module order: every listener that outlives the host element, and every + * frame, timer and retry a module scheduled (ruling 21). */ export function stopPageDocumentModules() { + // Last frames first: a module's own stop nulls the handle it holds, and this takes back every + // frame the document is still owed, including the ones no module tracks by id. + cancelDocumentFrames() + stopSurfaceTouchGestures() stopTapDispatch() + stopSelectionOverlay() + stopNormalBufferSmoothScroll() stopHostNotify() + stopTerminalInit() stopWebglRecovery() + stopFitScale() + stopViewportTransform() } export { scope } from './document-scope' diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts index 70509735b2c..ac9001d5199 100644 --- a/mobile/src/terminal/document/query-reply.ts +++ b/mobile/src/terminal/document/query-reply.ts @@ -18,20 +18,16 @@ export type QueryReplyTerminal = { onData: (listener: (data: string) => void) => TerminalDocumentDisposable } -// Written from four places, all of them here, so it is this module's state rather than the -// document's and stays a local. -let terminalDataRepliesEnabled = false - export function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false + scope.terminalDataRepliesEnabled = false } export function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true + scope.terminalDataRepliesEnabled = true } export function forwardTerminalDataReply(data: string) { - if (terminalDataRepliesEnabled) { + if (scope.terminalDataRepliesEnabled) { notify({ type: 'terminal-data', bytes: data }) } } @@ -39,7 +35,7 @@ export function forwardTerminalDataReply(data: string) { export function enqueueTerminalDataReplyBoundary(gen: number) { enqueueWriteBoundary(function () { if (gen === scope.terminalGeneration) { - terminalDataRepliesEnabled = true + scope.terminalDataRepliesEnabled = true } }) } diff --git a/mobile/src/terminal/document/runtime-constants.ts b/mobile/src/terminal/document/runtime-constants.ts index 71d31dd1d9c..c28079779f2 100644 --- a/mobile/src/terminal/document/runtime-constants.ts +++ b/mobile/src/terminal/document/runtime-constants.ts @@ -9,18 +9,4 @@ import { scope } from './document-scope' export function startRuntimeConstants() { scope.surface = document.getElementById('terminal-surface') - scope.ESC = String.fromCharCode(27) - scope.C1_CSI = String.fromCharCode(155) - scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa) - scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) - scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) - scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp( - scope.CLAUDE_STATUS_DOT + - '[' + - scope.TEXT_PRESENTATION_SELECTOR + - scope.EMOJI_PRESENTATION_SELECTOR + - ']*', - 'g' - ) - scope.statusDotPendingSelector = false } diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts index 24e0edcecbe..d179535c49d 100644 --- a/mobile/src/terminal/document/selection-overlay.ts +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -139,3 +139,8 @@ export function handleDragMove(handle: string, clientX: number, clientY: number) } // Latching document-level touch dispatcher: see tap-dispatch.ts. + +/** Ruling 21: the edge-scroll interval, which outlives the selection that started it. */ +export function stopSelectionOverlay() { + stopEdgeScroll() +} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts index 5b9a17a9ae9..c42cd99cca1 100644 --- a/mobile/src/terminal/document/selection-state-and-eviction.ts +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -23,20 +23,19 @@ import { scope } from './document-scope' // the tap (which opens links/paths). {x,y,t,identifier} or null once the // gesture is disqualified as a tap (moved too far or held too long). -// Eviction watchdog: linesEverWritten counts onLineFeed since last init. +// Eviction watchdog: linesEverWritten counts onLineFeed since the last init. // Once buffer is full, every onLineFeed evicts the top row in xterm and // we mirror that by decrementing stored absolute rows. -let linesEverWritten = 0 export function resetEvictionCounter() { - linesEverWritten = 0 + scope.linesEverWritten = 0 } export function isBufferFull() { if (!scope.term) { return false } - return linesEverWritten >= 5000 + (scope.term.rows || 0) + return scope.linesEverWritten >= 5000 + (scope.term.rows || 0) } export function checkEviction() { @@ -51,7 +50,7 @@ export function checkEviction() { } export function logFeedAndEvict() { - linesEverWritten++ + scope.linesEverWritten++ if (scope.initialOscLinkEvictionReady && isBufferFull()) { scope.initialOscLinkRowOffset += 1 } @@ -64,26 +63,10 @@ export function logFeedAndEvict() { } export function startSelectionStateAndEviction() { - scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u - scope.LONG_PRESS_MS = 500 - scope.LONG_PRESS_SLOP = 10 - scope.TAP_SLOP = 24 - scope.TAP_MAX_MS = 700 - scope.EDGE_SCROLL_PX = 40 - scope.EDGE_SCROLL_INTERVAL = 60 scope.selectionOverlay = document.getElementById('selection-overlay') scope.handleStart = document.getElementById('sel-handle-start') scope.handleEnd = document.getElementById('sel-handle-end') scope.selMenu = document.getElementById('sel-menu') scope.btnCopy = document.getElementById('sel-menu-copy') scope.btnSelAll = document.getElementById('sel-menu-all') - scope.selMode = 'navigate' - scope.sel = null - scope.longPressTimer = null - scope.longPressOrigin = null - scope.tapCandidate = null - scope.edgeScrollTimer = null - scope.edgeScrollDir = 0 - scope.edgeScrollClientX = 0 - scope.edgeScrollClientY = 0 } diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts index f80fd4d0651..3f942694c30 100644 --- a/mobile/src/terminal/document/surface-swap.ts +++ b/mobile/src/terminal/document/surface-swap.ts @@ -9,31 +9,24 @@ export type TerminalSurfaceSwap = { nextSurface: HTMLElement } -// Why: phone-fit startup can issue several init() calls before xterm finishes -// replaying. Track the last painted surface separately from its replacement. -let committedTerm: TerminalDocumentTerminal | null = null -let committedSurface: HTMLElement | null = null - -let pendingSurface: HTMLElement | null = null - export function beginTerminalSurfaceSwap() { // Why: a superseded hidden replacement must not remain between the last // painted surface and the newest one, or the newest commits below the viewport. - if (pendingSurface) { + if (scope.pendingSurface) { try { - pendingSurface.remove() + scope.pendingSurface.remove() } catch {} if (scope.pendingTerm) { try { scope.pendingTerm.dispose() } catch {} } - pendingSurface = null + scope.pendingSurface = null scope.pendingTerm = null } const swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, + oldTerm: scope.committedTerm, + oldSurface: scope.committedSurface, nextSurface: document.createElement('div') } disposeTermObservers() @@ -44,7 +37,7 @@ export function beginTerminalSurfaceSwap() { swap.nextSurface.style.top = '0' document.getElementById('terminal-container')!.appendChild(swap.nextSurface) scope.surface = swap.nextSurface - pendingSurface = swap.nextSurface + scope.pendingSurface = swap.nextSurface attachSurfaceEventHandlers(scope.surface) swap.oldSurface!.removeAttribute('id') return swap @@ -62,13 +55,15 @@ export function commitTerminalSurfaceSwap( if (swap.oldTerm) { swap.oldTerm.dispose() } - committedTerm = nextTerm - committedSurface = swap.nextSurface + scope.committedTerm = nextTerm + scope.committedSurface = swap.nextSurface scope.pendingTerm = null - pendingSurface = null + scope.pendingSurface = null } +// Why: phone-fit startup can issue several init() calls before xterm finishes replaying, so the +// last painted surface is tracked apart from its replacement — on the scope (ruling 21), because +// the page mounts this module more than once and a second mount must not inherit the first's. export function startSurfaceSwap() { - committedSurface = scope.surface - scope.pendingTerm = null + scope.committedSurface = scope.surface } diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index f49a76cf79c..2eeb34bfaa9 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -1,4 +1,4 @@ -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' import { clampPan, getCellHeight } from './fit-scale' import { notify } from './host-notify' import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' @@ -17,7 +17,7 @@ import { attachSurfaceWheelHandler } from './wheel-scroll' type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } /** The live touch gesture: the last point, the velocity, and the pinch it may be in. */ -type TerminalTouchState = { +export type TerminalTouchState = { lastX: number lastY: number lastTime: number @@ -31,20 +31,6 @@ type TerminalTouchState = { pinchSurfY: number } -const ts: TerminalTouchState = { - lastX: 0, - lastY: 0, - lastTime: 0, - velY: 0, - accumDelta: 0, - momentumId: null, - isPinching: false, - pinchDist: 0, - pinchScale: 0, - pinchSurfX: 0, - pinchSurfY: 0 -} - export function updateTouchVelocity(deltaY: number, dt: number) { if (dt <= 0) { return @@ -55,7 +41,10 @@ export function updateTouchVelocity(deltaY: number, dt: number) { } // Why: touchmove cadence is uneven in WebView. Blend recent samples so // momentum launch doesn't inherit a one-frame spike or stall. - ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45 + scope.touchGesture.velY = + scope.touchGesture.velY === 0 + ? instantVelocity + : scope.touchGesture.velY * 0.55 + instantVelocity * 0.45 } export function getDistance(a: Touch, b: Touch) { @@ -97,27 +86,27 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface if (dispatcherShouldBlockSurface()) { return } - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId) - ts.momentumId = null + if (scope.touchGesture.momentumId) { + cancelAnimationFrame(scope.touchGesture.momentumId) + scope.touchGesture.momentumId = null } if (e.touches.length === 2) { - ts.isPinching = true + scope.touchGesture.isPinching = true scope.smoothScrollOffsetY = 0 - ts.pinchDist = getDistance(e.touches[0], e.touches[1]) - ts.pinchScale = scope.userScale + scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1]) + scope.touchGesture.pinchScale = scope.userScale const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 const total = getTotalScale() - ts.pinchSurfX = (mx - scope.panX) / total - ts.pinchSurfY = (my - scope.panY) / total + scope.touchGesture.pinchSurfX = (mx - scope.panX) / total + scope.touchGesture.pinchSurfY = (my - scope.panY) / total } else if (e.touches.length === 1) { - ts.isPinching = false - ts.lastX = e.touches[0].clientX - ts.lastY = e.touches[0].clientY - ts.lastTime = Date.now() - ts.velY = 0 - ts.accumDelta = 0 + scope.touchGesture.isPinching = false + scope.touchGesture.lastX = e.touches[0].clientX + scope.touchGesture.lastY = e.touches[0].clientY + scope.touchGesture.lastTime = Date.now() + scope.touchGesture.velY = 0 + scope.touchGesture.accumDelta = 0 } }, { capture: true, passive: true } @@ -136,28 +125,31 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface e.stopPropagation() if (e.touches.length === 2) { - ts.isPinching = true + scope.touchGesture.isPinching = true const dist = getDistance(e.touches[0], e.touches[1]) const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - const ratio = dist / ts.pinchDist + const ratio = dist / scope.touchGesture.pinchDist // Why: userScale is a CSS multiplier on the current font size; bound it so // the resulting apparent size (currentTextScale × userScale) stays within // the preset range, since release snaps to one of those presets. const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale - scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)) + scope.userScale = Math.max( + loScale, + Math.min(hiScale, scope.touchGesture.pinchScale * ratio) + ) const total = getTotalScale() - scope.panX = mx - ts.pinchSurfX * total - scope.panY = my - ts.pinchSurfY * total + scope.panX = mx - scope.touchGesture.pinchSurfX * total + scope.panY = my - scope.touchGesture.pinchSurfY * total clampPan() updateTransform() - } else if (e.touches.length === 1 && !ts.isPinching) { + } else if (e.touches.length === 1 && !scope.touchGesture.isPinching) { const x = e.touches[0].clientX, y = e.touches[0].clientY const now = Date.now(), - dt = now - ts.lastTime + dt = now - scope.touchGesture.lastTime // Why: pan horizontally only when content overflows the viewport (larger // than fit) — same check clampPan() uses. Vertical always drives buffer @@ -168,32 +160,32 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface scope.term.element && scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1 ) { - scope.panX += x - ts.lastX + scope.panX += x - scope.touchGesture.lastX clampPan() updateTransform() } - const deltaY = ts.lastY - y - ts.lastTime = now + const deltaY = scope.touchGesture.lastY - y + scope.touchGesture.lastTime = now if (shouldRouteScrollToTerminalInput()) { updateTouchVelocity(deltaY, dt) resetSmoothScrollOffset() const effectiveCellH = getCellHeight() * getTotalScale() - ts.accumDelta += deltaY - const lines = Math.trunc(ts.accumDelta / effectiveCellH) + scope.touchGesture.accumDelta += deltaY + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH) if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH + scope.touchGesture.accumDelta -= lines * effectiveCellH routeScrollLines(lines, x, y) } } else { if (enqueueNormalBufferScrollDelta(deltaY)) { updateTouchVelocity(deltaY, dt) } else { - ts.velY = 0 + scope.touchGesture.velY = 0 } } - ts.lastX = x - ts.lastY = y + scope.touchGesture.lastX = x + scope.touchGesture.lastY = y } }, { capture: true, passive: false } @@ -209,8 +201,8 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface return } - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false + if (scope.touchGesture.isPinching && e.touches.length < 2) { + scope.touchGesture.isPinching = false // Why: a finished pinch snaps to the nearest preset and becomes the new // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way // to set the text size. The CSS pinch zoom (userScale) is reset; the real @@ -227,45 +219,45 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface notify({ type: 'haptic', kind: 'selection' }) } if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX - ts.lastY = e.touches[0].clientY - ts.lastTime = Date.now() - ts.velY = 0 - ts.accumDelta = 0 + scope.touchGesture.lastX = e.touches[0].clientX + scope.touchGesture.lastY = e.touches[0].clientY + scope.touchGesture.lastTime = Date.now() + scope.touchGesture.velY = 0 + scope.touchGesture.accumDelta = 0 } return } if (e.touches.length === 0) { - let vel = ts.velY + let vel = scope.touchGesture.velY const FRICTION = 0.972 const MIN_VEL = 0.012 function momentumStep() { vel *= FRICTION if (Math.abs(vel) < MIN_VEL) { - ts.momentumId = null + scope.touchGesture.momentumId = null return } const delta = vel * 16 if (shouldRouteScrollToTerminalInput()) { resetSmoothScrollOffset() const effectiveCellH = getCellHeight() * getTotalScale() - ts.accumDelta += delta - const lines = Math.trunc(ts.accumDelta / effectiveCellH) + scope.touchGesture.accumDelta += delta + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH) if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH - routeScrollLines(lines, ts.lastX, ts.lastY) + scope.touchGesture.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY) } } else { if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null + scope.touchGesture.momentumId = null return } } - ts.momentumId = requestAnimationFrame(momentumStep) + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep) } if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep) + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep) } } }, @@ -276,3 +268,11 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface export function startSurfaceTouchGestures() { attachSurfaceEventHandlers(scope.surface!) } + +/** Ruling 21: the momentum loop, which would keep scrolling into the terminal that replaced it. */ +export function stopSurfaceTouchGestures() { + if (scope.touchGesture.momentumId !== null) { + cancelAnimationFrame(scope.touchGesture.momentumId) + scope.touchGesture.momentumId = null + } +} diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index 1212c8a3d26..8ca6f36c4d3 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -20,13 +20,6 @@ export type TerminalTouchDispatch = { /** An element a target can be tested against; a method so a real element satisfies it. */ type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean } -const dispatch: TerminalTouchDispatch = { - mode: 'idle', - touchId: null, - touchIds: null, - longPressFingerInsideOverlay: false -} - export function touchById(touches: TouchList, id: number | null) { for (let i = 0; i < touches.length; i++) { if (touches[i].identifier === id) { @@ -81,7 +74,7 @@ export function touchSlopExceeded(t: Touch) { // Why: existing surface handlers stay attached to surface but we wrap // their entry to no-op when the dispatcher latches into select-drag. export function dispatcherShouldBlockSurface() { - return dispatch.mode === 'select-drag' + return scope.touchDispatch.mode === 'select-drag' } /** @@ -108,8 +101,8 @@ function onDocumentTouchStart(e: TouchEvent) { notify({ type: 'mobile-clip-cancel-by-pinch' }) cancelSelect() } - dispatch.mode = 'pinch' - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] + scope.touchDispatch.mode = 'pinch' + scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] clearLongPress() return } @@ -118,8 +111,8 @@ function onDocumentTouchStart(e: TouchEvent) { // start handle drag const handleName = target === scope.handleStart ? 'start' : 'end' scope.sel!.activeHandle = handleName - dispatch.mode = 'select-drag' - dispatch.touchId = t.identifier + scope.touchDispatch.mode = 'select-drag' + scope.touchDispatch.touchId = t.identifier e.preventDefault() return } @@ -134,22 +127,22 @@ function onDocumentTouchStart(e: TouchEvent) { // selection clears it. We cancel immediately and latch to 'surface' so // the same gesture still drives scroll/pan without a second touch. cancelSelect() - dispatch.mode = 'surface' - dispatch.touchId = t.identifier + scope.touchDispatch.mode = 'surface' + scope.touchDispatch.touchId = t.identifier return } if (inSurface) { - dispatch.mode = 'surface' - dispatch.touchId = t.identifier + scope.touchDispatch.mode = 'surface' + scope.touchDispatch.touchId = t.identifier scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } armLongPress(t) } } function onDocumentTouchMove(e: TouchEvent) { - if (dispatch.mode === 'select-drag') { - const t = touchById(e.touches, dispatch.touchId) + if (scope.touchDispatch.mode === 'select-drag') { + const t = touchById(e.touches, scope.touchDispatch.touchId) if (!t || !scope.sel || !scope.sel.activeHandle) { return } @@ -157,7 +150,7 @@ function onDocumentTouchMove(e: TouchEvent) { handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) return } - if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + if (scope.touchDispatch.mode === 'surface' || scope.touchDispatch.mode === 'pinch') { // long-press slop check if (scope.longPressTimer && e.touches.length === 1) { if (touchSlopExceeded(e.touches[0])) { @@ -184,26 +177,26 @@ function onDocumentTouchMove(e: TouchEvent) { } function onDocumentTouchEnd(e: TouchEvent) { - if (dispatch.mode === 'select-drag') { + if (scope.touchDispatch.mode === 'select-drag') { if (scope.sel) { scope.sel.activeHandle = null } stopEdgeScroll() - dispatch.mode = 'idle' - dispatch.touchId = null + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null return } - if (dispatch.mode === 'pinch') { + if (scope.touchDispatch.mode === 'pinch') { if (e.touches.length < 2) { - dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' - dispatch.touchIds = null + scope.touchDispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' + scope.touchDispatch.touchIds = null if (e.touches.length === 1) { - dispatch.touchId = e.touches[0].identifier + scope.touchDispatch.touchId = e.touches[0].identifier } } return } - if (dispatch.mode === 'surface') { + if (scope.touchDispatch.mode === 'surface') { // Why: fire the tap from the tap-candidate origin (survives jitter under // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop // can null mid-tap — that was dropping URL/file taps that moved a few px. @@ -218,8 +211,8 @@ function onDocumentTouchEnd(e: TouchEvent) { clearLongPress() scope.tapCandidate = null if (e.touches.length === 0) { - dispatch.mode = 'idle' - dispatch.touchId = null + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null } } } @@ -228,14 +221,14 @@ function onDocumentTouchCancel() { clearLongPress() scope.tapCandidate = null stopEdgeScroll() - if (dispatch.mode === 'select-drag') { + if (scope.touchDispatch.mode === 'select-drag') { if (scope.sel) { scope.sel.activeHandle = null } } - dispatch.mode = 'idle' - dispatch.touchId = null - dispatch.touchIds = null + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null + scope.touchDispatch.touchIds = null } /** @@ -256,4 +249,5 @@ export function stopTapDispatch() { document.removeEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE) document.removeEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE) document.removeEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE) + clearLongPress() } diff --git a/mobile/src/terminal/document/terminal-handle.ts b/mobile/src/terminal/document/terminal-handle.ts deleted file mode 100644 index d205ad53425..00000000000 --- a/mobile/src/terminal/document/terminal-handle.ts +++ /dev/null @@ -1,13 +0,0 @@ -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. - */ - -export function startTerminalHandle() { - 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 5c148c366b8..dbb60a7a274 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -7,7 +7,7 @@ import { } from './document-constants' import { notify } from './host-notify' import { fontPxForScale } from './text-scaling' -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' import { applyFitScale } from './fit-scale' import { isAltScreenActive, @@ -128,7 +128,7 @@ export function init( attachTermObservers() attachTerminalQueryReplyBridge(scope.term, gen) - requestAnimationFrame(function () { + scheduleDocumentFrame(function () { if (gen !== scope.terminalGeneration) { return } @@ -189,3 +189,11 @@ export function resize(cols: number, rows: number) { } // reflow(): see reflow.ts. + +/** + * Ruling 21: init's own frames carry the generation they were scheduled under, so bumping it is + * what abandons them — the same guard a re-init already uses against its predecessor. + */ +export function stopTerminalInit() { + scope.terminalGeneration++ +} diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts index f36aef93fc6..c9accd0f979 100644 --- a/mobile/src/terminal/document/text-scaling.ts +++ b/mobile/src/terminal/document/text-scaling.ts @@ -1,12 +1,9 @@ import { terminalTextScalePresets } from './document-constants' -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' import { applyFitScale, getCellHeight } from './fit-scale' import { getCellWidth } from './viewport-transform' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' -export let scrollIndicator: HTMLElement | null = null -export let scrollThumb: HTMLElement | null = null - // 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. @@ -64,8 +61,11 @@ export function applyTextScale(scale: number) { return } scope.term.options.fontSize = px - requestAnimationFrame(function () { - if (!scope.term) { + // Ruling 21: the generation this frame was scheduled under. `scope.term` alone is not enough — + // a mount that came and went leaves a live terminal here, and this would resize that one. + const gen = scope.terminalGeneration + scheduleDocumentFrame(function () { + if (!scope.term || gen !== scope.terminalGeneration) { return } const cellW = getCellWidth() @@ -84,22 +84,8 @@ export function applyTextScale(scale: number) { } export function startTextScaling() { - scrollIndicator = document.getElementById('scroll-indicator') - scrollThumb = document.getElementById('scroll-thumb') - scope.scrollIndicatorHideTimer = null - scope.writeQueue = [] - scope.writeQueueHead = 0 - scope.writesDraining = false - scope.afterDrainCallbacks = [] - scope.termObserverDisposables = [] - scope.ready = false - scope.everReady = false - scope.currentScale = 1 - scope.userScale = 1 - scope.MIN_FIT_COLS = 20 - scope.currentTextScale = 1 - scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0] - scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1] + scope.scrollIndicator = document.getElementById('scroll-indicator') + scope.scrollThumb = document.getElementById('scroll-thumb') scope.terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS } diff --git a/mobile/src/terminal/document/viewport-transform.ts b/mobile/src/terminal/document/viewport-transform.ts index cdb1cc90547..2d265f273c9 100644 --- a/mobile/src/terminal/document/viewport-transform.ts +++ b/mobile/src/terminal/document/viewport-transform.ts @@ -1,8 +1,6 @@ -import { terminalDefaultTheme } from './document-constants' import { repositionOverlay } from './selection-overlay' import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding' import { scope } from './document-scope' -import { scrollIndicator, scrollThumb } from './text-scaling' // Why: after init() the initial scrollback applyFitScale may have run // against an empty buffer (or one without the widest line yet). Re-fit @@ -71,8 +69,8 @@ export function updateTransform() { export function updateScrollIndicator(reveal: boolean) { if ( - !scrollIndicator || - !scrollThumb || + !scope.scrollIndicator || + !scope.scrollThumb || !scope.term || !scope.term.buffer || !scope.term.buffer.active @@ -82,7 +80,7 @@ export function updateScrollIndicator(reveal: boolean) { const buffer = scope.term.buffer.active const maxViewportY = buffer.baseY || 0 if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { - scrollIndicator.classList.remove('visible') + scope.scrollIndicator.classList.remove('visible') return } const trackHeight = Math.max(0, window.innerHeight - 8) @@ -93,43 +91,25 @@ export function updateScrollIndicator(reveal: boolean) { 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)' + scope.scrollThumb.style.height = thumbHeight + 'px' + scope.scrollThumb.style.transform = 'translateY(' + top + 'px)' if (!reveal) { return } - scrollIndicator.classList.add('visible') + scope.scrollIndicator.classList.add('visible') if (scope.scrollIndicatorHideTimer) { clearTimeout(scope.scrollIndicatorHideTimer) } scope.scrollIndicatorHideTimer = setTimeout(function () { - scrollIndicator!.classList.remove('visible') + scope.scrollIndicator!.classList.remove('visible') scope.scrollIndicatorHideTimer = null }, 550) } -export function startViewportTransform() { - 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 = [] - scope.firstDataPending = false +/** Ruling 21: the hide timer is the one thing this module schedules. */ +export function stopViewportTransform() { + if (scope.scrollIndicatorHideTimer) { + clearTimeout(scope.scrollIndicatorHideTimer) + scope.scrollIndicatorHideTimer = null + } } diff --git a/mobile/src/terminal/document/webgl-recovery.ts b/mobile/src/terminal/document/webgl-recovery.ts index e06cd371cab..3c43f146157 100644 --- a/mobile/src/terminal/document/webgl-recovery.ts +++ b/mobile/src/terminal/document/webgl-recovery.ts @@ -106,4 +106,5 @@ export function startWebglRecovery() { export function stopWebglRecovery() { document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + cancelWebglContextRecovery() } diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 8661f53bb43..f2b08f3896f 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -71,7 +71,3 @@ export function attachSurfaceWheelHandler(targetSurface: HTMLElement) { { capture: true, passive: false } ) } - -export function startWheelScroll() { - scope.wheelAccumDeltaY = 0 -} diff --git a/mobile/src/terminal/terminal-document-golden.txt b/mobile/src/terminal/terminal-document-golden.txt index 71790a5a2b8..1b03d918cd6 100644 --- a/mobile/src/terminal/terminal-document-golden.txt +++ b/mobile/src/terminal/terminal-document-golden.txt @@ -183,7 +183,7 @@ window.onerror = function(msg) { const statusDot = String.fromCharCode(9210); const textPresentationSelector = String.fromCharCode(65038); const emojiPresentationSelector = String.fromCharCode(65039); - function createTerminalDocumentScope() { + function createTerminalDocumentState() { return { term: null, panX: 0, @@ -220,7 +220,7 @@ window.onerror = function(msg) { handledMessageIds: [], currentTextScale: 1, terminalFontFamily: "", - firstDataPending: true, + firstDataPending: false, activeAltScreenSnapshot: false, currentScale: 1, userScale: 1, @@ -267,6 +267,41 @@ window.onerror = function(msg) { wheelAccumDeltaY: 0, surface: null, pendingTerm: null, + committedTerm: null, + committedSurface: null, + pendingSurface: null, + scrollIndicator: null, + scrollThumb: null, + terminalDataRepliesEnabled: false, + linesEverWritten: 0, + nonFatalErrorNotifies: 0, + uninstallErrorReporter: null, + fitRetryToken: 0, + mouseGesture: null, + touchDispatch: { + mode: "idle", + touchId: null, + touchIds: null, + longPressFingerInsideOverlay: false + }, + scheduledFrames: [], + touchGesture: { + lastX: 0, + lastY: 0, + lastTime: 0, + velY: 0, + accumDelta: 0, + momentumId: null, + isPinching: false, + pinchDist: 0, + pinchScale: 0, + pinchSurfX: 0, + pinchSurfY: 0 + } + }; + } + function createTerminalDocumentHostSeams() { + return { postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, @@ -274,40 +309,52 @@ window.onerror = function(msg) { installErrorReporter: installWindowErrorReporter }; } + function createTerminalDocumentScope() { + return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() }; + } + function resetTerminalDocumentScope() { + const generations = { + terminalGeneration: scope.terminalGeneration + 1, + fitRetryToken: scope.fitRetryToken + 1 + }; + Object.assign(scope, createTerminalDocumentState(), generations); + } const scope = createTerminalDocumentScope(); + function scheduleDocumentFrame(callback) { + const id = requestAnimationFrame(function(time) { + const at = scope.scheduledFrames.indexOf(id); + if (at !== -1) { + scope.scheduledFrames.splice(at, 1); + } + callback(time); + }); + scope.scheduledFrames.push(id); + return id; + } + function cancelDocumentFrames() { + for (const id of scope.scheduledFrames) { + cancelAnimationFrame(id); + } + scope.scheduledFrames = []; + } function startRuntimeConstants() { scope.surface = document.getElementById("terminal-surface"); - scope.ESC = String.fromCharCode(27); - scope.C1_CSI = String.fromCharCode(155); - scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210); - scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038); - scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039); - scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp( - scope.CLAUDE_STATUS_DOT + "[" + scope.TEXT_PRESENTATION_SELECTOR + scope.EMOJI_PRESENTATION_SELECTOR + "]*", - "g" - ); - scope.statusDotPendingSelector = false; } - function startTerminalHandle() { - scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - scope.term = null; - } - let terminalDataRepliesEnabled = false; function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false; + scope.terminalDataRepliesEnabled = false; } function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true; + scope.terminalDataRepliesEnabled = true; } function forwardTerminalDataReply(data) { - if (terminalDataRepliesEnabled) { + if (scope.terminalDataRepliesEnabled) { notify({ type: "terminal-data", bytes: data }); } } function enqueueTerminalDataReplyBoundary(gen) { enqueueWriteBoundary(function() { if (gen === scope.terminalGeneration) { - terminalDataRepliesEnabled = true; + scope.terminalDataRepliesEnabled = true; } }); } @@ -333,13 +380,10 @@ window.onerror = function(msg) { } enqueueTerminalDataReplyBoundary(gen); } - let committedTerm = null; - let committedSurface = null; - let pendingSurface = null; function beginTerminalSurfaceSwap() { - if (pendingSurface) { + if (scope.pendingSurface) { try { - pendingSurface.remove(); + scope.pendingSurface.remove(); } catch { } if (scope.pendingTerm) { @@ -348,12 +392,12 @@ window.onerror = function(msg) { } catch { } } - pendingSurface = null; + scope.pendingSurface = null; scope.pendingTerm = null; } const swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, + oldTerm: scope.committedTerm, + oldSurface: scope.committedSurface, nextSurface: document.createElement("div") }; disposeTermObservers(); @@ -364,7 +408,7 @@ window.onerror = function(msg) { swap.nextSurface.style.top = "0"; document.getElementById("terminal-container").appendChild(swap.nextSurface); scope.surface = swap.nextSurface; - pendingSurface = swap.nextSurface; + scope.pendingSurface = swap.nextSurface; attachSurfaceEventHandlers(scope.surface); swap.oldSurface.removeAttribute("id"); return swap; @@ -378,17 +422,14 @@ window.onerror = function(msg) { if (swap.oldTerm) { swap.oldTerm.dispose(); } - committedTerm = nextTerm; - committedSurface = swap.nextSurface; + scope.committedTerm = nextTerm; + scope.committedSurface = swap.nextSurface; scope.pendingTerm = null; - pendingSurface = null; + scope.pendingSurface = null; } function startSurfaceSwap() { - committedSurface = scope.surface; - scope.pendingTerm = null; + scope.committedSurface = scope.surface; } - let scrollIndicator = null; - let scrollThumb = null; const BASE_FONT_PX = 13; const MIN_FONT_PX = 6; const TEXT_SCALE_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2]; @@ -423,8 +464,9 @@ window.onerror = function(msg) { return; } scope.term.options.fontSize = px; - requestAnimationFrame(function() { - if (!scope.term) { + const gen = scope.terminalGeneration; + scheduleDocumentFrame(function() { + if (!scope.term || gen !== scope.terminalGeneration) { return; } const cellW = getCellWidth(); @@ -442,22 +484,8 @@ window.onerror = function(msg) { }); } function startTextScaling() { - scrollIndicator = document.getElementById("scroll-indicator"); - scrollThumb = document.getElementById("scroll-thumb"); - scope.scrollIndicatorHideTimer = null; - scope.writeQueue = []; - scope.writeQueueHead = 0; - scope.writesDraining = false; - scope.afterDrainCallbacks = []; - scope.termObserverDisposables = []; - scope.ready = false; - scope.everReady = false; - scope.currentScale = 1; - scope.userScale = 1; - scope.MIN_FIT_COLS = 20; - scope.currentTextScale = 1; - scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; - scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; + scope.scrollIndicator = document.getElementById("scroll-indicator"); + scope.scrollThumb = document.getElementById("scroll-thumb"); scope.terminalFontFamily = (isIOSWebView() ? "ui-monospace, " : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; } function flog(tag, payload) { @@ -503,13 +531,13 @@ window.onerror = function(msg) { } } function updateScrollIndicator(reveal) { - if (!scrollIndicator || !scrollThumb || !scope.term || !scope.term.buffer || !scope.term.buffer.active) { + if (!scope.scrollIndicator || !scope.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"); + scope.scrollIndicator.classList.remove("visible"); return; } const trackHeight = Math.max(0, window.innerHeight - 8); @@ -520,44 +548,25 @@ window.onerror = function(msg) { 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)"; + scope.scrollThumb.style.height = thumbHeight + "px"; + scope.scrollThumb.style.transform = "translateY(" + top + "px)"; if (!reveal) { return; } - scrollIndicator.classList.add("visible"); + scope.scrollIndicator.classList.add("visible"); if (scope.scrollIndicatorHideTimer) { clearTimeout(scope.scrollIndicatorHideTimer); } scope.scrollIndicatorHideTimer = setTimeout(function() { - scrollIndicator.classList.remove("visible"); + scope.scrollIndicator.classList.remove("visible"); scope.scrollIndicatorHideTimer = null; }, 550); } - function startViewportTransform() { - scope.panX = 0; - scope.panY = 0; - scope.smoothScrollOffsetY = 0; - scope.pendingNormalScrollDeltaY = 0; - scope.normalScrollFrameId = null; - scope.initRows = 24; - scope.terminalGeneration = 0; - scope.defaultTheme = { "background": "#1a1b26", "foreground": "#c0caf5", "cursor": "#c0caf5", "cursorAccent": "#1a1b26", "selectionBackground": "#33467c", "selectionForeground": "#c0caf5", "black": "#15161e", "red": "#f7768e", "green": "#9ece6a", "yellow": "#e0af68", "blue": "#7aa2f7", "magenta": "#bb9af7", "cyan": "#7dcfff", "white": "#a9b1d6", "brightBlack": "#414868", "brightRed": "#f7768e", "brightGreen": "#9ece6a", "brightYellow": "#e0af68", "brightBlue": "#7aa2f7", "brightMagenta": "#bb9af7", "brightCyan": "#7dcfff", "brightWhite": "#c0caf5" }; - 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 = []; - scope.firstDataPending = false; + function stopViewportTransform() { + if (scope.scrollIndicatorHideTimer) { + clearTimeout(scope.scrollIndicatorHideTimer); + scope.scrollIndicatorHideTimer = null; + } } const DARK_BG_MIN_CONTRAST = 3; const LIGHT_BG_MIN_CONTRAST = 4.5; @@ -716,16 +725,15 @@ window.onerror = function(msg) { function adjustRowsForViewport() { } const FIT_RETRY_MAX_FRAMES = 60; - let fitRetryToken = 0; function applyFitScale(reason) { if (!scope.term || !scope.term.element) { return; } - const token = ++fitRetryToken; + const token = ++scope.fitRetryToken; let attempts = 0; let lastScrollWidth = -1; function attempt() { - if (token !== fitRetryToken) { + if (token !== scope.fitRetryToken) { return; } if (!scope.term || !scope.term.element) { @@ -754,9 +762,9 @@ window.onerror = function(msg) { commitFitScale(reason, attempts, "timeout"); return; } - requestAnimationFrame(attempt); + scheduleDocumentFrame(attempt); } - requestAnimationFrame(attempt); + scheduleDocumentFrame(attempt); } function commitFitScale(reason, attempts, gate) { if (!scope.term || !scope.term.element) { @@ -794,6 +802,9 @@ window.onerror = function(msg) { } repositionOverlay(); } + function stopFitScale() { + scope.fitRetryToken++; + } function isAltScreenActive(data) { if (typeof data !== "string") { return false; @@ -1074,6 +1085,7 @@ window.onerror = function(msg) { } function stopWebglRecovery() { document.removeEventListener("visibilitychange", onDocumentVisibilityChange); + cancelWebglContextRecovery(); } function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === "number" && nextFontScale > 0) { @@ -1156,7 +1168,7 @@ window.onerror = function(msg) { cancelSelect(); attachTermObservers(); attachTerminalQueryReplyBridge(scope.term, gen); - requestAnimationFrame(function() { + scheduleDocumentFrame(function() { if (gen !== scope.terminalGeneration) { return; } @@ -1208,6 +1220,9 @@ window.onerror = function(msg) { applyFitScale("resize-msg"); notify({ type: "ready", cols, rows }); } + function stopTerminalInit() { + scope.terminalGeneration++; + } function reflow(cols, rows) { if (!scope.term || isAlternateBufferActive()) { return; @@ -1255,12 +1270,11 @@ window.onerror = function(msg) { const match = String(navigator.userAgent || "").match(/(?:Chrome|Chromium)\/([0-9.]+)/); return match ? "Chrome " + match[1] : "Chrome version unknown"; } - let nonFatalErrorNotifies = 0; function reportEngineError(context, err, fatal) { const isFatal = fatal === void 0 ? !scope.everReady : !!fatal; if (!isFatal) { - nonFatalErrorNotifies++; - if (nonFatalErrorNotifies > 5) { + scope.nonFatalErrorNotifies++; + if (scope.nonFatalErrorNotifies > 5) { return; } } @@ -1279,9 +1293,8 @@ window.onerror = function(msg) { message: parts.join(" - ") }); } - let uninstallErrorReporter = null; function startHostNotify() { - uninstallErrorReporter = scope.installErrorReporter(function(msg, source, line, column, err) { + scope.uninstallErrorReporter = scope.installErrorReporter(function(msg, source, line, column, err) { if (window.__engineErrors.length < 20) { window.__engineErrors.push(String(msg)); } @@ -1289,9 +1302,9 @@ window.onerror = function(msg) { }); } function stopHostNotify() { - if (uninstallErrorReporter) { - uninstallErrorReporter(); - uninstallErrorReporter = null; + if (scope.uninstallErrorReporter) { + scope.uninstallErrorReporter(); + scope.uninstallErrorReporter = null; } } function measureFitDimensions(containerHeightPx, retriesLeft) { @@ -1310,7 +1323,11 @@ window.onerror = function(msg) { } if (notReady || cellWidth <= 0 || cellHeight <= 0) { if (retriesLeft > 0) { - requestAnimationFrame(function() { + const gen = scope.terminalGeneration; + scheduleDocumentFrame(function() { + if (gen !== scope.terminalGeneration) { + return; + } measureFitDimensions(containerHeightPx, retriesLeft - 1); }); return; @@ -1430,15 +1447,14 @@ window.onerror = function(msg) { } } } - let linesEverWritten = 0; function resetEvictionCounter() { - linesEverWritten = 0; + scope.linesEverWritten = 0; } function isBufferFull() { if (!scope.term) { return false; } - return linesEverWritten >= 5e3 + (scope.term.rows || 0); + return scope.linesEverWritten >= 5e3 + (scope.term.rows || 0); } function checkEviction() { if (scope.selMode !== "select" || !scope.sel) { @@ -1451,7 +1467,7 @@ window.onerror = function(msg) { } } function logFeedAndEvict() { - linesEverWritten++; + scope.linesEverWritten++; if (scope.initialOscLinkEvictionReady && isBufferFull()) { scope.initialOscLinkRowOffset += 1; } @@ -1463,28 +1479,12 @@ window.onerror = function(msg) { } } function startSelectionStateAndEviction() { - scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u; - scope.LONG_PRESS_MS = 500; - scope.LONG_PRESS_SLOP = 10; - scope.TAP_SLOP = 24; - scope.TAP_MAX_MS = 700; - scope.EDGE_SCROLL_PX = 40; - scope.EDGE_SCROLL_INTERVAL = 60; scope.selectionOverlay = document.getElementById("selection-overlay"); scope.handleStart = document.getElementById("sel-handle-start"); scope.handleEnd = document.getElementById("sel-handle-end"); scope.selMenu = document.getElementById("sel-menu"); scope.btnCopy = document.getElementById("sel-menu-copy"); scope.btnSelAll = document.getElementById("sel-menu-all"); - scope.selMode = "navigate"; - scope.sel = null; - scope.longPressTimer = null; - scope.longPressOrigin = null; - scope.tapCandidate = null; - scope.edgeScrollTimer = null; - scope.edgeScrollDir = 0; - scope.edgeScrollClientX = 0; - scope.edgeScrollClientY = 0; } function emitModesIfChanged() { if (!scope.term) { @@ -1515,15 +1515,6 @@ window.onerror = function(msg) { }); } } - function startModeMirroring() { - scope.lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: "none", - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - } function lineHasVisibleContent(line, cell) { if (line.translateToString(true).trim().length > 0) { return true; @@ -1946,7 +1937,7 @@ window.onerror = function(msg) { if (scope.normalScrollFrameId !== null) { return true; } - scope.normalScrollFrameId = requestAnimationFrame(function() { + scope.normalScrollFrameId = scheduleDocumentFrame(function() { scope.normalScrollFrameId = null; const delta = scope.pendingNormalScrollDeltaY; scope.pendingNormalScrollDeltaY = 0; @@ -1968,6 +1959,9 @@ window.onerror = function(msg) { scope.smoothScrollOffsetY = 0; updateScrollIndicator(false); } + function stopNormalBufferSmoothScroll() { + resetSmoothScrollOffset(); + } function cellToViewportPx(col, absRow) { if (!scope.term) { return { x: 0, y: 0 }; @@ -2616,12 +2610,9 @@ window.onerror = function(msg) { stopEdgeScroll(); } } - const dispatch = { - mode: "idle", - touchId: null, - touchIds: null, - longPressFingerInsideOverlay: false - }; + function stopSelectionOverlay() { + stopEdgeScroll(); + } function touchById(touches, id) { for (let i = 0; i < touches.length; i++) { if (touches[i].identifier === id) { @@ -2666,7 +2657,7 @@ window.onerror = function(msg) { return dx + dy > scope.LONG_PRESS_SLOP; } function dispatcherShouldBlockSurface() { - return dispatch.mode === "select-drag"; + return scope.touchDispatch.mode === "select-drag"; } const CAPTURE_ACTIVE = { capture: true, passive: false }; const CAPTURE_PASSIVE = { capture: true, passive: true }; @@ -2682,16 +2673,16 @@ window.onerror = function(msg) { notify({ type: "mobile-clip-cancel-by-pinch" }); cancelSelect(); } - dispatch.mode = "pinch"; - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; + scope.touchDispatch.mode = "pinch"; + scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; clearLongPress(); return; } if (onHandle && scope.selMode === "select") { const handleName = target === scope.handleStart ? "start" : "end"; scope.sel.activeHandle = handleName; - dispatch.mode = "select-drag"; - dispatch.touchId = t.identifier; + scope.touchDispatch.mode = "select-drag"; + scope.touchDispatch.touchId = t.identifier; e.preventDefault(); return; } @@ -2700,20 +2691,20 @@ window.onerror = function(msg) { } if (inSurface && scope.selMode === "select") { cancelSelect(); - dispatch.mode = "surface"; - dispatch.touchId = t.identifier; + scope.touchDispatch.mode = "surface"; + scope.touchDispatch.touchId = t.identifier; return; } if (inSurface) { - dispatch.mode = "surface"; - dispatch.touchId = t.identifier; + scope.touchDispatch.mode = "surface"; + scope.touchDispatch.touchId = t.identifier; scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; armLongPress(t); } } function onDocumentTouchMove(e) { - if (dispatch.mode === "select-drag") { - const t = touchById(e.touches, dispatch.touchId); + if (scope.touchDispatch.mode === "select-drag") { + const t = touchById(e.touches, scope.touchDispatch.touchId); if (!t || !scope.sel || !scope.sel.activeHandle) { return; } @@ -2721,7 +2712,7 @@ window.onerror = function(msg) { handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY); return; } - if (dispatch.mode === "surface" || dispatch.mode === "pinch") { + if (scope.touchDispatch.mode === "surface" || scope.touchDispatch.mode === "pinch") { if (scope.longPressTimer && e.touches.length === 1) { if (touchSlopExceeded(e.touches[0])) { clearLongPress(); @@ -2742,34 +2733,34 @@ window.onerror = function(msg) { } } function onDocumentTouchEnd(e) { - if (dispatch.mode === "select-drag") { + if (scope.touchDispatch.mode === "select-drag") { if (scope.sel) { scope.sel.activeHandle = null; } stopEdgeScroll(); - dispatch.mode = "idle"; - dispatch.touchId = null; + scope.touchDispatch.mode = "idle"; + scope.touchDispatch.touchId = null; return; } - if (dispatch.mode === "pinch") { + if (scope.touchDispatch.mode === "pinch") { if (e.touches.length < 2) { - dispatch.mode = e.touches.length === 1 ? "surface" : "idle"; - dispatch.touchIds = null; + scope.touchDispatch.mode = e.touches.length === 1 ? "surface" : "idle"; + scope.touchDispatch.touchIds = null; if (e.touches.length === 1) { - dispatch.touchId = e.touches[0].identifier; + scope.touchDispatch.touchId = e.touches[0].identifier; } } return; } - if (dispatch.mode === "surface") { + if (scope.touchDispatch.mode === "surface") { if (e.touches.length === 0 && scope.tapCandidate && scope.selMode !== "select" && Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS) { notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true); } clearLongPress(); scope.tapCandidate = null; if (e.touches.length === 0) { - dispatch.mode = "idle"; - dispatch.touchId = null; + scope.touchDispatch.mode = "idle"; + scope.touchDispatch.touchId = null; } } } @@ -2777,14 +2768,14 @@ window.onerror = function(msg) { clearLongPress(); scope.tapCandidate = null; stopEdgeScroll(); - if (dispatch.mode === "select-drag") { + if (scope.touchDispatch.mode === "select-drag") { if (scope.sel) { scope.sel.activeHandle = null; } } - dispatch.mode = "idle"; - dispatch.touchId = null; - dispatch.touchIds = null; + scope.touchDispatch.mode = "idle"; + scope.touchDispatch.touchId = null; + scope.touchDispatch.touchIds = null; } function startTapDispatch() { document.addEventListener("touchstart", onDocumentTouchStart, CAPTURE_ACTIVE); @@ -2797,6 +2788,7 @@ window.onerror = function(msg) { document.removeEventListener("touchmove", onDocumentTouchMove, CAPTURE_ACTIVE); document.removeEventListener("touchend", onDocumentTouchEnd, CAPTURE_PASSIVE); document.removeEventListener("touchcancel", onDocumentTouchCancel, CAPTURE_PASSIVE); + clearLongPress(); } function wheelEventPixelDeltaY(e) { const delta = e.deltaY; @@ -2850,10 +2842,6 @@ window.onerror = function(msg) { { capture: true, passive: false } ); } - function startWheelScroll() { - scope.wheelAccumDeltaY = 0; - } - let mouseGesture = null; function buildMouseButtonReport(kind, clientX, clientY) { const mouseTrackingMode = getMouseTrackingMode(); if (mouseTrackingMode === "none") { @@ -2898,8 +2886,8 @@ window.onerror = function(msg) { return cell ? cell.col + "," + cell.row : null; } function abandonMouseGesture() { - const gesture = mouseGesture; - mouseGesture = null; + const gesture = scope.mouseGesture; + scope.mouseGesture = null; if (!gesture) { return; } @@ -2949,7 +2937,7 @@ window.onerror = function(msg) { if (dispatcherShouldBlockSurface() || !scope.term) { return; } - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture(); } try { @@ -2958,7 +2946,7 @@ window.onerror = function(msg) { } } catch { } - mouseGesture = { + scope.mouseGesture = { startX: e.clientX, startY: e.clientY, lastX: e.clientX, @@ -2970,7 +2958,7 @@ window.onerror = function(msg) { }; if (scope.selMode === "select") { cancelSelect(); - mouseGesture.dismissedSelection = true; + scope.mouseGesture.dismissedSelection = true; } }, true @@ -2978,7 +2966,7 @@ window.onerror = function(msg) { targetSurface.addEventListener( "pointermove", function(e) { - const gesture = mouseGesture; + const gesture = scope.mouseGesture; if (e.pointerType !== "mouse" || !gesture || gesture.mode === "cancelled") { return; } @@ -3017,11 +3005,11 @@ window.onerror = function(msg) { targetSurface.addEventListener( "pointerup", function(e) { - const gesture = mouseGesture; + const gesture = scope.mouseGesture; if (e.pointerType !== "mouse" || !gesture || e.button !== 0) { return; } - mouseGesture = null; + scope.mouseGesture = null; if (gesture.mode === "cancelled" || !scope.term) { return; } @@ -3063,7 +3051,7 @@ window.onerror = function(msg) { targetSurface.addEventListener( "touchstart", function() { - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture(); } }, @@ -3103,19 +3091,6 @@ window.onerror = function(msg) { } }); } - const ts = { - lastX: 0, - lastY: 0, - lastTime: 0, - velY: 0, - accumDelta: 0, - momentumId: null, - isPinching: false, - pinchDist: 0, - pinchScale: 0, - pinchSurfX: 0, - pinchSurfY: 0 - }; function updateTouchVelocity(deltaY, dt) { if (dt <= 0) { return; @@ -3124,7 +3099,7 @@ window.onerror = function(msg) { if (!Number.isFinite(instantVelocity)) { return; } - ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45; + scope.touchGesture.velY = scope.touchGesture.velY === 0 ? instantVelocity : scope.touchGesture.velY * 0.55 + instantVelocity * 0.45; } function getDistance(a, b) { const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; @@ -3159,27 +3134,27 @@ window.onerror = function(msg) { if (dispatcherShouldBlockSurface()) { return; } - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId); - ts.momentumId = null; + if (scope.touchGesture.momentumId) { + cancelAnimationFrame(scope.touchGesture.momentumId); + scope.touchGesture.momentumId = null; } if (e.touches.length === 2) { - ts.isPinching = true; + scope.touchGesture.isPinching = true; scope.smoothScrollOffsetY = 0; - ts.pinchDist = getDistance(e.touches[0], e.touches[1]); - ts.pinchScale = scope.userScale; + scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1]); + scope.touchGesture.pinchScale = scope.userScale; const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; const my = (e.touches[0].clientY + e.touches[1].clientY) / 2; const total = getTotalScale(); - ts.pinchSurfX = (mx - scope.panX) / total; - ts.pinchSurfY = (my - scope.panY) / total; + scope.touchGesture.pinchSurfX = (mx - scope.panX) / total; + scope.touchGesture.pinchSurfY = (my - scope.panY) / total; } else if (e.touches.length === 1) { - ts.isPinching = false; - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; + scope.touchGesture.isPinching = false; + scope.touchGesture.lastX = e.touches[0].clientX; + scope.touchGesture.lastY = e.touches[0].clientY; + scope.touchGesture.lastTime = Date.now(); + scope.touchGesture.velY = 0; + scope.touchGesture.accumDelta = 0; } }, { capture: true, passive: true } @@ -3196,48 +3171,51 @@ window.onerror = function(msg) { e.preventDefault(); e.stopPropagation(); if (e.touches.length === 2) { - ts.isPinching = true; + scope.touchGesture.isPinching = true; const dist = getDistance(e.touches[0], e.touches[1]); const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; const my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - const ratio = dist / ts.pinchDist; + const ratio = dist / scope.touchGesture.pinchDist; const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale; const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale; - scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); + scope.userScale = Math.max( + loScale, + Math.min(hiScale, scope.touchGesture.pinchScale * ratio) + ); const total = getTotalScale(); - scope.panX = mx - ts.pinchSurfX * total; - scope.panY = my - ts.pinchSurfY * total; + scope.panX = mx - scope.touchGesture.pinchSurfX * total; + scope.panY = my - scope.touchGesture.pinchSurfY * total; clampPan(); updateTransform(); - } else if (e.touches.length === 1 && !ts.isPinching) { + } else if (e.touches.length === 1 && !scope.touchGesture.isPinching) { const x = e.touches[0].clientX, y = e.touches[0].clientY; - const now = Date.now(), dt = now - ts.lastTime; + const now = Date.now(), dt = now - scope.touchGesture.lastTime; if (scope.term.element && scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { - scope.panX += x - ts.lastX; + scope.panX += x - scope.touchGesture.lastX; clampPan(); updateTransform(); } - const deltaY = ts.lastY - y; - ts.lastTime = now; + const deltaY = scope.touchGesture.lastY - y; + scope.touchGesture.lastTime = now; if (shouldRouteScrollToTerminalInput()) { updateTouchVelocity(deltaY, dt); resetSmoothScrollOffset(); const effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - const lines = Math.trunc(ts.accumDelta / effectiveCellH); + scope.touchGesture.accumDelta += deltaY; + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH); if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; + scope.touchGesture.accumDelta -= lines * effectiveCellH; routeScrollLines(lines, x, y); } } else { if (enqueueNormalBufferScrollDelta(deltaY)) { updateTouchVelocity(deltaY, dt); } else { - ts.velY = 0; + scope.touchGesture.velY = 0; } } - ts.lastX = x; - ts.lastY = y; + scope.touchGesture.lastX = x; + scope.touchGesture.lastY = y; } }, { capture: true, passive: false } @@ -3251,8 +3229,8 @@ window.onerror = function(msg) { if (!scope.term) { return; } - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; + if (scope.touchGesture.isPinching && e.touches.length < 2) { + scope.touchGesture.isPinching = false; const target = snapToTextScalePreset(scope.currentTextScale * scope.userScale); const changed = target !== scope.currentTextScale; scope.userScale = 1; @@ -3265,11 +3243,11 @@ window.onerror = function(msg) { notify({ type: "haptic", kind: "selection" }); } if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; + scope.touchGesture.lastX = e.touches[0].clientX; + scope.touchGesture.lastY = e.touches[0].clientY; + scope.touchGesture.lastTime = Date.now(); + scope.touchGesture.velY = 0; + scope.touchGesture.accumDelta = 0; } return; } @@ -3277,32 +3255,32 @@ window.onerror = function(msg) { let momentumStep = function() { vel *= FRICTION; if (Math.abs(vel) < MIN_VEL) { - ts.momentumId = null; + scope.touchGesture.momentumId = null; return; } const delta = vel * 16; if (shouldRouteScrollToTerminalInput()) { resetSmoothScrollOffset(); const effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - const lines = Math.trunc(ts.accumDelta / effectiveCellH); + scope.touchGesture.accumDelta += delta; + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH); if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); + scope.touchGesture.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY); } } else { if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; + scope.touchGesture.momentumId = null; return; } } - ts.momentumId = requestAnimationFrame(momentumStep); + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep); }; - let vel = ts.velY; + let vel = scope.touchGesture.velY; const FRICTION = 0.972; const MIN_VEL = 0.012; if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep); } } }, @@ -3312,6 +3290,12 @@ window.onerror = function(msg) { function startSurfaceTouchGestures() { attachSurfaceEventHandlers(scope.surface); } + function stopSurfaceTouchGestures() { + if (scope.touchGesture.momentumId !== null) { + cancelAnimationFrame(scope.touchGesture.momentumId); + scope.touchGesture.momentumId = null; + } + } function handleIncomingMessage(e) { let msg; try { @@ -3345,17 +3329,14 @@ window.onerror = function(msg) { reportEngineError("terminal engine missing", "xterm failed to load", true); } } + resetTerminalDocumentScope(); startRuntimeConstants(); - startTerminalHandle(); startSurfaceSwap(); startTextScaling(); - startViewportTransform(); startWebglRecovery(); startHostNotify(); startSelectionStateAndEviction(); - startModeMirroring(); startTapDispatch(); - startWheelScroll(); startSelectionMenuButtons(); startSurfaceTouchGestures(); startMessageBridge(); diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts index cbcfe96e5c8..87aa9b99c4e 100644 --- a/mobile/src/terminal/terminal-web-document-mount.ts +++ b/mobile/src/terminal/terminal-web-document-mount.ts @@ -36,8 +36,11 @@ const STYLE_ELEMENT_ID = 'orca-terminal-document-style' * * `