@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
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silvery/tea",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "TEA (The Elm Architecture) state machine store for silvery",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bjørn Stabell <bjorn@stabell.org>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/beorn/silvery.git",
|
|
10
|
+
"directory": "packages/tea"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "src/index.ts",
|
|
17
|
+
"types": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"import": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./core": {
|
|
24
|
+
"types": "./src/core/index.ts",
|
|
25
|
+
"import": "./src/core/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"./store": {
|
|
28
|
+
"types": "./src/store/index.ts",
|
|
29
|
+
"import": "./src/store/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"./tea": {
|
|
32
|
+
"types": "./src/tea/index.ts",
|
|
33
|
+
"import": "./src/tea/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"./streams": {
|
|
36
|
+
"types": "./src/streams/index.ts",
|
|
37
|
+
"import": "./src/streams/index.ts"
|
|
38
|
+
},
|
|
39
|
+
"./plugins": {
|
|
40
|
+
"types": "./src/plugins.ts",
|
|
41
|
+
"import": "./src/plugins.ts"
|
|
42
|
+
},
|
|
43
|
+
"./*": "./src/*.ts"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@silvery/compat": "workspace:*",
|
|
50
|
+
"@silvery/react": "workspace:*",
|
|
51
|
+
"@silvery/term": "workspace:*",
|
|
52
|
+
"@silvery/test": "workspace:*"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @silvery/core — TEA runtime, React reconciler, store, and effect system.
|
|
3
|
+
*
|
|
4
|
+
* Portable core that doesn't depend on terminal specifics. Can target
|
|
5
|
+
* terminal, canvas, DOM, or any custom render backend.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* silvery/core — Pure functions and types, NO React dependency.
|
|
12
|
+
*
|
|
13
|
+
* This sub-path export provides:
|
|
14
|
+
* - TEA (The Elm Architecture) types: SilveryModel, SilveryMsg, Effect, Sub, Plugin
|
|
15
|
+
* - Focus manager: createFocusManager + types
|
|
16
|
+
* - Focus events: event factories + dispatch functions
|
|
17
|
+
* - Focus queries: tree query functions
|
|
18
|
+
* - Plugin composition: compose() for middleware-style plugin chaining
|
|
19
|
+
*
|
|
20
|
+
* Everything here is pure TypeScript — no React import anywhere in the
|
|
21
|
+
* dependency graph. Safe for use in non-React contexts (CLI tools, tests,
|
|
22
|
+
* server-side logic).
|
|
23
|
+
*
|
|
24
|
+
* @packageDocumentation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// TEA Types (The Elm Architecture)
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
import type { FocusOrigin } from "../focus-manager.js"
|
|
32
|
+
export type { FocusOrigin } from "../focus-manager.js"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The model type that silvery manages for focus state.
|
|
36
|
+
*
|
|
37
|
+
* Applications extend this with their own model fields.
|
|
38
|
+
* The store's update function receives the full model and returns
|
|
39
|
+
* a new model + effects tuple.
|
|
40
|
+
*/
|
|
41
|
+
export interface SilveryModel {
|
|
42
|
+
focus: {
|
|
43
|
+
activeId: string | null
|
|
44
|
+
previousId: string | null
|
|
45
|
+
origin: FocusOrigin | null
|
|
46
|
+
scopeStack: string[]
|
|
47
|
+
scopeMemory: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Direction type used in spatial navigation messages.
|
|
53
|
+
*/
|
|
54
|
+
export type Direction = "up" | "down" | "left" | "right"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Message types that silvery understands.
|
|
58
|
+
*
|
|
59
|
+
* Applications can extend this union with their own message types.
|
|
60
|
+
* The store's update function pattern-matches on `type` to decide
|
|
61
|
+
* how to update the model.
|
|
62
|
+
*/
|
|
63
|
+
export type SilveryMsg =
|
|
64
|
+
| { type: "focus"; nodeId: string; origin?: FocusOrigin }
|
|
65
|
+
| { type: "blur" }
|
|
66
|
+
| { type: "focus-next" }
|
|
67
|
+
| { type: "focus-prev" }
|
|
68
|
+
| { type: "focus-direction"; direction: Direction }
|
|
69
|
+
| { type: "scope-enter"; scopeId: string }
|
|
70
|
+
| { type: "scope-exit" }
|
|
71
|
+
| {
|
|
72
|
+
type: "term:key"
|
|
73
|
+
key: string
|
|
74
|
+
input: string
|
|
75
|
+
ctrl: boolean
|
|
76
|
+
meta: boolean
|
|
77
|
+
shift: boolean
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
type: "term:mouse"
|
|
81
|
+
action: "down" | "up" | "move" | "scroll"
|
|
82
|
+
x: number
|
|
83
|
+
y: number
|
|
84
|
+
button: number
|
|
85
|
+
}
|
|
86
|
+
| { type: "term:resize"; cols: number; rows: number }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Effect commands returned by update functions.
|
|
90
|
+
*
|
|
91
|
+
* Effects are declarative descriptions of side effects. The store
|
|
92
|
+
* executes them after the model update, keeping the update function pure.
|
|
93
|
+
*
|
|
94
|
+
* - `none`: No effect (useful as a default)
|
|
95
|
+
* - `batch`: Multiple effects to execute
|
|
96
|
+
* - `dispatch`: Queue another message (no re-entrant dispatch)
|
|
97
|
+
*/
|
|
98
|
+
export type Effect = { type: "none" } | { type: "batch"; effects: Effect[] } | { type: "dispatch"; msg: SilveryMsg }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Subscription descriptor (for future use).
|
|
102
|
+
*
|
|
103
|
+
* Subscriptions represent long-running side effects (timers, event listeners)
|
|
104
|
+
* that produce messages over time. The store manages their lifecycle.
|
|
105
|
+
*/
|
|
106
|
+
export type Sub = {
|
|
107
|
+
type: "none"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Effect Constructors
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
/** No-op effect. */
|
|
115
|
+
export const none: Effect = { type: "none" }
|
|
116
|
+
|
|
117
|
+
/** Batch multiple effects. */
|
|
118
|
+
export function batch(...effects: Effect[]): Effect {
|
|
119
|
+
// Flatten nested batches and filter out none effects
|
|
120
|
+
const flat: Effect[] = []
|
|
121
|
+
for (const e of effects) {
|
|
122
|
+
if (e.type === "none") continue
|
|
123
|
+
if (e.type === "batch") {
|
|
124
|
+
flat.push(...e.effects)
|
|
125
|
+
} else {
|
|
126
|
+
flat.push(e)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (flat.length === 0) return none
|
|
130
|
+
if (flat.length === 1) return flat[0]!
|
|
131
|
+
return { type: "batch", effects: flat }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Queue a message dispatch as an effect. */
|
|
135
|
+
export function dispatch(msg: SilveryMsg): Effect {
|
|
136
|
+
return { type: "dispatch", msg }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// Plugin Type (Middleware Composition)
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A plugin wraps an update function, adding behavior before/after/around it.
|
|
145
|
+
*
|
|
146
|
+
* Plugins compose via `compose()` — the outermost plugin runs first on
|
|
147
|
+
* message receive, but the innermost (original) update runs first for
|
|
148
|
+
* model updates. This is the standard middleware pattern.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* const logging: Plugin<MyModel, MyMsg> = (inner) => (msg, model) => {
|
|
153
|
+
* console.log('msg:', msg.type)
|
|
154
|
+
* const result = inner(msg, model)
|
|
155
|
+
* console.log('new model:', result[0])
|
|
156
|
+
* return result
|
|
157
|
+
* }
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export type Plugin<Model, Msg> = (
|
|
161
|
+
innerUpdate: (msg: Msg, model: Model) => [Model, Effect[]],
|
|
162
|
+
) => (msg: Msg, model: Model) => [Model, Effect[]]
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compose multiple plugins into a single update function wrapper.
|
|
166
|
+
*
|
|
167
|
+
* Plugins are applied right-to-left (innermost first), so the first
|
|
168
|
+
* plugin in the array is the outermost wrapper — it sees messages first
|
|
169
|
+
* and model changes last.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* const update = compose(logging, focusNav, spatialNav)(baseUpdate)
|
|
174
|
+
* // Equivalent to: logging(focusNav(spatialNav(baseUpdate)))
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function compose<Model, Msg>(...plugins: Plugin<Model, Msg>[]): Plugin<Model, Msg> {
|
|
178
|
+
return (innerUpdate) => {
|
|
179
|
+
let update = innerUpdate
|
|
180
|
+
// Apply right-to-left so first plugin is outermost
|
|
181
|
+
for (let i = plugins.length - 1; i >= 0; i--) {
|
|
182
|
+
update = plugins[i]!(update)
|
|
183
|
+
}
|
|
184
|
+
return update
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// =============================================================================
|
|
189
|
+
// Focus Manager (pure, no React)
|
|
190
|
+
// =============================================================================
|
|
191
|
+
|
|
192
|
+
export { createFocusManager } from "../focus-manager.js"
|
|
193
|
+
export type { FocusManager, FocusManagerOptions, FocusChangeCallback, FocusSnapshot } from "../focus-manager.js"
|
|
194
|
+
|
|
195
|
+
// =============================================================================
|
|
196
|
+
// Focus Events (pure, no React)
|
|
197
|
+
// =============================================================================
|
|
198
|
+
|
|
199
|
+
export { createKeyEvent, createFocusEvent, dispatchKeyEvent, dispatchFocusEvent } from "../focus-events.js"
|
|
200
|
+
export type { SilveryKeyEvent, SilveryFocusEvent, FocusEventProps } from "../focus-events.js"
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Focus Queries (pure, no React)
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
export {
|
|
207
|
+
findFocusableAncestor,
|
|
208
|
+
getTabOrder,
|
|
209
|
+
findByTestID,
|
|
210
|
+
findSpatialTarget,
|
|
211
|
+
getExplicitFocusLink,
|
|
212
|
+
} from "../focus-queries.js"
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// Slices (ops-as-data helper)
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
export { createSlice } from "./slice.js"
|
|
219
|
+
export type { Slice, SliceWithInit, InferOp } from "./slice.js"
|
|
220
|
+
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// Shared Types (pure)
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
export type { TeaNode, Rect } from "../types.js"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// --- Types ---
|
|
2
|
+
|
|
3
|
+
/** True if F accepts at least 2 args */
|
|
4
|
+
type HasParams<F> = F extends (a: any, b: any, ...rest: any[]) => any ? true : false
|
|
5
|
+
|
|
6
|
+
/** Extract 2nd arg type, or never for 1-arg handlers */
|
|
7
|
+
type HandlerParams<F> = HasParams<F> extends true ? (F extends (s: any, params: infer P) => any ? P : never) : never
|
|
8
|
+
|
|
9
|
+
/** One variant of the op union */
|
|
10
|
+
type OpVariant<Name extends string, F> = [HandlerParams<F>] extends [never]
|
|
11
|
+
? { op: Name }
|
|
12
|
+
: { op: Name } & HandlerParams<F>
|
|
13
|
+
|
|
14
|
+
/** Full op union inferred from handler map */
|
|
15
|
+
export type InferOp<H> = {
|
|
16
|
+
[K in keyof H & string]: OpVariant<K, H[K]>
|
|
17
|
+
}[keyof H & string]
|
|
18
|
+
|
|
19
|
+
/** Union of all handler return types */
|
|
20
|
+
type ApplyReturn<H> = {
|
|
21
|
+
[K in keyof H]: H[K] extends (...args: any[]) => infer R ? R : never
|
|
22
|
+
}[keyof H]
|
|
23
|
+
|
|
24
|
+
/** Handlers-only slice (no state factory) */
|
|
25
|
+
export type Slice<S, H extends Record<string, (s: S, ...args: any[]) => any>> = H & {
|
|
26
|
+
apply(s: S, op: InferOp<H>): ApplyReturn<H>
|
|
27
|
+
readonly Op: InferOp<H>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Slice with bundled state factory */
|
|
31
|
+
export type SliceWithInit<S, H extends Record<string, (s: S, ...args: any[]) => any>> = Slice<S, H> & {
|
|
32
|
+
create(): { state: S; apply: (op: InferOp<H>) => ApplyReturn<H> }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Implementation ---
|
|
36
|
+
|
|
37
|
+
// Shared: build the slice object from handlers
|
|
38
|
+
function makeSlice<S, H extends Record<string, (s: S, ...args: any[]) => any>>(handlers: H): Slice<S, H> {
|
|
39
|
+
const apply = (s: S, op: { op: string }) => {
|
|
40
|
+
const handler = handlers[op.op]
|
|
41
|
+
if (!handler) throw new Error(`Unknown op: ${op.op}`)
|
|
42
|
+
return handler(s, op)
|
|
43
|
+
}
|
|
44
|
+
return Object.assign({ apply }, handlers) as any
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Overload 1: state factory + handlers (primary)
|
|
48
|
+
export function createSlice<S, H extends Record<string, (s: S, ...args: any[]) => any>>(
|
|
49
|
+
init: () => S,
|
|
50
|
+
handlers: H,
|
|
51
|
+
): SliceWithInit<S, H>
|
|
52
|
+
|
|
53
|
+
// Overload 2: curried, no state (fallback)
|
|
54
|
+
export function createSlice<S>(): <H extends Record<string, (s: S, ...args: any[]) => any>>(handlers: H) => Slice<S, H>
|
|
55
|
+
|
|
56
|
+
export function createSlice(...args: any[]): any {
|
|
57
|
+
if (args.length === 0) {
|
|
58
|
+
// Curried form
|
|
59
|
+
return <H extends Record<string, (s: any, ...args: any[]) => any>>(handlers: H) => makeSlice(handlers)
|
|
60
|
+
}
|
|
61
|
+
// State factory form
|
|
62
|
+
const [init, handlers] = args
|
|
63
|
+
const slice = makeSlice(handlers)
|
|
64
|
+
;(slice as any).create = () => {
|
|
65
|
+
const state = init()
|
|
66
|
+
return { state, apply: (op: any) => slice.apply(state, op) }
|
|
67
|
+
}
|
|
68
|
+
return slice
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createCommandRegistry() — Build a typed command registry from a definition object.
|
|
3
|
+
*
|
|
4
|
+
* Silvery's own command registry builder. Creates a `CommandRegistryLike`
|
|
5
|
+
* from a plain object of command definitions, suitable for use with
|
|
6
|
+
* `withCommands()`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const registry = createCommandRegistry({
|
|
11
|
+
* cursor_down: {
|
|
12
|
+
* name: 'Move Down',
|
|
13
|
+
* description: 'Move cursor down one row',
|
|
14
|
+
* shortcuts: ['j', 'ArrowDown'],
|
|
15
|
+
* execute: (ctx) => ({ type: 'moveCursor', delta: 1 }),
|
|
16
|
+
* },
|
|
17
|
+
* cursor_up: {
|
|
18
|
+
* name: 'Move Up',
|
|
19
|
+
* description: 'Move cursor up one row',
|
|
20
|
+
* shortcuts: ['k', 'ArrowUp'],
|
|
21
|
+
* execute: (ctx) => ({ type: 'moveCursor', delta: -1 }),
|
|
22
|
+
* },
|
|
23
|
+
* toggle_done: {
|
|
24
|
+
* name: 'Toggle Done',
|
|
25
|
+
* description: 'Toggle the done state of the current item',
|
|
26
|
+
* execute: (ctx) => ({ type: 'toggleDone', index: ctx.cursor }),
|
|
27
|
+
* },
|
|
28
|
+
* })
|
|
29
|
+
*
|
|
30
|
+
* // Use with withCommands
|
|
31
|
+
* const app = withCommands(baseApp, {
|
|
32
|
+
* registry,
|
|
33
|
+
* getContext: () => buildContext(state),
|
|
34
|
+
* handleAction: (action) => dispatch(action),
|
|
35
|
+
* })
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { CommandDef, CommandRegistryLike } from "./with-commands"
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Types
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Definition for a single command in the registry builder.
|
|
47
|
+
*
|
|
48
|
+
* Unlike the full `CommandDef`, the `id` is inferred from the object key.
|
|
49
|
+
*/
|
|
50
|
+
export interface CommandDefInput<TContext = unknown, TAction = unknown> {
|
|
51
|
+
/** Human-readable name */
|
|
52
|
+
name: string
|
|
53
|
+
/** Description of what the command does */
|
|
54
|
+
description?: string
|
|
55
|
+
/** Default keyboard shortcuts */
|
|
56
|
+
shortcuts?: string[]
|
|
57
|
+
/** Execute the command, returning action(s) or null */
|
|
58
|
+
execute: (ctx: TContext) => TAction | TAction[] | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A record mapping command IDs to their definitions.
|
|
63
|
+
*/
|
|
64
|
+
export type CommandDefs<TContext = unknown, TAction = unknown> = Record<string, CommandDefInput<TContext, TAction>>
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// Implementation
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a command registry from a definition object.
|
|
72
|
+
*
|
|
73
|
+
* Each key in the object becomes the command ID. The returned registry
|
|
74
|
+
* implements `CommandRegistryLike` for use with `withCommands()`.
|
|
75
|
+
*
|
|
76
|
+
* @param defs - Object mapping command IDs to their definitions
|
|
77
|
+
* @returns A command registry with `get()` and `getAll()`
|
|
78
|
+
*/
|
|
79
|
+
export function createCommandRegistry<TContext, TAction>(
|
|
80
|
+
defs: CommandDefs<TContext, TAction>,
|
|
81
|
+
): CommandRegistryLike<TContext, TAction> {
|
|
82
|
+
// Build the full CommandDef array with IDs
|
|
83
|
+
const commands: CommandDef<TContext, TAction>[] = []
|
|
84
|
+
const byId = new Map<string, CommandDef<TContext, TAction>>()
|
|
85
|
+
|
|
86
|
+
for (const [id, def] of Object.entries(defs)) {
|
|
87
|
+
const command: CommandDef<TContext, TAction> = {
|
|
88
|
+
id,
|
|
89
|
+
name: def.name,
|
|
90
|
+
description: def.description ?? def.name,
|
|
91
|
+
shortcuts: def.shortcuts,
|
|
92
|
+
execute: def.execute,
|
|
93
|
+
}
|
|
94
|
+
commands.push(command)
|
|
95
|
+
byId.set(id, command)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
get(id: string): CommandDef<TContext, TAction> | undefined {
|
|
100
|
+
return byId.get(id)
|
|
101
|
+
},
|
|
102
|
+
getAll(): CommandDef<TContext, TAction>[] {
|
|
103
|
+
return commands
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/effects.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in TEA effects for timers and dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Effect constructors create plain data objects. Effect runners execute them.
|
|
5
|
+
* The update function returns effects as data — the runtime executes them.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* function update(state, msg) {
|
|
10
|
+
* switch (msg.type) {
|
|
11
|
+
* case "start":
|
|
12
|
+
* return [{ ...state, phase: "thinking" }, [fx.delay(1200, { type: "done" })]]
|
|
13
|
+
* case "tick":
|
|
14
|
+
* return { ...state, count: state.count + 1 } // no effects
|
|
15
|
+
* case "done":
|
|
16
|
+
* return [{ ...state, phase: "idle" }, [fx.cancel("ticker")]]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { EffectLike, EffectRunners } from "./tea"
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Effect Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/** Fire a message after a delay. */
|
|
29
|
+
export interface DelayEffect<Msg = unknown> extends EffectLike {
|
|
30
|
+
type: "delay"
|
|
31
|
+
ms: number
|
|
32
|
+
msg: Msg
|
|
33
|
+
id?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fire a message repeatedly on an interval. */
|
|
37
|
+
export interface IntervalEffect<Msg = unknown> extends EffectLike {
|
|
38
|
+
type: "interval"
|
|
39
|
+
ms: number
|
|
40
|
+
msg: Msg
|
|
41
|
+
id: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Cancel a named timer (delay or interval). */
|
|
45
|
+
export interface CancelEffect extends EffectLike {
|
|
46
|
+
type: "cancel"
|
|
47
|
+
id: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** All built-in effect types. */
|
|
51
|
+
export type TimerEffect<Msg = unknown> = DelayEffect<Msg> | IntervalEffect<Msg> | CancelEffect
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Effect Constructors (the fx namespace)
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/** Fire `msg` after `ms` milliseconds. Optionally named for cancellation. */
|
|
58
|
+
function delay<Msg>(ms: number, msg: Msg, id?: string): DelayEffect<Msg> {
|
|
59
|
+
return { type: "delay", ms, msg, id }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fire `msg` every `ms` milliseconds. Must be named (for cancellation). */
|
|
63
|
+
function interval<Msg>(ms: number, msg: Msg, id: string): IntervalEffect<Msg> {
|
|
64
|
+
return { type: "interval", ms, msg, id }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Cancel a named delay or interval. */
|
|
68
|
+
function cancel(id: string): CancelEffect {
|
|
69
|
+
return { type: "cancel", id }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Built-in effect constructors.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* return [newState, [fx.delay(1000, { type: "tick" }), fx.cancel("old")]]
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export const fx = { delay, interval, cancel } as const
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Effect Runners
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create timer effect runners that manage named timers.
|
|
87
|
+
*
|
|
88
|
+
* Returns runners + a cleanup function. Call cleanup on unmount to
|
|
89
|
+
* cancel all active timers.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const { runners, cleanup } = createTimerRunners<MyMsg>()
|
|
94
|
+
* const [state, send] = useTea(init, update, runners)
|
|
95
|
+
* useEffect(() => cleanup, [])
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function createTimerRunners<Msg>(): {
|
|
99
|
+
runners: EffectRunners<TimerEffect<Msg>, Msg>
|
|
100
|
+
cleanup: () => void
|
|
101
|
+
} {
|
|
102
|
+
const timers = new Map<string, ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>()
|
|
103
|
+
let nextAnon = 0
|
|
104
|
+
|
|
105
|
+
function clearTimer(id: string): void {
|
|
106
|
+
const existing = timers.get(id)
|
|
107
|
+
if (existing !== undefined) {
|
|
108
|
+
clearTimeout(existing as ReturnType<typeof setTimeout>)
|
|
109
|
+
clearInterval(existing as ReturnType<typeof setInterval>)
|
|
110
|
+
timers.delete(id)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const runners: EffectRunners<TimerEffect<Msg>, Msg> = {
|
|
115
|
+
delay(effect, dispatch) {
|
|
116
|
+
const id = effect.id ?? `__anon_${nextAnon++}`
|
|
117
|
+
clearTimer(id)
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
timers.delete(id)
|
|
120
|
+
dispatch(effect.msg as Msg)
|
|
121
|
+
}, effect.ms)
|
|
122
|
+
timers.set(id, timer)
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
interval(effect, dispatch) {
|
|
126
|
+
clearTimer(effect.id)
|
|
127
|
+
const timer = setInterval(() => {
|
|
128
|
+
dispatch(effect.msg as Msg)
|
|
129
|
+
}, effect.ms)
|
|
130
|
+
timers.set(effect.id, timer)
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
cancel(effect) {
|
|
134
|
+
clearTimer(effect.id)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cleanup(): void {
|
|
139
|
+
for (const [id] of timers) {
|
|
140
|
+
clearTimer(id)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { runners, cleanup }
|
|
145
|
+
}
|