@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App - Unified render API for silvery
|
|
3
|
+
*
|
|
4
|
+
* Both production and testing return an App instance with the same interface.
|
|
5
|
+
* Key improvements over the old API:
|
|
6
|
+
* - Auto-refreshing locators (no stale locator problem)
|
|
7
|
+
* - Playwright-style API (app.press(), app.getByTestId())
|
|
8
|
+
* - Bound terminal (app.term) with node awareness
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* // Both production and testing
|
|
13
|
+
* const app = await render(<App />, term)
|
|
14
|
+
*
|
|
15
|
+
* // Query and interact
|
|
16
|
+
* app.text // rendered text (no ANSI)
|
|
17
|
+
* app.getByTestId('modal') // auto-refreshing locator
|
|
18
|
+
* await app.press('ArrowUp') // send key
|
|
19
|
+
* await app.waitUntilExit() // wait until exit
|
|
20
|
+
*
|
|
21
|
+
* // Terminal access
|
|
22
|
+
* app.term.cell(x, y) // { char, fg, bg, attrs }
|
|
23
|
+
* app.term.nodeAt(x, y) // node at screen coords
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ReactNode } from "react"
|
|
28
|
+
import { type AutoLocator, createAutoLocator } from "@silvery/test/auto-locator"
|
|
29
|
+
import { type BoundTerm, createBoundTerm } from "./bound-term"
|
|
30
|
+
import type { TerminalBuffer } from "./buffer"
|
|
31
|
+
import { bufferToHTML, bufferToStyledText, bufferToText } from "./buffer"
|
|
32
|
+
import { type Screenshotter, createScreenshotter } from "./screenshot"
|
|
33
|
+
import { keyToAnsi, keyToKittyAnsi } from "@silvery/tea/keys"
|
|
34
|
+
import type { ParsedMouse } from "./mouse"
|
|
35
|
+
import { createMouseEventProcessor, processMouseEvent } from "./mouse-events"
|
|
36
|
+
import type { FocusManager } from "@silvery/tea/focus-manager"
|
|
37
|
+
import { pointInRect } from "@silvery/tea/tree-utils"
|
|
38
|
+
import type { TeaNode } from "@silvery/tea/types"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* App interface - unified return type from render()
|
|
42
|
+
*/
|
|
43
|
+
export interface App {
|
|
44
|
+
// === Content/Document Perspective ===
|
|
45
|
+
|
|
46
|
+
/** Full rendered text (no ANSI codes) */
|
|
47
|
+
readonly text: string
|
|
48
|
+
|
|
49
|
+
/** Full rendered text with ANSI styling */
|
|
50
|
+
readonly ansi: string
|
|
51
|
+
|
|
52
|
+
/** Get node at content coordinates */
|
|
53
|
+
nodeAt(x: number, y: number): TeaNode | null
|
|
54
|
+
|
|
55
|
+
/** Get locator by testID attribute */
|
|
56
|
+
getByTestId(id: string): AutoLocator
|
|
57
|
+
|
|
58
|
+
/** Get locator by text content */
|
|
59
|
+
getByText(text: string | RegExp): AutoLocator
|
|
60
|
+
|
|
61
|
+
/** Get locator by CSS-style selector */
|
|
62
|
+
locator(selector: string): AutoLocator
|
|
63
|
+
|
|
64
|
+
// === Actions (return this for chaining) ===
|
|
65
|
+
|
|
66
|
+
/** Send a key press (uses keyToAnsi internally) */
|
|
67
|
+
press(key: string): Promise<this>
|
|
68
|
+
|
|
69
|
+
/** Send multiple key presses */
|
|
70
|
+
pressSequence(...keys: string[]): Promise<this>
|
|
71
|
+
|
|
72
|
+
/** Type text input */
|
|
73
|
+
type(text: string): Promise<this>
|
|
74
|
+
|
|
75
|
+
/** Simulate a mouse click at (x, y) terminal coordinates */
|
|
76
|
+
click(x: number, y: number, options?: { button?: number }): Promise<this>
|
|
77
|
+
|
|
78
|
+
/** Simulate a double-click at (x, y) terminal coordinates */
|
|
79
|
+
doubleClick(x: number, y: number, options?: { button?: number }): Promise<this>
|
|
80
|
+
|
|
81
|
+
/** Simulate a mouse wheel event at (x, y) with delta (-1=up, +1=down) */
|
|
82
|
+
wheel(x: number, y: number, delta: number): Promise<this>
|
|
83
|
+
|
|
84
|
+
/** Resize the virtual terminal and re-render. Only available in test renderer. */
|
|
85
|
+
resize(cols: number, rows: number): void
|
|
86
|
+
|
|
87
|
+
/** Wait until app exits */
|
|
88
|
+
run(): Promise<void>
|
|
89
|
+
|
|
90
|
+
// === Terminal Binding ===
|
|
91
|
+
|
|
92
|
+
/** Bound terminal for screen-space access */
|
|
93
|
+
readonly term: BoundTerm
|
|
94
|
+
|
|
95
|
+
// === Lifecycle (Instance compatibility) ===
|
|
96
|
+
|
|
97
|
+
/** Re-render with a new element */
|
|
98
|
+
rerender(element: ReactNode): void
|
|
99
|
+
|
|
100
|
+
/** Unmount the component and clean up */
|
|
101
|
+
unmount(): void
|
|
102
|
+
|
|
103
|
+
/** Dispose (alias for unmount) — enables `using` */
|
|
104
|
+
[Symbol.dispose](): void
|
|
105
|
+
|
|
106
|
+
/** Promise that resolves when the app exits (alias for run()) */
|
|
107
|
+
waitUntilExit(): Promise<void>
|
|
108
|
+
|
|
109
|
+
/** Clear the terminal output */
|
|
110
|
+
clear(): void
|
|
111
|
+
|
|
112
|
+
// === Screenshot ===
|
|
113
|
+
|
|
114
|
+
/** Render current buffer to PNG. Requires Playwright (lazy-loaded on first call). */
|
|
115
|
+
screenshot(outputPath?: string): Promise<Buffer>
|
|
116
|
+
|
|
117
|
+
// === Debug ===
|
|
118
|
+
|
|
119
|
+
/** Print component tree to console */
|
|
120
|
+
debug(): void
|
|
121
|
+
|
|
122
|
+
// === Testing extras ===
|
|
123
|
+
|
|
124
|
+
/** Render the current tree from scratch (no incremental buffer reuse).
|
|
125
|
+
* Returns the fresh buffer without updating incremental state.
|
|
126
|
+
* Only available in test renderer - throws otherwise. */
|
|
127
|
+
freshRender(): TerminalBuffer
|
|
128
|
+
|
|
129
|
+
/** Check if exit() was called */
|
|
130
|
+
exitCalled(): boolean
|
|
131
|
+
|
|
132
|
+
/** Get error passed to exit() */
|
|
133
|
+
exitError(): Error | undefined
|
|
134
|
+
|
|
135
|
+
/** Send raw stdin input (for sync test helpers; prefer app.press() for new code) */
|
|
136
|
+
readonly stdin: { write: (data: string) => void }
|
|
137
|
+
|
|
138
|
+
// === Internal/Legacy (kept for silvery test compatibility, not for external use) ===
|
|
139
|
+
|
|
140
|
+
/** All rendered frames (internal) */
|
|
141
|
+
readonly frames: string[]
|
|
142
|
+
|
|
143
|
+
/** Get last frame with ANSI codes (internal - use app.ansi instead) */
|
|
144
|
+
lastFrame(): string | undefined
|
|
145
|
+
|
|
146
|
+
/** Get last buffer (internal - use app.term.buffer instead) */
|
|
147
|
+
lastBuffer(): TerminalBuffer | undefined
|
|
148
|
+
|
|
149
|
+
/** Get last frame as plain text (internal - use app.text instead) */
|
|
150
|
+
lastFrameText(): string | undefined
|
|
151
|
+
|
|
152
|
+
/** Get container root node (internal - use app.locator() instead) */
|
|
153
|
+
getContainer(): TeaNode
|
|
154
|
+
|
|
155
|
+
// === Focus System ===
|
|
156
|
+
|
|
157
|
+
/** Focus a node by testID */
|
|
158
|
+
focus(testID: string): void
|
|
159
|
+
|
|
160
|
+
/** Get the focus path from focused node to root (testID[]) */
|
|
161
|
+
getFocusPath(): string[]
|
|
162
|
+
|
|
163
|
+
/** Direct access to the FocusManager instance */
|
|
164
|
+
readonly focusManager: FocusManager
|
|
165
|
+
|
|
166
|
+
// === Cursor State ===
|
|
167
|
+
|
|
168
|
+
/** Get the current cursor state for this silvery instance (per-instance, not global). */
|
|
169
|
+
getCursorState(): import("@silvery/react/hooks/useCursor").CursorState | null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Options for creating an App instance
|
|
174
|
+
*/
|
|
175
|
+
export interface AppOptions {
|
|
176
|
+
/** Function to get current container root */
|
|
177
|
+
getContainer: () => TeaNode
|
|
178
|
+
|
|
179
|
+
/** Function to get current buffer */
|
|
180
|
+
getBuffer: () => TerminalBuffer | null
|
|
181
|
+
|
|
182
|
+
/** Function to send input */
|
|
183
|
+
sendInput: (data: string) => void
|
|
184
|
+
|
|
185
|
+
/** Function to rerender */
|
|
186
|
+
rerender: (element: ReactNode) => void
|
|
187
|
+
|
|
188
|
+
/** Function to unmount */
|
|
189
|
+
unmount: () => void
|
|
190
|
+
|
|
191
|
+
/** Function to wait for exit */
|
|
192
|
+
waitUntilExit: () => Promise<void>
|
|
193
|
+
|
|
194
|
+
/** Function to clear output */
|
|
195
|
+
clear: () => void
|
|
196
|
+
|
|
197
|
+
/** Function to check if exit was called */
|
|
198
|
+
exitCalled?: () => boolean
|
|
199
|
+
|
|
200
|
+
/** Function to get exit error */
|
|
201
|
+
exitError?: () => Error | undefined
|
|
202
|
+
|
|
203
|
+
/** Fresh render function (test renderer only) */
|
|
204
|
+
freshRender?: () => TerminalBuffer
|
|
205
|
+
|
|
206
|
+
/** Debug print function */
|
|
207
|
+
debugFn?: () => void
|
|
208
|
+
|
|
209
|
+
/** Captured frames array (internal) */
|
|
210
|
+
frames?: string[]
|
|
211
|
+
|
|
212
|
+
/** Terminal dimensions */
|
|
213
|
+
columns: number
|
|
214
|
+
rows: number
|
|
215
|
+
|
|
216
|
+
/** Use Kitty keyboard protocol encoding for press(). When true, press() uses keyToKittyAnsi. */
|
|
217
|
+
kittyMode?: boolean
|
|
218
|
+
|
|
219
|
+
/** Wrap a callback in act() + doRender() for the test renderer. Ensures React state updates from mouse handlers are flushed. */
|
|
220
|
+
actAndRender?: (fn: () => void) => void
|
|
221
|
+
|
|
222
|
+
/** Resize the virtual terminal (test renderer only). */
|
|
223
|
+
resize?: (cols: number, rows: number) => void
|
|
224
|
+
|
|
225
|
+
/** Focus manager instance for focus system */
|
|
226
|
+
focusManager?: FocusManager
|
|
227
|
+
|
|
228
|
+
/** Per-instance cursor state accessor */
|
|
229
|
+
getCursorState?: () => import("@silvery/react/hooks/useCursor").CursorState | null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create an App instance
|
|
234
|
+
*/
|
|
235
|
+
export function buildApp(options: AppOptions): App {
|
|
236
|
+
const {
|
|
237
|
+
getContainer,
|
|
238
|
+
getBuffer,
|
|
239
|
+
sendInput,
|
|
240
|
+
rerender,
|
|
241
|
+
unmount,
|
|
242
|
+
waitUntilExit,
|
|
243
|
+
clear,
|
|
244
|
+
exitCalled = () => false,
|
|
245
|
+
exitError = () => undefined,
|
|
246
|
+
freshRender: freshRenderFn,
|
|
247
|
+
debugFn,
|
|
248
|
+
frames = [],
|
|
249
|
+
columns,
|
|
250
|
+
rows,
|
|
251
|
+
kittyMode = false,
|
|
252
|
+
actAndRender,
|
|
253
|
+
resize: resizeFn,
|
|
254
|
+
focusManager: fm,
|
|
255
|
+
} = options
|
|
256
|
+
|
|
257
|
+
// Create auto-refreshing locator factory
|
|
258
|
+
const createLocator = () => createAutoLocator(getContainer)
|
|
259
|
+
|
|
260
|
+
// Create bound terminal
|
|
261
|
+
const getText = () => {
|
|
262
|
+
const buffer = getBuffer()
|
|
263
|
+
return buffer ? bufferToText(buffer) : ""
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Note: BoundTerm is created lazily since buffer may not exist initially
|
|
267
|
+
let boundTerm: BoundTerm | null = null
|
|
268
|
+
|
|
269
|
+
// Mouse event processor for click/doubleClick/wheel
|
|
270
|
+
const mouseState = createMouseEventProcessor()
|
|
271
|
+
|
|
272
|
+
// Screenshotter is created lazily on first screenshot() call
|
|
273
|
+
let screenshotter: Screenshotter | null = null
|
|
274
|
+
|
|
275
|
+
const app: App = {
|
|
276
|
+
// === Content/Document Perspective ===
|
|
277
|
+
|
|
278
|
+
get text(): string {
|
|
279
|
+
return getText()
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
get ansi(): string {
|
|
283
|
+
const buffer = getBuffer()
|
|
284
|
+
return buffer ? bufferToStyledText(buffer) : ""
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
nodeAt(x: number, y: number): TeaNode | null {
|
|
288
|
+
const root = getContainer()
|
|
289
|
+
return findNodeAtContentPosition(root, x, y)
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
getByTestId(id: string): AutoLocator {
|
|
293
|
+
return createLocator().getByTestId(id)
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
getByText(text: string | RegExp): AutoLocator {
|
|
297
|
+
return createLocator().getByText(text)
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
locator(selector: string): AutoLocator {
|
|
301
|
+
return createLocator().locator(selector)
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// === Actions ===
|
|
305
|
+
|
|
306
|
+
async press(key: string): Promise<App> {
|
|
307
|
+
const sequence = kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key)
|
|
308
|
+
sendInput(sequence)
|
|
309
|
+
// Allow microtask to flush for test synchronization
|
|
310
|
+
await Promise.resolve()
|
|
311
|
+
return app
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async pressSequence(...keys: string[]): Promise<App> {
|
|
315
|
+
for (const key of keys) {
|
|
316
|
+
await app.press(key)
|
|
317
|
+
}
|
|
318
|
+
return app
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
async type(text: string): Promise<App> {
|
|
322
|
+
for (const char of text) {
|
|
323
|
+
sendInput(char)
|
|
324
|
+
}
|
|
325
|
+
await Promise.resolve()
|
|
326
|
+
return app
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
async click(x: number, y: number, options?: { button?: number }): Promise<App> {
|
|
330
|
+
const button = options?.button ?? 0
|
|
331
|
+
const doClick = () => {
|
|
332
|
+
const parsed: ParsedMouse = {
|
|
333
|
+
button,
|
|
334
|
+
x,
|
|
335
|
+
y,
|
|
336
|
+
action: "down",
|
|
337
|
+
shift: false,
|
|
338
|
+
meta: false,
|
|
339
|
+
ctrl: false,
|
|
340
|
+
}
|
|
341
|
+
processMouseEvent(mouseState, parsed, getContainer())
|
|
342
|
+
const upParsed: ParsedMouse = { ...parsed, action: "up" }
|
|
343
|
+
processMouseEvent(mouseState, upParsed, getContainer())
|
|
344
|
+
}
|
|
345
|
+
if (actAndRender) {
|
|
346
|
+
actAndRender(doClick)
|
|
347
|
+
} else {
|
|
348
|
+
doClick()
|
|
349
|
+
}
|
|
350
|
+
await Promise.resolve()
|
|
351
|
+
return app
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async doubleClick(x: number, y: number, options?: { button?: number }): Promise<App> {
|
|
355
|
+
const button = options?.button ?? 0
|
|
356
|
+
const doDblClick = () => {
|
|
357
|
+
const baseParsed: ParsedMouse = {
|
|
358
|
+
button,
|
|
359
|
+
x,
|
|
360
|
+
y,
|
|
361
|
+
action: "down",
|
|
362
|
+
shift: false,
|
|
363
|
+
meta: false,
|
|
364
|
+
ctrl: false,
|
|
365
|
+
}
|
|
366
|
+
// First click
|
|
367
|
+
processMouseEvent(mouseState, baseParsed, getContainer())
|
|
368
|
+
processMouseEvent(mouseState, { ...baseParsed, action: "up" }, getContainer())
|
|
369
|
+
// Second click (triggers double-click detection)
|
|
370
|
+
processMouseEvent(mouseState, baseParsed, getContainer())
|
|
371
|
+
processMouseEvent(mouseState, { ...baseParsed, action: "up" }, getContainer())
|
|
372
|
+
}
|
|
373
|
+
if (actAndRender) {
|
|
374
|
+
actAndRender(doDblClick)
|
|
375
|
+
} else {
|
|
376
|
+
doDblClick()
|
|
377
|
+
}
|
|
378
|
+
await Promise.resolve()
|
|
379
|
+
return app
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
async wheel(x: number, y: number, delta: number): Promise<App> {
|
|
383
|
+
const doWheel = () => {
|
|
384
|
+
const parsed: ParsedMouse = {
|
|
385
|
+
button: 0,
|
|
386
|
+
x,
|
|
387
|
+
y,
|
|
388
|
+
action: "wheel",
|
|
389
|
+
delta,
|
|
390
|
+
shift: false,
|
|
391
|
+
meta: false,
|
|
392
|
+
ctrl: false,
|
|
393
|
+
}
|
|
394
|
+
processMouseEvent(mouseState, parsed, getContainer())
|
|
395
|
+
}
|
|
396
|
+
if (actAndRender) {
|
|
397
|
+
actAndRender(doWheel)
|
|
398
|
+
} else {
|
|
399
|
+
doWheel()
|
|
400
|
+
}
|
|
401
|
+
await Promise.resolve()
|
|
402
|
+
return app
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
resize(cols: number, rows: number): void {
|
|
406
|
+
if (!resizeFn) {
|
|
407
|
+
throw new Error("resize() is only available in test renderer")
|
|
408
|
+
}
|
|
409
|
+
resizeFn(cols, rows)
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
async run(): Promise<void> {
|
|
413
|
+
return waitUntilExit()
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
// === Terminal Binding ===
|
|
417
|
+
|
|
418
|
+
get term(): BoundTerm {
|
|
419
|
+
const buffer = getBuffer()
|
|
420
|
+
if (!buffer) {
|
|
421
|
+
// Return a dummy bound term if no buffer yet
|
|
422
|
+
const dummyBuffer = {
|
|
423
|
+
width: columns,
|
|
424
|
+
height: rows,
|
|
425
|
+
getCell: () => ({
|
|
426
|
+
char: " ",
|
|
427
|
+
fg: null,
|
|
428
|
+
bg: null,
|
|
429
|
+
attrs: {},
|
|
430
|
+
wide: false,
|
|
431
|
+
continuation: false,
|
|
432
|
+
}),
|
|
433
|
+
setCell: () => {},
|
|
434
|
+
clear: () => {},
|
|
435
|
+
inBounds: () => false,
|
|
436
|
+
} as unknown as TerminalBuffer
|
|
437
|
+
return createBoundTerm(dummyBuffer, getContainer, getText)
|
|
438
|
+
}
|
|
439
|
+
if (!boundTerm || boundTerm.buffer !== buffer) {
|
|
440
|
+
boundTerm = createBoundTerm(buffer, getContainer, getText)
|
|
441
|
+
}
|
|
442
|
+
return boundTerm
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
// === Screenshot ===
|
|
446
|
+
|
|
447
|
+
async screenshot(outputPath?: string): Promise<Buffer> {
|
|
448
|
+
const buffer = getBuffer()
|
|
449
|
+
if (!buffer) {
|
|
450
|
+
throw new Error("No buffer available for screenshot")
|
|
451
|
+
}
|
|
452
|
+
const html = bufferToHTML(buffer)
|
|
453
|
+
if (!screenshotter) {
|
|
454
|
+
screenshotter = createScreenshotter()
|
|
455
|
+
}
|
|
456
|
+
return screenshotter.capture(html, outputPath)
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
// === Lifecycle ===
|
|
460
|
+
|
|
461
|
+
rerender,
|
|
462
|
+
unmount() {
|
|
463
|
+
// Close screenshotter if it was created
|
|
464
|
+
if (screenshotter) {
|
|
465
|
+
screenshotter.close().catch(() => {})
|
|
466
|
+
screenshotter = null
|
|
467
|
+
}
|
|
468
|
+
unmount()
|
|
469
|
+
},
|
|
470
|
+
[Symbol.dispose]() {
|
|
471
|
+
app.unmount()
|
|
472
|
+
},
|
|
473
|
+
waitUntilExit,
|
|
474
|
+
clear,
|
|
475
|
+
|
|
476
|
+
// === Debug ===
|
|
477
|
+
|
|
478
|
+
debug(): void {
|
|
479
|
+
if (debugFn) {
|
|
480
|
+
debugFn()
|
|
481
|
+
} else {
|
|
482
|
+
console.log(app.text)
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
// === Testing extras ===
|
|
487
|
+
|
|
488
|
+
freshRender(): TerminalBuffer {
|
|
489
|
+
if (!freshRenderFn) {
|
|
490
|
+
throw new Error("freshRender() is only available in test renderer")
|
|
491
|
+
}
|
|
492
|
+
return freshRenderFn()
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
exitCalled,
|
|
496
|
+
exitError,
|
|
497
|
+
|
|
498
|
+
stdin: {
|
|
499
|
+
write: sendInput,
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
// Internal/Legacy (kept for silvery test compatibility)
|
|
503
|
+
frames,
|
|
504
|
+
|
|
505
|
+
lastFrame(): string | undefined {
|
|
506
|
+
return frames[frames.length - 1]
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
lastBuffer(): TerminalBuffer | undefined {
|
|
510
|
+
return getBuffer() ?? undefined
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
lastFrameText(): string | undefined {
|
|
514
|
+
const buffer = getBuffer()
|
|
515
|
+
return buffer ? bufferToText(buffer) : undefined
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
getContainer(): TeaNode {
|
|
519
|
+
return getContainer()
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
// === Focus System ===
|
|
523
|
+
|
|
524
|
+
focus(testID: string): void {
|
|
525
|
+
if (fm) {
|
|
526
|
+
const root = getContainer()
|
|
527
|
+
fm.focusById(testID, root, "programmatic")
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
getFocusPath(): string[] {
|
|
532
|
+
if (fm) {
|
|
533
|
+
const root = getContainer()
|
|
534
|
+
return fm.getFocusPath(root)
|
|
535
|
+
}
|
|
536
|
+
return []
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
get focusManager(): FocusManager {
|
|
540
|
+
if (!fm) {
|
|
541
|
+
throw new Error("FocusManager not available — pass focusManager to buildApp()")
|
|
542
|
+
}
|
|
543
|
+
return fm
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
getCursorState() {
|
|
547
|
+
return options.getCursorState?.() ?? null
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return app
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Find node at content coordinates (not screen coordinates)
|
|
556
|
+
*/
|
|
557
|
+
function findNodeAtContentPosition(node: TeaNode, x: number, y: number): TeaNode | null {
|
|
558
|
+
const rect = node.contentRect
|
|
559
|
+
if (!rect) return null
|
|
560
|
+
|
|
561
|
+
if (!pointInRect(x, y, rect)) {
|
|
562
|
+
return null
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const child of node.children) {
|
|
566
|
+
const found = findNodeAtContentPosition(child, x, y)
|
|
567
|
+
if (found) return found
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return node
|
|
571
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoundTerm - Terminal buffer with node awareness
|
|
3
|
+
*
|
|
4
|
+
* Bridges the terminal buffer (screen space) with the SilveryNode tree.
|
|
5
|
+
* Provides screen-coordinate queries that return nodes.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const app = render(<Board />)
|
|
10
|
+
*
|
|
11
|
+
* // Screen-space access
|
|
12
|
+
* const cell = app.term.cell(10, 5)
|
|
13
|
+
* const node = app.term.nodeAt(10, 5)
|
|
14
|
+
* console.log(app.term.text)
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Cell, TerminalBuffer } from "./buffer"
|
|
19
|
+
import type { TeaNode } from "@silvery/tea/types"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* BoundTerm interface - terminal with node awareness
|
|
23
|
+
*/
|
|
24
|
+
export interface BoundTerm {
|
|
25
|
+
/** Get cell at screen coordinates */
|
|
26
|
+
cell(x: number, y: number): Cell
|
|
27
|
+
|
|
28
|
+
/** Get node at screen coordinates */
|
|
29
|
+
nodeAt(x: number, y: number): TeaNode | null
|
|
30
|
+
|
|
31
|
+
/** Get visible text (plain, no ANSI) */
|
|
32
|
+
readonly text: string
|
|
33
|
+
|
|
34
|
+
/** Terminal dimensions */
|
|
35
|
+
readonly columns: number
|
|
36
|
+
readonly rows: number
|
|
37
|
+
|
|
38
|
+
/** Access underlying buffer */
|
|
39
|
+
readonly buffer: TerminalBuffer
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a BoundTerm from a buffer and root node getter
|
|
44
|
+
*/
|
|
45
|
+
export function createBoundTerm(buffer: TerminalBuffer, getRoot: () => TeaNode, getText: () => string): BoundTerm {
|
|
46
|
+
return {
|
|
47
|
+
cell(x: number, y: number): Cell {
|
|
48
|
+
return buffer.getCell(x, y)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
nodeAt(x: number, y: number): TeaNode | null {
|
|
52
|
+
const root = getRoot()
|
|
53
|
+
return findNodeAtScreenPosition(root, x, y)
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
get text(): string {
|
|
57
|
+
return getText()
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
get columns(): number {
|
|
61
|
+
return buffer.width
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
get rows(): number {
|
|
65
|
+
return buffer.height
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
get buffer(): TerminalBuffer {
|
|
69
|
+
return buffer
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the deepest node at the given screen coordinates
|
|
76
|
+
*/
|
|
77
|
+
function findNodeAtScreenPosition(node: TeaNode, x: number, y: number): TeaNode | null {
|
|
78
|
+
const rect = node.screenRect
|
|
79
|
+
if (!rect) return null
|
|
80
|
+
|
|
81
|
+
// Check if point is within this node's bounds
|
|
82
|
+
if (x < rect.x || x >= rect.x + rect.width || y < rect.y || y >= rect.y + rect.height) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check children (deepest match wins)
|
|
87
|
+
for (const child of node.children) {
|
|
88
|
+
const found = findNodeAtScreenPosition(child, x, y)
|
|
89
|
+
if (found) return found
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// No child matched, this node is the deepest match
|
|
93
|
+
return node
|
|
94
|
+
}
|