@rlabs-inc/tui 0.1.0 → 0.2.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.
Files changed (38) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/mount.ts +42 -27
  5. package/src/engine/arrays/core.ts +13 -21
  6. package/src/engine/arrays/dimensions.ts +22 -32
  7. package/src/engine/arrays/index.ts +88 -86
  8. package/src/engine/arrays/interaction.ts +34 -48
  9. package/src/engine/arrays/layout.ts +67 -92
  10. package/src/engine/arrays/spacing.ts +37 -52
  11. package/src/engine/arrays/text.ts +23 -31
  12. package/src/engine/arrays/visual.ts +56 -75
  13. package/src/engine/inheritance.ts +18 -18
  14. package/src/engine/registry.ts +15 -0
  15. package/src/pipeline/frameBuffer.ts +26 -26
  16. package/src/pipeline/layout/index.ts +2 -2
  17. package/src/pipeline/layout/titan-engine.ts +112 -84
  18. package/src/primitives/animation.ts +194 -0
  19. package/src/primitives/box.ts +74 -86
  20. package/src/primitives/each.ts +87 -0
  21. package/src/primitives/index.ts +7 -0
  22. package/src/primitives/scope.ts +215 -0
  23. package/src/primitives/show.ts +77 -0
  24. package/src/primitives/text.ts +63 -59
  25. package/src/primitives/types.ts +1 -1
  26. package/src/primitives/when.ts +102 -0
  27. package/src/renderer/append-region.ts +303 -0
  28. package/src/renderer/index.ts +4 -2
  29. package/src/renderer/output.ts +11 -34
  30. package/src/state/focus.ts +16 -5
  31. package/src/state/global-keys.ts +184 -0
  32. package/src/state/index.ts +44 -8
  33. package/src/state/input.ts +534 -0
  34. package/src/state/keyboard.ts +98 -674
  35. package/src/state/mouse.ts +163 -340
  36. package/src/state/scroll.ts +7 -9
  37. package/src/types/index.ts +6 -0
  38. package/src/renderer/input.ts +0 -518
@@ -0,0 +1,194 @@
1
+ /**
2
+ * TUI Framework - Animation Primitives
3
+ *
4
+ * Reusable animation utilities for spinners, progress indicators, etc.
5
+ * Handles frame cycling with proper lifecycle management.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10
+ *
11
+ * function LoadingSpinner(props): Cleanup {
12
+ * return scoped(() => {
13
+ * const frame = useAnimation(SPINNER, {
14
+ * fps: 12,
15
+ * active: () => props.loading.value,
16
+ * })
17
+ *
18
+ * text({ content: frame, fg: t.warning })
19
+ * })
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { signal, derived, effect } from '@rlabs-inc/signals'
25
+ import type { DerivedSignal } from '@rlabs-inc/signals'
26
+ import { onCleanup, getActiveScope } from './scope'
27
+
28
+ export interface AnimationOptions {
29
+ /** Frames per second (default: 12) */
30
+ fps?: number
31
+ /** Whether animation is active - can be reactive */
32
+ active?: boolean | (() => boolean) | { readonly value: boolean }
33
+ /** Start at a specific frame index (default: 0) */
34
+ startFrame?: number
35
+ }
36
+
37
+ // Global animation registry - shared intervals for same FPS
38
+ interface AnimationRegistry {
39
+ frameIndex: ReturnType<typeof signal<number>>
40
+ interval: ReturnType<typeof setInterval> | null
41
+ subscribers: number
42
+ }
43
+
44
+ const animationRegistry = new Map<number, AnimationRegistry>()
45
+
46
+ /**
47
+ * Get or create a shared animation clock for the given FPS.
48
+ */
49
+ function getAnimationClock(fps: number): AnimationRegistry {
50
+ let registry = animationRegistry.get(fps)
51
+
52
+ if (!registry) {
53
+ registry = {
54
+ frameIndex: signal(0),
55
+ interval: null,
56
+ subscribers: 0,
57
+ }
58
+ animationRegistry.set(fps, registry)
59
+ }
60
+
61
+ return registry
62
+ }
63
+
64
+ /**
65
+ * Subscribe to an animation clock.
66
+ */
67
+ function subscribeToAnimation(fps: number): () => void {
68
+ const registry = getAnimationClock(fps)
69
+ registry.subscribers++
70
+
71
+ // Start interval if this is the first subscriber
72
+ if (registry.subscribers === 1 && !registry.interval) {
73
+ const ms = Math.floor(1000 / fps)
74
+ registry.interval = setInterval(() => {
75
+ registry.frameIndex.value++
76
+ }, ms)
77
+ }
78
+
79
+ // Return unsubscribe function
80
+ return () => {
81
+ registry.subscribers = Math.max(0, registry.subscribers - 1)
82
+
83
+ // Stop interval if no more subscribers
84
+ if (registry.subscribers === 0 && registry.interval) {
85
+ clearInterval(registry.interval)
86
+ registry.interval = null
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create an animated signal that cycles through frames.
93
+ *
94
+ * @example Basic spinner
95
+ * ```ts
96
+ * const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
97
+ * const frame = useAnimation(SPINNER)
98
+ * text({ content: frame }) // Animates automatically
99
+ * ```
100
+ *
101
+ * @example Conditional animation
102
+ * ```ts
103
+ * const frame = useAnimation(SPINNER, {
104
+ * fps: 12,
105
+ * active: () => isLoading.value,
106
+ * })
107
+ * // Animation only runs when isLoading is true
108
+ * ```
109
+ *
110
+ * @example With scope (auto-cleanup)
111
+ * ```ts
112
+ * function Spinner(): Cleanup {
113
+ * return scoped(() => {
114
+ * const frame = useAnimation(SPINNER, { active: () => loading.value })
115
+ * text({ content: frame, fg: t.warning })
116
+ * })
117
+ * }
118
+ * ```
119
+ */
120
+ export function useAnimation<T>(
121
+ frames: readonly T[],
122
+ options: AnimationOptions = {}
123
+ ): DerivedSignal<T> {
124
+ const { fps = 12, active = true, startFrame = 0 } = options
125
+
126
+ // Get shared animation clock
127
+ const clock = getAnimationClock(fps)
128
+
129
+ // Track whether we're subscribed
130
+ let unsubscribe: (() => void) | null = null
131
+
132
+ // Unwrap active prop
133
+ const isActive = (): boolean => {
134
+ if (typeof active === 'function') return active()
135
+ if (typeof active === 'object' && 'value' in active) return active.value
136
+ return active
137
+ }
138
+
139
+ // Effect to manage subscription based on active state
140
+ const stopEffect = effect(() => {
141
+ const shouldBeActive = isActive()
142
+
143
+ if (shouldBeActive && !unsubscribe) {
144
+ unsubscribe = subscribeToAnimation(fps)
145
+ } else if (!shouldBeActive && unsubscribe) {
146
+ unsubscribe()
147
+ unsubscribe = null
148
+ }
149
+ })
150
+
151
+ // Auto-register cleanup with active scope
152
+ const scope = getActiveScope()
153
+ if (scope) {
154
+ scope.cleanups.push(() => {
155
+ stopEffect()
156
+ if (unsubscribe) {
157
+ unsubscribe()
158
+ unsubscribe = null
159
+ }
160
+ })
161
+ }
162
+
163
+ // Return derived that computes current frame
164
+ return derived(() => {
165
+ const index = (clock.frameIndex.value + startFrame) % frames.length
166
+ return frames[index]!
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Common animation frame sets.
172
+ */
173
+ export const AnimationFrames = {
174
+ /** Braille spinner (smooth) */
175
+ spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const,
176
+
177
+ /** Braille dots (vertical) */
178
+ dots: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const,
179
+
180
+ /** Simple line spinner */
181
+ line: ['|', '/', '-', '\\'] as const,
182
+
183
+ /** Growing bar */
184
+ bar: ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] as const,
185
+
186
+ /** Bouncing ball */
187
+ bounce: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'] as const,
188
+
189
+ /** Clock */
190
+ clock: ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛'] as const,
191
+
192
+ /** Pulse */
193
+ pulse: ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'] as const,
194
+ } as const
@@ -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'