From efeede31e010c234a69bf32f0f3f0806cdaa9d41 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 14:46:27 -0400 Subject: [PATCH] fix(mobile): move the document's state onto the scope (ruling 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2's blocking finding, and ruling 20's second half: moving parse-time effects out of the module bodies left the state behind. Nine module-level bindings survived a mount, so the second terminal inherited a spent non-fatal error budget (reporting nothing however it failed), the first terminal as its committed surface (disposing it twice), and the first mount's momentum loop. Every mutable binding now lives on the scope, and the scope carries one reset the start sequence calls first: native once at parse, the page once per mount. Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1, host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1, tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the objects tap-dispatch and surface-touch-gestures used to own outright. Because the reset is now the one initialiser, the start functions keep only what it cannot do: element reads, listener installs and the reporter install. Four start functions emptied and went; terminal-handle held nothing else and is deleted from the order list. The scope type splits into state and host seams, because a reset must restore the first and never the second. Every stop function cancels what its module scheduled. Timers go back through the handles the scope already held; frames go through the scope's own scheduleDocumentFrame, so dispose can take back the ones no module tracks by id. terminalGeneration and fitRetryToken carry forward across a reset, because a stale callback tests itself against them and a reset to zero would make the old number match again. L2: the seams-before-scope case asserts the order in the emitted document, not just non-membership. L3: the style docstring says what is true — one scope per page, so mount refuses a second live document and gives the page back when a mount fails. Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256 6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/mobile-web-app-render-harness.mjs | 73 +++ .../mobile-web-app-terminal-render.test.mjs | 149 ++++-- .../build-terminal-document-script.mjs | 33 +- .../terminal-document-module-order.mjs | 14 +- .../document/document-module-order.test.ts | 27 +- .../document-parse-time-effects.test.ts | 53 +- .../src/terminal/document/document-scope.ts | 139 +++++- mobile/src/terminal/document/fit-scale.ts | 19 +- .../terminal/document/host-message-router.ts | 9 +- mobile/src/terminal/document/host-notify.ts | 16 +- .../src/terminal/document/mode-mirroring.ts | 10 - .../src/terminal/document/mouse-click-drag.ts | 20 +- .../document/normal-buffer-smooth-scroll.ts | 9 +- .../document/page-document-modules.ts | 57 +-- mobile/src/terminal/document/query-reply.ts | 12 +- .../terminal/document/runtime-constants.ts | 14 - .../terminal/document/selection-overlay.ts | 5 + .../document/selection-state-and-eviction.ts | 25 +- mobile/src/terminal/document/surface-swap.ts | 31 +- .../document/surface-touch-gestures.ts | 126 ++--- mobile/src/terminal/document/tap-dispatch.ts | 60 ++- .../src/terminal/document/terminal-handle.ts | 13 - mobile/src/terminal/document/terminal-init.ts | 12 +- mobile/src/terminal/document/text-scaling.ts | 30 +- .../terminal/document/viewport-transform.ts | 46 +- .../src/terminal/document/webgl-recovery.ts | 1 + mobile/src/terminal/document/wheel-scroll.ts | 4 - .../src/terminal/terminal-document-golden.txt | 459 +++++++++--------- .../terminal/terminal-web-document-mount.ts | 37 +- ...terminal-web-document-single-mount.test.ts | 58 +++ .../terminal/terminal-webview-engine.test.ts | 3 +- .../terminal-webview-payload-hash.test.ts | 4 +- .../terminal/terminal-webview-reflow.test.ts | 7 +- .../terminal-webview-scroll-routing.test.ts | 16 +- .../terminal-webview-text-zoom.test.ts | 32 +- 35 files changed, 994 insertions(+), 629 deletions(-) delete mode 100644 mobile/src/terminal/document/terminal-handle.ts create mode 100644 mobile/src/terminal/terminal-web-document-single-mount.test.ts 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' * * `