@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.
Files changed (39) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/history.ts +451 -0
  5. package/src/api/mount.ts +66 -31
  6. package/src/engine/arrays/core.ts +13 -21
  7. package/src/engine/arrays/dimensions.ts +22 -32
  8. package/src/engine/arrays/index.ts +88 -86
  9. package/src/engine/arrays/interaction.ts +34 -48
  10. package/src/engine/arrays/layout.ts +67 -92
  11. package/src/engine/arrays/spacing.ts +37 -52
  12. package/src/engine/arrays/text.ts +23 -31
  13. package/src/engine/arrays/visual.ts +56 -75
  14. package/src/engine/inheritance.ts +18 -18
  15. package/src/engine/registry.ts +15 -0
  16. package/src/pipeline/frameBuffer.ts +26 -26
  17. package/src/pipeline/layout/index.ts +2 -2
  18. package/src/pipeline/layout/titan-engine.ts +112 -84
  19. package/src/primitives/animation.ts +194 -0
  20. package/src/primitives/box.ts +74 -86
  21. package/src/primitives/each.ts +87 -0
  22. package/src/primitives/index.ts +7 -0
  23. package/src/primitives/scope.ts +215 -0
  24. package/src/primitives/show.ts +77 -0
  25. package/src/primitives/text.ts +63 -59
  26. package/src/primitives/types.ts +1 -1
  27. package/src/primitives/when.ts +102 -0
  28. package/src/renderer/append-region.ts +159 -0
  29. package/src/renderer/index.ts +4 -2
  30. package/src/renderer/output.ts +11 -34
  31. package/src/state/focus.ts +16 -5
  32. package/src/state/global-keys.ts +184 -0
  33. package/src/state/index.ts +44 -8
  34. package/src/state/input.ts +534 -0
  35. package/src/state/keyboard.ts +98 -674
  36. package/src/state/mouse.ts +163 -340
  37. package/src/state/scroll.ts +7 -9
  38. package/src/types/index.ts +23 -2
  39. package/src/renderer/input.ts +0 -518
@@ -21,7 +21,7 @@
21
21
  * ```
22
22
  */
23
23
 
24
- import { bind, BINDING_SYMBOL } from '@rlabs-inc/signals'
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 binding for enum props that converts at read time.
117
- * No derived needed - reads signal directly and converts inline.
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 bindEnumProp<T extends string>(
120
+ function enumSource<T extends string>(
121
121
  prop: T | { value: T } | undefined,
122
122
  converter: (val: T | undefined) => number
123
- ): ReturnType<typeof bind<number>> {
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
- // Static value - just convert and bind
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[index] = bind(getCurrentParentIndex())
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[index] = bind(props.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[index] = bind(props.width)
184
- if (props.height !== undefined) dimensions.height[index] = bind(props.height)
185
- if (props.minWidth !== undefined) dimensions.minWidth[index] = bind(props.minWidth)
186
- if (props.maxWidth !== undefined) dimensions.maxWidth[index] = bind(props.maxWidth)
187
- if (props.minHeight !== undefined) dimensions.minHeight[index] = bind(props.minHeight)
188
- if (props.maxHeight !== undefined) dimensions.maxHeight[index] = bind(props.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[index] = bind(props.paddingTop ?? props.padding)
196
- spacing.paddingRight[index] = bind(props.paddingRight ?? props.padding)
197
- spacing.paddingBottom[index] = bind(props.paddingBottom ?? props.padding)
198
- spacing.paddingLeft[index] = bind(props.paddingLeft ?? props.padding)
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[index] = bind(props.paddingTop)
202
- if (props.paddingRight !== undefined) spacing.paddingRight[index] = bind(props.paddingRight)
203
- if (props.paddingBottom !== undefined) spacing.paddingBottom[index] = bind(props.paddingBottom)
204
- if (props.paddingLeft !== undefined) spacing.paddingLeft[index] = bind(props.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[index] = bind(props.marginTop ?? props.margin)
213
- spacing.marginRight[index] = bind(props.marginRight ?? props.margin)
214
- spacing.marginBottom[index] = bind(props.marginBottom ?? props.margin)
215
- spacing.marginLeft[index] = bind(props.marginLeft ?? props.margin)
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[index] = bind(props.marginTop)
219
- if (props.marginRight !== undefined) spacing.marginRight[index] = bind(props.marginRight)
220
- if (props.marginBottom !== undefined) spacing.marginBottom[index] = bind(props.marginBottom)
221
- if (props.marginLeft !== undefined) spacing.marginLeft[index] = bind(props.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[index] = bind(props.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[index] = bindEnumProp(props.flexDirection, flexDirectionToNum)
231
- if (props.flexWrap !== undefined) layout.flexWrap[index] = bindEnumProp(props.flexWrap, flexWrapToNum)
232
- if (props.justifyContent !== undefined) layout.justifyContent[index] = bindEnumProp(props.justifyContent, justifyToNum)
233
- if (props.alignItems !== undefined) layout.alignItems[index] = bindEnumProp(props.alignItems, alignToNum)
234
- if (props.overflow !== undefined) layout.overflow[index] = bindEnumProp(props.overflow, overflowToNum)
235
- if (props.grow !== undefined) layout.flexGrow[index] = bind(props.grow)
236
- if (props.shrink !== undefined) layout.flexShrink[index] = bind(props.shrink)
237
- if (props.flexBasis !== undefined) layout.flexBasis[index] = bind(props.flexBasis)
238
- if (props.zIndex !== undefined) layout.zIndex[index] = bind(props.zIndex)
239
- if (props.alignSelf !== undefined) layout.alignSelf[index] = bindEnumProp(props.alignSelf, alignSelfToNum)
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 - Only bind if focusable
232
+ // INTERACTION - Focusable handling
233
+ // Auto-focusable for overflow:'scroll' (unless explicitly disabled)
243
234
  // ==========================================================================
244
- if (props.focusable) {
245
- interaction.focusable[index] = bind(1)
246
- if (props.tabIndex !== undefined) interaction.tabIndex[index] = bind(props.tabIndex)
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 - inline bindings that read theme at read time (no deriveds!)
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[index] = bind(props.fg)
248
+ visual.fgColor.setSource(index, props.fg)
257
249
  } else {
258
- visual.fgColor[index] = {
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[index] = bind(props.bg)
253
+ visual.bgColor.setSource(index, props.bg)
266
254
  } else {
267
- visual.bgColor[index] = {
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[index] = bind(props.borderColor)
258
+ visual.borderColor.setSource(index, props.borderColor)
275
259
  } else {
276
- visual.borderColor[index] = {
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[index] = bind(props.fg)
285
- if (props.bg !== undefined) visual.bgColor[index] = bind(props.bg)
286
- if (props.borderColor !== undefined) visual.borderColor[index] = bind(props.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[index] = bind(props.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[index] = bind(props.border)
292
- if (props.borderTop !== undefined) visual.borderTop[index] = bind(props.borderTop)
293
- if (props.borderRight !== undefined) visual.borderRight[index] = bind(props.borderRight)
294
- if (props.borderBottom !== undefined) visual.borderBottom[index] = bind(props.borderBottom)
295
- if (props.borderLeft !== undefined) visual.borderLeft[index] = bind(props.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
- return () => {
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
+ }
@@ -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
+ }