@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/src/pipe.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * pipe() — Compose app plugins left-to-right.
3
+ *
4
+ * The foundational composition function for silvery's plugin system.
5
+ * Each plugin is a function `(app) => enhancedApp` that takes an app object
6
+ * and returns an enhanced version with additional capabilities.
7
+ *
8
+ * Plugins compose left-to-right: `pipe(base, p1, p2)` = `p2(p1(base))`
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { pipe, createApp, withReact, withTerminal, withFocus, withDomEvents } from '@silvery/tea'
13
+ *
14
+ * const app = pipe(
15
+ * createApp(store),
16
+ * withReact(<Board />),
17
+ * withTerminal(process),
18
+ * withFocus(),
19
+ * withDomEvents(),
20
+ * )
21
+ * await app.run()
22
+ * ```
23
+ *
24
+ * @example Typed plugin
25
+ * ```tsx
26
+ * type MyPlugin = (app: App) => App & { custom: () => void }
27
+ *
28
+ * const withCustom: MyPlugin = (app) => ({
29
+ * ...app,
30
+ * custom: () => console.log('hello'),
31
+ * })
32
+ *
33
+ * const enhanced = pipe(baseApp, withCustom)
34
+ * enhanced.custom() // typed!
35
+ * ```
36
+ */
37
+
38
+ // =============================================================================
39
+ // Types
40
+ // =============================================================================
41
+
42
+ /**
43
+ * A plugin function that enhances an app.
44
+ *
45
+ * Takes an app of type A and returns an enhanced app of type B.
46
+ * The plugin can add new methods, override existing ones, or
47
+ * wrap behavior via closures.
48
+ */
49
+ export type AppPlugin<A, B> = (app: A) => B
50
+
51
+ // =============================================================================
52
+ // Implementation
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Compose app plugins left-to-right.
57
+ *
58
+ * `pipe(base, p1, p2, p3)` is equivalent to `p3(p2(p1(base)))`.
59
+ *
60
+ * Each plugin receives the result of the previous plugin, allowing
61
+ * progressive enhancement of the app object.
62
+ *
63
+ * Type inference works through the chain: if p1 adds `.cmd` and p2
64
+ * requires it, TypeScript catches the error at the call site.
65
+ */
66
+ export function pipe<A>(base: A): A
67
+ export function pipe<A, B>(base: A, p1: AppPlugin<A, B>): B
68
+ export function pipe<A, B, C>(base: A, p1: AppPlugin<A, B>, p2: AppPlugin<B, C>): C
69
+ export function pipe<A, B, C, D>(base: A, p1: AppPlugin<A, B>, p2: AppPlugin<B, C>, p3: AppPlugin<C, D>): D
70
+ export function pipe<A, B, C, D, E>(
71
+ base: A,
72
+ p1: AppPlugin<A, B>,
73
+ p2: AppPlugin<B, C>,
74
+ p3: AppPlugin<C, D>,
75
+ p4: AppPlugin<D, E>,
76
+ ): E
77
+ export function pipe<A, B, C, D, E, F>(
78
+ base: A,
79
+ p1: AppPlugin<A, B>,
80
+ p2: AppPlugin<B, C>,
81
+ p3: AppPlugin<C, D>,
82
+ p4: AppPlugin<D, E>,
83
+ p5: AppPlugin<E, F>,
84
+ ): F
85
+ export function pipe<A, B, C, D, E, F, G>(
86
+ base: A,
87
+ p1: AppPlugin<A, B>,
88
+ p2: AppPlugin<B, C>,
89
+ p3: AppPlugin<C, D>,
90
+ p4: AppPlugin<D, E>,
91
+ p5: AppPlugin<E, F>,
92
+ p6: AppPlugin<F, G>,
93
+ ): G
94
+ export function pipe<A, B, C, D, E, F, G, H>(
95
+ base: A,
96
+ p1: AppPlugin<A, B>,
97
+ p2: AppPlugin<B, C>,
98
+ p3: AppPlugin<C, D>,
99
+ p4: AppPlugin<D, E>,
100
+ p5: AppPlugin<E, F>,
101
+ p6: AppPlugin<F, G>,
102
+ p7: AppPlugin<G, H>,
103
+ ): H
104
+ export function pipe(base: unknown, ...plugins: AppPlugin<unknown, unknown>[]): unknown {
105
+ let result = base
106
+ for (const plugin of plugins) {
107
+ result = plugin(result)
108
+ }
109
+ return result
110
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * silvery/plugins -- Composable plugin system for silvery apps.
3
+ *
4
+ * Plugins are functions `(app) => enhancedApp` that compose via `pipe()`:
5
+ *
6
+ * ```tsx
7
+ * import { pipe, withCommands, withKeybindings, withFocus, withDomEvents } from '@silvery/tea/plugins'
8
+ *
9
+ * const app = pipe(
10
+ * baseApp,
11
+ * withFocus(),
12
+ * withDomEvents(),
13
+ * withCommands(cmdOpts),
14
+ * withKeybindings(kbOpts),
15
+ * )
16
+ *
17
+ * await app.cmd.down() // Direct command invocation
18
+ * await app.press('j') // Key -> command -> action
19
+ * ```
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ // =============================================================================
25
+ // pipe — Plugin composition
26
+ // =============================================================================
27
+
28
+ export { pipe } from "./pipe"
29
+ export type { AppPlugin } from "./pipe"
30
+
31
+ // =============================================================================
32
+ // withReact — React reconciler mounting
33
+ // =============================================================================
34
+
35
+ export { withReact } from "./with-react"
36
+ export type { AppWithReact } from "./with-react"
37
+
38
+ // =============================================================================
39
+ // withTerminal — Terminal I/O
40
+ // =============================================================================
41
+
42
+ export { withTerminal } from "./with-terminal"
43
+ export type { WithTerminalOptions, AppWithTerminal, ProcessLike } from "./with-terminal"
44
+
45
+ // =============================================================================
46
+ // withFocus — Focus management
47
+ // =============================================================================
48
+
49
+ export { withFocus } from "./with-focus"
50
+ export type { WithFocusOptions, AppWithFocus } from "./with-focus"
51
+
52
+ // =============================================================================
53
+ // withDomEvents — DOM-style event dispatch
54
+ // =============================================================================
55
+
56
+ export { withDomEvents } from "./with-dom-events"
57
+ export type { WithDomEventsOptions } from "./with-dom-events"
58
+
59
+ // =============================================================================
60
+ // createCommandRegistry — Command registry builder
61
+ // =============================================================================
62
+
63
+ export { createCommandRegistry } from "./create-command-registry"
64
+ export type { CommandDefInput, CommandDefs } from "./create-command-registry"
65
+
66
+ // =============================================================================
67
+ // withCommands — Command system
68
+ // =============================================================================
69
+
70
+ export { withCommands } from "./with-commands"
71
+ export type {
72
+ WithCommandsOptions,
73
+ CommandDef,
74
+ CommandRegistryLike,
75
+ CommandInfo,
76
+ Command,
77
+ Cmd,
78
+ AppWithCommands,
79
+ AppState,
80
+ KeybindingDef,
81
+ } from "./with-commands"
82
+
83
+ // =============================================================================
84
+ // withKeybindings — Keybinding resolution
85
+ // =============================================================================
86
+
87
+ export { withKeybindings } from "./with-keybindings"
88
+ export type { WithKeybindingsOptions, KeybindingContext, ExtendedKeybindingDef } from "./with-keybindings"
89
+
90
+ // =============================================================================
91
+ // withDiagnostics — Testing invariants
92
+ // =============================================================================
93
+
94
+ export { withDiagnostics, VirtualTerminal } from "./with-diagnostics"
95
+ export type { DiagnosticOptions } from "./with-diagnostics"
96
+
97
+ // =============================================================================
98
+ // withInk — Ink compatibility layer (from @silvery/compat)
99
+ // =============================================================================
100
+
101
+ export { withInk } from "@silvery/compat/with-ink"
102
+ export type { WithInkOptions, AppWithInk } from "@silvery/compat/with-ink"
103
+
104
+ // =============================================================================
105
+ // withInkCursor — Ink cursor compatibility adapter (from @silvery/compat)
106
+ // =============================================================================
107
+
108
+ export { withInkCursor } from "@silvery/compat/with-ink-cursor"
109
+ export type { WithInkCursorOptions, AppWithInkCursor } from "@silvery/compat/with-ink-cursor"
110
+
111
+ // =============================================================================
112
+ // withInkFocus — Ink focus compatibility adapter (from @silvery/compat)
113
+ // =============================================================================
114
+
115
+ export { withInkFocus } from "@silvery/compat/with-ink-focus"
116
+ export type { WithInkFocusOptions, AppWithInkFocus } from "@silvery/compat/with-ink-focus"
117
+
118
+ // Scheduler errors (for catching incremental render mismatches)
119
+ export { IncrementalRenderMismatchError } from "@silvery/term/scheduler"
@@ -0,0 +1,306 @@
1
+ /**
2
+ * silvery/store — TEA-style state container with effects.
3
+ *
4
+ * Wraps FocusManager into a dispatch/subscribe loop following
5
+ * The Elm Architecture (TEA): Model + Msg -> [Model, Effect[]].
6
+ *
7
+ * The store provides:
8
+ * - `dispatch(msg)` — send a message, run update, execute effects
9
+ * - `getModel()` — current model snapshot
10
+ * - `subscribe(listener)` — for useSyncExternalStore integration
11
+ * - `getSnapshot(selector)` — selector-based access
12
+ *
13
+ * Effects are executed after each update cycle. `dispatch` effects
14
+ * are queued (not re-entrant) to prevent stack overflow.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import type { Effect, SilveryModel, SilveryMsg, Plugin } from "../core"
20
+ import { none } from "../core"
21
+
22
+ // =============================================================================
23
+ // Types
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Configuration for creating a store.
28
+ */
29
+ export interface StoreConfig<Model extends SilveryModel, Msg extends SilveryMsg> {
30
+ /** Initialize model and effects. Called once on store creation. */
31
+ init: () => [Model, Effect[]]
32
+ /** Pure update function: (msg, model) -> [newModel, effects] */
33
+ update: (msg: Msg, model: Model) => [Model, Effect[]]
34
+ }
35
+
36
+ /**
37
+ * The store API returned by createStore.
38
+ */
39
+ export interface StoreApi<Model extends SilveryModel, Msg extends SilveryMsg> {
40
+ /** Send a message through the update function. */
41
+ dispatch(msg: Msg): void
42
+ /** Get the current model. */
43
+ getModel(): Model
44
+ /** Subscribe to model changes. Returns unsubscribe function. */
45
+ subscribe(listener: () => void): () => void
46
+ /** Get a derived value from the model via selector. */
47
+ getSnapshot<T>(selector: (model: Model) => T): T
48
+ }
49
+
50
+ // =============================================================================
51
+ // Built-in Plugins
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Plugin that handles focus-related messages by updating the focus slice.
56
+ *
57
+ * Handles: focus, blur, scope-enter, scope-exit.
58
+ * Passes focus-next, focus-prev, focus-direction through (they need
59
+ * the node tree, which the store doesn't have — those are handled
60
+ * at the React integration layer).
61
+ */
62
+ export function withFocusManagement<Model extends SilveryModel, Msg extends SilveryMsg>(): Plugin<Model, Msg> {
63
+ return (innerUpdate) => (msg, model) => {
64
+ switch (msg.type) {
65
+ case "focus": {
66
+ const focusMsg = msg as Extract<SilveryMsg, { type: "focus" }>
67
+ const newModel = {
68
+ ...model,
69
+ focus: {
70
+ ...model.focus,
71
+ previousId: model.focus.activeId,
72
+ activeId: focusMsg.nodeId,
73
+ origin: focusMsg.origin ?? "programmatic",
74
+ // Remember in current scope
75
+ scopeMemory:
76
+ model.focus.scopeStack.length > 0
77
+ ? {
78
+ ...model.focus.scopeMemory,
79
+ [model.focus.scopeStack[model.focus.scopeStack.length - 1]!]: focusMsg.nodeId,
80
+ }
81
+ : model.focus.scopeMemory,
82
+ },
83
+ }
84
+ return [newModel as Model, [none]]
85
+ }
86
+
87
+ case "blur": {
88
+ const newModel = {
89
+ ...model,
90
+ focus: {
91
+ ...model.focus,
92
+ previousId: model.focus.activeId,
93
+ activeId: null,
94
+ origin: null,
95
+ },
96
+ }
97
+ return [newModel as Model, [none]]
98
+ }
99
+
100
+ case "scope-enter": {
101
+ const scopeMsg = msg as Extract<SilveryMsg, { type: "scope-enter" }>
102
+ const newModel = {
103
+ ...model,
104
+ focus: {
105
+ ...model.focus,
106
+ scopeStack: [...model.focus.scopeStack, scopeMsg.scopeId],
107
+ },
108
+ }
109
+ return [newModel as Model, [none]]
110
+ }
111
+
112
+ case "scope-exit": {
113
+ const newModel = {
114
+ ...model,
115
+ focus: {
116
+ ...model.focus,
117
+ scopeStack: model.focus.scopeStack.slice(0, -1),
118
+ },
119
+ }
120
+ return [newModel as Model, [none]]
121
+ }
122
+
123
+ default:
124
+ return innerUpdate(msg, model)
125
+ }
126
+ }
127
+ }
128
+
129
+ // =============================================================================
130
+ // Default Update
131
+ // =============================================================================
132
+
133
+ /**
134
+ * The default silvery update function.
135
+ *
136
+ * Returns the model unchanged with no effects for any unhandled message.
137
+ * Compose with plugins to add behavior.
138
+ */
139
+ export function silveryUpdate<Model extends SilveryModel, Msg extends SilveryMsg>(
140
+ _msg: Msg,
141
+ model: Model,
142
+ ): [Model, Effect[]] {
143
+ return [model, [none]]
144
+ }
145
+
146
+ // =============================================================================
147
+ // Default Init
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Create a default initial SilveryModel.
152
+ */
153
+ export function defaultInit(): [SilveryModel, Effect[]] {
154
+ return [
155
+ {
156
+ focus: {
157
+ activeId: null,
158
+ previousId: null,
159
+ origin: null,
160
+ scopeStack: [],
161
+ scopeMemory: {},
162
+ },
163
+ },
164
+ [none],
165
+ ]
166
+ }
167
+
168
+ // =============================================================================
169
+ // Store Factory
170
+ // =============================================================================
171
+
172
+ /**
173
+ * Create a TEA-style store.
174
+ *
175
+ * The store manages model state and effect execution. Messages are
176
+ * dispatched through the update function, which returns a new model
177
+ * and a list of effects. Effects are executed after each update cycle.
178
+ *
179
+ * Dispatch effects are queued to prevent re-entrant dispatch:
180
+ * if dispatching msg A triggers effect dispatch(B), B is queued and
181
+ * processed after A's full update cycle completes.
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * import { createStore, withFocusManagement, silveryUpdate } from '@silvery/tea/store'
186
+ * import { compose } from '@silvery/tea/core'
187
+ *
188
+ * const store = createStore({
189
+ * init: () => [{ focus: { activeId: null, ... }, count: 0 }, []],
190
+ * update: compose(withFocusManagement())(silveryUpdate),
191
+ * })
192
+ *
193
+ * store.dispatch({ type: 'focus', nodeId: 'btn1' })
194
+ * console.log(store.getModel().focus.activeId) // 'btn1'
195
+ * ```
196
+ */
197
+ export function createStore<Model extends SilveryModel, Msg extends SilveryMsg>(
198
+ config: StoreConfig<Model, Msg>,
199
+ ): StoreApi<Model, Msg> {
200
+ // Initialize
201
+ const [initialModel, initialEffects] = config.init()
202
+ let model = initialModel
203
+
204
+ // Subscriber management
205
+ const listeners = new Set<() => void>()
206
+
207
+ function notify(): void {
208
+ for (const listener of listeners) {
209
+ listener()
210
+ }
211
+ }
212
+
213
+ // Effect execution with queue for dispatch effects
214
+ let isDispatching = false
215
+ const dispatchQueue: Msg[] = []
216
+
217
+ function executeEffects(effects: Effect[]): void {
218
+ for (const effect of effects) {
219
+ executeEffect(effect)
220
+ }
221
+ }
222
+
223
+ function executeEffect(effect: Effect): void {
224
+ switch (effect.type) {
225
+ case "none":
226
+ break
227
+ case "batch":
228
+ executeEffects(effect.effects)
229
+ break
230
+ case "dispatch":
231
+ // Queue dispatch effects to prevent re-entrant dispatch
232
+ dispatchQueue.push(effect.msg as Msg)
233
+ break
234
+ }
235
+ }
236
+
237
+ function dispatch(msg: Msg): void {
238
+ if (isDispatching) {
239
+ // Queue if we're already in a dispatch cycle
240
+ dispatchQueue.push(msg)
241
+ return
242
+ }
243
+
244
+ isDispatching = true
245
+ try {
246
+ // Run update
247
+ const [newModel, effects] = config.update(msg, model)
248
+ const changed = newModel !== model
249
+ model = newModel
250
+
251
+ // Execute effects (may queue more dispatches)
252
+ executeEffects(effects)
253
+
254
+ // Notify subscribers if model changed
255
+ if (changed) {
256
+ notify()
257
+ }
258
+
259
+ // Process queued dispatches
260
+ while (dispatchQueue.length > 0) {
261
+ const queued = dispatchQueue.shift()!
262
+ const [nextModel, nextEffects] = config.update(queued, model)
263
+ const nextChanged = nextModel !== model
264
+ model = nextModel
265
+ executeEffects(nextEffects)
266
+ if (nextChanged) {
267
+ notify()
268
+ }
269
+ }
270
+ } finally {
271
+ isDispatching = false
272
+ }
273
+ }
274
+
275
+ function getModel(): Model {
276
+ return model
277
+ }
278
+
279
+ function subscribe(listener: () => void): () => void {
280
+ listeners.add(listener)
281
+ return () => {
282
+ listeners.delete(listener)
283
+ }
284
+ }
285
+
286
+ function getSnapshot<T>(selector: (model: Model) => T): T {
287
+ return selector(model)
288
+ }
289
+
290
+ // Execute initial effects and drain any queued dispatches
291
+ executeEffects(initialEffects)
292
+ while (dispatchQueue.length > 0) {
293
+ const queued = dispatchQueue.shift()!
294
+ const [nextModel, nextEffects] = config.update(queued, model)
295
+ model = nextModel
296
+ executeEffects(nextEffects)
297
+ // No notify during init — no subscribers yet
298
+ }
299
+
300
+ return {
301
+ dispatch,
302
+ getModel,
303
+ subscribe,
304
+ getSnapshot,
305
+ }
306
+ }