@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type TerminalBuffer, bufferToStyledText, bufferToText } from "../buffer"
|
|
2
|
+
import type { TeaNode } from "@silvery/tea/types"
|
|
3
|
+
import type { Buffer } from "./types"
|
|
4
|
+
|
|
5
|
+
export function createBuffer(termBuffer: TerminalBuffer, nodes: TeaNode): Buffer {
|
|
6
|
+
let _text: string | undefined
|
|
7
|
+
let _ansi: string | undefined
|
|
8
|
+
return {
|
|
9
|
+
get text() {
|
|
10
|
+
return (_text ??= bufferToText(termBuffer))
|
|
11
|
+
},
|
|
12
|
+
get ansi() {
|
|
13
|
+
return (_ansi ??= bufferToStyledText(termBuffer))
|
|
14
|
+
},
|
|
15
|
+
nodes,
|
|
16
|
+
_buffer: termBuffer,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create the silvery-loop runtime kernel.
|
|
3
|
+
*
|
|
4
|
+
* The runtime owns the event loop, diffing, and output. Users interact via:
|
|
5
|
+
* - events() - AsyncIterable of all events (keys, resize, effects)
|
|
6
|
+
* - schedule() - Queue effects for async execution
|
|
7
|
+
* - render() - Output a buffer (diffing handled internally)
|
|
8
|
+
*
|
|
9
|
+
* NOTE: This runtime is designed for single-consumer use. Calling events()
|
|
10
|
+
* multiple times concurrently will cause events to be split between consumers.
|
|
11
|
+
* Each call returns a fresh AsyncIterable, but they share the underlying queue.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* using runtime = createRuntime({ target: termTarget })
|
|
16
|
+
*
|
|
17
|
+
* for await (const event of runtime.events()) {
|
|
18
|
+
* state = reducer(state, event)
|
|
19
|
+
* runtime.render(layout(view(state), runtime.getDims()))
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createOutputPhase } from "../pipeline/output-phase"
|
|
25
|
+
import { takeUntil } from "@silvery/tea/streams"
|
|
26
|
+
import { diff } from "./diff"
|
|
27
|
+
import type { Buffer, Dims, Event, Runtime, RuntimeOptions } from "./types"
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Event Channel - unified async iterable for all internal events
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
interface EventChannel {
|
|
34
|
+
push(event: Event): void
|
|
35
|
+
events(): AsyncIterable<Event>
|
|
36
|
+
dispose(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create an event channel that bridges callbacks to AsyncIterable.
|
|
41
|
+
*
|
|
42
|
+
* This is the single point where callbacks (resize, effect completion)
|
|
43
|
+
* are converted to the async iterable pattern. External sources like
|
|
44
|
+
* keyboard events are already AsyncIterable and merged at a higher level.
|
|
45
|
+
*/
|
|
46
|
+
function createEventChannel(signal: AbortSignal): EventChannel {
|
|
47
|
+
const queue: Event[] = []
|
|
48
|
+
let pendingResolve: ((event: Event | null) => void) | undefined
|
|
49
|
+
let disposed = false
|
|
50
|
+
|
|
51
|
+
// Resolve pending waiter on abort
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
if (pendingResolve) {
|
|
54
|
+
pendingResolve(null)
|
|
55
|
+
pendingResolve = undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
push(event: Event): void {
|
|
62
|
+
if (disposed || signal.aborted) return
|
|
63
|
+
|
|
64
|
+
if (pendingResolve) {
|
|
65
|
+
const r = pendingResolve
|
|
66
|
+
pendingResolve = undefined
|
|
67
|
+
r(event)
|
|
68
|
+
} else {
|
|
69
|
+
queue.push(event)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
events(): AsyncIterable<Event> {
|
|
74
|
+
return {
|
|
75
|
+
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
76
|
+
return {
|
|
77
|
+
async next(): Promise<IteratorResult<Event>> {
|
|
78
|
+
if (disposed || signal.aborted) {
|
|
79
|
+
return { done: true, value: undefined }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Return queued event if available
|
|
83
|
+
if (queue.length > 0) {
|
|
84
|
+
return { done: false, value: queue.shift()! }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Wait for next event or abort
|
|
88
|
+
const event = await new Promise<Event | null>((resolve) => {
|
|
89
|
+
pendingResolve = resolve
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (event === null || disposed || signal.aborted) {
|
|
93
|
+
return { done: true, value: undefined }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { done: false, value: event }
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
dispose(): void {
|
|
104
|
+
disposed = true
|
|
105
|
+
signal.removeEventListener("abort", onAbort)
|
|
106
|
+
if (pendingResolve) {
|
|
107
|
+
pendingResolve(null)
|
|
108
|
+
pendingResolve = undefined
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Runtime Factory
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create a runtime kernel.
|
|
120
|
+
*
|
|
121
|
+
* @param options Runtime configuration
|
|
122
|
+
* @returns Runtime instance implementing Symbol.dispose
|
|
123
|
+
*/
|
|
124
|
+
export function createRuntime(options: RuntimeOptions): Runtime {
|
|
125
|
+
const { target, signal: externalSignal, mode = "fullscreen" } = options
|
|
126
|
+
|
|
127
|
+
// Inline mode needs persistent cursor tracking across frames.
|
|
128
|
+
// If no outputPhaseFn provided, create one so prevCursorRow/prevOutputLines
|
|
129
|
+
// persist between renders (bare diff() creates fresh state each call).
|
|
130
|
+
const fallbackOutputPhase = mode === "inline" ? createOutputPhase({}) : undefined
|
|
131
|
+
const outputPhaseFn = options.outputPhaseFn ?? fallbackOutputPhase
|
|
132
|
+
|
|
133
|
+
// Internal abort controller for cleanup
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
const signal = controller.signal
|
|
136
|
+
|
|
137
|
+
// Wire external signal if provided - track for cleanup
|
|
138
|
+
let externalAbortHandler: (() => void) | undefined
|
|
139
|
+
if (externalSignal) {
|
|
140
|
+
if (externalSignal.aborted) {
|
|
141
|
+
controller.abort()
|
|
142
|
+
} else {
|
|
143
|
+
externalAbortHandler = () => controller.abort()
|
|
144
|
+
externalSignal.addEventListener("abort", externalAbortHandler, {
|
|
145
|
+
once: true,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Track previous buffer for diffing
|
|
151
|
+
let prevBuffer: Buffer | null = null
|
|
152
|
+
|
|
153
|
+
// Scrollback offset tracking (inline mode only)
|
|
154
|
+
let scrollbackOffset = 0
|
|
155
|
+
|
|
156
|
+
// Track if disposed
|
|
157
|
+
let disposed = false
|
|
158
|
+
|
|
159
|
+
// Unified event channel for resize and effect events
|
|
160
|
+
const eventChannel = createEventChannel(signal)
|
|
161
|
+
|
|
162
|
+
// Subscribe to resize events if supported
|
|
163
|
+
let unsubscribeResize: (() => void) | undefined
|
|
164
|
+
if (target.onResize) {
|
|
165
|
+
unsubscribeResize = target.onResize((dims) => {
|
|
166
|
+
eventChannel.push({ type: "resize", cols: dims.cols, rows: dims.rows })
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Effect ID counter
|
|
171
|
+
let effectId = 0
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
events(): AsyncIterable<Event> {
|
|
175
|
+
// Return channel events wrapped with takeUntil for cleanup
|
|
176
|
+
return takeUntil(eventChannel.events(), signal)
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
schedule<T>(effect: () => Promise<T>, opts?: { signal?: AbortSignal }): void {
|
|
180
|
+
if (disposed) return
|
|
181
|
+
|
|
182
|
+
const id = `effect-${effectId++}`
|
|
183
|
+
const effectSignal = opts?.signal
|
|
184
|
+
|
|
185
|
+
// Check if already aborted
|
|
186
|
+
if (effectSignal?.aborted) return
|
|
187
|
+
|
|
188
|
+
// Execute effect asynchronously
|
|
189
|
+
const execute = async () => {
|
|
190
|
+
// Track abort handler for cleanup
|
|
191
|
+
let abortHandler: (() => void) | undefined
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
if (effectSignal) {
|
|
195
|
+
// Create abort race with cleanup
|
|
196
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
197
|
+
abortHandler = () => reject(new Error("Effect aborted"))
|
|
198
|
+
effectSignal.addEventListener("abort", abortHandler, {
|
|
199
|
+
once: true,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const result = await Promise.race([effect(), aborted])
|
|
204
|
+
|
|
205
|
+
// Clean up abort listener after success
|
|
206
|
+
if (abortHandler) {
|
|
207
|
+
effectSignal.removeEventListener("abort", abortHandler)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
eventChannel.push({ type: "effect", id, result })
|
|
211
|
+
} else {
|
|
212
|
+
const result = await effect()
|
|
213
|
+
eventChannel.push({ type: "effect", id, result })
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// Clean up abort listener on error too
|
|
217
|
+
if (abortHandler && effectSignal) {
|
|
218
|
+
effectSignal.removeEventListener("abort", abortHandler)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check for abort by name (handles DOMException, AbortError, etc.)
|
|
222
|
+
if (error instanceof Error && (error.message === "Effect aborted" || error.name === "AbortError")) {
|
|
223
|
+
// Silently ignore aborted effects
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
eventChannel.push({
|
|
227
|
+
type: "error",
|
|
228
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Start immediately (microtask)
|
|
234
|
+
queueMicrotask(() => {
|
|
235
|
+
void execute()
|
|
236
|
+
})
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
render(buffer: Buffer): void {
|
|
240
|
+
if (disposed) return
|
|
241
|
+
|
|
242
|
+
// Compute diff internally — pass terminal rows to cap output.
|
|
243
|
+
// Inline mode: prevents scrollback corruption (cursor-up clamped at row 0).
|
|
244
|
+
// Fullscreen mode: prevents buffer overflow that scrolls the terminal and
|
|
245
|
+
// desynchronizes prevBuffer from actual terminal state (ghost pixel garble).
|
|
246
|
+
const offset = scrollbackOffset
|
|
247
|
+
scrollbackOffset = 0 // Consume the offset
|
|
248
|
+
const termRows = target.getDims().rows
|
|
249
|
+
|
|
250
|
+
// Use scoped output phase if provided (threads measurer/caps correctly),
|
|
251
|
+
// otherwise fall back to raw diff() for backwards compatibility
|
|
252
|
+
let patch: string
|
|
253
|
+
if (outputPhaseFn) {
|
|
254
|
+
const prevBuf = prevBuffer?._buffer ?? null
|
|
255
|
+
const nextBuf = buffer._buffer
|
|
256
|
+
patch = outputPhaseFn(prevBuf, nextBuf, mode, offset, termRows)
|
|
257
|
+
} else {
|
|
258
|
+
patch = diff(prevBuffer, buffer, mode, offset, termRows)
|
|
259
|
+
}
|
|
260
|
+
prevBuffer = buffer
|
|
261
|
+
|
|
262
|
+
// Debug: capture raw ANSI output that's actually written to the terminal
|
|
263
|
+
if (process.env.SILVERY_CAPTURE_RAW) {
|
|
264
|
+
try {
|
|
265
|
+
const fs = require("fs")
|
|
266
|
+
fs.appendFileSync("/tmp/silvery-runtime-raw.ansi", patch)
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Write to target
|
|
271
|
+
target.write(patch)
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
addScrollbackLines(lines: number): void {
|
|
275
|
+
if (mode !== "inline" || lines <= 0) return
|
|
276
|
+
scrollbackOffset += lines
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
invalidate(): void {
|
|
280
|
+
prevBuffer = null
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
resetInlineCursor(): void {
|
|
284
|
+
// Reset inline cursor tracking — delegates to the output phase (either
|
|
285
|
+
// the caller-provided one or the inline-mode fallback created above).
|
|
286
|
+
const fn = outputPhaseFn as { resetInlineState?: () => void } | undefined
|
|
287
|
+
fn?.resetInlineState?.()
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
getInlineCursorRow(): number {
|
|
291
|
+
const fn = outputPhaseFn as { getInlineCursorRow?: () => number } | undefined
|
|
292
|
+
return fn?.getInlineCursorRow?.() ?? -1
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
promoteScrollback(content: string, lines: number): void {
|
|
296
|
+
const fn = outputPhaseFn as { promoteScrollback?: (c: string, l: number) => void } | undefined
|
|
297
|
+
fn?.promoteScrollback?.(content, lines)
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
getDims(): Dims {
|
|
301
|
+
return target.getDims()
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
[Symbol.dispose](): void {
|
|
305
|
+
if (disposed) return
|
|
306
|
+
disposed = true
|
|
307
|
+
|
|
308
|
+
// Abort all pending operations
|
|
309
|
+
controller.abort()
|
|
310
|
+
|
|
311
|
+
// Remove external signal listener if still attached
|
|
312
|
+
if (externalAbortHandler && externalSignal) {
|
|
313
|
+
externalSignal.removeEventListener("abort", externalAbortHandler)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Unsubscribe from resize
|
|
317
|
+
if (unsubscribeResize) {
|
|
318
|
+
unsubscribeResize()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Dispose event channel
|
|
322
|
+
eventChannel.dispose()
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diff function for silvery-loop.
|
|
3
|
+
*
|
|
4
|
+
* Takes prev and next buffers, returns minimal ANSI patch.
|
|
5
|
+
* This is an internal function used by the runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { outputPhase } from "../pipeline"
|
|
9
|
+
import type { Buffer } from "./types"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Diff mode for ANSI output.
|
|
13
|
+
*/
|
|
14
|
+
export type DiffMode = "fullscreen" | "inline"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute the minimal ANSI diff between two buffers.
|
|
18
|
+
*
|
|
19
|
+
* @param prev Previous buffer (null on first render)
|
|
20
|
+
* @param next Current buffer
|
|
21
|
+
* @param mode Render mode (fullscreen or inline)
|
|
22
|
+
* @returns ANSI escape sequence string to transform prev into next
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { diff, layout } from '@silvery/term/runtime'
|
|
27
|
+
*
|
|
28
|
+
* const prev = layout(<Text>Hello</Text>, dims)
|
|
29
|
+
* const next = layout(<Text>World</Text>, dims)
|
|
30
|
+
* const patch = diff(prev, next)
|
|
31
|
+
* process.stdout.write(patch)
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function diff(
|
|
35
|
+
prev: Buffer | null,
|
|
36
|
+
next: Buffer,
|
|
37
|
+
mode: DiffMode = "fullscreen",
|
|
38
|
+
scrollbackOffset = 0,
|
|
39
|
+
termRows?: number,
|
|
40
|
+
): string {
|
|
41
|
+
const prevBuffer = prev?._buffer ?? null
|
|
42
|
+
const nextBuffer = next._buffer
|
|
43
|
+
|
|
44
|
+
return outputPhase(prevBuffer, nextBuffer, mode, scrollbackOffset, termRows)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a buffer to ANSI string (no diff, full render).
|
|
49
|
+
*
|
|
50
|
+
* @param buffer Buffer to render
|
|
51
|
+
* @param mode Render mode (fullscreen or inline)
|
|
52
|
+
* @returns Full ANSI output
|
|
53
|
+
*/
|
|
54
|
+
export function render(buffer: Buffer, mode: DiffMode = "fullscreen"): string {
|
|
55
|
+
return outputPhase(null, buffer._buffer, mode)
|
|
56
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event handler composition for createApp runtime.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from create-app.tsx to reduce nesting depth.
|
|
5
|
+
* Contains: handler context creation, focus navigation dispatch,
|
|
6
|
+
* mouse event dispatch, and key handler dispatch.
|
|
7
|
+
*
|
|
8
|
+
* All functions are pure or near-pure — they don't access the event loop's
|
|
9
|
+
* mutable state (pendingRerender, isRendering, etc.), which stays in create-app.tsx.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StoreApi } from "zustand"
|
|
13
|
+
|
|
14
|
+
import { createKeyEvent, dispatchKeyEvent } from "@silvery/tea/focus-events"
|
|
15
|
+
import type { FocusManager } from "@silvery/tea/focus-manager"
|
|
16
|
+
import { findByTestID } from "@silvery/tea/focus-queries"
|
|
17
|
+
import { type MouseEventProcessorState, processMouseEvent, hitTest } from "../mouse-events"
|
|
18
|
+
import type { Container } from "@silvery/react/reconciler"
|
|
19
|
+
import { getContainerRoot } from "@silvery/react/reconciler"
|
|
20
|
+
import type { TeaNode } from "@silvery/tea/types"
|
|
21
|
+
import type { Key } from "./keys"
|
|
22
|
+
import type { EventHandler, EventHandlerContext, EventHandlers } from "./create-app"
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Namespaced event from a provider.
|
|
30
|
+
*/
|
|
31
|
+
export interface NamespacedEvent {
|
|
32
|
+
type: string
|
|
33
|
+
provider: string
|
|
34
|
+
event: string
|
|
35
|
+
data: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Handler Context
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the EventHandlerContext passed to user-defined event handlers.
|
|
44
|
+
* Shared by runEventHandler() and press().
|
|
45
|
+
*
|
|
46
|
+
* When the store was created with `tea()` middleware, `dispatch` is
|
|
47
|
+
* automatically wired from the store state.
|
|
48
|
+
*/
|
|
49
|
+
export function createHandlerContext<S>(
|
|
50
|
+
store: StoreApi<S>,
|
|
51
|
+
focusManager: FocusManager,
|
|
52
|
+
container: Container,
|
|
53
|
+
): EventHandlerContext<S> {
|
|
54
|
+
// Detect tea() middleware: store state has a dispatch function
|
|
55
|
+
const state = store.getState() as Record<string, unknown>
|
|
56
|
+
const teaDispatch = typeof state.dispatch === "function" ? state.dispatch : undefined
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
set: store.setState,
|
|
60
|
+
get: store.getState,
|
|
61
|
+
focusManager,
|
|
62
|
+
focus(testID: string) {
|
|
63
|
+
const root = getContainerRoot(container)
|
|
64
|
+
focusManager.focusById(testID, root, "programmatic")
|
|
65
|
+
},
|
|
66
|
+
activateScope(scopeId: string) {
|
|
67
|
+
const root = getContainerRoot(container)
|
|
68
|
+
focusManager.activateScope(scopeId, root)
|
|
69
|
+
},
|
|
70
|
+
getFocusPath() {
|
|
71
|
+
const root = getContainerRoot(container)
|
|
72
|
+
return focusManager.getFocusPath(root)
|
|
73
|
+
},
|
|
74
|
+
dispatch: teaDispatch as EventHandlerContext<S>["dispatch"],
|
|
75
|
+
hitTest(x: number, y: number) {
|
|
76
|
+
const root = getContainerRoot(container)
|
|
77
|
+
return hitTest(root, x, y)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Focus Navigation
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dispatch a key event through the focus system and handle default
|
|
88
|
+
* focus navigation (Tab, Shift+Tab, Enter scope, Escape scope).
|
|
89
|
+
*
|
|
90
|
+
* Returns "consumed" if the focus system handled the event (caller should
|
|
91
|
+
* render and return), or "continue" if the event should proceed to app handlers.
|
|
92
|
+
*/
|
|
93
|
+
export function handleFocusNavigation(
|
|
94
|
+
input: string,
|
|
95
|
+
parsedKey: Key,
|
|
96
|
+
focusManager: FocusManager,
|
|
97
|
+
container: Container,
|
|
98
|
+
): "consumed" | "continue" {
|
|
99
|
+
// Dispatch key event to focused node (capture + bubble phases)
|
|
100
|
+
if (focusManager.activeElement) {
|
|
101
|
+
const keyEvent = createKeyEvent(input, parsedKey, focusManager.activeElement)
|
|
102
|
+
dispatchKeyEvent(keyEvent)
|
|
103
|
+
|
|
104
|
+
// If focus system consumed the event, skip app handlers
|
|
105
|
+
if (keyEvent.propagationStopped || keyEvent.defaultPrevented) {
|
|
106
|
+
return "consumed"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const root = getContainerRoot(container)
|
|
111
|
+
|
|
112
|
+
// Tab: focus next (works even when nothing is focused — starts from first)
|
|
113
|
+
if (parsedKey.tab && !parsedKey.shift) {
|
|
114
|
+
focusManager.focusNext(root)
|
|
115
|
+
return "consumed"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Shift+Tab: focus previous (works even when nothing is focused — starts from last)
|
|
119
|
+
if (parsedKey.tab && parsedKey.shift) {
|
|
120
|
+
focusManager.focusPrev(root)
|
|
121
|
+
return "consumed"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Enter: if focused element has focusScope, enter that scope
|
|
125
|
+
if (parsedKey.return && focusManager.activeElement) {
|
|
126
|
+
const activeEl = focusManager.activeElement
|
|
127
|
+
const props = activeEl.props as Record<string, unknown>
|
|
128
|
+
const testID = typeof props.testID === "string" ? props.testID : null
|
|
129
|
+
if (props.focusScope && testID) {
|
|
130
|
+
focusManager.enterScope(testID)
|
|
131
|
+
focusManager.focusNext(root, activeEl)
|
|
132
|
+
return "consumed"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Escape: blur current focus or exit the current focus scope
|
|
137
|
+
if (parsedKey.escape) {
|
|
138
|
+
if (focusManager.scopeStack.length > 0) {
|
|
139
|
+
const scopeId = focusManager.scopeStack[focusManager.scopeStack.length - 1]!
|
|
140
|
+
focusManager.exitScope()
|
|
141
|
+
const scopeNode = findByTestID(root, scopeId)
|
|
142
|
+
if (scopeNode) {
|
|
143
|
+
focusManager.focus(scopeNode, "keyboard")
|
|
144
|
+
}
|
|
145
|
+
return "consumed"
|
|
146
|
+
}
|
|
147
|
+
if (focusManager.activeElement) {
|
|
148
|
+
focusManager.blur()
|
|
149
|
+
return "consumed"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return "continue"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Mouse Event Dispatch
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Dispatch a DOM-level mouse event to the node tree.
|
|
162
|
+
* Called from runEventHandler for mouse events.
|
|
163
|
+
*/
|
|
164
|
+
export function dispatchMouseEventToTree(
|
|
165
|
+
event: NamespacedEvent,
|
|
166
|
+
mouseEventState: MouseEventProcessorState,
|
|
167
|
+
root: TeaNode,
|
|
168
|
+
): void {
|
|
169
|
+
if (event.event !== "mouse" || !event.data) return
|
|
170
|
+
|
|
171
|
+
const mouseData = event.data as {
|
|
172
|
+
button: number
|
|
173
|
+
x: number
|
|
174
|
+
y: number
|
|
175
|
+
action: string
|
|
176
|
+
delta?: number
|
|
177
|
+
shift: boolean
|
|
178
|
+
meta: boolean
|
|
179
|
+
ctrl: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
processMouseEvent(
|
|
183
|
+
mouseEventState,
|
|
184
|
+
{
|
|
185
|
+
button: mouseData.button,
|
|
186
|
+
x: mouseData.x,
|
|
187
|
+
y: mouseData.y,
|
|
188
|
+
action: mouseData.action as "down" | "up" | "move" | "wheel",
|
|
189
|
+
delta: mouseData.delta,
|
|
190
|
+
shift: mouseData.shift,
|
|
191
|
+
meta: mouseData.meta,
|
|
192
|
+
ctrl: mouseData.ctrl,
|
|
193
|
+
},
|
|
194
|
+
root,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Event Handler Dispatch
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Invoke the namespaced handler for a single event (state mutation only, no render).
|
|
204
|
+
* Returns true to continue, false to exit, or "flush" for a render barrier.
|
|
205
|
+
*
|
|
206
|
+
* Also dispatches DOM-level mouse events when applicable.
|
|
207
|
+
*/
|
|
208
|
+
export function invokeEventHandler<S>(
|
|
209
|
+
event: NamespacedEvent,
|
|
210
|
+
handlers: EventHandlers<S> | undefined,
|
|
211
|
+
ctx: EventHandlerContext<S>,
|
|
212
|
+
mouseEventState: MouseEventProcessorState,
|
|
213
|
+
container: Container,
|
|
214
|
+
): boolean | "flush" {
|
|
215
|
+
const namespacedHandler = handlers?.[event.type as keyof typeof handlers]
|
|
216
|
+
|
|
217
|
+
if (namespacedHandler && typeof namespacedHandler === "function") {
|
|
218
|
+
const result = (namespacedHandler as EventHandler<unknown, S>)(event.data, ctx)
|
|
219
|
+
if (result === "exit") return false
|
|
220
|
+
if (result === "flush") return "flush"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// DOM-level mouse event dispatch
|
|
224
|
+
const root = getContainerRoot(container)
|
|
225
|
+
dispatchMouseEventToTree(event, mouseEventState, root)
|
|
226
|
+
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dispatch a term:key event to app handlers (namespaced + legacy).
|
|
232
|
+
* Returns "exit" if the handler signaled exit, undefined otherwise.
|
|
233
|
+
*/
|
|
234
|
+
export function dispatchKeyToHandlers<S>(
|
|
235
|
+
input: string,
|
|
236
|
+
parsedKey: Key,
|
|
237
|
+
handlers: EventHandlers<S> | undefined,
|
|
238
|
+
ctx: EventHandlerContext<S>,
|
|
239
|
+
): "exit" | undefined {
|
|
240
|
+
// Namespaced handler
|
|
241
|
+
const namespacedHandler = handlers?.["term:key" as keyof typeof handlers]
|
|
242
|
+
if (namespacedHandler && typeof namespacedHandler === "function") {
|
|
243
|
+
const result = (namespacedHandler as EventHandler<unknown, S>)({ input, key: parsedKey }, ctx)
|
|
244
|
+
if (result === "exit") return "exit"
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Legacy handler
|
|
248
|
+
if ((handlers as any)?.key) {
|
|
249
|
+
const result = (handlers as any).key(input, parsedKey, ctx)
|
|
250
|
+
if (result === "exit") return "exit"
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return undefined
|
|
254
|
+
}
|