mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix(mobile): the page pushes its terminal frame into the document from RN layout (#23079)
* fix(mobile): keep one mounted terminal frame under every session branch The frame was keyed so react-native-web would attach its onLayout: a View that gains onLayout after mount is never observed, and unkeyed the frame reused the loading branch's View. One contentFrame View now wraps every branch and carries the handler from the first mount, so no key is needed. terminalFrame stays on the terminal branch, so nothing else is clipped. The parity pin moves: the key string leaves, one View and one style reference arrive. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): push the page terminal's box into its document from RN layout The page's document sized itself through a ResizeObserver on its host, which duplicated RN layout and needed three rules in fit-scale: skip a 0-wide box, skip the last fitted box, and forget that box on any fit request. The host View's onLayout now pushes into the mount, which is the page's counterpart of the WebView's window resize. react-native-web still lays a display:none screen out as 0x0 and its return as the old box, so the mount treats neither as a change and keeps answering the last real box while hidden, as a WebView keeps its size. A fit asked for while hidden therefore lands at once, and fittedBox and all three rules go. The zero-width wait in applyFitScale stays for a host that mounts under a hidden screen before its first layout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a page terminal's fit while its host is hidden A fit asked for under a covering screen read the last box, but where xterm cannot measure cells on a display:none host (its DOM measure, with no OffscreenCanvas) the retry loop ran out and committed scale 1, and the show that followed was not a change, so 1 stuck. The page's rect now says when the host is hidden, and the document holds any fit asked for then as fitPending instead of committing. The mount reports the same box coming back as 'shown', distinct from 'resized', and the document runs a held fit on it and otherwise does nothing, so pan and zoom still survive a plain hide and show. Native's window resize reports 'resized' and is never hidden. The mount also seeds its box from the host, so a first layout that lands before the mount is not later mistaken for a resize. The hidden text-scale case now asserts the resized column count, which stale cells miss. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the page terminal's grid once xterm has sized its cell The init render check read the grid the moment .xterm-screen existed, but xterm sizes its one-cell helper textarea only on a cursor move or resize, after the replay drains. Under full-suite load the read won that race and measured a 0-wide cell. In the failing runs the fit had committed at 390/560 on the cell-width gate, so the page was right and the read was early. It now waits for a sized cell: 8/8 under four-way parallel load, where it was 4/8. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a held page terminal fit runs once A held fit that lands on show must be spent: a second hide and show re-running it would reset the pan and zoom the user set in between. The case now hides and shows again and expects no new transform, which a commit that stops clearing fitPending fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the refit a narrow hidden text-scale change owes A text-scale change whose new cells leave fewer than MIN_FIT_COLS in the box skips the grid resize and returned before any fit. Base cleared fittedBox there so the next box refit; with that gone, a hidden host shown at the same box kept the old scale under the larger font. The branch now asks for the fit while the host is hidden, which holds it until show. A shown host, and every native one, returns as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fit a text-scale change too large to resize the grid A visible viewport too narrow for the new cells (280 px at 200%, 18 columns) skipped the resize and returned without a fit, so the larger text overflowed the fit made for the smaller cells. The branch now fits unconditionally; applyFitScale already holds the fit while hidden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -257,8 +257,17 @@ describeRender(
|
||||
// `.xterm-screen` does: it is created by `term.open()`, which only ever runs from the
|
||||
// document's `init`. The emulator run had the surface with zero children inside a correctly
|
||||
// sized container, which is this element missing.
|
||||
// Then the grid readable: xterm sizes its one-cell helper textarea only on a cursor move or
|
||||
// resize, so a read the moment the screen exists can land before the replay has drained.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.xterm-screen') !== null,
|
||||
() => {
|
||||
const cell = document.querySelector('#terminal-surface .xterm-helper-textarea')
|
||||
return (
|
||||
document.querySelector('.xterm-screen') !== null &&
|
||||
cell !== null &&
|
||||
cell.getBoundingClientRect().width > 0
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -81,7 +81,7 @@ export function MobileSessionActiveContent({
|
||||
toastAnimatedStyle,
|
||||
createTabBusy
|
||||
} = controller
|
||||
return showLoadingState ? (
|
||||
const content = showLoadingState ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
@@ -192,19 +192,7 @@ export function MobileSessionActiveContent({
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
// Why: react-native-web observes onLayout only on a View that mounts with it; unkeyed, this reuses the loading View and never reports.
|
||||
key="terminal-frame"
|
||||
style={styles.terminalFrame}
|
||||
onLayout={(e) => {
|
||||
terminalFrameHeightRef.current = e.nativeEvent.layout.height
|
||||
// Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
|
||||
const nextWidth = Math.round(e.nativeEvent.layout.width)
|
||||
const nextHeight = Math.round(e.nativeEvent.layout.height)
|
||||
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
|
||||
notifyTerminalFrameHeight(nextHeight)
|
||||
}}
|
||||
>
|
||||
<View style={styles.terminalFrame}>
|
||||
{terminals.map((terminal) => (
|
||||
<TerminalPaneView
|
||||
key={terminal.handle}
|
||||
@@ -256,4 +244,20 @@ export function MobileSessionActiveContent({
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<View
|
||||
// Why: one frame under every branch; react-native-web observes onLayout only on a View that mounts with it.
|
||||
style={styles.contentFrame}
|
||||
onLayout={(e) => {
|
||||
terminalFrameHeightRef.current = e.nativeEvent.layout.height
|
||||
// Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
|
||||
const nextWidth = Math.round(e.nativeEvent.layout.width)
|
||||
const nextHeight = Math.round(e.nativeEvent.layout.height)
|
||||
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
|
||||
notifyTerminalFrameHeight(nextHeight)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ export const mobileSessionFrameStyles = StyleSheet.create({
|
||||
height: 18,
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
// The content slot every branch renders in, measured as the terminal's frame; clips nothing.
|
||||
contentFrame: {
|
||||
flex: 1,
|
||||
minHeight: 0
|
||||
},
|
||||
terminalFrame: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -174,13 +174,18 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345
|
||||
// 529 -> 530, and the host-JSX hash: `key="terminal-frame"`, so the page's frame mounts with its
|
||||
// onLayout rather than reusing the loading View. Native measured 47 rows before and after: its
|
||||
// frame reported either way, and its window is its frame, so both measure paths agree there.
|
||||
//
|
||||
// 531 -> 530, and the host-JSX and style hashes: the key left, and one `contentFrame` View wraps
|
||||
// every branch and carries the frame's onLayout, so the page's frame mounts with it. The page measured
|
||||
// 47 rows before and after; native was measured only on main's bundle (47), and the wrapper is a
|
||||
// flex:1 View around the same flex:1 frame, so its box is the frame's.
|
||||
const HEAD_RUNTIME_STRING_SHA256 =
|
||||
'1be5398cdbdf96b6f7738b63a0e19f536496844140a947939a7f71824b08fe50'
|
||||
'7dd03af1ad61e2f394b8cba35a422f7195de60ae5d608923b52575a56a4dbe42'
|
||||
// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now.
|
||||
const HEAD_HOST_JSX_SHA256 = 'ca4c8b46af86a05cb671de91c22111c9e4aaeefd4a04c7b8b09976ca01a31c9b'
|
||||
const HEAD_HOST_JSX_SHA256 = '1478283a1597c88920aedfea9f6ed13d119ea8f93546e1628d0918cd0a5248ef'
|
||||
const HEAD_LEAF_JSX_SHA256 = 'c7e1a4b90197697f1eaa640c38da63281b4f7b84fb036ae2152f00c2f7d7cb77'
|
||||
const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
|
||||
'56a005a1f65b30c11092e3422caef67810e1ec50f66fdd06471c370138b1eeb6'
|
||||
const HEAD_IDENTITY_FIELD_SHA256 =
|
||||
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
|
||||
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
|
||||
@@ -620,14 +625,14 @@ describe('mobile session route extraction parity', () => {
|
||||
|
||||
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
|
||||
const strings = readRuntimeStrings()
|
||||
expect(strings).toHaveLength(531)
|
||||
expect(strings).toHaveLength(530)
|
||||
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
|
||||
const jsx = readJsxFacts(readDefinitions())
|
||||
expect(jsx.host).toHaveLength(124)
|
||||
expect(jsx.host).toHaveLength(125)
|
||||
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
|
||||
expect(jsx.leaf).toHaveLength(61)
|
||||
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
|
||||
expect(jsx.styleReferences).toHaveLength(172)
|
||||
expect(jsx.styleReferences).toHaveLength(173)
|
||||
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,12 +45,17 @@ import { MobileSessionActiveContent } from './MobileSessionActiveContent'
|
||||
|
||||
type Controller = Parameters<typeof MobileSessionActiveContent>[0]['controller']
|
||||
|
||||
type Branch = 'loading' | 'pending' | 'terminal'
|
||||
|
||||
function controller(
|
||||
showLoadingState: boolean,
|
||||
branch: Branch | boolean,
|
||||
notifyTerminalFrameHeight: (height: number) => void
|
||||
): Controller {
|
||||
const shown = branch === true ? 'loading' : branch === false ? 'terminal' : branch
|
||||
const scope = {
|
||||
showLoadingState,
|
||||
showLoadingState: shown === 'loading',
|
||||
activePendingTerminalTab: shown === 'pending' ? { title: 'Loading terminal' } : null,
|
||||
isPendingTerminalRecoveryParked: false,
|
||||
showEmptyState: false,
|
||||
terminals: [],
|
||||
terminalFrameHeightRef: { current: 0 },
|
||||
@@ -82,4 +87,30 @@ describe('the terminal frame on the page', () => {
|
||||
})
|
||||
expect(heights).toEqual([FRAME.height])
|
||||
})
|
||||
|
||||
it('stays one mounted frame across loading, a pending terminal and the terminal', () => {
|
||||
// A frame that remounts per branch reports again on every return; one that stays reports once.
|
||||
const heights: number[] = []
|
||||
const notify = (height: number): void => {
|
||||
heights.push(height)
|
||||
}
|
||||
let renderer: ReturnType<typeof create> | undefined
|
||||
const show = (branch: Branch) => {
|
||||
const element = createElement(MobileSessionActiveContent, {
|
||||
controller: controller(branch, notify)
|
||||
})
|
||||
act(() => {
|
||||
if (renderer) {
|
||||
renderer.update(element)
|
||||
} else {
|
||||
renderer = create(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
show('loading')
|
||||
show('terminal')
|
||||
show('pending')
|
||||
show('terminal')
|
||||
expect(heights).toEqual([FRAME.height])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,6 +103,10 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
|
||||
// scrollback, and the controller's identity changes with every callback prop.
|
||||
}, [generation])
|
||||
|
||||
const handleHostLayout = useCallback(() => {
|
||||
documentRef.current?.notifyViewport()
|
||||
}, [])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
resetReadiness()
|
||||
@@ -112,7 +116,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
|
||||
|
||||
return (
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, props.style]}>
|
||||
<View key={generation} ref={hostRef} style={TERMINAL_WEBVIEW_FRAME_STYLES.webview} />
|
||||
{/* Why: mounted with onLayout, so react-native-web observes it; the document sizes to this box. */}
|
||||
<View
|
||||
key={generation}
|
||||
ref={hostRef}
|
||||
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
|
||||
onLayout={handleHostLayout}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
|
||||
@@ -26,8 +26,13 @@ export type TerminalDocumentViewportRect = {
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
/** The host is `display:none` and the size is the last one it had; a fit waits for it to show. */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/** What the host reports: a new box, or the same box back from being hidden. */
|
||||
export type TerminalViewportChange = 'resized' | 'shown'
|
||||
|
||||
/** The document's runtime error reporter, taking the window error handler's own arguments. */
|
||||
export type TerminalDocumentErrorReporter = (
|
||||
message: string | (Event & { message?: unknown }),
|
||||
@@ -65,8 +70,8 @@ export type TerminalDocumentHostSeams = {
|
||||
hasEngine: () => boolean
|
||||
/** 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, handing back its removal. */
|
||||
observeViewport: (onChange: () => void) => () => void
|
||||
/** `fit-scale`: calls back when that box changes size or is shown again, handing back its removal. */
|
||||
observeViewport: (onChange: (change: TerminalViewportChange) => void) => () => void
|
||||
/**
|
||||
* Where this document's elements are: the node its markup was planted in, or null for the page
|
||||
* the document is running in.
|
||||
@@ -222,10 +227,11 @@ export function windowViewportRect(): TerminalDocumentViewportRect {
|
||||
}
|
||||
|
||||
/** The WebView's frame changes size exactly when its window does. */
|
||||
export function observeWindowViewport(onChange: () => void) {
|
||||
window.addEventListener('resize', onChange)
|
||||
export function observeWindowViewport(onChange: (change: TerminalViewportChange) => void) {
|
||||
const listener = () => onChange('resized')
|
||||
window.addEventListener('resize', listener)
|
||||
return function () {
|
||||
window.removeEventListener('resize', onChange)
|
||||
window.removeEventListener('resize', listener)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,8 +181,8 @@ export type TerminalDocumentState = {
|
||||
removeWebglRecovery: (() => void) | null
|
||||
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
|
||||
fitRetryToken: number
|
||||
/** `fit-scale`: the viewport box the last fit was committed for, or null before one. */
|
||||
fittedBox: { width: number; height: number } | null
|
||||
/** `fit-scale`: the reason of a fit held while the host is hidden, or null when none is owed. */
|
||||
fitPending: string | null
|
||||
/** `mouse-click-drag`: the mouse gesture in progress, or null. */
|
||||
mouseGesture: TerminalMouseGesture | null
|
||||
/** `tap-dispatch`: what the document-level dispatcher has latched onto. */
|
||||
@@ -308,7 +308,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
|
||||
removeTapDispatch: null,
|
||||
removeWebglRecovery: null,
|
||||
fitRetryToken: 0,
|
||||
fittedBox: null,
|
||||
fitPending: null,
|
||||
mouseGesture: null,
|
||||
touchDispatch: {
|
||||
mode: 'idle',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
updateTransform
|
||||
} from './viewport-transform'
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import type { TerminalViewportChange } from './document-host-seams'
|
||||
import { scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
|
||||
@@ -60,23 +61,15 @@ export function adjustRowsForViewport() {}
|
||||
// so a backgrounded WebView never spins forever.
|
||||
const FIT_RETRY_MAX_FRAMES = 60
|
||||
|
||||
function isFittedBox(scope: TerminalDocumentScope) {
|
||||
const { width, height } = scope.viewportRect()
|
||||
const fitted = scope.fittedBox
|
||||
return fitted !== null && fitted.width === width && fitted.height === height
|
||||
}
|
||||
|
||||
function hasViewportWidth(scope: TerminalDocumentScope) {
|
||||
const width = scope.viewportRect().width
|
||||
return Number.isFinite(width) && width > 0
|
||||
function isViewportShown(scope: TerminalDocumentScope) {
|
||||
const { width, hidden } = scope.viewportRect()
|
||||
return hidden !== true && Number.isFinite(width) && width > 0
|
||||
}
|
||||
|
||||
export function applyFitScale(scope: TerminalDocumentScope, reason: string) {
|
||||
if (!scope.term || !scope.term.element) {
|
||||
return
|
||||
}
|
||||
// Why: a fit asked for while hidden is dropped until a box arrives, and that box may be the old one.
|
||||
scope.fittedBox = null
|
||||
const token = ++scope.fitRetryToken
|
||||
let attempts = 0
|
||||
let lastScrollWidth = -1
|
||||
@@ -87,8 +80,9 @@ export function applyFitScale(scope: TerminalDocumentScope, reason: string) {
|
||||
if (!scope.term || !scope.term.element) {
|
||||
return
|
||||
}
|
||||
// Why: a display:none host measures 0 wide; the fit stays pending until observeViewport reports a box.
|
||||
if (!hasViewportWidth(scope)) {
|
||||
// Why: a hidden grid may not measure its cells, so the fit is held until the host is shown.
|
||||
if (!isViewportShown(scope)) {
|
||||
scope.fitPending = reason
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
@@ -129,8 +123,7 @@ export function commitFitScale(
|
||||
return
|
||||
}
|
||||
const preSnapScale = computeFitScale(scope)
|
||||
const { width, height } = scope.viewportRect()
|
||||
scope.fittedBox = { width, height }
|
||||
scope.fitPending = null
|
||||
scope.currentScale = preSnapScale
|
||||
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
|
||||
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
|
||||
@@ -178,10 +171,12 @@ export function commitFitScale(
|
||||
* had to copy the five calls into its mount to get it at all (ruling 24).
|
||||
*/
|
||||
export function startFitScale(scope: TerminalDocumentScope) {
|
||||
const refit = () => {
|
||||
// Why: a hidden screen reports 0x0 and then its old box again; native never refits on that, so
|
||||
// only a box other than the one last fitted refits, and the user's pan and zoom survive.
|
||||
if (!hasViewportWidth(scope) || isFittedBox(scope)) {
|
||||
const refit = (change: TerminalViewportChange) => {
|
||||
// Why: showing the same box again is not a resize; only a fit held while hidden runs, so pan and zoom survive.
|
||||
if (change === 'shown') {
|
||||
if (scope.fitPending !== null) {
|
||||
applyFitScale(scope, scope.fitPending)
|
||||
}
|
||||
return
|
||||
}
|
||||
applyFitScale(scope, 'window-resize')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { handleMsg } from './host-message-router'
|
||||
import { notify } from './host-notify'
|
||||
import { flog } from './viewport-transform'
|
||||
import { attachWebglAddon } from './webgl-recovery'
|
||||
import type { TerminalDocumentHost } from './document-host-seams'
|
||||
import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
|
||||
import { documentSourceText } from './document-module-source.test-support'
|
||||
|
||||
/**
|
||||
@@ -299,7 +299,7 @@ describe('the document host seams, once the page sets them', () => {
|
||||
|
||||
describe("the document's viewport", () => {
|
||||
it('refits when the host says its box changed, and not on a window resize it does not own', () => {
|
||||
const changes: (() => void)[] = []
|
||||
const changes: ((change: TerminalViewportChange) => void)[] = []
|
||||
const scope = startedScope({
|
||||
createTerminal: () => terminalDouble(),
|
||||
observeViewport: (onChange) => {
|
||||
@@ -312,7 +312,10 @@ describe("the document's viewport", () => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(scope.panX).toBe(50)
|
||||
expect(changes).toHaveLength(1)
|
||||
changes[0]!()
|
||||
// The same box shown again with no fit held is not a resize: the pan stays.
|
||||
changes[0]!('shown')
|
||||
expect(scope.panX).toBe(50)
|
||||
changes[0]!('resized')
|
||||
expect(scope.panX).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export function applyTextScale(scope: TerminalDocumentScope, scale: number) {
|
||||
if (cellW > 0 && cellH > 0) {
|
||||
const cols = Math.floor(scope.viewportRect().width / cellW)
|
||||
if (cols < MIN_FIT_COLS) {
|
||||
// Why: hidden (0 wide) or too narrow; the next box must refit at the new cell size.
|
||||
scope.fittedBox = null
|
||||
// Why: too narrow to resize the grid, but the fit still tracks the new cell size; hidden hosts hold it.
|
||||
applyFitScale(scope, 'text-scale')
|
||||
return
|
||||
}
|
||||
const rows = Math.max(8, Math.floor(scope.viewportRect().height / cellH))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
|
||||
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
|
||||
import type { TerminalDocumentHost } from './document-host-seams'
|
||||
import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
|
||||
import { terminalDocumentDouble } from './document-terminal-double.test-support'
|
||||
import { handleMsg } from './host-message-router'
|
||||
import { viewportToMouseReportCell } from './mouse-report-cell'
|
||||
@@ -110,10 +110,12 @@ describe("the document's frame on the page", () => {
|
||||
expect(computeFitScale(scope)).toBe(1)
|
||||
})
|
||||
|
||||
it('commits no fit while the host is hidden, and one once it has a box', async () => {
|
||||
it('commits no fit before the host has a box, and one once it has', async () => {
|
||||
// A page host mounted under a hidden screen has no box until RN lays it out; hide and show after
|
||||
// that are the mount's to absorb (terminal-web-document-mount.test.ts).
|
||||
let box = { left: 0, top: 0, width: 0, height: 0 }
|
||||
const widthsRead: number[] = []
|
||||
const changes: (() => void)[] = []
|
||||
const changes: ((change: TerminalViewportChange) => void)[] = []
|
||||
const scope = startedWithGrid({
|
||||
viewportRect: () => {
|
||||
widthsRead.push(box.width)
|
||||
@@ -135,7 +137,7 @@ describe("the document's frame on the page", () => {
|
||||
await nextFrame()
|
||||
expect(scales).toEqual([])
|
||||
box = { left: 0, top: 0, width: 390, height: 600 }
|
||||
changes.forEach((onChange) => onChange())
|
||||
changes.forEach((onChange) => onChange('resized'))
|
||||
await framesUntil(() => scales.length === 2)
|
||||
// The refit repaints at the scale it has, then the fit commits once: 390 / (7.5 x 55).
|
||||
expect(scales).toEqual(['1', String(FIT_390)])
|
||||
@@ -157,92 +159,4 @@ describe("the document's frame on the page", () => {
|
||||
expect(edge(400)).toBe(0)
|
||||
expect(edge(120)).toBe(-1)
|
||||
})
|
||||
|
||||
it('keeps pan and zoom across hide and show, and refits when the box really changes', async () => {
|
||||
// react-native-screens hides the session when another screen covers it and shows it again at
|
||||
// the same size; native never refits on navigation, so the pan the user left must survive.
|
||||
let box = { left: 0, top: 0, width: 390, height: 600 }
|
||||
const changes: (() => void)[] = []
|
||||
const scope = startedWithGrid({
|
||||
viewportRect: () => box,
|
||||
observeViewport: (onChange) => {
|
||||
changes.push(onChange)
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
const resize = async (width: number, height: number) => {
|
||||
box = { left: 0, top: 0, width, height }
|
||||
changes.forEach((onChange) => onChange())
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
}
|
||||
await framesUntil(() => scope.currentScale === FIT_390)
|
||||
scope.panX = -40
|
||||
scope.panY = -30
|
||||
scope.userScale = 1.5
|
||||
const kept = { panX: -40, panY: -30, userScale: 1.5, currentScale: scope.currentScale }
|
||||
const view = () => {
|
||||
const { panX, panY, userScale, currentScale } = scope
|
||||
return { panX, panY, userScale, currentScale }
|
||||
}
|
||||
|
||||
await resize(0, 0)
|
||||
expect(view()).toEqual(kept)
|
||||
await resize(390, 600)
|
||||
expect(view()).toEqual(kept)
|
||||
|
||||
box = { left: 0, top: 0, width: 300, height: 600 }
|
||||
changes.forEach((onChange) => onChange())
|
||||
await framesUntil(() => scope.currentScale === 300 / (7.5 * 55))
|
||||
expect(view()).toEqual({ panX: 0, panY: 0, userScale: 1, currentScale: 300 / (7.5 * 55) })
|
||||
})
|
||||
|
||||
it('refits on show when a fit was asked for while hidden, even at the same box', async () => {
|
||||
let box = { left: 0, top: 0, width: 390, height: 600 }
|
||||
const changes: (() => void)[] = []
|
||||
const scope = startedWithGrid({
|
||||
viewportRect: () => box,
|
||||
observeViewport: (onChange) => {
|
||||
changes.push(onChange)
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
const show = (width: number, height: number) => {
|
||||
box = { left: 0, top: 0, width, height }
|
||||
changes.forEach((onChange) => onChange())
|
||||
}
|
||||
await framesUntil(() => scope.currentScale === FIT_390)
|
||||
show(0, 0)
|
||||
handleMsg(scope, { type: 'resize', cols: 80, rows: 40 })
|
||||
await nextFrame()
|
||||
show(390, 600)
|
||||
await framesUntil(() => scope.currentScale !== FIT_390, { required: false })
|
||||
expect(scope.currentScale).toBe(390 / (7.5 * 80))
|
||||
})
|
||||
|
||||
it('refits on show after a text-scale change while hidden', async () => {
|
||||
let box = { left: 0, top: 0, width: 390, height: 600 }
|
||||
const changes: (() => void)[] = []
|
||||
const scope = startedWithGrid({
|
||||
viewportRect: () => box,
|
||||
observeViewport: (onChange) => {
|
||||
changes.push(onChange)
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
const show = (width: number, height: number) => {
|
||||
box = { left: 0, top: 0, width, height }
|
||||
changes.forEach((onChange) => onChange())
|
||||
}
|
||||
await framesUntil(() => scope.currentScale === FIT_390)
|
||||
show(0, 0)
|
||||
handleMsg(scope, { type: 'set-font-scale', fontScale: 0.8 })
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
show(390, 600)
|
||||
// The smaller font's cells: 55 columns at fontPxForScale(0.8) = 10 px, 7.5 x 10/13 each.
|
||||
const fitted = 390 / (7.5 * (10 / 13) * 55)
|
||||
await framesUntil(() => scope.currentScale !== FIT_390, { required: false })
|
||||
expect(scope.currentScale).toBe(Math.min(1, fitted) >= 0.95 ? 1 : fitted)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ export function computeFitScale(scope: TerminalDocumentScope) {
|
||||
return 1
|
||||
}
|
||||
const vpWidth = scope.viewportRect().width
|
||||
// Why: a host hidden with display:none measures 0; a 0 scale would blank it when shown again.
|
||||
// Why: a viewport with no width yet (a page host never laid out) would give scale 0 and blank the grid.
|
||||
if (vpWidth <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { terminalDocumentDouble } from './document/document-terminal-double.test-support'
|
||||
import type { TerminalDocumentTerminal } from './document/document-terminal-shape'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,8 @@ import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
|
||||
*/
|
||||
/** Set for the length of one case; the factory throws it instead of building a document. */
|
||||
let startThrows: Error | null = null
|
||||
/** Set for the length of one case; the document builds its terminals here instead of xterm. */
|
||||
let gridTerminal: (() => TerminalDocumentTerminal) | null = null
|
||||
|
||||
vi.mock('./document/create-terminal-document', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./document/create-terminal-document')>()
|
||||
@@ -27,7 +31,8 @@ vi.mock('./document/create-terminal-document', async (importOriginal) => {
|
||||
if (startThrows) {
|
||||
throw startThrows
|
||||
}
|
||||
return actual.createTerminalDocument(host)
|
||||
const grid = gridTerminal
|
||||
return actual.createTerminalDocument(grid ? { ...host, createTerminal: grid } : host)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -61,9 +66,11 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 50))
|
||||
const INIT = { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false } as const
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let restoreTransform: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
startThrows = null
|
||||
gridTerminal = null
|
||||
document.body.innerHTML = ''
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
@@ -71,6 +78,8 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
restoreTransform?.()
|
||||
restoreTransform = null
|
||||
})
|
||||
|
||||
describe('a stopped document takes its engines with it', () => {
|
||||
@@ -244,3 +253,229 @@ describe('the component names the cause of a start that threw', () => {
|
||||
expect(engineErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
const CELL = { width: 7.5, height: 15 }
|
||||
const FIT_390 = 390 / (7.5 * 55)
|
||||
|
||||
/**
|
||||
* A laid-out 55x40 grid whose cells scale with the font, as xterm's do, or read 0 while `cells`
|
||||
* says they cannot be measured: xterm's DOM measure on a `display:none` host, where no
|
||||
* OffscreenCanvas is available.
|
||||
*/
|
||||
function gridDouble(cells: { measurable: boolean }): TerminalDocumentTerminal {
|
||||
const terminal = terminalDocumentDouble().terminal
|
||||
const cell = () => {
|
||||
const k = cells.measurable ? terminal.options.fontSize / 13 : 0
|
||||
return { width: CELL.width * k, height: CELL.height * k }
|
||||
}
|
||||
const grid = Object.assign(terminal, {
|
||||
cols: 55,
|
||||
rows: 40,
|
||||
_core: {
|
||||
_renderService: {
|
||||
get dimensions() {
|
||||
return { css: { cell: cell() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
grid.resize = (cols: number, rows: number) => {
|
||||
grid.cols = cols
|
||||
grid.rows = rows
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
/**
|
||||
* A mounted page document over a grid, whose host box the case sets and pushes as RN layout does:
|
||||
* react-native-web's `onLayout` reports a `display:none` screen as 0x0 and its return as the old box.
|
||||
*/
|
||||
async function mountedOverGrid({ laidOutFirst = true } = {}) {
|
||||
const cells = { measurable: true }
|
||||
const grids: TerminalDocumentTerminal[] = []
|
||||
gridTerminal = () => {
|
||||
const grid = gridDouble(cells)
|
||||
grids.push(grid)
|
||||
return grid
|
||||
}
|
||||
const host = plantHost()
|
||||
let box = { width: 390, height: 600 }
|
||||
host.getBoundingClientRect = () => new DOMRect(0, 134, box.width, box.height)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
// Every surface transform the document writes; a refit writes one even when nothing moved. Read
|
||||
// off the style prototype because an init swaps the surface element for a fresh one.
|
||||
const scales: number[] = []
|
||||
const proto: object = Object.getPrototypeOf(host.style)
|
||||
const own = Object.getOwnPropertyDescriptor(proto, 'transform')!
|
||||
const write = own.set!
|
||||
Object.defineProperty(proto, 'transform', {
|
||||
...own,
|
||||
set(this: CSSStyleDeclaration, value: string) {
|
||||
const scale = /scale\(([^)]*)\)/.exec(String(value))
|
||||
if (scale) {
|
||||
scales.push(Number(scale[1]))
|
||||
}
|
||||
write.call(this, value)
|
||||
}
|
||||
})
|
||||
restoreTransform = () => Object.defineProperty(proto, 'transform', own)
|
||||
let id = 0
|
||||
const send = (command: TerminalWebViewCommand) => mounted.send({ ...command, id: ++id })
|
||||
const layOut = (width: number, height: number) => {
|
||||
box = { width, height }
|
||||
mounted.notifyViewport()
|
||||
}
|
||||
if (laidOutFirst) {
|
||||
layOut(390, 600)
|
||||
}
|
||||
send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false })
|
||||
await framesUntil(() => scales.at(-1) === FIT_390)
|
||||
return { mounted, scales, send, layOut, cells, grid: () => grids.at(-1)! }
|
||||
}
|
||||
|
||||
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
async function framesUntil(done: () => boolean, frames = 30) {
|
||||
for (let frame = 0; frame < frames && !done(); frame++) {
|
||||
await nextFrame()
|
||||
}
|
||||
expect(done()).toBe(true)
|
||||
}
|
||||
|
||||
describe("the page pushes its terminal frame's box into the document", () => {
|
||||
it('refits on a box RN laid out, through the host View, with no ResizeObserver of its own', () => {
|
||||
let observers = 0
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor() {
|
||||
observers += 1
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
try {
|
||||
const host = plantHost()
|
||||
let height = 600
|
||||
host.getBoundingClientRect = () => new DOMRect(0, 134, 390, height)
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, {}), { createNodeMock: () => host })
|
||||
})
|
||||
const surface = host.querySelector<HTMLElement>('#terminal-surface')!
|
||||
expect(surface.style.transform).toBe('')
|
||||
const laidOut = renderer!.root.findAll((node) => typeof node.props.onLayout === 'function')
|
||||
expect(laidOut).toHaveLength(1)
|
||||
height = 560
|
||||
act(() => {
|
||||
laidOut[0]!.props.onLayout({ nativeEvent: { layout: { width: 390, height: 560 } } })
|
||||
})
|
||||
expect(surface.style.transform).toContain('scale(1)')
|
||||
expect(observers).toBe(0)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps pan and zoom across hide and show, and refits when the box really changes', async () => {
|
||||
// Native never refits on navigation: its WebView keeps its size while another screen covers it.
|
||||
const { mounted, scales, layOut } = await mountedOverGrid()
|
||||
const fitted = scales.length
|
||||
layOut(0, 0)
|
||||
layOut(390, 600)
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
expect(scales).toHaveLength(fitted)
|
||||
|
||||
layOut(300, 600)
|
||||
await framesUntil(() => scales.at(-1) === 300 / (7.5 * 55))
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('keeps pan and zoom when RN laid the host out before the mount existed', async () => {
|
||||
// The first layout can land before the document does; the box it reported is still the box.
|
||||
const { mounted, scales, layOut } = await mountedOverGrid({ laidOutFirst: false })
|
||||
const fitted = scales.length
|
||||
layOut(0, 0)
|
||||
layOut(390, 600)
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
expect(scales).toHaveLength(fitted)
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('holds a fit asked for while hidden and lands it on show', async () => {
|
||||
const { mounted, scales, send, layOut } = await mountedOverGrid()
|
||||
layOut(0, 0)
|
||||
send({ type: 'resize', cols: 80, rows: 40 })
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
expect(scales.at(-1)).toBe(FIT_390)
|
||||
layOut(390, 600)
|
||||
await framesUntil(() => scales.at(-1) === 390 / (7.5 * 80))
|
||||
// The held fit is spent: a second hide and show must not run it again over the user's pan.
|
||||
const landed = scales.length
|
||||
layOut(0, 0)
|
||||
layOut(390, 600)
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
expect(scales).toHaveLength(landed)
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('never commits a blind fit when the grid cannot be measured while hidden', async () => {
|
||||
// A reconnect re-inits under a covering screen, where xterm's DOM measure reads every cell as 0.
|
||||
const { mounted, scales, send, layOut, cells } = await mountedOverGrid()
|
||||
layOut(0, 0)
|
||||
const shown = scales.length
|
||||
cells.measurable = false
|
||||
send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false })
|
||||
// Past the retry loop's 60-frame cap, where a fit that did not wait commits scale 1.
|
||||
for (let frame = 0; frame < 75; frame++) {
|
||||
await nextFrame()
|
||||
}
|
||||
expect(scales.slice(shown)).not.toContain(1)
|
||||
cells.measurable = true
|
||||
layOut(390, 600)
|
||||
await framesUntil(() => scales.at(-1) === FIT_390)
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('fits a text scale too large to resize the grid while visible', async () => {
|
||||
const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
|
||||
layOut(280, 600)
|
||||
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
|
||||
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
|
||||
send({ type: 'set-font-scale', fontScale: 2 })
|
||||
await framesUntil(() => scales.at(-1) === 280 / (15 * 55))
|
||||
expect(grid().cols).toBe(55)
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('refits on show after a text scale too large to resize the grid while hidden', async () => {
|
||||
const { mounted, scales, send, layOut } = await mountedOverGrid()
|
||||
layOut(280, 600)
|
||||
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
|
||||
layOut(0, 0)
|
||||
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
|
||||
send({ type: 'set-font-scale', fontScale: 2 })
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
layOut(280, 600)
|
||||
await framesUntil(() => scales.at(-1) === 280 / (15 * 55))
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
it('resizes the grid to a text scale changed while hidden, and fits it on show', async () => {
|
||||
const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
|
||||
layOut(0, 0)
|
||||
send({ type: 'set-font-scale', fontScale: 0.8 })
|
||||
// fontPxForScale(0.8) = 10 px: 390 / (7.5 x 10/13) = 67 columns, where stale cells give 52.
|
||||
await framesUntil(() => grid().cols === 67)
|
||||
const hidden = scales.length
|
||||
layOut(390, 600)
|
||||
await framesUntil(() => scales.length > hidden)
|
||||
expect(scales.at(-1)).toBe(1)
|
||||
mounted.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +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 { 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'
|
||||
@@ -25,6 +26,8 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
export type TerminalWebDocument = {
|
||||
/** Hands one host command to the document, as a bridge frame does inside the WebView. */
|
||||
send: (command: TerminalWebViewCommand & { id: number }) => void
|
||||
/** RN laid the host out again: the page's counterpart of the WebView's window resize. */
|
||||
notifyViewport: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
@@ -102,12 +105,14 @@ export function mountTerminalWebDocument(
|
||||
ensureDocumentStyle()
|
||||
host.classList.add(HOST_CLASS)
|
||||
host.innerHTML = TERMINAL_DOCUMENT_MARKUP
|
||||
const started = startDocumentOrGiveTheHostBack(host, receive)
|
||||
const viewport = pageViewport(host)
|
||||
const started = startDocumentOrGiveTheHostBack(host, receive, viewport)
|
||||
|
||||
return {
|
||||
send: (command) => {
|
||||
started.send(command)
|
||||
},
|
||||
notifyViewport: viewport.notify,
|
||||
dispose: () => {
|
||||
started.stop()
|
||||
host.innerHTML = ''
|
||||
@@ -128,10 +133,11 @@ export function mountTerminalWebDocument(
|
||||
*/
|
||||
function startDocumentOrGiveTheHostBack(
|
||||
host: HTMLElement,
|
||||
receive: (message: Record<string, unknown>) => void
|
||||
receive: (message: Record<string, unknown>) => void,
|
||||
viewport: PageViewport
|
||||
) {
|
||||
try {
|
||||
return startPageDocument(host, receive)
|
||||
return startPageDocument(host, receive, viewport)
|
||||
} catch (error) {
|
||||
host.innerHTML = ''
|
||||
host.classList.remove(HOST_CLASS)
|
||||
@@ -139,8 +145,52 @@ function startDocumentOrGiveTheHostBack(
|
||||
}
|
||||
}
|
||||
|
||||
type PageViewport = ReturnType<typeof pageViewport>
|
||||
|
||||
/**
|
||||
* The host's box, pushed by RN layout. RN web lays a `display:none` host out as 0x0 and its return
|
||||
* as the same box: the size stays the last real one, as a covered WebView's does, and the return is
|
||||
* a show rather than a resize. Sizes are the client rect's; RN web's are whole-pixel `offsetWidth`s.
|
||||
*/
|
||||
function pageViewport(host: HTMLElement) {
|
||||
// Seeded from the host, since RN's first layout can land before this mount exists.
|
||||
const seed = host.getBoundingClientRect()
|
||||
let laidOut = { width: seed.width, height: seed.height }
|
||||
let onChange: ((change: TerminalViewportChange) => void) | null = null
|
||||
return {
|
||||
rect: () => {
|
||||
const box = host.getBoundingClientRect()
|
||||
const hidden = box.width <= 0
|
||||
const size = hidden ? laidOut : box
|
||||
return { left: box.left, top: box.top, width: size.width, height: size.height, hidden }
|
||||
},
|
||||
observe: (change: (change: TerminalViewportChange) => void) => {
|
||||
onChange = change
|
||||
return () => {
|
||||
onChange = null
|
||||
}
|
||||
},
|
||||
notify: () => {
|
||||
const { width, height } = host.getBoundingClientRect()
|
||||
if (width <= 0) {
|
||||
return
|
||||
}
|
||||
if (width === laidOut.width && height === laidOut.height) {
|
||||
onChange?.('shown')
|
||||
return
|
||||
}
|
||||
laidOut = { width, height }
|
||||
onChange?.('resized')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The eleven seams, as the page answers them. */
|
||||
function startPageDocument(host: HTMLElement, receive: (message: Record<string, unknown>) => void) {
|
||||
function startPageDocument(
|
||||
host: HTMLElement,
|
||||
receive: (message: Record<string, unknown>) => void,
|
||||
viewport: PageViewport
|
||||
) {
|
||||
// Written by this document's own reporter: `startHostNotify` installs it through the seam below,
|
||||
// which here is a `window` error listener, and every error it forwards is appended before the
|
||||
// report that quotes it. What the page cannot have is the WebView head's half — a buffer open
|
||||
@@ -187,17 +237,10 @@ function startPageDocument(host: HTMLElement, receive: (message: Record<string,
|
||||
hasEngine: () => true,
|
||||
|
||||
// The window here is the whole page, header and dock included; the grid is shown in the host.
|
||||
viewportRect: () => {
|
||||
const box = host.getBoundingClientRect()
|
||||
return { left: box.left, top: box.top, width: box.width, height: box.height }
|
||||
},
|
||||
viewportRect: viewport.rect,
|
||||
|
||||
// The host resizes without the window: a dock growing, a panel docking beside it.
|
||||
observeViewport: (onChange) => {
|
||||
const observer = new ResizeObserver(onChange)
|
||||
observer.observe(host)
|
||||
return () => observer.disconnect()
|
||||
},
|
||||
// The host resizes without the window, and RN layout is what says so: the component pushes it.
|
||||
observeViewport: viewport.observe,
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shape is xterm's own, except that `getCell` takes back the cell xterm allocated and the document declares only the members it reads on one.
|
||||
createTerminal: (options) => new Terminal(options) as unknown as TerminalDocumentTerminal,
|
||||
|
||||
Reference in New Issue
Block a user