@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.
@@ -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"