@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/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
|
+
}
|