@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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
package/src/layout.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * silvery/layout -- Layout feedback hooks.
3
+ *
4
+ * The key differentiator of silvery over Ink: components that know their
5
+ * own size during render, not after.
6
+ *
7
+ * ```tsx
8
+ * import { useContentRect, useScreenRect } from '@silvery/react/layout'
9
+ *
10
+ * function ResponsiveCard() {
11
+ * const { width, height } = useContentRect()
12
+ * const { x, y } = useScreenRect()
13
+ * return <Text>{`${width}x${height} at (${x},${y})`}</Text>
14
+ * }
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ export {
21
+ useContentRect,
22
+ useContentRectCallback,
23
+ useScreenRect,
24
+ useScreenRectCallback,
25
+ } from "@silvery/react/hooks/useLayout"
26
+ export type { Rect } from "@silvery/tea/types"
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Measurer composition layer.
3
+ *
4
+ * Creates term-scoped measurers and pipeline configs.
5
+ * Bridges ansi Term with silvery measurement capabilities.
6
+ */
7
+
8
+ import type { Term, TerminalCaps } from "./ansi/index"
9
+ import { createWidthMeasurer, type Measurer } from "./unicode"
10
+ import { createOutputPhase } from "./pipeline/output-phase"
11
+ import type { PipelineConfig } from "./pipeline"
12
+
13
+ export type { Measurer } from "./unicode"
14
+
15
+ /**
16
+ * Term extended with measurement capabilities.
17
+ */
18
+ export interface MeasuredTerm extends Term, Measurer {}
19
+
20
+ /**
21
+ * Extend a Term with measurement capabilities.
22
+ *
23
+ * Creates a width measurer from the term's caps and adds measurement
24
+ * methods (displayWidth, graphemeWidth, wrapText, etc.) to the term.
25
+ */
26
+ export function withMeasurer(term: Term): MeasuredTerm {
27
+ const caps = term.caps
28
+ const measurer = createWidthMeasurer(
29
+ caps ? { textEmojiWide: caps.textEmojiWide, textSizingEnabled: caps.textSizingSupported } : {},
30
+ )
31
+
32
+ return Object.create(term, {
33
+ textEmojiWide: { get: () => measurer.textEmojiWide, enumerable: true },
34
+ textSizingEnabled: { get: () => measurer.textSizingEnabled, enumerable: true },
35
+ displayWidth: { value: measurer.displayWidth.bind(measurer), enumerable: true },
36
+ displayWidthAnsi: { value: measurer.displayWidthAnsi.bind(measurer), enumerable: true },
37
+ graphemeWidth: { value: measurer.graphemeWidth.bind(measurer), enumerable: true },
38
+ wrapText: { value: measurer.wrapText.bind(measurer), enumerable: true },
39
+ sliceByWidth: { value: measurer.sliceByWidth.bind(measurer), enumerable: true },
40
+ sliceByWidthFromEnd: { value: measurer.sliceByWidthFromEnd.bind(measurer), enumerable: true },
41
+ }) as MeasuredTerm
42
+ }
43
+
44
+ /**
45
+ * Create a pipeline configuration from caps and/or measurer.
46
+ *
47
+ * This is the single factory for PipelineConfig -- use it instead of
48
+ * manually constructing { measurer, outputPhaseFn }.
49
+ *
50
+ * @param options.caps - Terminal capabilities (for output phase SGR generation)
51
+ * @param options.measurer - Explicit measurer (if omitted, created from caps)
52
+ */
53
+ export function createPipeline(
54
+ options: {
55
+ caps?: TerminalCaps
56
+ measurer?: Measurer
57
+ } = {},
58
+ ): PipelineConfig {
59
+ const { caps, measurer: explicitMeasurer } = options
60
+ const measurer =
61
+ explicitMeasurer ??
62
+ createWidthMeasurer(caps ? { textEmojiWide: caps.textEmojiWide, textSizingEnabled: caps.textSizingSupported } : {})
63
+ const outputPhaseFn = createOutputPhase(
64
+ caps
65
+ ? {
66
+ underlineStyles: caps.underlineStyles,
67
+ underlineColor: caps.underlineColor,
68
+ colorLevel: caps.colorLevel,
69
+ }
70
+ : {},
71
+ measurer,
72
+ )
73
+ return { measurer, outputPhaseFn }
74
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * DECRQM — DEC Private Mode Query
3
+ *
4
+ * Queries the terminal for the state of DEC private modes.
5
+ *
6
+ * Protocol:
7
+ * - Query: CSI ? {mode} $ p
8
+ * - Response: CSI ? {mode} ; {Ps} $ y
9
+ *
10
+ * Where Ps is:
11
+ * 1 = set (mode is enabled)
12
+ * 2 = reset (mode is disabled)
13
+ * 0 = not recognized (unknown mode)
14
+ * 3 = permanently set
15
+ * 4 = permanently reset
16
+ *
17
+ * We normalize 3→"set" and 4→"reset" for simplicity.
18
+ *
19
+ * Supported by: xterm, Ghostty, Kitty, WezTerm, foot, VTE-based terminals
20
+ */
21
+
22
+ /** Regex for DECRPM response: CSI ? mode ; Ps $ y */
23
+ const DECRPM_RESPONSE_RE = /\x1b\[\?(\d+);(\d+)\$y/
24
+
25
+ /** Well-known DEC private mode constants. */
26
+ export const DecMode = {
27
+ /** DEC cursor visible (DECTCEM) */
28
+ CURSOR_VISIBLE: 25,
29
+ /** Alternate screen buffer (DECSET 1049) */
30
+ ALT_SCREEN: 1049,
31
+ /** Normal mouse tracking (X10) */
32
+ MOUSE_TRACKING: 1000,
33
+ /** Bracketed paste mode */
34
+ BRACKETED_PASTE: 2004,
35
+ /** Synchronized output */
36
+ SYNC_OUTPUT: 2026,
37
+ /** Focus reporting */
38
+ FOCUS_REPORTING: 1004,
39
+ } as const
40
+
41
+ type ModeState = "set" | "reset" | "unknown"
42
+
43
+ /**
44
+ * Query the state of a single DEC private mode.
45
+ *
46
+ * @param write Function to write to stdout
47
+ * @param read Function to read a chunk from stdin
48
+ * @param mode DEC private mode number (e.g., DecMode.ALT_SCREEN)
49
+ * @param timeoutMs How long to wait for response (default: 200ms)
50
+ * @returns "set", "reset", or "unknown"
51
+ */
52
+ export async function queryMode(
53
+ write: (data: string) => void,
54
+ read: (timeoutMs: number) => Promise<string | null>,
55
+ mode: number,
56
+ timeoutMs = 200,
57
+ ): Promise<ModeState> {
58
+ write(`\x1b[?${mode}$p`)
59
+
60
+ const data = await read(timeoutMs)
61
+ if (data == null) return "unknown"
62
+
63
+ const match = DECRPM_RESPONSE_RE.exec(data)
64
+ if (!match) return "unknown"
65
+
66
+ const reportedMode = parseInt(match[1]!, 10)
67
+ if (reportedMode !== mode) return "unknown"
68
+
69
+ const ps = parseInt(match[2]!, 10)
70
+ switch (ps) {
71
+ case 1:
72
+ case 3:
73
+ return "set"
74
+ case 2:
75
+ case 4:
76
+ return "reset"
77
+ default:
78
+ return "unknown"
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Query the state of multiple DEC private modes.
84
+ *
85
+ * Queries each mode sequentially and returns a Map of results.
86
+ *
87
+ * @param write Function to write to stdout
88
+ * @param read Function to read a chunk from stdin
89
+ * @param modes Array of DEC private mode numbers
90
+ * @param timeoutMs Per-query timeout (default: 200ms)
91
+ */
92
+ export async function queryModes(
93
+ write: (data: string) => void,
94
+ read: (timeoutMs: number) => Promise<string | null>,
95
+ modes: number[],
96
+ timeoutMs = 200,
97
+ ): Promise<Map<number, ModeState>> {
98
+ const results = new Map<number, ModeState>()
99
+
100
+ for (const mode of modes) {
101
+ const state = await queryMode(write, read, mode, timeoutMs)
102
+ results.set(mode, state)
103
+ }
104
+
105
+ return results
106
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * DOM-level Mouse Events for silvery
3
+ *
4
+ * Provides React DOM-compatible mouse event infrastructure:
5
+ * - SilveryMouseEvent / SilveryWheelEvent synthetic event objects
6
+ * - Tree-based hit testing using screenRect (replaces manual HitRegistry)
7
+ * - Event dispatch with bubbling (target → root, stopPropagation support)
8
+ * - Double-click detection (300ms / 2-cell threshold)
9
+ * - mouseenter/mouseleave tracking (no bubble, like DOM spec)
10
+ */
11
+
12
+ import type { FocusManager } from "@silvery/tea/focus-manager"
13
+ import { findFocusableAncestor } from "@silvery/tea/focus-queries"
14
+ import type { ParsedMouse } from "./mouse"
15
+ import { getAncestorPath, pointInRect } from "@silvery/tea/tree-utils"
16
+ import type { TeaNode } from "@silvery/tea/types"
17
+
18
+ // ============================================================================
19
+ // Event Types
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Synthetic mouse event, mirroring React.MouseEvent / DOM MouseEvent.
24
+ */
25
+ export interface SilveryMouseEvent {
26
+ /** Terminal column (0-indexed) */
27
+ clientX: number
28
+ /** Terminal row (0-indexed) */
29
+ clientY: number
30
+ /** Mouse button: 0=left, 1=middle, 2=right */
31
+ button: number
32
+ /** Modifier keys */
33
+ altKey: boolean
34
+ ctrlKey: boolean
35
+ metaKey: boolean
36
+ shiftKey: boolean
37
+ /** Deepest node under cursor */
38
+ target: TeaNode
39
+ /** Node whose handler is currently firing (changes during bubble) */
40
+ currentTarget: TeaNode
41
+ /** Event type */
42
+ type: "click" | "dblclick" | "mousedown" | "mouseup" | "mousemove" | "mouseenter" | "mouseleave" | "wheel"
43
+ /** Stop event from bubbling to parent nodes */
44
+ stopPropagation(): void
45
+ /** Prevent default behavior */
46
+ preventDefault(): void
47
+ /** Whether stopPropagation() was called */
48
+ readonly propagationStopped: boolean
49
+ /** Whether preventDefault() was called */
50
+ readonly defaultPrevented: boolean
51
+ /** Raw parsed mouse data from SGR protocol */
52
+ nativeEvent: ParsedMouse
53
+ }
54
+
55
+ /**
56
+ * Synthetic wheel event, extending SilveryMouseEvent with scroll deltas.
57
+ */
58
+ export interface SilveryWheelEvent extends SilveryMouseEvent {
59
+ /** Vertical scroll: -1 (up) or +1 (down) */
60
+ deltaY: number
61
+ /** Horizontal scroll: always 0 for terminals */
62
+ deltaX: number
63
+ }
64
+
65
+ // ============================================================================
66
+ // Mouse Event Handler Props (added to BoxProps/TextProps)
67
+ // ============================================================================
68
+
69
+ export interface MouseEventProps {
70
+ onClick?: (event: SilveryMouseEvent) => void
71
+ onDoubleClick?: (event: SilveryMouseEvent) => void
72
+ onMouseDown?: (event: SilveryMouseEvent) => void
73
+ onMouseUp?: (event: SilveryMouseEvent) => void
74
+ onMouseMove?: (event: SilveryMouseEvent) => void
75
+ onMouseEnter?: (event: SilveryMouseEvent) => void
76
+ onMouseLeave?: (event: SilveryMouseEvent) => void
77
+ onWheel?: (event: SilveryWheelEvent) => void
78
+ }
79
+
80
+ // ============================================================================
81
+ // Event Factory
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Create a synthetic mouse event.
86
+ */
87
+ export function createMouseEvent(
88
+ type: SilveryMouseEvent["type"],
89
+ x: number,
90
+ y: number,
91
+ target: TeaNode,
92
+ parsed: ParsedMouse,
93
+ ): SilveryMouseEvent {
94
+ let propagationStopped = false
95
+ let defaultPrevented = false
96
+
97
+ return {
98
+ type,
99
+ clientX: x,
100
+ clientY: y,
101
+ button: parsed.button,
102
+ altKey: parsed.meta,
103
+ ctrlKey: parsed.ctrl,
104
+ metaKey: false,
105
+ shiftKey: parsed.shift,
106
+ target,
107
+ currentTarget: target,
108
+ nativeEvent: parsed,
109
+ get propagationStopped() {
110
+ return propagationStopped
111
+ },
112
+ get defaultPrevented() {
113
+ return defaultPrevented
114
+ },
115
+ stopPropagation() {
116
+ propagationStopped = true
117
+ },
118
+ preventDefault() {
119
+ defaultPrevented = true
120
+ },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Create a synthetic wheel event.
126
+ */
127
+ export function createWheelEvent(x: number, y: number, target: TeaNode, parsed: ParsedMouse): SilveryWheelEvent {
128
+ const base = createMouseEvent("wheel", x, y, target, parsed) as SilveryWheelEvent
129
+ base.deltaY = parsed.delta ?? 0
130
+ base.deltaX = 0
131
+ return base
132
+ }
133
+
134
+ // ============================================================================
135
+ // Hit Testing
136
+ // ============================================================================
137
+
138
+ /**
139
+ * Tree-based hit test: find the deepest node whose screenRect contains (x, y).
140
+ * Uses reverse child order (last sibling wins = highest z-order, like DOM).
141
+ * Respects overflow:hidden clipping.
142
+ */
143
+ export function hitTest(node: TeaNode, x: number, y: number): TeaNode | null {
144
+ const rect = node.screenRect
145
+ if (!rect) return null
146
+
147
+ // Check if point is within this node's bounds
148
+ if (!pointInRect(x, y, rect)) return null
149
+
150
+ // pointerEvents="none" makes this node and its subtree invisible to hit testing
151
+ const props = node.props as { overflow?: string; pointerEvents?: string }
152
+ if (props.pointerEvents === "none") return null
153
+
154
+ // Check overflow clipping — if overflow is "hidden" or "scroll",
155
+ // children outside this node's rect are not hittable
156
+ const clips = props.overflow === "hidden" || props.overflow === "scroll"
157
+
158
+ // DFS: check children in reverse order (last child = top z-order, like DOM)
159
+ for (let i = node.children.length - 1; i >= 0; i--) {
160
+ const child = node.children[i]!
161
+ // If parent clips, skip children whose screenRect doesn't overlap parent
162
+ if (clips) {
163
+ const childRect = child.screenRect
164
+ if (childRect && !pointInRect(x, y, rect)) {
165
+ continue
166
+ }
167
+ }
168
+ const hit = hitTest(child, x, y)
169
+ if (hit) return hit
170
+ }
171
+
172
+ // No child matched — this node is the target (if it has a screenRect)
173
+ return node
174
+ }
175
+
176
+ // ============================================================================
177
+ // Event Dispatch
178
+ // ============================================================================
179
+
180
+ /** Map event type to the handler prop name */
181
+ const EVENT_HANDLER_MAP: Record<string, keyof MouseEventProps> = {
182
+ click: "onClick",
183
+ dblclick: "onDoubleClick",
184
+ mousedown: "onMouseDown",
185
+ mouseup: "onMouseUp",
186
+ mousemove: "onMouseMove",
187
+ mouseenter: "onMouseEnter",
188
+ mouseleave: "onMouseLeave",
189
+ wheel: "onWheel",
190
+ }
191
+
192
+ /**
193
+ * Dispatch a mouse event through the render tree with DOM-style bubbling.
194
+ *
195
+ * Bubbles from target → root, calling the appropriate handler on each node.
196
+ * stopPropagation() halts bubbling. mouseenter/mouseleave do NOT bubble (DOM spec).
197
+ */
198
+ export function dispatchMouseEvent(event: SilveryMouseEvent): void {
199
+ const handlerProp = EVENT_HANDLER_MAP[event.type]
200
+ if (!handlerProp) return
201
+
202
+ // mouseenter/mouseleave don't bubble (DOM spec)
203
+ const noBubble = event.type === "mouseenter" || event.type === "mouseleave"
204
+
205
+ if (noBubble) {
206
+ // Only fire on the target itself
207
+ const handler = (event.target.props as Record<string, unknown>)[handlerProp] as
208
+ | ((e: SilveryMouseEvent) => void)
209
+ | undefined
210
+ if (handler) {
211
+ const mutableEvent = event as { currentTarget: TeaNode }
212
+ mutableEvent.currentTarget = event.target
213
+ handler(event)
214
+ }
215
+ return
216
+ }
217
+
218
+ // Bubble phase: fire from target up to root
219
+ const path = getAncestorPath(event.target)
220
+ for (const node of path) {
221
+ if (event.propagationStopped) break
222
+
223
+ const handler = (node.props as Record<string, unknown>)[handlerProp] as ((e: SilveryMouseEvent) => void) | undefined
224
+ if (handler) {
225
+ const mutableEvent = event as { currentTarget: TeaNode }
226
+ mutableEvent.currentTarget = node
227
+ handler(event)
228
+ }
229
+ }
230
+ }
231
+
232
+ // ============================================================================
233
+ // Double-Click Detection
234
+ // ============================================================================
235
+
236
+ export interface DoubleClickState {
237
+ lastClickTime: number
238
+ lastClickX: number
239
+ lastClickY: number
240
+ lastClickButton: number
241
+ }
242
+
243
+ export function createDoubleClickState(): DoubleClickState {
244
+ return {
245
+ lastClickTime: 0,
246
+ lastClickX: -999,
247
+ lastClickY: -999,
248
+ lastClickButton: -1,
249
+ }
250
+ }
251
+
252
+ const DOUBLE_CLICK_TIME_MS = 300
253
+ const DOUBLE_CLICK_DISTANCE = 2
254
+
255
+ /**
256
+ * Check if a click qualifies as a double-click, given the previous click state.
257
+ * Updates the state for the next check.
258
+ * Returns true if this is a double-click.
259
+ */
260
+ export function checkDoubleClick(
261
+ state: DoubleClickState,
262
+ x: number,
263
+ y: number,
264
+ button: number,
265
+ now: number = Date.now(),
266
+ ): boolean {
267
+ const timeDelta = now - state.lastClickTime
268
+ const dx = Math.abs(x - state.lastClickX)
269
+ const dy = Math.abs(y - state.lastClickY)
270
+ const sameButton = button === state.lastClickButton
271
+
272
+ const isDouble =
273
+ sameButton && timeDelta <= DOUBLE_CLICK_TIME_MS && dx <= DOUBLE_CLICK_DISTANCE && dy <= DOUBLE_CLICK_DISTANCE
274
+
275
+ // Update state
276
+ state.lastClickTime = now
277
+ state.lastClickX = x
278
+ state.lastClickY = y
279
+ state.lastClickButton = button
280
+
281
+ // If double-click, reset so triple-click doesn't register as another double
282
+ if (isDouble) {
283
+ state.lastClickTime = 0
284
+ }
285
+
286
+ return isDouble
287
+ }
288
+
289
+ // ============================================================================
290
+ // Mouse Enter/Leave Tracking
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Compute mouseenter/mouseleave transitions between two ancestor paths.
295
+ *
296
+ * Returns { entered, left } — arrays of nodes that were entered or left.
297
+ * Mirrors the DOM spec: fire mouseleave on nodes in prevPath not in nextPath,
298
+ * and mouseenter on nodes in nextPath not in prevPath.
299
+ */
300
+ export function computeEnterLeave(prevPath: TeaNode[], nextPath: TeaNode[]): { entered: TeaNode[]; left: TeaNode[] } {
301
+ const prevSet = new Set(prevPath)
302
+ const nextSet = new Set(nextPath)
303
+
304
+ const entered = nextPath.filter((n) => !prevSet.has(n))
305
+ const left = prevPath.filter((n) => !nextSet.has(n))
306
+
307
+ return { entered, left }
308
+ }
309
+
310
+ // ============================================================================
311
+ // High-Level Mouse Event Processor
312
+ // ============================================================================
313
+
314
+ /**
315
+ * Options for creating a mouse event processor.
316
+ */
317
+ export interface MouseEventProcessorOptions {
318
+ /** Optional focus manager — enables click-to-focus behavior.
319
+ * On mousedown, the deepest focusable ancestor of the hit target is focused. */
320
+ focusManager?: FocusManager
321
+ }
322
+
323
+ /**
324
+ * State for the mouse event processor.
325
+ */
326
+ export interface MouseEventProcessorState {
327
+ doubleClick: DoubleClickState
328
+ /** Previous hover path (for enter/leave tracking) */
329
+ hoverPath: TeaNode[]
330
+ /** Whether the left button is currently down (for click detection) */
331
+ mouseDownTarget: TeaNode | null
332
+ /** Optional focus manager for click-to-focus */
333
+ focusManager?: FocusManager
334
+ }
335
+
336
+ export function createMouseEventProcessor(options?: MouseEventProcessorOptions): MouseEventProcessorState {
337
+ return {
338
+ doubleClick: createDoubleClickState(),
339
+ hoverPath: [],
340
+ mouseDownTarget: null,
341
+ focusManager: options?.focusManager,
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Process a raw ParsedMouse event and dispatch DOM-level events on the render tree.
347
+ *
348
+ * Call this for every SGR mouse event received. It handles:
349
+ * - mousedown / mouseup
350
+ * - click (on mouseup if same target as mousedown)
351
+ * - dblclick (based on timing)
352
+ * - mousemove + mouseenter/mouseleave
353
+ * - wheel
354
+ */
355
+ export function processMouseEvent(state: MouseEventProcessorState, parsed: ParsedMouse, root: TeaNode): void {
356
+ const { x, y, action } = parsed
357
+ const target = hitTest(root, x, y)
358
+ if (!target) return
359
+
360
+ if (action === "down") {
361
+ state.mouseDownTarget = target
362
+
363
+ // Click-to-focus: find nearest focusable ancestor and focus it
364
+ if (state.focusManager) {
365
+ const focusable = findFocusableAncestor(target)
366
+ if (focusable) {
367
+ state.focusManager.focus(focusable, "mouse")
368
+ }
369
+ }
370
+
371
+ const event = createMouseEvent("mousedown", x, y, target, parsed)
372
+ dispatchMouseEvent(event)
373
+ } else if (action === "up") {
374
+ const event = createMouseEvent("mouseup", x, y, target, parsed)
375
+ dispatchMouseEvent(event)
376
+
377
+ // Click = mouseup on the same node (or ancestor) where mousedown happened
378
+ // DOM actually fires click even if up is on a different element, but the target
379
+ // is the nearest common ancestor. For simplicity, we fire click on the up target
380
+ // if mousedown was on the same target or a descendant.
381
+ if (state.mouseDownTarget) {
382
+ const clickEvent = createMouseEvent("click", x, y, target, parsed)
383
+ dispatchMouseEvent(clickEvent)
384
+
385
+ // Check for double-click
386
+ const isDouble = checkDoubleClick(state.doubleClick, x, y, parsed.button)
387
+ if (isDouble) {
388
+ const dblEvent = createMouseEvent("dblclick", x, y, target, parsed)
389
+ dispatchMouseEvent(dblEvent)
390
+ }
391
+ }
392
+
393
+ state.mouseDownTarget = null
394
+ } else if (action === "move") {
395
+ const event = createMouseEvent("mousemove", x, y, target, parsed)
396
+ dispatchMouseEvent(event)
397
+
398
+ // Compute enter/leave transitions
399
+ const newPath = getAncestorPath(target)
400
+ const { entered, left } = computeEnterLeave(state.hoverPath, newPath)
401
+
402
+ // Fire mouseleave on nodes that were left (reverse order = deepest first)
403
+ for (const node of left) {
404
+ const leaveEvent = createMouseEvent("mouseleave", x, y, node, parsed)
405
+ dispatchMouseEvent(leaveEvent)
406
+ }
407
+
408
+ // Fire mouseenter on newly entered nodes (forward order = shallowest first)
409
+ for (const node of entered.reverse()) {
410
+ const enterEvent = createMouseEvent("mouseenter", x, y, node, parsed)
411
+ dispatchMouseEvent(enterEvent)
412
+ }
413
+
414
+ state.hoverPath = newPath
415
+ } else if (action === "wheel") {
416
+ const event = createWheelEvent(x, y, target, parsed)
417
+ dispatchMouseEvent(event)
418
+ }
419
+ }
package/src/mouse.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * SGR mouse event parsing (mode 1006).
3
+ *
4
+ * SGR format: CSI < button;x;y M (press) or CSI < button;x;y m (release)
5
+ *
6
+ * Button encoding:
7
+ * - Bits 0-1: 0=left, 1=middle, 2=right, 3=release (X10 only, not SGR)
8
+ * - Bit 2 (+4): Shift held
9
+ * - Bit 3 (+8): Meta/Alt held
10
+ * - Bit 4 (+16): Ctrl held
11
+ * - Bit 5 (+32): Motion event (mouse moved while button held)
12
+ * - Bits 6-7: 64=wheel-up, 65=wheel-down, 66=wheel-left, 67=wheel-right
13
+ */
14
+
15
+ /**
16
+ * Parsed mouse event from SGR mouse protocol (mode 1006).
17
+ */
18
+ export interface ParsedMouse {
19
+ /** Mouse button: 0=left, 1=middle, 2=right */
20
+ button: number
21
+ /** Column (0-indexed) */
22
+ x: number
23
+ /** Row (0-indexed) */
24
+ y: number
25
+ /** Event action */
26
+ action: "down" | "up" | "move" | "wheel"
27
+ /** Wheel delta: -1 for up, +1 for down */
28
+ delta?: number
29
+ /** Shift was held */
30
+ shift: boolean
31
+ /** Alt/Meta was held */
32
+ meta: boolean
33
+ /** Ctrl was held */
34
+ ctrl: boolean
35
+ }
36
+
37
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
38
+
39
+ /**
40
+ * Parse an SGR mouse sequence.
41
+ *
42
+ * @returns ParsedMouse or null if not a valid mouse sequence
43
+ */
44
+ export function parseMouseSequence(input: string): ParsedMouse | null {
45
+ const m = SGR_MOUSE_RE.exec(input)
46
+ if (!m) return null
47
+
48
+ const raw = parseInt(m[1]!)
49
+ const x = parseInt(m[2]!) - 1 // 1-indexed → 0-indexed
50
+ const y = parseInt(m[3]!) - 1
51
+ const terminator = m[4]!
52
+
53
+ const shift = !!(raw & 4)
54
+ const meta = !!(raw & 8)
55
+ const ctrl = !!(raw & 16)
56
+ const motion = !!(raw & 32)
57
+ const isWheel = !!(raw & 64)
58
+
59
+ if (isWheel) {
60
+ const wheelButton = raw & 3 // 0=up, 1=down, 2=left, 3=right
61
+ return {
62
+ button: 0,
63
+ x,
64
+ y,
65
+ action: "wheel",
66
+ delta: wheelButton === 0 ? -1 : 1,
67
+ shift,
68
+ meta,
69
+ ctrl,
70
+ }
71
+ }
72
+
73
+ const button = raw & 3
74
+ const action = motion ? "move" : terminator === "M" ? "down" : "up"
75
+ return { button, x, y, action, shift, meta, ctrl }
76
+ }
77
+
78
+ const SGR_MOUSE_TEST_RE = /^\x1b\[<\d+;\d+;\d+[Mm]$/
79
+
80
+ /** Check if a raw input string is a mouse sequence */
81
+ export function isMouseSequence(input: string): boolean {
82
+ return SGR_MOUSE_TEST_RE.test(input)
83
+ }