@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,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus Queries — pure tree query functions for the silvery focus system.
|
|
3
|
+
*
|
|
4
|
+
* All functions are pure: no state, no React, no side effects.
|
|
5
|
+
* They operate on the SilveryNode tree to resolve focusable elements,
|
|
6
|
+
* tab order, spatial navigation targets, and explicit focus links.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TeaNode, Rect } from "./types"
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Focusable Detection
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/** Check if a node has the focusable prop set to true (or truthy). */
|
|
16
|
+
function isFocusable(node: TeaNode): boolean {
|
|
17
|
+
if (node.hidden) return false
|
|
18
|
+
const props = node.props as Record<string, unknown>
|
|
19
|
+
return Boolean(props.focusable) && props.display !== "none"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Check if a node creates a focus scope (isolated Tab cycle). */
|
|
23
|
+
function isFocusScope(node: TeaNode): boolean {
|
|
24
|
+
const props = node.props as Record<string, unknown>
|
|
25
|
+
return Boolean(props.focusScope)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Tree Queries
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Walk up from node to nearest ancestor (or self) with focusable prop.
|
|
34
|
+
* Useful for mouse clicks — find the focusable target from a deep text node.
|
|
35
|
+
*/
|
|
36
|
+
export function findFocusableAncestor(node: TeaNode): TeaNode | null {
|
|
37
|
+
let current: TeaNode | null = node
|
|
38
|
+
while (current) {
|
|
39
|
+
if (isFocusable(current)) return current
|
|
40
|
+
current = current.parent
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DFS traversal of focusable nodes in tab order, optionally scoped.
|
|
47
|
+
*
|
|
48
|
+
* When scope is provided, only nodes within that scope subtree are included.
|
|
49
|
+
* If a focusScope node is encountered during traversal, its children are
|
|
50
|
+
* skipped (they belong to a different scope), unless that scope IS the
|
|
51
|
+
* provided scope node.
|
|
52
|
+
*/
|
|
53
|
+
export function getTabOrder(root: TeaNode, scope?: TeaNode): TeaNode[] {
|
|
54
|
+
const result: TeaNode[] = []
|
|
55
|
+
const walkRoot = scope ?? root
|
|
56
|
+
|
|
57
|
+
function walk(node: TeaNode): void {
|
|
58
|
+
// Skip hidden nodes (Suspense) and display: none — entire subtree is excluded
|
|
59
|
+
if (node.hidden) return
|
|
60
|
+
const props = node.props as Record<string, unknown>
|
|
61
|
+
if (props.display === "none") return
|
|
62
|
+
|
|
63
|
+
// If this node is a focusScope boundary and it's NOT the walk root,
|
|
64
|
+
// skip its children — they belong to a different Tab cycle.
|
|
65
|
+
// The focusScope node itself may still be focusable (included below).
|
|
66
|
+
if (node !== walkRoot && isFocusScope(node)) {
|
|
67
|
+
// Include the scope node itself if it's focusable, but don't descend
|
|
68
|
+
if (isFocusable(node)) {
|
|
69
|
+
result.push(node)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isFocusable(node)) {
|
|
75
|
+
result.push(node)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const child of node.children) {
|
|
79
|
+
walk(child)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
walk(walkRoot)
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Walk up from a node to find the nearest ancestor (or self) with focusScope prop.
|
|
89
|
+
* Returns the testID of the enclosing scope, or null if none found.
|
|
90
|
+
*/
|
|
91
|
+
export function findEnclosingScope(node: TeaNode): string | null {
|
|
92
|
+
let current: TeaNode | null = node
|
|
93
|
+
while (current) {
|
|
94
|
+
if (isFocusScope(current)) {
|
|
95
|
+
const props = current.props as Record<string, unknown>
|
|
96
|
+
return typeof props.testID === "string" ? props.testID : null
|
|
97
|
+
}
|
|
98
|
+
current = current.parent
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Find a node by testID in the subtree rooted at root.
|
|
105
|
+
* DFS, returns the first match.
|
|
106
|
+
*/
|
|
107
|
+
export function findByTestID(root: TeaNode, testID: string): TeaNode | null {
|
|
108
|
+
const props = root.props as Record<string, unknown>
|
|
109
|
+
if (props.testID === testID) return root
|
|
110
|
+
|
|
111
|
+
for (const child of root.children) {
|
|
112
|
+
const found = findByTestID(child, testID)
|
|
113
|
+
if (found) return found
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Spatial Navigation
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Compute center point of a Rect.
|
|
124
|
+
*/
|
|
125
|
+
function rectCenter(rect: Rect): { cx: number; cy: number } {
|
|
126
|
+
return {
|
|
127
|
+
cx: rect.x + rect.width / 2,
|
|
128
|
+
cy: rect.y + rect.height / 2,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a candidate point falls within a 45-degree cone from source
|
|
134
|
+
* in the given direction (tvOS-style spatial navigation).
|
|
135
|
+
*
|
|
136
|
+
* The cone extends from the center of the source rect in the specified
|
|
137
|
+
* direction with a 45-degree half-angle (90-degree total aperture).
|
|
138
|
+
*/
|
|
139
|
+
function isInCone(
|
|
140
|
+
source: { cx: number; cy: number },
|
|
141
|
+
candidate: { cx: number; cy: number },
|
|
142
|
+
direction: "up" | "down" | "left" | "right",
|
|
143
|
+
): boolean {
|
|
144
|
+
const dx = candidate.cx - source.cx
|
|
145
|
+
const dy = candidate.cy - source.cy
|
|
146
|
+
|
|
147
|
+
// Must be in the correct general direction
|
|
148
|
+
switch (direction) {
|
|
149
|
+
case "up":
|
|
150
|
+
if (dy >= 0) return false
|
|
151
|
+
// Within 45-degree cone: |dx| <= |dy|
|
|
152
|
+
return Math.abs(dx) <= Math.abs(dy)
|
|
153
|
+
case "down":
|
|
154
|
+
if (dy <= 0) return false
|
|
155
|
+
return Math.abs(dx) <= Math.abs(dy)
|
|
156
|
+
case "left":
|
|
157
|
+
if (dx >= 0) return false
|
|
158
|
+
return Math.abs(dy) <= Math.abs(dx)
|
|
159
|
+
case "right":
|
|
160
|
+
if (dx <= 0) return false
|
|
161
|
+
return Math.abs(dy) <= Math.abs(dx)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Euclidean distance between two points.
|
|
167
|
+
*/
|
|
168
|
+
function distance(a: { cx: number; cy: number }, b: { cx: number; cy: number }): number {
|
|
169
|
+
const dx = a.cx - b.cx
|
|
170
|
+
const dy = a.cy - b.cy
|
|
171
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find the nearest focusable candidate in a given direction using
|
|
176
|
+
* 45-degree cone heuristic (tvOS-style spatial navigation).
|
|
177
|
+
*
|
|
178
|
+
* From the center of the source rect, draw a cone in the target direction.
|
|
179
|
+
* Filter candidates whose center falls within the cone. Pick the closest
|
|
180
|
+
* by Euclidean distance.
|
|
181
|
+
*
|
|
182
|
+
* @param from - The currently focused node
|
|
183
|
+
* @param direction - Direction to search
|
|
184
|
+
* @param candidates - All focusable nodes to consider
|
|
185
|
+
* @param layoutFn - Function to get screen rect for a node (null if not laid out)
|
|
186
|
+
*/
|
|
187
|
+
export function findSpatialTarget(
|
|
188
|
+
from: TeaNode,
|
|
189
|
+
direction: "up" | "down" | "left" | "right",
|
|
190
|
+
candidates: TeaNode[],
|
|
191
|
+
layoutFn: (node: TeaNode) => Rect | null,
|
|
192
|
+
): TeaNode | null {
|
|
193
|
+
const sourceRect = layoutFn(from)
|
|
194
|
+
if (!sourceRect) return null
|
|
195
|
+
|
|
196
|
+
const source = rectCenter(sourceRect)
|
|
197
|
+
|
|
198
|
+
let best: TeaNode | null = null
|
|
199
|
+
let bestDist = Infinity
|
|
200
|
+
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
if (candidate === from) continue
|
|
203
|
+
|
|
204
|
+
const candidateRect = layoutFn(candidate)
|
|
205
|
+
if (!candidateRect) continue
|
|
206
|
+
|
|
207
|
+
const target = rectCenter(candidateRect)
|
|
208
|
+
|
|
209
|
+
if (!isInCone(source, target, direction)) continue
|
|
210
|
+
|
|
211
|
+
const dist = distance(source, target)
|
|
212
|
+
if (dist < bestDist) {
|
|
213
|
+
bestDist = dist
|
|
214
|
+
best = candidate
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return best
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Explicit Focus Links
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if a node has an explicit nextFocus{Direction} override prop.
|
|
227
|
+
*
|
|
228
|
+
* These props allow components to declare explicit focus targets for
|
|
229
|
+
* spatial navigation, overriding the cone heuristic.
|
|
230
|
+
*
|
|
231
|
+
* @param node - The node to check
|
|
232
|
+
* @param direction - Direction string: "up", "down", "left", "right"
|
|
233
|
+
* @returns The testID of the explicit target, or null
|
|
234
|
+
*/
|
|
235
|
+
export function getExplicitFocusLink(node: TeaNode, direction: string): string | null {
|
|
236
|
+
const props = node.props as Record<string, unknown>
|
|
237
|
+
// Props follow the pattern: nextFocusUp, nextFocusDown, nextFocusLeft, nextFocusRight
|
|
238
|
+
const propName = `nextFocus${direction.charAt(0).toUpperCase()}${direction.slice(1)}`
|
|
239
|
+
const value = props[propName]
|
|
240
|
+
return typeof value === "string" ? value : null
|
|
241
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @silvery/tea — TEA (The Elm Architecture) state machines, focus system, and utilities.
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript core with no React dependency. Provides:
|
|
5
|
+
* - TEA types and effect constructors (core/)
|
|
6
|
+
* - TEA store with focus management (store/)
|
|
7
|
+
* - Zustand TEA middleware (tea/)
|
|
8
|
+
* - Focus manager, events, and queries
|
|
9
|
+
* - Key parsing and hotkey matching
|
|
10
|
+
* - Text cursor utilities
|
|
11
|
+
* - AsyncIterable stream helpers (streams/)
|
|
12
|
+
* - Plugin composition (withCommands, withKeybindings, withDiagnostics, withRender)
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Core (TEA types, effects, plugin composition, focus manager, slices)
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
// TEA effect constructors
|
|
23
|
+
none,
|
|
24
|
+
batch,
|
|
25
|
+
dispatch,
|
|
26
|
+
// Plugin composition
|
|
27
|
+
compose,
|
|
28
|
+
// Focus manager
|
|
29
|
+
createFocusManager,
|
|
30
|
+
// Slices
|
|
31
|
+
createSlice,
|
|
32
|
+
} from "./core"
|
|
33
|
+
export type {
|
|
34
|
+
// TEA types
|
|
35
|
+
SilveryModel,
|
|
36
|
+
SilveryMsg,
|
|
37
|
+
Effect,
|
|
38
|
+
Sub,
|
|
39
|
+
Direction,
|
|
40
|
+
Plugin,
|
|
41
|
+
// Focus manager types
|
|
42
|
+
FocusManager,
|
|
43
|
+
FocusManagerOptions,
|
|
44
|
+
FocusChangeCallback,
|
|
45
|
+
FocusOrigin,
|
|
46
|
+
FocusSnapshot,
|
|
47
|
+
// Focus event types
|
|
48
|
+
SilveryKeyEvent,
|
|
49
|
+
SilveryFocusEvent,
|
|
50
|
+
FocusEventProps,
|
|
51
|
+
// Slice types
|
|
52
|
+
Slice,
|
|
53
|
+
SliceWithInit,
|
|
54
|
+
InferOp,
|
|
55
|
+
// Shared types
|
|
56
|
+
TeaNode,
|
|
57
|
+
Rect,
|
|
58
|
+
} from "./core"
|
|
59
|
+
|
|
60
|
+
// Focus events
|
|
61
|
+
export { createKeyEvent, createFocusEvent, dispatchKeyEvent, dispatchFocusEvent } from "./focus-events"
|
|
62
|
+
|
|
63
|
+
// Focus queries
|
|
64
|
+
export {
|
|
65
|
+
findFocusableAncestor,
|
|
66
|
+
getTabOrder,
|
|
67
|
+
findByTestID,
|
|
68
|
+
findSpatialTarget,
|
|
69
|
+
getExplicitFocusLink,
|
|
70
|
+
} from "./focus-queries"
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// Store (TEA-style state container)
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
export { createStore, silveryUpdate, defaultInit, withFocusManagement } from "./store"
|
|
77
|
+
export type { StoreConfig, StoreApi } from "./store"
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// Tea (Zustand middleware)
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
export { tea, collect } from "./tea"
|
|
84
|
+
export type { TeaResult, TeaReducer, EffectRunners, TeaSlice, EffectLike, TeaOptions } from "./tea"
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Effects (built-in timer effects)
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
export { fx, createTimerRunners } from "./effects"
|
|
91
|
+
export type { DelayEffect, IntervalEffect, CancelEffect, TimerEffect } from "./effects"
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// Keys
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
export { keyToName, keyToModifiers, parseHotkey, matchHotkey, parseKeypress, parseKey, emptyKey } from "./keys"
|
|
98
|
+
export type { ParsedKeypress, ParsedHotkey, Key } from "./keys"
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Text Cursor Utilities
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
cursorToRowCol,
|
|
106
|
+
getWrappedLines,
|
|
107
|
+
rowColToCursor,
|
|
108
|
+
cursorMoveUp,
|
|
109
|
+
cursorMoveDown,
|
|
110
|
+
countVisualLines,
|
|
111
|
+
} from "./text-cursor"
|
|
112
|
+
export type { WrappedLine } from "./text-cursor"
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Text Operations
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
export { applyTextOp, invertTextOp, mergeTextOps } from "./text-ops"
|
|
119
|
+
export type { TextOp } from "./text-ops"
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// Text Decorations
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
export { splitIntoSegments, createSearchDecorations, adjustDecorations } from "./text-decorations"
|
|
126
|
+
export type {
|
|
127
|
+
Decoration,
|
|
128
|
+
DecorationStyle,
|
|
129
|
+
StyledSegment as DecorationSegment,
|
|
130
|
+
SelectionRange,
|
|
131
|
+
} from "./text-decorations"
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Types
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
export { rectEqual } from "./types"
|
|
138
|
+
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// Tree Utilities
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
export { getAncestorPath, pointInRect } from "./tree-utils"
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Streams (AsyncIterable helpers)
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
merge,
|
|
151
|
+
map,
|
|
152
|
+
filter,
|
|
153
|
+
filterMap,
|
|
154
|
+
takeUntil,
|
|
155
|
+
take,
|
|
156
|
+
throttle,
|
|
157
|
+
debounce,
|
|
158
|
+
batch as batchStream,
|
|
159
|
+
concat,
|
|
160
|
+
zip,
|
|
161
|
+
fromArray,
|
|
162
|
+
fromArrayWithDelay,
|
|
163
|
+
} from "./streams"
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Plugin Composition — pipe() and plugins
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
export { pipe } from "./pipe"
|
|
170
|
+
export type { AppPlugin } from "./pipe"
|
|
171
|
+
|
|
172
|
+
export { withReact } from "./with-react"
|
|
173
|
+
export type { AppWithReact } from "./with-react"
|
|
174
|
+
|
|
175
|
+
export { withTerminal } from "./with-terminal"
|
|
176
|
+
export type { WithTerminalOptions, AppWithTerminal, ProcessLike } from "./with-terminal"
|
|
177
|
+
|
|
178
|
+
export { withFocus } from "./with-focus"
|
|
179
|
+
export type { WithFocusOptions, AppWithFocus } from "./with-focus"
|
|
180
|
+
|
|
181
|
+
export { withDomEvents } from "./with-dom-events"
|
|
182
|
+
export type { WithDomEventsOptions } from "./with-dom-events"
|
|
183
|
+
|
|
184
|
+
export { createCommandRegistry } from "./create-command-registry"
|
|
185
|
+
export type { CommandDefInput, CommandDefs } from "./create-command-registry"
|
|
186
|
+
|
|
187
|
+
export { withCommands } from "./with-commands"
|
|
188
|
+
export type {
|
|
189
|
+
WithCommandsOptions,
|
|
190
|
+
CommandDef,
|
|
191
|
+
CommandRegistryLike,
|
|
192
|
+
CommandInfo,
|
|
193
|
+
Command,
|
|
194
|
+
Cmd,
|
|
195
|
+
AppWithCommands,
|
|
196
|
+
AppState,
|
|
197
|
+
KeybindingDef,
|
|
198
|
+
} from "./with-commands"
|
|
199
|
+
|
|
200
|
+
export { withKeybindings } from "./with-keybindings"
|
|
201
|
+
export type { WithKeybindingsOptions, KeybindingContext, ExtendedKeybindingDef } from "./with-keybindings"
|
|
202
|
+
|
|
203
|
+
export { withDiagnostics, checkLayoutInvariants, VirtualTerminal } from "./with-diagnostics"
|
|
204
|
+
export type { DiagnosticOptions } from "./with-diagnostics"
|
|
205
|
+
|
|
206
|
+
export { withRender } from "./with-render"
|
|
207
|
+
export type { RenderTerm } from "./with-render"
|
|
208
|
+
|
|
209
|
+
// =============================================================================
|
|
210
|
+
// Plugins barrel (re-exports all of the above)
|
|
211
|
+
// =============================================================================
|
|
212
|
+
|
|
213
|
+
export { IncrementalRenderMismatchError } from "./plugins"
|