@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/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"
|
package/src/measurer.ts
ADDED
|
@@ -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
|
+
}
|