@rlabs-inc/tui 0.1.0 → 0.2.1
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/README.md +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/history.ts +451 -0
- package/src/api/mount.ts +66 -31
- package/src/engine/arrays/core.ts +13 -21
- package/src/engine/arrays/dimensions.ts +22 -32
- package/src/engine/arrays/index.ts +88 -86
- package/src/engine/arrays/interaction.ts +34 -48
- package/src/engine/arrays/layout.ts +67 -92
- package/src/engine/arrays/spacing.ts +37 -52
- package/src/engine/arrays/text.ts +23 -31
- package/src/engine/arrays/visual.ts +56 -75
- package/src/engine/inheritance.ts +18 -18
- package/src/engine/registry.ts +15 -0
- package/src/pipeline/frameBuffer.ts +26 -26
- package/src/pipeline/layout/index.ts +2 -2
- package/src/pipeline/layout/titan-engine.ts +112 -84
- package/src/primitives/animation.ts +194 -0
- package/src/primitives/box.ts +74 -86
- package/src/primitives/each.ts +87 -0
- package/src/primitives/index.ts +7 -0
- package/src/primitives/scope.ts +215 -0
- package/src/primitives/show.ts +77 -0
- package/src/primitives/text.ts +63 -59
- package/src/primitives/types.ts +1 -1
- package/src/primitives/when.ts +102 -0
- package/src/renderer/append-region.ts +159 -0
- package/src/renderer/index.ts +4 -2
- package/src/renderer/output.ts +11 -34
- package/src/state/focus.ts +16 -5
- package/src/state/global-keys.ts +184 -0
- package/src/state/index.ts +44 -8
- package/src/state/input.ts +534 -0
- package/src/state/keyboard.ts +98 -674
- package/src/state/mouse.ts +163 -340
- package/src/state/scroll.ts +7 -9
- package/src/types/index.ts +23 -2
- package/src/renderer/input.ts +0 -518
package/src/primitives/box.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// No direct imports from signals - using slotArray APIs
|
|
25
25
|
import { ComponentType } from '../types'
|
|
26
26
|
import {
|
|
27
27
|
allocateIndex,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from '../engine/registry'
|
|
33
33
|
import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
|
|
34
34
|
import { getVariantStyle } from '../state/theme'
|
|
35
|
+
import { getActiveScope } from './scope'
|
|
35
36
|
|
|
36
37
|
// Import arrays
|
|
37
38
|
import * as core from '../engine/arrays/core'
|
|
@@ -113,29 +114,18 @@ function alignSelfToNum(align: string | undefined): number {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/**
|
|
116
|
-
* Create a
|
|
117
|
-
*
|
|
118
|
-
* This creates dependency directly on user's signal, no intermediate objects.
|
|
117
|
+
* Create a slot source for enum props - returns getter for reactive, value for static.
|
|
118
|
+
* For use with slotArray.setSource()
|
|
119
119
|
*/
|
|
120
|
-
function
|
|
120
|
+
function enumSource<T extends string>(
|
|
121
121
|
prop: T | { value: T } | undefined,
|
|
122
122
|
converter: (val: T | undefined) => number
|
|
123
|
-
):
|
|
124
|
-
// If it's reactive (has .value), create binding that converts at read time
|
|
123
|
+
): number | (() => number) {
|
|
125
124
|
if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
|
|
126
125
|
const reactiveSource = prop as { value: T }
|
|
127
|
-
return
|
|
128
|
-
[BINDING_SYMBOL]: true,
|
|
129
|
-
get value(): number {
|
|
130
|
-
return converter(reactiveSource.value)
|
|
131
|
-
},
|
|
132
|
-
set value(_: number) {
|
|
133
|
-
// Enum props are read-only from number side
|
|
134
|
-
},
|
|
135
|
-
} as unknown as ReturnType<typeof bind<number>>
|
|
126
|
+
return () => converter(reactiveSource.value)
|
|
136
127
|
}
|
|
137
|
-
|
|
138
|
-
return bind(converter(prop as T | undefined))
|
|
128
|
+
return converter(prop as T | undefined)
|
|
139
129
|
}
|
|
140
130
|
|
|
141
131
|
/** Get static boolean for visible prop */
|
|
@@ -170,38 +160,38 @@ export function box(props: BoxProps = {}): Cleanup {
|
|
|
170
160
|
// CORE - Always needed
|
|
171
161
|
// ==========================================================================
|
|
172
162
|
core.componentType[index] = ComponentType.BOX
|
|
173
|
-
core.parentIndex
|
|
163
|
+
core.parentIndex.setSource(index, getCurrentParentIndex())
|
|
174
164
|
|
|
175
165
|
// Visible - only bind if passed (default is visible, handled by TITAN)
|
|
176
166
|
if (props.visible !== undefined) {
|
|
177
|
-
core.visible
|
|
167
|
+
core.visible.setSource(index, props.visible)
|
|
178
168
|
}
|
|
179
169
|
|
|
180
170
|
// ==========================================================================
|
|
181
171
|
// DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
|
|
182
172
|
// ==========================================================================
|
|
183
|
-
if (props.width !== undefined) dimensions.width
|
|
184
|
-
if (props.height !== undefined) dimensions.height
|
|
185
|
-
if (props.minWidth !== undefined) dimensions.minWidth
|
|
186
|
-
if (props.maxWidth !== undefined) dimensions.maxWidth
|
|
187
|
-
if (props.minHeight !== undefined) dimensions.minHeight
|
|
188
|
-
if (props.maxHeight !== undefined) dimensions.maxHeight
|
|
173
|
+
if (props.width !== undefined) dimensions.width.setSource(index, props.width)
|
|
174
|
+
if (props.height !== undefined) dimensions.height.setSource(index, props.height)
|
|
175
|
+
if (props.minWidth !== undefined) dimensions.minWidth.setSource(index, props.minWidth)
|
|
176
|
+
if (props.maxWidth !== undefined) dimensions.maxWidth.setSource(index, props.maxWidth)
|
|
177
|
+
if (props.minHeight !== undefined) dimensions.minHeight.setSource(index, props.minHeight)
|
|
178
|
+
if (props.maxHeight !== undefined) dimensions.maxHeight.setSource(index, props.maxHeight)
|
|
189
179
|
|
|
190
180
|
// ==========================================================================
|
|
191
181
|
// PADDING - Shorthand support: padding sets all 4, individual overrides
|
|
192
182
|
// ==========================================================================
|
|
193
183
|
if (props.padding !== undefined) {
|
|
194
184
|
// Shorthand - set all 4 sides
|
|
195
|
-
spacing.paddingTop
|
|
196
|
-
spacing.paddingRight
|
|
197
|
-
spacing.paddingBottom
|
|
198
|
-
spacing.paddingLeft
|
|
185
|
+
spacing.paddingTop.setSource(index, props.paddingTop ?? props.padding)
|
|
186
|
+
spacing.paddingRight.setSource(index, props.paddingRight ?? props.padding)
|
|
187
|
+
spacing.paddingBottom.setSource(index, props.paddingBottom ?? props.padding)
|
|
188
|
+
spacing.paddingLeft.setSource(index, props.paddingLeft ?? props.padding)
|
|
199
189
|
} else {
|
|
200
190
|
// Individual only - bind only what's passed
|
|
201
|
-
if (props.paddingTop !== undefined) spacing.paddingTop
|
|
202
|
-
if (props.paddingRight !== undefined) spacing.paddingRight
|
|
203
|
-
if (props.paddingBottom !== undefined) spacing.paddingBottom
|
|
204
|
-
if (props.paddingLeft !== undefined) spacing.paddingLeft
|
|
191
|
+
if (props.paddingTop !== undefined) spacing.paddingTop.setSource(index, props.paddingTop)
|
|
192
|
+
if (props.paddingRight !== undefined) spacing.paddingRight.setSource(index, props.paddingRight)
|
|
193
|
+
if (props.paddingBottom !== undefined) spacing.paddingBottom.setSource(index, props.paddingBottom)
|
|
194
|
+
if (props.paddingLeft !== undefined) spacing.paddingLeft.setSource(index, props.paddingLeft)
|
|
205
195
|
}
|
|
206
196
|
|
|
207
197
|
// ==========================================================================
|
|
@@ -209,90 +199,80 @@ export function box(props: BoxProps = {}): Cleanup {
|
|
|
209
199
|
// ==========================================================================
|
|
210
200
|
if (props.margin !== undefined) {
|
|
211
201
|
// Shorthand - set all 4 sides
|
|
212
|
-
spacing.marginTop
|
|
213
|
-
spacing.marginRight
|
|
214
|
-
spacing.marginBottom
|
|
215
|
-
spacing.marginLeft
|
|
202
|
+
spacing.marginTop.setSource(index, props.marginTop ?? props.margin)
|
|
203
|
+
spacing.marginRight.setSource(index, props.marginRight ?? props.margin)
|
|
204
|
+
spacing.marginBottom.setSource(index, props.marginBottom ?? props.margin)
|
|
205
|
+
spacing.marginLeft.setSource(index, props.marginLeft ?? props.margin)
|
|
216
206
|
} else {
|
|
217
207
|
// Individual only - bind only what's passed
|
|
218
|
-
if (props.marginTop !== undefined) spacing.marginTop
|
|
219
|
-
if (props.marginRight !== undefined) spacing.marginRight
|
|
220
|
-
if (props.marginBottom !== undefined) spacing.marginBottom
|
|
221
|
-
if (props.marginLeft !== undefined) spacing.marginLeft
|
|
208
|
+
if (props.marginTop !== undefined) spacing.marginTop.setSource(index, props.marginTop)
|
|
209
|
+
if (props.marginRight !== undefined) spacing.marginRight.setSource(index, props.marginRight)
|
|
210
|
+
if (props.marginBottom !== undefined) spacing.marginBottom.setSource(index, props.marginBottom)
|
|
211
|
+
if (props.marginLeft !== undefined) spacing.marginLeft.setSource(index, props.marginLeft)
|
|
222
212
|
}
|
|
223
213
|
|
|
224
214
|
// Gap - only bind if passed
|
|
225
|
-
if (props.gap !== undefined) spacing.gap
|
|
215
|
+
if (props.gap !== undefined) spacing.gap.setSource(index, props.gap)
|
|
226
216
|
|
|
227
217
|
// ==========================================================================
|
|
228
218
|
// LAYOUT - Only bind what's passed (TITAN uses sensible defaults)
|
|
229
219
|
// ==========================================================================
|
|
230
|
-
if (props.flexDirection !== undefined) layout.flexDirection
|
|
231
|
-
if (props.flexWrap !== undefined) layout.flexWrap
|
|
232
|
-
if (props.justifyContent !== undefined) layout.justifyContent
|
|
233
|
-
if (props.alignItems !== undefined) layout.alignItems
|
|
234
|
-
if (props.overflow !== undefined) layout.overflow
|
|
235
|
-
if (props.grow !== undefined) layout.flexGrow
|
|
236
|
-
if (props.shrink !== undefined) layout.flexShrink
|
|
237
|
-
if (props.flexBasis !== undefined) layout.flexBasis
|
|
238
|
-
if (props.zIndex !== undefined) layout.zIndex
|
|
239
|
-
if (props.alignSelf !== undefined) layout.alignSelf
|
|
220
|
+
if (props.flexDirection !== undefined) layout.flexDirection.setSource(index, enumSource(props.flexDirection, flexDirectionToNum))
|
|
221
|
+
if (props.flexWrap !== undefined) layout.flexWrap.setSource(index, enumSource(props.flexWrap, flexWrapToNum))
|
|
222
|
+
if (props.justifyContent !== undefined) layout.justifyContent.setSource(index, enumSource(props.justifyContent, justifyToNum))
|
|
223
|
+
if (props.alignItems !== undefined) layout.alignItems.setSource(index, enumSource(props.alignItems, alignToNum))
|
|
224
|
+
if (props.overflow !== undefined) layout.overflow.setSource(index, enumSource(props.overflow, overflowToNum))
|
|
225
|
+
if (props.grow !== undefined) layout.flexGrow.setSource(index, props.grow)
|
|
226
|
+
if (props.shrink !== undefined) layout.flexShrink.setSource(index, props.shrink)
|
|
227
|
+
if (props.flexBasis !== undefined) layout.flexBasis.setSource(index, props.flexBasis)
|
|
228
|
+
if (props.zIndex !== undefined) layout.zIndex.setSource(index, props.zIndex)
|
|
229
|
+
if (props.alignSelf !== undefined) layout.alignSelf.setSource(index, enumSource(props.alignSelf, alignSelfToNum))
|
|
240
230
|
|
|
241
231
|
// ==========================================================================
|
|
242
|
-
// INTERACTION -
|
|
232
|
+
// INTERACTION - Focusable handling
|
|
233
|
+
// Auto-focusable for overflow:'scroll' (unless explicitly disabled)
|
|
243
234
|
// ==========================================================================
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
235
|
+
const shouldBeFocusable = props.focusable || (props.overflow === 'scroll' && props.focusable !== false)
|
|
236
|
+
if (shouldBeFocusable) {
|
|
237
|
+
interaction.focusable.setSource(index, 1)
|
|
238
|
+
if (props.tabIndex !== undefined) interaction.tabIndex.setSource(index, props.tabIndex)
|
|
247
239
|
}
|
|
248
240
|
|
|
249
241
|
// ==========================================================================
|
|
250
242
|
// VISUAL - Colors and borders (only bind what's passed)
|
|
251
243
|
// ==========================================================================
|
|
252
244
|
if (props.variant && props.variant !== 'default') {
|
|
253
|
-
// Variant colors -
|
|
245
|
+
// Variant colors - use getters that read theme at read time
|
|
254
246
|
const variant = props.variant
|
|
255
247
|
if (props.fg !== undefined) {
|
|
256
|
-
visual.fgColor
|
|
248
|
+
visual.fgColor.setSource(index, props.fg)
|
|
257
249
|
} else {
|
|
258
|
-
visual.fgColor
|
|
259
|
-
[BINDING_SYMBOL]: true,
|
|
260
|
-
get value() { return getVariantStyle(variant).fg },
|
|
261
|
-
set value(_) {},
|
|
262
|
-
} as any
|
|
250
|
+
visual.fgColor.setSource(index, () => getVariantStyle(variant).fg)
|
|
263
251
|
}
|
|
264
252
|
if (props.bg !== undefined) {
|
|
265
|
-
visual.bgColor
|
|
253
|
+
visual.bgColor.setSource(index, props.bg)
|
|
266
254
|
} else {
|
|
267
|
-
visual.bgColor
|
|
268
|
-
[BINDING_SYMBOL]: true,
|
|
269
|
-
get value() { return getVariantStyle(variant).bg },
|
|
270
|
-
set value(_) {},
|
|
271
|
-
} as any
|
|
255
|
+
visual.bgColor.setSource(index, () => getVariantStyle(variant).bg)
|
|
272
256
|
}
|
|
273
257
|
if (props.borderColor !== undefined) {
|
|
274
|
-
visual.borderColor
|
|
258
|
+
visual.borderColor.setSource(index, props.borderColor)
|
|
275
259
|
} else {
|
|
276
|
-
visual.borderColor
|
|
277
|
-
[BINDING_SYMBOL]: true,
|
|
278
|
-
get value() { return getVariantStyle(variant).border },
|
|
279
|
-
set value(_) {},
|
|
280
|
-
} as any
|
|
260
|
+
visual.borderColor.setSource(index, () => getVariantStyle(variant).border)
|
|
281
261
|
}
|
|
282
262
|
} else {
|
|
283
263
|
// Direct colors - only bind if passed
|
|
284
|
-
if (props.fg !== undefined) visual.fgColor
|
|
285
|
-
if (props.bg !== undefined) visual.bgColor
|
|
286
|
-
if (props.borderColor !== undefined) visual.borderColor
|
|
264
|
+
if (props.fg !== undefined) visual.fgColor.setSource(index, props.fg)
|
|
265
|
+
if (props.bg !== undefined) visual.bgColor.setSource(index, props.bg)
|
|
266
|
+
if (props.borderColor !== undefined) visual.borderColor.setSource(index, props.borderColor)
|
|
287
267
|
}
|
|
288
|
-
if (props.opacity !== undefined) visual.opacity
|
|
268
|
+
if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
|
|
289
269
|
|
|
290
270
|
// Border style - shorthand and individual
|
|
291
|
-
if (props.border !== undefined) visual.borderStyle
|
|
292
|
-
if (props.borderTop !== undefined) visual.borderTop
|
|
293
|
-
if (props.borderRight !== undefined) visual.borderRight
|
|
294
|
-
if (props.borderBottom !== undefined) visual.borderBottom
|
|
295
|
-
if (props.borderLeft !== undefined) visual.borderLeft
|
|
271
|
+
if (props.border !== undefined) visual.borderStyle.setSource(index, props.border)
|
|
272
|
+
if (props.borderTop !== undefined) visual.borderTop.setSource(index, props.borderTop)
|
|
273
|
+
if (props.borderRight !== undefined) visual.borderRight.setSource(index, props.borderRight)
|
|
274
|
+
if (props.borderBottom !== undefined) visual.borderBottom.setSource(index, props.borderBottom)
|
|
275
|
+
if (props.borderLeft !== undefined) visual.borderLeft.setSource(index, props.borderLeft)
|
|
296
276
|
|
|
297
277
|
// Render children with this box as parent context
|
|
298
278
|
if (props.children) {
|
|
@@ -305,8 +285,16 @@ export function box(props: BoxProps = {}): Cleanup {
|
|
|
305
285
|
}
|
|
306
286
|
|
|
307
287
|
// Cleanup function
|
|
308
|
-
|
|
288
|
+
const cleanup = () => {
|
|
309
289
|
cleanupKeyboardListeners(index) // Remove any focused key handlers
|
|
310
290
|
releaseIndex(index)
|
|
311
291
|
}
|
|
292
|
+
|
|
293
|
+
// Auto-register with active scope if one exists
|
|
294
|
+
const scope = getActiveScope()
|
|
295
|
+
if (scope) {
|
|
296
|
+
scope.cleanups.push(cleanup)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return cleanup
|
|
312
300
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Each Primitive
|
|
3
|
+
*
|
|
4
|
+
* Reactive list rendering. Creates components for each item in an array.
|
|
5
|
+
* When the array changes, components are automatically added/removed.
|
|
6
|
+
* Props inside components remain reactive through normal slot binding.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```ts
|
|
10
|
+
* each(() => items.value, (getItem, key) => {
|
|
11
|
+
* // key is STABLE (use for selection!)
|
|
12
|
+
* // getItem() returns current item value (reactive!)
|
|
13
|
+
* text({ content: () => getItem().name, id: `item-${key}` })
|
|
14
|
+
* }, { key: item => item.id })
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { signal, effect, effectScope, onScopeDispose, type WritableSignal } from '@rlabs-inc/signals'
|
|
19
|
+
import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
|
|
20
|
+
import type { Cleanup } from './types'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render a list of components reactively.
|
|
24
|
+
*
|
|
25
|
+
* Uses fine-grained reactivity: each item is stored in a signal.
|
|
26
|
+
* When items change, signals are updated (not recreated).
|
|
27
|
+
* Components read from signals via getter - truly reactive!
|
|
28
|
+
*
|
|
29
|
+
* @param itemsGetter - Getter that returns the items array
|
|
30
|
+
* @param renderFn - Receives: getItem() for reactive item access, key (stable ID)
|
|
31
|
+
* @param options.key - Function to get unique key for each item
|
|
32
|
+
*/
|
|
33
|
+
export function each<T>(
|
|
34
|
+
itemsGetter: () => T[],
|
|
35
|
+
renderFn: (getItem: () => T, key: string) => Cleanup,
|
|
36
|
+
options: { key: (item: T) => string }
|
|
37
|
+
): Cleanup {
|
|
38
|
+
const cleanups = new Map<string, Cleanup>()
|
|
39
|
+
const itemSignals = new Map<string, WritableSignal<T>>()
|
|
40
|
+
const parentIndex = getCurrentParentIndex()
|
|
41
|
+
const scope = effectScope()
|
|
42
|
+
|
|
43
|
+
scope.run(() => {
|
|
44
|
+
effect(() => {
|
|
45
|
+
const items = itemsGetter()
|
|
46
|
+
const currentKeys = new Set<string>()
|
|
47
|
+
|
|
48
|
+
pushParentContext(parentIndex)
|
|
49
|
+
try {
|
|
50
|
+
for (let i = 0; i < items.length; i++) {
|
|
51
|
+
const item = items[i]!
|
|
52
|
+
const key = options.key(item)
|
|
53
|
+
currentKeys.add(key)
|
|
54
|
+
|
|
55
|
+
if (!itemSignals.has(key)) {
|
|
56
|
+
// NEW item - create signal and component
|
|
57
|
+
const itemSignal = signal(item)
|
|
58
|
+
itemSignals.set(key, itemSignal)
|
|
59
|
+
cleanups.set(key, renderFn(() => itemSignal.value, key))
|
|
60
|
+
} else {
|
|
61
|
+
// EXISTING item - just update the signal (fine-grained!)
|
|
62
|
+
itemSignals.get(key)!.value = item
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
popParentContext()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cleanup removed items
|
|
70
|
+
for (const [key, cleanup] of cleanups) {
|
|
71
|
+
if (!currentKeys.has(key)) {
|
|
72
|
+
cleanup()
|
|
73
|
+
cleanups.delete(key)
|
|
74
|
+
itemSignals.delete(key)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
onScopeDispose(() => {
|
|
80
|
+
for (const cleanup of cleanups.values()) cleanup()
|
|
81
|
+
cleanups.clear()
|
|
82
|
+
itemSignals.clear()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return () => scope.stop()
|
|
87
|
+
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
export { box } from './box'
|
|
9
9
|
export { text } from './text'
|
|
10
|
+
export { each } from './each'
|
|
11
|
+
export { show } from './show'
|
|
12
|
+
export { when } from './when'
|
|
13
|
+
export { scoped, onCleanup, componentScope, cleanupCollector } from './scope'
|
|
14
|
+
export { useAnimation, AnimationFrames } from './animation'
|
|
10
15
|
|
|
11
16
|
// Types
|
|
12
17
|
export type { BoxProps, TextProps, Cleanup } from './types'
|
|
18
|
+
export type { ComponentScopeResult } from './scope'
|
|
19
|
+
export type { AnimationOptions } from './animation'
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Component Scope
|
|
3
|
+
*
|
|
4
|
+
* Automatic cleanup collection for component functions.
|
|
5
|
+
* box/text/effect automatically register with the active scope.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* function MyComponent(props): Cleanup {
|
|
10
|
+
* return scoped(() => {
|
|
11
|
+
* // Everything inside auto-registers cleanup
|
|
12
|
+
* box({ children: () => text({ content: 'Hello' }) })
|
|
13
|
+
* effect(() => console.log(count.value))
|
|
14
|
+
*
|
|
15
|
+
* // Manual cleanup for timers etc
|
|
16
|
+
* const timer = setInterval(() => tick(), 1000)
|
|
17
|
+
* onCleanup(() => clearInterval(timer))
|
|
18
|
+
* })
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { effectScope, onScopeDispose } from '@rlabs-inc/signals'
|
|
24
|
+
import type { EffectScope } from '@rlabs-inc/signals'
|
|
25
|
+
|
|
26
|
+
export type Cleanup = () => void
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// ACTIVE SCOPE TRACKING
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
interface ScopeContext {
|
|
33
|
+
cleanups: Cleanup[]
|
|
34
|
+
scope: EffectScope
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let activeContext: ScopeContext | null = null
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the currently active scope context.
|
|
41
|
+
* Used by box/text to auto-register cleanups.
|
|
42
|
+
*/
|
|
43
|
+
export function getActiveScope(): ScopeContext | null {
|
|
44
|
+
return activeContext
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register a cleanup with the active scope.
|
|
49
|
+
* Called automatically by box/text when a scope is active.
|
|
50
|
+
* Can also be called manually for timers/subscriptions.
|
|
51
|
+
*/
|
|
52
|
+
export function onCleanup(cleanup: Cleanup): void {
|
|
53
|
+
if (activeContext) {
|
|
54
|
+
activeContext.cleanups.push(cleanup)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a cleanup and return it (for chaining).
|
|
60
|
+
* Useful when you need the cleanup function reference.
|
|
61
|
+
*/
|
|
62
|
+
export function trackCleanup<T extends Cleanup>(cleanup: T): T {
|
|
63
|
+
if (activeContext) {
|
|
64
|
+
activeContext.cleanups.push(cleanup)
|
|
65
|
+
}
|
|
66
|
+
return cleanup
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// SCOPED EXECUTION
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Execute a function with automatic cleanup collection.
|
|
75
|
+
* box/text/effect calls inside automatically register their cleanups.
|
|
76
|
+
*
|
|
77
|
+
* @example Basic component
|
|
78
|
+
* ```ts
|
|
79
|
+
* function Counter(): Cleanup {
|
|
80
|
+
* return scoped(() => {
|
|
81
|
+
* const count = signal(0)
|
|
82
|
+
*
|
|
83
|
+
* box({
|
|
84
|
+
* children: () => {
|
|
85
|
+
* text({ content: () => `Count: ${count.value}` })
|
|
86
|
+
* }
|
|
87
|
+
* })
|
|
88
|
+
*
|
|
89
|
+
* effect(() => console.log('Count:', count.value))
|
|
90
|
+
* })
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* @example With manual cleanups
|
|
95
|
+
* ```ts
|
|
96
|
+
* function Timer(): Cleanup {
|
|
97
|
+
* return scoped(() => {
|
|
98
|
+
* const elapsed = signal(0)
|
|
99
|
+
*
|
|
100
|
+
* const interval = setInterval(() => elapsed.value++, 1000)
|
|
101
|
+
* onCleanup(() => clearInterval(interval))
|
|
102
|
+
*
|
|
103
|
+
* box({ children: () => text({ content: () => `${elapsed.value}s` }) })
|
|
104
|
+
* })
|
|
105
|
+
* }
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @example Nested components work too
|
|
109
|
+
* ```ts
|
|
110
|
+
* function Parent(): Cleanup {
|
|
111
|
+
* return scoped(() => {
|
|
112
|
+
* box({
|
|
113
|
+
* children: () => {
|
|
114
|
+
* Child() // Child's cleanup is tracked by parent
|
|
115
|
+
* }
|
|
116
|
+
* })
|
|
117
|
+
* })
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function scoped(fn: () => void): Cleanup {
|
|
122
|
+
const scope = effectScope()
|
|
123
|
+
const cleanups: Cleanup[] = []
|
|
124
|
+
|
|
125
|
+
const prevContext = activeContext
|
|
126
|
+
activeContext = { cleanups, scope }
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Run the function within effect scope
|
|
130
|
+
// Effects created inside are tracked by the scope
|
|
131
|
+
scope.run(fn)
|
|
132
|
+
} finally {
|
|
133
|
+
activeContext = prevContext
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Return master cleanup
|
|
137
|
+
return () => {
|
|
138
|
+
// Stop effect scope (disposes effects)
|
|
139
|
+
scope.stop()
|
|
140
|
+
|
|
141
|
+
// Run all registered cleanups
|
|
142
|
+
for (const cleanup of cleanups) {
|
|
143
|
+
try {
|
|
144
|
+
cleanup()
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// Cleanup errors logged but don't stop other cleanups
|
|
147
|
+
console.error('Cleanup error:', e)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// LEGACY API (for backwards compatibility)
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
export interface ComponentScopeResult {
|
|
158
|
+
onCleanup: <T extends Cleanup>(cleanup: T) => T
|
|
159
|
+
cleanup: Cleanup
|
|
160
|
+
scope: EffectScope
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create a component scope for manual cleanup collection.
|
|
165
|
+
* @deprecated Use scoped() for automatic cleanup instead
|
|
166
|
+
*/
|
|
167
|
+
export function componentScope(): ComponentScopeResult {
|
|
168
|
+
const cleanups: Cleanup[] = []
|
|
169
|
+
const scope = effectScope()
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
onCleanup: <T extends Cleanup>(cleanup: T): T => {
|
|
173
|
+
cleanups.push(cleanup)
|
|
174
|
+
return cleanup
|
|
175
|
+
},
|
|
176
|
+
cleanup: () => {
|
|
177
|
+
scope.stop()
|
|
178
|
+
for (const cleanup of cleanups) {
|
|
179
|
+
try {
|
|
180
|
+
cleanup()
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error('Cleanup error:', e)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
scope,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a simple cleanup collector.
|
|
192
|
+
* @deprecated Use scoped() for automatic cleanup instead
|
|
193
|
+
*/
|
|
194
|
+
export function cleanupCollector(): {
|
|
195
|
+
add: <T extends Cleanup>(cleanup: T) => T
|
|
196
|
+
cleanup: Cleanup
|
|
197
|
+
} {
|
|
198
|
+
const cleanups: Cleanup[] = []
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
add: <T extends Cleanup>(cleanup: T): T => {
|
|
202
|
+
cleanups.push(cleanup)
|
|
203
|
+
return cleanup
|
|
204
|
+
},
|
|
205
|
+
cleanup: () => {
|
|
206
|
+
for (const cleanup of cleanups) {
|
|
207
|
+
try {
|
|
208
|
+
cleanup()
|
|
209
|
+
} catch (e) {
|
|
210
|
+
console.error('Cleanup error:', e)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Show Primitive
|
|
3
|
+
*
|
|
4
|
+
* Conditional rendering. Shows or hides components based on a condition.
|
|
5
|
+
* When the condition changes, components are automatically created/destroyed.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* show(() => isVisible.value, () => {
|
|
10
|
+
* text({ content: 'I am visible!' })
|
|
11
|
+
* })
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
|
|
16
|
+
import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
|
|
17
|
+
import type { Cleanup } from './types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Conditionally render components.
|
|
21
|
+
*
|
|
22
|
+
* @param conditionGetter - Getter that returns boolean (creates dependency)
|
|
23
|
+
* @param renderFn - Function to render when condition is true (returns cleanup)
|
|
24
|
+
* @param elseFn - Optional function to render when condition is false
|
|
25
|
+
*/
|
|
26
|
+
export function show(
|
|
27
|
+
conditionGetter: () => boolean,
|
|
28
|
+
renderFn: () => Cleanup,
|
|
29
|
+
elseFn?: () => Cleanup
|
|
30
|
+
): Cleanup {
|
|
31
|
+
let cleanup: Cleanup | null = null
|
|
32
|
+
let wasTrue: boolean | null = null
|
|
33
|
+
const parentIndex = getCurrentParentIndex()
|
|
34
|
+
const scope = effectScope()
|
|
35
|
+
|
|
36
|
+
const update = (condition: boolean) => {
|
|
37
|
+
if (condition === wasTrue) return
|
|
38
|
+
wasTrue = condition
|
|
39
|
+
|
|
40
|
+
if (cleanup) {
|
|
41
|
+
cleanup()
|
|
42
|
+
cleanup = null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pushParentContext(parentIndex)
|
|
46
|
+
try {
|
|
47
|
+
if (condition) {
|
|
48
|
+
cleanup = renderFn()
|
|
49
|
+
} else if (elseFn) {
|
|
50
|
+
cleanup = elseFn()
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
popParentContext()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
scope.run(() => {
|
|
58
|
+
// Initial render
|
|
59
|
+
update(conditionGetter())
|
|
60
|
+
|
|
61
|
+
// Effect for updates - skip first run
|
|
62
|
+
let initialized = false
|
|
63
|
+
effect(() => {
|
|
64
|
+
const condition = conditionGetter()
|
|
65
|
+
if (initialized) {
|
|
66
|
+
update(condition)
|
|
67
|
+
}
|
|
68
|
+
initialized = true
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
onScopeDispose(() => {
|
|
72
|
+
if (cleanup) cleanup()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return () => scope.stop()
|
|
77
|
+
}
|