diff --git a/mobile/src/terminal/document/document-host-seams.ts b/mobile/src/terminal/document/document-host-seams.ts index 6eb2f799eaa..1afaac6c1a1 100644 --- a/mobile/src/terminal/document/document-host-seams.ts +++ b/mobile/src/terminal/document/document-host-seams.ts @@ -45,6 +45,9 @@ export type TerminalDocumentErrorReporter = ( /** A frame from the host, as its transport delivers it: JSON text from a bridge, or the object. */ export type TerminalDocumentHostFrame = string | Record | undefined +/** How a document starts, as its view mounted. */ +export type TerminalDocumentStart = { textScale: number; shown: boolean } + /** * The ten host seams, kept apart from the state because the host sets them once when it builds * the scope, before the start sequence runs, and no module writes them afterwards. @@ -68,13 +71,12 @@ export type TerminalDocumentHostSeams = { installHostTransport: (receive: (frame: TerminalDocumentHostFrame) => void) => () => void /** `message-bridge`: whether the engine is here, which is what readiness is reported on. */ hasEngine: () => boolean - /** `text-scaling`: the app's text scale, so the terminal built before ready lays out at it. */ - initialTextScale: () => number /** - * `message-bridge`: whether to build the terminal before ready. Only a document shown when its - * view mounted does; every terminal is a WebGL context, and a page or app holds about sixteen. + * How the document starts, as its view mounted: the app's text scale, which the terminal built + * before ready lays out at, and whether it was shown. Only a shown document builds before ready; + * every terminal is a WebGL context, and a page or app holds about sixteen. */ - buildsTerminalBeforeReady: () => boolean + start: () => TerminalDocumentStart /** Every fit, pan, scroll and overlay bound, and every client point mapped into the grid. */ viewportRect: () => TerminalDocumentViewportRect /** `fit-scale`: calls back when that box changes size or is shown again, handing back its removal. */ @@ -231,13 +233,12 @@ export function windowHasEngine() { return window.Terminal !== undefined } -export function windowInitialTextScale() { +export function windowStart(): TerminalDocumentStart { const scale = window.__orcaTerminalTextScale - return typeof scale === 'number' && scale > 0 ? scale : 1 -} - -export function windowBuildsTerminalBeforeReady() { - return window.__orcaTerminalShown !== false + return { + textScale: typeof scale === 'number' && scale > 0 ? scale : 1, + shown: window.__orcaTerminalShown !== false + } } /** diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 36194def1d2..1415f0f27f1 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -1,3 +1,4 @@ +import type { TerminalCellBox } from '../terminal-cell-box' import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme' import { createEngineTerminal, @@ -9,8 +10,7 @@ import { postToReactNativeWebView, windowCapturedEngineErrors, windowHasEngine, - windowInitialTextScale, - windowBuildsTerminalBeforeReady, + windowStart, observeWindowViewport, windowViewportRect, type TerminalDocumentHost, @@ -97,7 +97,7 @@ export type TerminalDocumentState = { /** `terminal-init`: whether the first live chunk since init is still pending. */ firstDataPending: boolean /** `cell-metrics-probe`: the laid-out cell box last reported for the current terminal. */ - reportedCellBox: string + reportedCellBox: { cellBox: TerminalCellBox; cols: number; rows: number } | null /** `terminal-init`: whether the replayed snapshot was an alternate screen. */ activeAltScreenSnapshot: boolean /** `fit-scale`: the fit scale the document committed. */ @@ -271,7 +271,7 @@ function createTerminalDocumentState(): TerminalDocumentState { currentTextScale: 1, terminalFontFamily: '', firstDataPending: false, - reportedCellBox: '', + reportedCellBox: null, activeAltScreenSnapshot: false, currentScale: 1, userScale: 1, @@ -357,8 +357,7 @@ function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams { paintDocumentBackground: paintWindowDocumentBackground, installHostTransport: installWindowHostTransport, hasEngine: windowHasEngine, - initialTextScale: windowInitialTextScale, - buildsTerminalBeforeReady: windowBuildsTerminalBeforeReady, + start: windowStart, viewportRect: windowViewportRect, observeViewport: observeWindowViewport, root: null diff --git a/mobile/src/terminal/document/laid-out-cell-box.test.ts b/mobile/src/terminal/document/laid-out-cell-box.test.ts index 9554484f995..a9c12bac065 100644 --- a/mobile/src/terminal/document/laid-out-cell-box.test.ts +++ b/mobile/src/terminal/document/laid-out-cell-box.test.ts @@ -146,7 +146,7 @@ describe('a started document', () => { }) it('reports ready with the box its own terminal laid out, at the app text scale', () => { - const { scope, posted, built } = started({ initialTextScale: () => 1.25 }) + const { scope, posted, built } = started({ start: () => ({ textScale: 1.25, shown: true }) }) try { expect(built).toHaveLength(1) expect(built[0].options.fontSize).toBe(fontPxForScale(1.25)) @@ -175,7 +175,7 @@ describe('a started document', () => { }) it('builds nothing before ready for a view hidden when it mounted, and reports no box', () => { - const { scope, posted, built } = started({ buildsTerminalBeforeReady: () => false }) + const { scope, posted, built } = started({ start: () => ({ textScale: 1, shown: false }) }) try { expect(built).toHaveLength(0) expect(posted[0]).toEqual({ type: 'web-ready', cellBox: null }) diff --git a/mobile/src/terminal/document/laid-out-cell-box.ts b/mobile/src/terminal/document/laid-out-cell-box.ts index d16c74e1abd..30bbe036e2e 100644 --- a/mobile/src/terminal/document/laid-out-cell-box.ts +++ b/mobile/src/terminal/document/laid-out-cell-box.ts @@ -28,15 +28,17 @@ export function reportLaidOutCellBox(scope: TerminalDocumentScope) { return } const { cols, rows } = scope.term - const key = `${laidOut.fontScale}:${laidOut.cellWidth}x${laidOut.cellHeight}@${cols}x${rows}` - if (key === scope.reportedCellBox) { + const last = scope.reportedCellBox + if ( + last && + last.cols === cols && + last.rows === rows && + last.cellBox.fontScale === laidOut.fontScale && + last.cellBox.cellWidth === laidOut.cellWidth && + last.cellBox.cellHeight === laidOut.cellHeight + ) { return } - scope.reportedCellBox = key - notify(scope, { - type: 'cell-metrics', - cellBox: laidOut, - cols, - rows - }) + scope.reportedCellBox = { cellBox: laidOut, cols, rows } + notify(scope, { type: 'cell-metrics', ...scope.reportedCellBox }) } diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index bc066efaacc..47b80f6eb15 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -48,7 +48,7 @@ export function startMessageBridge(scope: TerminalDocumentScope) { ) if (scope.hasEngine()) { // Why: ready carries the cell box xterm itself laid out, so the host sizes the first subscribe. - if (scope.buildsTerminalBeforeReady()) { + if (scope.start().shown) { prepareTerminal(scope) } notify(scope, { type: 'web-ready', cellBox: laidOutCellBox(scope) }) diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts index 3d4684ea144..b859fb23ef8 100644 --- a/mobile/src/terminal/document/term-observers.ts +++ b/mobile/src/terminal/document/term-observers.ts @@ -11,7 +11,7 @@ export function attachTermObservers(scope: TerminalDocumentScope) { return } disposeTermObservers(scope) - scope.reportedCellBox = '' + scope.reportedCellBox = null try { scope.termObserverDisposables.push( scope.term.onLineFeed!(function () { diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts index 0b0a11101de..88d7b5dd762 100644 --- a/mobile/src/terminal/document/text-scaling.ts +++ b/mobile/src/terminal/document/text-scaling.ts @@ -102,7 +102,7 @@ export function applyTextScale(scope: TerminalDocumentScope, scale: number) { } export function startTextScaling(scope: TerminalDocumentScope) { - scope.currentTextScale = scope.initialTextScale() + scope.currentTextScale = scope.start().textScale scope.scrollIndicator = elementInRoot(scope.root, 'scroll-indicator') scope.scrollThumb = elementInRoot(scope.root, 'scroll-thumb') scope.terminalFontFamily = diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 242624ebe07..ef7caedda3c 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -32,8 +32,8 @@ vi.mock('./document/create-terminal-document', async (importOriginal) => { return { ...actual, createTerminalDocument: (host: Parameters[0]) => { - startedScales.push(host?.initialTextScale?.() ?? 1) - startedBuilds.push(host?.buildsTerminalBeforeReady?.() ?? true) + startedScales.push(host?.start?.().textScale ?? 1) + startedBuilds.push(host?.start?.().shown ?? true) if (startThrows) { throw startThrows } diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts index f5ae7402a4d..b16afa80095 100644 --- a/mobile/src/terminal/terminal-web-document-mount.ts +++ b/mobile/src/terminal/terminal-web-document-mount.ts @@ -2,7 +2,7 @@ import { Terminal } from '@xterm/xterm' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebglAddon } from '@xterm/addon-webgl' import type { TerminalDocumentTerminal } from './document/document-terminal-shape' -import type { TerminalViewportChange } from './document/document-host-seams' +import type { TerminalDocumentStart, TerminalViewportChange } from './document/document-host-seams' import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' import { scopeStyleToHost } from '../style-scoping/document-style-scoping' import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' @@ -99,12 +99,10 @@ function createPageWebglAddon(onFallback: (reason: string) => void) { * A caller's cleanup can therefore never arrive before there is something to clean up. */ /** What the view fixed when it mounted, for every document it builds. */ -export type TerminalWebDocumentStart = { textScale: number; shown: boolean } - export function mountTerminalWebDocument( host: HTMLElement, receive: (message: Record) => void, - start: TerminalWebDocumentStart = { textScale: 1, shown: true } + start: TerminalDocumentStart = { textScale: 1, shown: true } ): TerminalWebDocument { ensureDocumentStyle() host.classList.add(HOST_CLASS) @@ -138,7 +136,7 @@ export function mountTerminalWebDocument( function startDocumentOrGiveTheHostBack( host: HTMLElement, receive: (message: Record) => void, - start: TerminalWebDocumentStart, + start: TerminalDocumentStart, viewport: PageViewport ) { try { @@ -194,7 +192,7 @@ function pageViewport(host: HTMLElement) { function startPageDocument( host: HTMLElement, receive: (message: Record) => void, - start: TerminalWebDocumentStart, + start: TerminalDocumentStart, viewport: PageViewport ) { // Written by this document's own reporter: `startHostNotify` installs it through the seam below, @@ -242,8 +240,7 @@ function startPageDocument( // fail. Here the engine is the import above, so it is here or this module did not load. hasEngine: () => true, - initialTextScale: () => start.textScale, - buildsTerminalBeforeReady: () => start.shown, + start: () => start, // The window here is the whole page, header and dock included; the grid is shown in the host. viewportRect: viewport.rect, diff --git a/mobile/src/terminal/terminal-webview-cell-metrics.test.ts b/mobile/src/terminal/terminal-webview-cell-metrics.test.ts index 2f4aa221752..f37cc22f1e2 100644 --- a/mobile/src/terminal/terminal-webview-cell-metrics.test.ts +++ b/mobile/src/terminal/terminal-webview-cell-metrics.test.ts @@ -244,6 +244,13 @@ describe('the cell box xterm laid out', () => { expect(start).toBeLessThan(html.indexOf('startTerminalDocument')) }) + it('keeps the source it mounted with across renders, so a new text scale does not reload', () => { + const { webView } = mount(1) + const source = webView().props.source + act(() => renderers[0]!.update(createElement(TerminalWebView, { textScale: 1.5 }))) + expect(webView().props.source).toBe(source) + }) + it('tells a document whose view was hidden at mount not to build before ready', () => { let renderer: ReactTestRenderer | undefined act(() => { diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 2e6752b0271..65bbff60d35 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -21,29 +21,20 @@ export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_CLOSE ].join('') -const webViewSources = new Map() - /** * The WebView's document, starting as its view mounted: at this text scale, and whether it was - * shown. Written into the page ahead of the document script, which reads them as it starts; one - * object per pair, so a parent render never hands the WebView a new source and reloads it. + * shown. Written into the page ahead of the document script, which reads them as it starts. */ export function xtermWebViewSource(start: { textScale: number; shown: boolean }) { - const key = `${start.textScale}:${start.shown}` - let source = webViewSources.get(key) - if (!source) { - const startValues = - `window.__orcaTerminalTextScale = ${JSON.stringify(start.textScale)};\n` + - `window.__orcaTerminalShown = ${JSON.stringify(start.shown)};\n` - source = { - html: [ - TERMINAL_HTML_DOCUMENT_SHELL, - startValues, - TERMINAL_DOCUMENT_SCRIPT, - TERMINAL_HTML_DOCUMENT_CLOSE - ].join('') - } - webViewSources.set(key, source) + const startValues = + `window.__orcaTerminalTextScale = ${JSON.stringify(start.textScale)};\n` + + `window.__orcaTerminalShown = ${JSON.stringify(start.shown)};\n` + return { + html: [ + TERMINAL_HTML_DOCUMENT_SHELL, + startValues, + TERMINAL_DOCUMENT_SCRIPT, + TERMINAL_HTML_DOCUMENT_CLOSE + ].join('') } - return source } diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index a1945cc07d8..20b2d2a0afd 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -8,10 +8,6 @@ import { startTextScaling } from './document/text-scaling' import { webviewPageSource } from './document/document-module-source.test-support' const terminalWebViewSource = readFileSync(join(import.meta.dirname, 'TerminalWebView.tsx'), 'utf8') -const terminalHtmlModuleSource = readFileSync( - join(import.meta.dirname, 'terminal-webview-html.ts'), - 'utf8' -) const terminalHtmlDocumentShellSource = readFileSync( join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), 'utf8' @@ -65,10 +61,9 @@ describe('TerminalWebView text zoom', () => { const end = terminalWebViewSource.indexOf('/>', start) expect(end).toBeGreaterThan(start) const webViewProps = terminalWebViewSource.slice(start, end) - // One source object per view, and one per start pair across views. + // One source object per view, pinned at mount. expect(terminalWebViewSource).toContain('const [source] = useState(() =>') expect(webViewProps).toContain('source={source}') - expect(terminalHtmlModuleSource).toContain('webViewSources.set(key, source)') }) it('forces the Claude status dot to text presentation before xterm writes', () => {