@silvery/tea 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/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withDomEvents() — Plugin for DOM-style event dispatch
|
|
3
|
+
*
|
|
4
|
+
* Wires mouse event dispatch through the render tree:
|
|
5
|
+
* - Hit testing via screenRect (tree-based, not manual registry)
|
|
6
|
+
* - Bubbling from target → root with stopPropagation() support
|
|
7
|
+
* - mouseenter/mouseleave tracking (no bubble, per DOM spec)
|
|
8
|
+
* - Double-click detection (300ms / 2-cell threshold)
|
|
9
|
+
* - Click-to-focus (focuses nearest focusable ancestor on mousedown)
|
|
10
|
+
*
|
|
11
|
+
* Mouse event handler props (onClick, onMouseDown, etc.) are already
|
|
12
|
+
* defined on BoxProps/TextProps via MouseEventProps. This plugin wires
|
|
13
|
+
* the dispatch that invokes them.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* import { pipe, withDomEvents } from '@silvery/tea'
|
|
18
|
+
*
|
|
19
|
+
* const app = pipe(
|
|
20
|
+
* baseApp,
|
|
21
|
+
* withFocus(),
|
|
22
|
+
* withDomEvents(),
|
|
23
|
+
* )
|
|
24
|
+
*
|
|
25
|
+
* // Components can now use mouse event handlers
|
|
26
|
+
* function Button() {
|
|
27
|
+
* return (
|
|
28
|
+
* <Box onClick={(e) => {
|
|
29
|
+
* console.log('clicked at', e.clientX, e.clientY)
|
|
30
|
+
* e.stopPropagation()
|
|
31
|
+
* }}>
|
|
32
|
+
* <Text>Click me</Text>
|
|
33
|
+
* </Box>
|
|
34
|
+
* )
|
|
35
|
+
* }
|
|
36
|
+
*
|
|
37
|
+
* // Programmatic mouse events also dispatch through the tree
|
|
38
|
+
* await app.click(10, 5)
|
|
39
|
+
* await app.wheel(10, 5, 1)
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { App } from "@silvery/term/app"
|
|
44
|
+
import type { FocusManager } from "./focus-manager"
|
|
45
|
+
import {
|
|
46
|
+
createMouseEventProcessor,
|
|
47
|
+
processMouseEvent,
|
|
48
|
+
type MouseEventProcessorOptions,
|
|
49
|
+
type MouseEventProcessorState,
|
|
50
|
+
} from "@silvery/term/mouse-events"
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// Types
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Options for withDomEvents.
|
|
58
|
+
*/
|
|
59
|
+
export interface WithDomEventsOptions {
|
|
60
|
+
/** Focus manager for click-to-focus behavior.
|
|
61
|
+
* If the app has a focusManager property, it's used automatically. */
|
|
62
|
+
focusManager?: FocusManager
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Implementation
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Add DOM-style mouse event dispatch to an App.
|
|
71
|
+
*
|
|
72
|
+
* This plugin creates a mouse event processor and ensures that
|
|
73
|
+
* click(), doubleClick(), and wheel() methods on the app dispatch
|
|
74
|
+
* events through the render tree with proper bubbling.
|
|
75
|
+
*
|
|
76
|
+
* The App's buildApp() already sets up mouse event processing.
|
|
77
|
+
* This plugin is provided for explicit composition via pipe()
|
|
78
|
+
* and ensures the focus manager is connected for click-to-focus.
|
|
79
|
+
*
|
|
80
|
+
* @param options - Configuration (focusManager for click-to-focus)
|
|
81
|
+
* @returns Plugin function that enhances an App with DOM event dispatch
|
|
82
|
+
*/
|
|
83
|
+
export function withDomEvents(options: WithDomEventsOptions = {}): <T extends App>(app: T) => T {
|
|
84
|
+
return <T extends App>(app: T): T => {
|
|
85
|
+
// Get focus manager from options or from the app itself
|
|
86
|
+
const fm = options.focusManager ?? (app as App & { focusManager?: FocusManager }).focusManager
|
|
87
|
+
|
|
88
|
+
// Create a mouse event processor with the focus manager
|
|
89
|
+
const processorOptions: MouseEventProcessorOptions = {}
|
|
90
|
+
if (fm) {
|
|
91
|
+
processorOptions.focusManager = fm
|
|
92
|
+
}
|
|
93
|
+
const mouseState = createMouseEventProcessor(processorOptions)
|
|
94
|
+
|
|
95
|
+
// Override click, doubleClick, and wheel to use our processor
|
|
96
|
+
// which is connected to the focus manager
|
|
97
|
+
return new Proxy(app, {
|
|
98
|
+
get(target, prop, receiver) {
|
|
99
|
+
if (prop === "click") {
|
|
100
|
+
return async function enhancedClick(x: number, y: number, clickOptions?: { button?: number }): Promise<T> {
|
|
101
|
+
const button = clickOptions?.button ?? 0
|
|
102
|
+
const root = target.getContainer()
|
|
103
|
+
processMouseEvent(
|
|
104
|
+
mouseState,
|
|
105
|
+
{ button, x, y, action: "down", shift: false, meta: false, ctrl: false },
|
|
106
|
+
root,
|
|
107
|
+
)
|
|
108
|
+
processMouseEvent(mouseState, { button, x, y, action: "up", shift: false, meta: false, ctrl: false }, root)
|
|
109
|
+
await Promise.resolve()
|
|
110
|
+
return receiver as T
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (prop === "doubleClick") {
|
|
115
|
+
return async function enhancedDoubleClick(
|
|
116
|
+
x: number,
|
|
117
|
+
y: number,
|
|
118
|
+
clickOptions?: { button?: number },
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
const button = clickOptions?.button ?? 0
|
|
121
|
+
const root = target.getContainer()
|
|
122
|
+
const parsed = {
|
|
123
|
+
button,
|
|
124
|
+
x,
|
|
125
|
+
y,
|
|
126
|
+
action: "down" as const,
|
|
127
|
+
shift: false,
|
|
128
|
+
meta: false,
|
|
129
|
+
ctrl: false,
|
|
130
|
+
}
|
|
131
|
+
// First click
|
|
132
|
+
processMouseEvent(mouseState, parsed, root)
|
|
133
|
+
processMouseEvent(mouseState, { ...parsed, action: "up" }, root)
|
|
134
|
+
// Second click (triggers double-click detection)
|
|
135
|
+
processMouseEvent(mouseState, parsed, root)
|
|
136
|
+
processMouseEvent(mouseState, { ...parsed, action: "up" }, root)
|
|
137
|
+
await Promise.resolve()
|
|
138
|
+
return receiver as T
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (prop === "wheel") {
|
|
143
|
+
return async function enhancedWheel(x: number, y: number, delta: number): Promise<T> {
|
|
144
|
+
const root = target.getContainer()
|
|
145
|
+
processMouseEvent(
|
|
146
|
+
mouseState,
|
|
147
|
+
{
|
|
148
|
+
button: 0,
|
|
149
|
+
x,
|
|
150
|
+
y,
|
|
151
|
+
action: "wheel",
|
|
152
|
+
delta,
|
|
153
|
+
shift: false,
|
|
154
|
+
meta: false,
|
|
155
|
+
ctrl: false,
|
|
156
|
+
},
|
|
157
|
+
root,
|
|
158
|
+
)
|
|
159
|
+
await Promise.resolve()
|
|
160
|
+
return receiver as T
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Reflect.get(target, prop, receiver)
|
|
165
|
+
},
|
|
166
|
+
}) as T
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withFocus() — Plugin for Tab/Shift+Tab focus navigation
|
|
3
|
+
*
|
|
4
|
+
* Intercepts `press()` calls to handle focus navigation keys:
|
|
5
|
+
* - Tab → focus next
|
|
6
|
+
* - Shift+Tab → focus previous
|
|
7
|
+
* - Escape → blur (when something is focused)
|
|
8
|
+
*
|
|
9
|
+
* Also provides focus scope management and dispatches focus/blur
|
|
10
|
+
* events through the render tree.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { pipe, withFocus } from '@silvery/tea'
|
|
15
|
+
*
|
|
16
|
+
* const app = pipe(
|
|
17
|
+
* baseApp,
|
|
18
|
+
* withFocus(),
|
|
19
|
+
* )
|
|
20
|
+
*
|
|
21
|
+
* // Tab/Shift+Tab now cycle focus through focusable nodes
|
|
22
|
+
* await app.press('Tab')
|
|
23
|
+
* await app.press('Shift+Tab')
|
|
24
|
+
*
|
|
25
|
+
* // Focus manager is accessible
|
|
26
|
+
* app.focusManager.activeId // currently focused testID
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { App } from "@silvery/term/app"
|
|
31
|
+
import { createFocusManager, type FocusManager, type FocusManagerOptions } from "./focus-manager"
|
|
32
|
+
import { createFocusEvent, createKeyEvent, dispatchFocusEvent, dispatchKeyEvent } from "./focus-events"
|
|
33
|
+
import { parseHotkey, parseKey } from "./keys"
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Types
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options for withFocus.
|
|
41
|
+
*/
|
|
42
|
+
export interface WithFocusOptions {
|
|
43
|
+
/** Custom focus manager (creates a new one if not provided) */
|
|
44
|
+
focusManager?: FocusManager
|
|
45
|
+
/** Focus manager options (ignored if focusManager is provided) */
|
|
46
|
+
focusManagerOptions?: FocusManagerOptions
|
|
47
|
+
/** Handle Tab key for focus cycling (default: true) */
|
|
48
|
+
handleTab?: boolean
|
|
49
|
+
/** Handle Escape key to blur (default: true) */
|
|
50
|
+
handleEscape?: boolean
|
|
51
|
+
/** Dispatch keyboard events through focus tree (default: true) */
|
|
52
|
+
dispatchKeyEvents?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* App enhanced with focus management.
|
|
57
|
+
*/
|
|
58
|
+
export type AppWithFocus = App & {
|
|
59
|
+
/** The focus manager instance */
|
|
60
|
+
readonly focusManager: FocusManager
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Implementation
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Add focus management to an App.
|
|
69
|
+
*
|
|
70
|
+
* Intercepts key presses for focus navigation (Tab/Shift+Tab/Escape)
|
|
71
|
+
* and optionally dispatches keyboard events through the focus tree
|
|
72
|
+
* with capture/target/bubble phases.
|
|
73
|
+
*
|
|
74
|
+
* @param options - Focus configuration (all defaults are sensible)
|
|
75
|
+
* @returns Plugin function that enhances an App with focus management
|
|
76
|
+
*/
|
|
77
|
+
export function withFocus(options: WithFocusOptions = {}): (app: App) => AppWithFocus {
|
|
78
|
+
return (app: App): AppWithFocus => {
|
|
79
|
+
const { handleTab = true, handleEscape = true, dispatchKeyEvents = true } = options
|
|
80
|
+
|
|
81
|
+
// Create or reuse focus manager
|
|
82
|
+
const fm =
|
|
83
|
+
options.focusManager ??
|
|
84
|
+
createFocusManager({
|
|
85
|
+
...options.focusManagerOptions,
|
|
86
|
+
// Wire up focus change events to dispatch through the tree
|
|
87
|
+
onFocusChange: (oldNode, newNode, origin) => {
|
|
88
|
+
// Call user's callback too if provided
|
|
89
|
+
options.focusManagerOptions?.onFocusChange?.(oldNode, newNode, origin)
|
|
90
|
+
|
|
91
|
+
// Dispatch blur event on old node
|
|
92
|
+
if (oldNode) {
|
|
93
|
+
const blurEvent = createFocusEvent("blur", oldNode, newNode)
|
|
94
|
+
dispatchFocusEvent(blurEvent)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Dispatch focus event on new node
|
|
98
|
+
if (newNode) {
|
|
99
|
+
const focusEvent = createFocusEvent("focus", newNode, oldNode)
|
|
100
|
+
dispatchFocusEvent(focusEvent)
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Wrap press() to intercept focus navigation keys
|
|
106
|
+
const originalPress = app.press.bind(app)
|
|
107
|
+
|
|
108
|
+
const enhancedApp = new Proxy(app, {
|
|
109
|
+
get(target, prop, receiver) {
|
|
110
|
+
if (prop === "focusManager") {
|
|
111
|
+
return fm
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (prop === "press") {
|
|
115
|
+
return async function focusPress(keyStr: string): Promise<typeof enhancedApp> {
|
|
116
|
+
const { key, shift } = parseHotkey(keyStr)
|
|
117
|
+
|
|
118
|
+
const root = target.getContainer()
|
|
119
|
+
|
|
120
|
+
// Tab → focus next
|
|
121
|
+
if (handleTab && key === "Tab" && !shift) {
|
|
122
|
+
fm.focusNext(root)
|
|
123
|
+
return enhancedApp
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Shift+Tab → focus previous
|
|
127
|
+
if (handleTab && key === "Tab" && shift) {
|
|
128
|
+
fm.focusPrev(root)
|
|
129
|
+
return enhancedApp
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Escape → blur (only when something is focused)
|
|
133
|
+
if (handleEscape && key === "Escape" && fm.activeElement) {
|
|
134
|
+
fm.blur()
|
|
135
|
+
return enhancedApp
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Dispatch keyboard event through focus tree before passing through
|
|
139
|
+
if (dispatchKeyEvents && fm.activeElement) {
|
|
140
|
+
const [input, parsedKey] = parseKey(keyStr)
|
|
141
|
+
const keyEvent = createKeyEvent(input, parsedKey, fm.activeElement)
|
|
142
|
+
dispatchKeyEvent(keyEvent)
|
|
143
|
+
|
|
144
|
+
// If the event was handled (stopPropagation), don't pass through
|
|
145
|
+
if (keyEvent.propagationStopped || keyEvent.defaultPrevented) {
|
|
146
|
+
return enhancedApp
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Pass through to original press handler
|
|
151
|
+
await originalPress(keyStr)
|
|
152
|
+
return enhancedApp
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Reflect.get(target, prop, receiver)
|
|
157
|
+
},
|
|
158
|
+
}) as AppWithFocus
|
|
159
|
+
|
|
160
|
+
return enhancedApp
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withKeybindings - SlateJS-style plugin for keybinding wiring
|
|
3
|
+
*
|
|
4
|
+
* Intercepts `press()` calls and routes them to commands via keybinding resolution.
|
|
5
|
+
* Commands not in the registry fall through to component useInput handlers.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const app = withKeybindings(withCommands(render(<Board />), cmdOpts), {
|
|
10
|
+
* bindings: defaultKeybindings,
|
|
11
|
+
* getKeyContext: () => ({ mode: 'normal', hasSelection: false, ... }),
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* // Press 'j' → resolves to cursor_down → calls app.cmd.down()
|
|
15
|
+
* await app.press('j')
|
|
16
|
+
*
|
|
17
|
+
* // Press 'x' (no binding) → passes through to useInput handlers
|
|
18
|
+
* await app.press('x')
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* See docs/future/silvery-command-api-research.md for design rationale.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AppWithCommands, KeybindingDef } from "./with-commands"
|
|
25
|
+
import { parseHotkey } from "./keys"
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Types
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Context for keybinding resolution.
|
|
33
|
+
* Used to match mode-specific bindings and conditional bindings.
|
|
34
|
+
*/
|
|
35
|
+
export interface KeybindingContext {
|
|
36
|
+
mode: string
|
|
37
|
+
hasSelection: boolean
|
|
38
|
+
[key: string]: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options for withKeybindings.
|
|
43
|
+
*/
|
|
44
|
+
export interface WithKeybindingsOptions {
|
|
45
|
+
/** Keybindings to resolve */
|
|
46
|
+
bindings: KeybindingDef[]
|
|
47
|
+
/** Build context for keybinding resolution */
|
|
48
|
+
getKeyContext: () => KeybindingContext
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extended keybinding with mode support.
|
|
53
|
+
*/
|
|
54
|
+
export interface ExtendedKeybindingDef extends KeybindingDef {
|
|
55
|
+
modes?: string[]
|
|
56
|
+
when?: (ctx: KeybindingContext) => boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Implementation
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
// parseKey replaced by parseHotkey from ./keys.js
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a key press to a command ID using keybinding lookup.
|
|
67
|
+
*/
|
|
68
|
+
function resolveKeybinding(
|
|
69
|
+
key: string,
|
|
70
|
+
modifiers: { ctrl: boolean; meta: boolean; shift: boolean; super: boolean },
|
|
71
|
+
bindings: ExtendedKeybindingDef[],
|
|
72
|
+
ctx: KeybindingContext,
|
|
73
|
+
): string | null {
|
|
74
|
+
for (const binding of bindings) {
|
|
75
|
+
// Check key match
|
|
76
|
+
if (binding.key !== key) continue
|
|
77
|
+
|
|
78
|
+
// Check modifiers
|
|
79
|
+
if (!!binding.ctrl !== !!modifiers.ctrl) continue
|
|
80
|
+
if (!!binding.opt !== !!modifiers.meta) continue
|
|
81
|
+
if (!!binding.cmd !== !!modifiers.super) continue
|
|
82
|
+
|
|
83
|
+
// For single uppercase letters (A-Z), the shift key is implicit
|
|
84
|
+
const isUppercaseLetter = key.length === 1 && key >= "A" && key <= "Z" && !binding.shift
|
|
85
|
+
if (!isUppercaseLetter && !!binding.shift !== !!modifiers.shift) continue
|
|
86
|
+
|
|
87
|
+
// alt removed — macOS uses opt (⌥), matched against modifiers.meta above
|
|
88
|
+
|
|
89
|
+
// Check mode
|
|
90
|
+
if (binding.modes && binding.modes.length > 0) {
|
|
91
|
+
if (!binding.modes.includes(ctx.mode)) continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check conditional
|
|
95
|
+
if (binding.when && !binding.when(ctx)) continue
|
|
96
|
+
|
|
97
|
+
return binding.commandId
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Wire keybindings to command invocation.
|
|
104
|
+
*
|
|
105
|
+
* Intercepts `press()` and routes matching keys to commands.
|
|
106
|
+
* Non-matching keys pass through to the original press handler.
|
|
107
|
+
*
|
|
108
|
+
* Supports two calling styles:
|
|
109
|
+
* - Direct: `withKeybindings(app, options)` — returns enhanced app immediately
|
|
110
|
+
* - Curried: `withKeybindings(options)` — returns a plugin for pipe() composition
|
|
111
|
+
*
|
|
112
|
+
* @example Direct
|
|
113
|
+
* ```tsx
|
|
114
|
+
* const app = withKeybindings(appWithCmd, {
|
|
115
|
+
* bindings: defaultKeybindings,
|
|
116
|
+
* getKeyContext: () => buildKeybindingContext(state),
|
|
117
|
+
* })
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* @example Curried (pipe)
|
|
121
|
+
* ```tsx
|
|
122
|
+
* const app = pipe(
|
|
123
|
+
* baseApp,
|
|
124
|
+
* withCommands(cmdOpts),
|
|
125
|
+
* withKeybindings({
|
|
126
|
+
* bindings: defaultKeybindings,
|
|
127
|
+
* getKeyContext: () => buildKeybindingContext(state),
|
|
128
|
+
* }),
|
|
129
|
+
* )
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
// Curried form: withKeybindings(options) => plugin
|
|
133
|
+
export function withKeybindings(options: WithKeybindingsOptions): <T extends AppWithCommands>(app: T) => T
|
|
134
|
+
// Direct form: withKeybindings(app, options) => enhancedApp
|
|
135
|
+
export function withKeybindings<T extends AppWithCommands>(app: T, options: WithKeybindingsOptions): T
|
|
136
|
+
export function withKeybindings<T extends AppWithCommands>(
|
|
137
|
+
appOrOptions: T | WithKeybindingsOptions,
|
|
138
|
+
maybeOptions?: WithKeybindingsOptions,
|
|
139
|
+
): T | (<U extends AppWithCommands>(app: U) => U) {
|
|
140
|
+
// Curried form: first arg is options (no press/text/ansi = not an App)
|
|
141
|
+
if (maybeOptions === undefined) {
|
|
142
|
+
const options = appOrOptions as WithKeybindingsOptions
|
|
143
|
+
return <U extends AppWithCommands>(app: U) => applyKeybindings(app, options)
|
|
144
|
+
}
|
|
145
|
+
// Direct form: first arg is app
|
|
146
|
+
return applyKeybindings(appOrOptions as T, maybeOptions)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function applyKeybindings<T extends AppWithCommands>(app: T, options: WithKeybindingsOptions): T {
|
|
150
|
+
const { bindings, getKeyContext } = options
|
|
151
|
+
const originalPress = app.press.bind(app)
|
|
152
|
+
|
|
153
|
+
// Create a proxy to intercept press() while preserving all other properties
|
|
154
|
+
return new Proxy(app, {
|
|
155
|
+
get(target, prop, receiver) {
|
|
156
|
+
if (prop === "press") {
|
|
157
|
+
return async function interceptedPress(keyStr: string): Promise<T> {
|
|
158
|
+
const { key, ...modifiers } = parseHotkey(keyStr)
|
|
159
|
+
const ctx = getKeyContext()
|
|
160
|
+
|
|
161
|
+
// Try to resolve to a command
|
|
162
|
+
const commandId = resolveKeybinding(key, modifiers, bindings as ExtendedKeybindingDef[], ctx)
|
|
163
|
+
|
|
164
|
+
if (commandId) {
|
|
165
|
+
const cmd = target.cmd[commandId]
|
|
166
|
+
if (cmd) {
|
|
167
|
+
await cmd()
|
|
168
|
+
return receiver as T
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Pass through to original press handler (for useInput)
|
|
173
|
+
await originalPress(keyStr)
|
|
174
|
+
return receiver as T
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return Reflect.get(target, prop, receiver)
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withReact(element) — Plugin: mount React reconciler + virtual buffer
|
|
3
|
+
*
|
|
4
|
+
* This plugin represents the React rendering layer in silvery's plugin
|
|
5
|
+
* composition model. It mounts a React element through the reconciler,
|
|
6
|
+
* manages the virtual buffer, and re-renders reactively on store changes.
|
|
7
|
+
*
|
|
8
|
+
* In the current architecture, React mounting is handled by createApp()
|
|
9
|
+
* and render(). This plugin provides the declarative interface for
|
|
10
|
+
* pipe() composition:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const app = pipe(
|
|
14
|
+
* createApp(store),
|
|
15
|
+
* withReact(<Board />),
|
|
16
|
+
* withTerminal(process),
|
|
17
|
+
* )
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Currently, withReact stores the element for use by the runtime
|
|
21
|
+
* that calls run(). Future iterations will extract the full React
|
|
22
|
+
* reconciler lifecycle into this plugin.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* import { pipe, withReact } from '@silvery/tea'
|
|
27
|
+
*
|
|
28
|
+
* // The element is associated with the app for later mounting
|
|
29
|
+
* const app = pipe(baseApp, withReact(<MyComponent />))
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ReactElement } from "react"
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Types
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* App enhanced with a React element for rendering.
|
|
41
|
+
*/
|
|
42
|
+
export interface AppWithReact {
|
|
43
|
+
/** The React element to render */
|
|
44
|
+
readonly element: ReactElement
|
|
45
|
+
/** Run the app (renders the element and starts the event loop) */
|
|
46
|
+
run(): Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Minimal app shape that withReact can enhance.
|
|
51
|
+
* Requires a run() method that accepts an element.
|
|
52
|
+
*/
|
|
53
|
+
interface RunnableApp {
|
|
54
|
+
run(element: ReactElement, ...args: unknown[]): unknown
|
|
55
|
+
[key: string]: unknown
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Implementation
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Associate a React element with an app for rendering.
|
|
64
|
+
*
|
|
65
|
+
* In pipe() composition, this captures the element so that subsequent
|
|
66
|
+
* plugins and the final run() know what to render.
|
|
67
|
+
*
|
|
68
|
+
* The plugin wraps `run()` to automatically pass the element:
|
|
69
|
+
* - Before: `app.run(<Board />, options)`
|
|
70
|
+
* - After: `app.run()` (element already bound)
|
|
71
|
+
*
|
|
72
|
+
* @param element - The React element to render
|
|
73
|
+
* @returns Plugin function that binds the element to the app
|
|
74
|
+
*/
|
|
75
|
+
export function withReact<T extends RunnableApp>(element: ReactElement): (app: T) => T & AppWithReact {
|
|
76
|
+
return (app: T): T & AppWithReact => {
|
|
77
|
+
const originalRun = app.run
|
|
78
|
+
|
|
79
|
+
return Object.assign(Object.create(app), {
|
|
80
|
+
element,
|
|
81
|
+
run(...args: unknown[]) {
|
|
82
|
+
// If run() is called without an element, inject our bound element
|
|
83
|
+
if (args.length === 0 || typeof args[0] !== "object" || args[0] === null || !("type" in (args[0] as object))) {
|
|
84
|
+
// args[0] is likely options, not an element
|
|
85
|
+
return originalRun.call(app, element, ...args)
|
|
86
|
+
}
|
|
87
|
+
// Otherwise pass through as-is
|
|
88
|
+
return originalRun.apply(app, args as [ReactElement, ...unknown[]])
|
|
89
|
+
},
|
|
90
|
+
}) as T & AppWithReact
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withRender(term) -- extends a Term with render pipeline capabilities.
|
|
3
|
+
*
|
|
4
|
+
* Creates term-scoped pipeline config (width measurer + output phase) from caps,
|
|
5
|
+
* then returns an extended term with render() and renderStatic() methods.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const term = withRender(createTerm())
|
|
9
|
+
* const { output, buffer } = term.render(root, 80, 24, null, { mode: "fullscreen" })
|
|
10
|
+
* const html = await term.renderStatic(<Report />)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Term } from "@silvery/term/ansi"
|
|
14
|
+
import type { ReactElement } from "react"
|
|
15
|
+
import type { TerminalBuffer } from "@silvery/term/buffer"
|
|
16
|
+
import { createPipeline, type MeasuredTerm } from "@silvery/term/measurer"
|
|
17
|
+
import { executeRender, type ExecuteRenderOptions, type PipelineConfig } from "@silvery/term/pipeline"
|
|
18
|
+
import type { TeaNode } from "./types"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extended Term with render pipeline capabilities.
|
|
22
|
+
*
|
|
23
|
+
* Extends MeasuredTerm (Term + Measurer methods) with render/renderStatic.
|
|
24
|
+
*/
|
|
25
|
+
export interface RenderTerm extends MeasuredTerm {
|
|
26
|
+
/** Pipeline configuration (measurer + output phase) */
|
|
27
|
+
readonly pipelineConfig: PipelineConfig
|
|
28
|
+
/**
|
|
29
|
+
* Run the full render pipeline.
|
|
30
|
+
*/
|
|
31
|
+
render(
|
|
32
|
+
root: TeaNode,
|
|
33
|
+
width: number,
|
|
34
|
+
height: number,
|
|
35
|
+
prevBuffer: TerminalBuffer | null,
|
|
36
|
+
options?: ExecuteRenderOptions | "fullscreen" | "inline",
|
|
37
|
+
): { output: string; buffer: TerminalBuffer }
|
|
38
|
+
/**
|
|
39
|
+
* Render a React element to a string using this terminal's caps.
|
|
40
|
+
* Uses the term's width measurer for correct text measurement.
|
|
41
|
+
*/
|
|
42
|
+
renderStatic(element: ReactElement, options?: { width?: number; height?: number; plain?: boolean }): Promise<string>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extend a Term with render pipeline capabilities.
|
|
47
|
+
*
|
|
48
|
+
* Creates a pipeline config (width measurer + output phase) from the term's caps,
|
|
49
|
+
* and adds render() and renderStatic() methods plus measurer methods.
|
|
50
|
+
*
|
|
51
|
+
* @param term - A Term instance (from createTerm)
|
|
52
|
+
* @returns Extended term with render and measurement capabilities
|
|
53
|
+
*/
|
|
54
|
+
export function withRender(term: Term): RenderTerm {
|
|
55
|
+
const pipelineConfig = createPipeline({ caps: term.caps })
|
|
56
|
+
const { measurer } = pipelineConfig
|
|
57
|
+
|
|
58
|
+
function renderPipeline(
|
|
59
|
+
root: TeaNode,
|
|
60
|
+
width: number,
|
|
61
|
+
height: number,
|
|
62
|
+
prevBuffer: TerminalBuffer | null,
|
|
63
|
+
options?: ExecuteRenderOptions | "fullscreen" | "inline",
|
|
64
|
+
): { output: string; buffer: TerminalBuffer } {
|
|
65
|
+
return executeRender(root, width, height, prevBuffer, options, pipelineConfig)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function renderStaticFn(
|
|
69
|
+
element: ReactElement,
|
|
70
|
+
options?: { width?: number; height?: number; plain?: boolean },
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const { renderString } = await import("@silvery/react/render-string")
|
|
73
|
+
return renderString(element, { ...options, pipelineConfig })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Return a proxy that extends the original term with measurer methods and render capabilities
|
|
77
|
+
return Object.create(term, {
|
|
78
|
+
// Measurer methods (from pipeline config)
|
|
79
|
+
textEmojiWide: { get: () => measurer.textEmojiWide, enumerable: true },
|
|
80
|
+
textSizingEnabled: { get: () => measurer.textSizingEnabled, enumerable: true },
|
|
81
|
+
displayWidth: { value: measurer.displayWidth.bind(measurer), enumerable: true },
|
|
82
|
+
displayWidthAnsi: { value: measurer.displayWidthAnsi.bind(measurer), enumerable: true },
|
|
83
|
+
graphemeWidth: { value: measurer.graphemeWidth.bind(measurer), enumerable: true },
|
|
84
|
+
wrapText: { value: measurer.wrapText.bind(measurer), enumerable: true },
|
|
85
|
+
sliceByWidth: { value: measurer.sliceByWidth.bind(measurer), enumerable: true },
|
|
86
|
+
sliceByWidthFromEnd: { value: measurer.sliceByWidthFromEnd.bind(measurer), enumerable: true },
|
|
87
|
+
// Pipeline config and render methods
|
|
88
|
+
pipelineConfig: { value: pipelineConfig, enumerable: true },
|
|
89
|
+
render: { value: renderPipeline, enumerable: true },
|
|
90
|
+
renderStatic: { value: renderStaticFn, enumerable: true },
|
|
91
|
+
}) as RenderTerm
|
|
92
|
+
}
|