@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.
- package/README.md +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/mount.ts +42 -27
- 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 +303 -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 +6 -0
- 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
|
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'
|