@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,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
|
+
}
|
package/src/primitives/text.ts
CHANGED
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// Using slotArray APIs - no direct signal imports needed
|
|
27
27
|
import { ComponentType, Attr } from '../types'
|
|
28
28
|
import { allocateIndex, releaseIndex, getCurrentParentIndex } from '../engine/registry'
|
|
29
29
|
import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
|
|
30
30
|
import { getVariantStyle } from '../state/theme'
|
|
31
|
+
import { getActiveScope } from './scope'
|
|
31
32
|
|
|
32
33
|
// Import arrays
|
|
33
34
|
import * as core from '../engine/arrays/core'
|
|
@@ -61,31 +62,33 @@ function wrapToNum(wrap: string | undefined): number {
|
|
|
61
62
|
default: return 1 // wrap
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
/** Override alignItems for this item: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end */
|
|
66
|
+
function alignSelfToNum(alignSelf: string | undefined): number {
|
|
67
|
+
switch (alignSelf) {
|
|
68
|
+
case 'auto': return 0
|
|
69
|
+
case 'stretch': return 1
|
|
70
|
+
case 'flex-start': return 2
|
|
71
|
+
case 'center': return 3
|
|
72
|
+
case 'flex-end': return 4
|
|
73
|
+
default: return 0
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// bindEnumProp removed - using enumSource instead
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
|
-
* Create a
|
|
67
|
-
*
|
|
68
|
-
* This creates dependency directly on user's signal, no intermediate objects.
|
|
80
|
+
* Create a slot source for enum props - returns getter for reactive, value for static.
|
|
81
|
+
* For use with slotArray.setSource()
|
|
69
82
|
*/
|
|
70
|
-
function
|
|
83
|
+
function enumSource<T extends string>(
|
|
71
84
|
prop: T | { value: T } | undefined,
|
|
72
85
|
converter: (val: T | undefined) => number
|
|
73
|
-
):
|
|
74
|
-
// If it's reactive (has .value), create binding that converts at read time
|
|
86
|
+
): number | (() => number) {
|
|
75
87
|
if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
|
|
76
88
|
const reactiveSource = prop as { value: T }
|
|
77
|
-
return
|
|
78
|
-
[BINDING_SYMBOL]: true,
|
|
79
|
-
get value(): number {
|
|
80
|
-
return converter(reactiveSource.value)
|
|
81
|
-
},
|
|
82
|
-
set value(_: number) {
|
|
83
|
-
// Enum props are read-only from number side
|
|
84
|
-
},
|
|
85
|
-
} as unknown as ReturnType<typeof bind<number>>
|
|
89
|
+
return () => converter(reactiveSource.value)
|
|
86
90
|
}
|
|
87
|
-
|
|
88
|
-
return bind(converter(prop as T | undefined))
|
|
91
|
+
return converter(prop as T | undefined)
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
// =============================================================================
|
|
@@ -110,55 +113,56 @@ export function text(props: TextProps): Cleanup {
|
|
|
110
113
|
// CORE - Always needed
|
|
111
114
|
// ==========================================================================
|
|
112
115
|
core.componentType[index] = ComponentType.TEXT
|
|
113
|
-
core.parentIndex
|
|
116
|
+
core.parentIndex.setSource(index, getCurrentParentIndex())
|
|
114
117
|
|
|
115
118
|
// Visible - only bind if passed
|
|
116
119
|
if (props.visible !== undefined) {
|
|
117
|
-
core.visible
|
|
120
|
+
core.visible.setSource(index, props.visible)
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
// ==========================================================================
|
|
121
124
|
// TEXT CONTENT - Always needed (this is a text component!)
|
|
125
|
+
// Uses setSource() for stable slot tracking (fixes bind() replacement bug)
|
|
122
126
|
// ==========================================================================
|
|
123
|
-
textArrays.textContent
|
|
127
|
+
textArrays.textContent.setSource(index, props.content)
|
|
124
128
|
|
|
125
|
-
// Text styling - only
|
|
126
|
-
if (props.attrs !== undefined) textArrays.textAttrs
|
|
127
|
-
if (props.align !== undefined) textArrays.textAlign
|
|
128
|
-
if (props.wrap !== undefined) textArrays.textWrap
|
|
129
|
+
// Text styling - only set if passed
|
|
130
|
+
if (props.attrs !== undefined) textArrays.textAttrs.setSource(index, props.attrs)
|
|
131
|
+
if (props.align !== undefined) textArrays.textAlign.setSource(index, enumSource(props.align, alignToNum))
|
|
132
|
+
if (props.wrap !== undefined) textArrays.textWrap.setSource(index, enumSource(props.wrap, wrapToNum))
|
|
129
133
|
|
|
130
134
|
// ==========================================================================
|
|
131
135
|
// DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
|
|
132
136
|
// ==========================================================================
|
|
133
|
-
if (props.width !== undefined) dimensions.width
|
|
134
|
-
if (props.height !== undefined) dimensions.height
|
|
135
|
-
if (props.minWidth !== undefined) dimensions.minWidth
|
|
136
|
-
if (props.maxWidth !== undefined) dimensions.maxWidth
|
|
137
|
-
if (props.minHeight !== undefined) dimensions.minHeight
|
|
138
|
-
if (props.maxHeight !== undefined) dimensions.maxHeight
|
|
137
|
+
if (props.width !== undefined) dimensions.width.setSource(index, props.width)
|
|
138
|
+
if (props.height !== undefined) dimensions.height.setSource(index, props.height)
|
|
139
|
+
if (props.minWidth !== undefined) dimensions.minWidth.setSource(index, props.minWidth)
|
|
140
|
+
if (props.maxWidth !== undefined) dimensions.maxWidth.setSource(index, props.maxWidth)
|
|
141
|
+
if (props.minHeight !== undefined) dimensions.minHeight.setSource(index, props.minHeight)
|
|
142
|
+
if (props.maxHeight !== undefined) dimensions.maxHeight.setSource(index, props.maxHeight)
|
|
139
143
|
|
|
140
144
|
// ==========================================================================
|
|
141
145
|
// PADDING - Shorthand support
|
|
142
146
|
// ==========================================================================
|
|
143
147
|
if (props.padding !== undefined) {
|
|
144
|
-
spacing.paddingTop
|
|
145
|
-
spacing.paddingRight
|
|
146
|
-
spacing.paddingBottom
|
|
147
|
-
spacing.paddingLeft
|
|
148
|
+
spacing.paddingTop.setSource(index, props.paddingTop ?? props.padding)
|
|
149
|
+
spacing.paddingRight.setSource(index, props.paddingRight ?? props.padding)
|
|
150
|
+
spacing.paddingBottom.setSource(index, props.paddingBottom ?? props.padding)
|
|
151
|
+
spacing.paddingLeft.setSource(index, props.paddingLeft ?? props.padding)
|
|
148
152
|
} else {
|
|
149
|
-
if (props.paddingTop !== undefined) spacing.paddingTop
|
|
150
|
-
if (props.paddingRight !== undefined) spacing.paddingRight
|
|
151
|
-
if (props.paddingBottom !== undefined) spacing.paddingBottom
|
|
152
|
-
if (props.paddingLeft !== undefined) spacing.paddingLeft
|
|
153
|
+
if (props.paddingTop !== undefined) spacing.paddingTop.setSource(index, props.paddingTop)
|
|
154
|
+
if (props.paddingRight !== undefined) spacing.paddingRight.setSource(index, props.paddingRight)
|
|
155
|
+
if (props.paddingBottom !== undefined) spacing.paddingBottom.setSource(index, props.paddingBottom)
|
|
156
|
+
if (props.paddingLeft !== undefined) spacing.paddingLeft.setSource(index, props.paddingLeft)
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
// ==========================================================================
|
|
156
160
|
// FLEX ITEM - Only bind if passed (text can be a flex item)
|
|
157
161
|
// ==========================================================================
|
|
158
|
-
if (props.grow !== undefined) layout.flexGrow
|
|
159
|
-
if (props.shrink !== undefined) layout.flexShrink
|
|
160
|
-
if (props.flexBasis !== undefined) layout.flexBasis
|
|
161
|
-
if (props.alignSelf !== undefined) layout.alignSelf
|
|
162
|
+
if (props.grow !== undefined) layout.flexGrow.setSource(index, props.grow)
|
|
163
|
+
if (props.shrink !== undefined) layout.flexShrink.setSource(index, props.shrink)
|
|
164
|
+
if (props.flexBasis !== undefined) layout.flexBasis.setSource(index, props.flexBasis)
|
|
165
|
+
if (props.alignSelf !== undefined) layout.alignSelf.setSource(index, enumSource(props.alignSelf, alignSelfToNum))
|
|
162
166
|
|
|
163
167
|
// ==========================================================================
|
|
164
168
|
// VISUAL - Colors with variant support (only bind what's needed)
|
|
@@ -167,33 +171,33 @@ export function text(props: TextProps): Cleanup {
|
|
|
167
171
|
// Variant colors - inline bindings that read theme at read time (no deriveds!)
|
|
168
172
|
const variant = props.variant
|
|
169
173
|
if (props.fg !== undefined) {
|
|
170
|
-
visual.fgColor
|
|
174
|
+
visual.fgColor.setSource(index, props.fg)
|
|
171
175
|
} else {
|
|
172
|
-
visual.fgColor
|
|
173
|
-
[BINDING_SYMBOL]: true,
|
|
174
|
-
get value() { return getVariantStyle(variant).fg },
|
|
175
|
-
set value(_) {},
|
|
176
|
-
} as any
|
|
176
|
+
visual.fgColor.setSource(index, () => getVariantStyle(variant).fg)
|
|
177
177
|
}
|
|
178
178
|
if (props.bg !== undefined) {
|
|
179
|
-
visual.bgColor
|
|
179
|
+
visual.bgColor.setSource(index, props.bg)
|
|
180
180
|
} else {
|
|
181
|
-
visual.bgColor
|
|
182
|
-
[BINDING_SYMBOL]: true,
|
|
183
|
-
get value() { return getVariantStyle(variant).bg },
|
|
184
|
-
set value(_) {},
|
|
185
|
-
} as any
|
|
181
|
+
visual.bgColor.setSource(index, () => getVariantStyle(variant).bg)
|
|
186
182
|
}
|
|
187
183
|
} else {
|
|
188
184
|
// Direct colors - only bind if passed
|
|
189
|
-
if (props.fg !== undefined) visual.fgColor
|
|
190
|
-
if (props.bg !== undefined) visual.bgColor
|
|
185
|
+
if (props.fg !== undefined) visual.fgColor.setSource(index, props.fg)
|
|
186
|
+
if (props.bg !== undefined) visual.bgColor.setSource(index, props.bg)
|
|
191
187
|
}
|
|
192
|
-
if (props.opacity !== undefined) visual.opacity
|
|
188
|
+
if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
|
|
193
189
|
|
|
194
190
|
// Cleanup function
|
|
195
|
-
|
|
191
|
+
const cleanup = () => {
|
|
196
192
|
cleanupKeyboardListeners(index)
|
|
197
193
|
releaseIndex(index)
|
|
198
194
|
}
|
|
195
|
+
|
|
196
|
+
// Auto-register with active scope if one exists
|
|
197
|
+
const scope = getActiveScope()
|
|
198
|
+
if (scope) {
|
|
199
|
+
scope.cleanups.push(cleanup)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return cleanup
|
|
199
203
|
}
|
package/src/primitives/types.ts
CHANGED
|
@@ -137,7 +137,7 @@ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, Spaci
|
|
|
137
137
|
// TEXT PROPS
|
|
138
138
|
// =============================================================================
|
|
139
139
|
|
|
140
|
-
export interface TextProps extends StyleProps, DimensionProps, SpacingProps {
|
|
140
|
+
export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps {
|
|
141
141
|
/** Text content */
|
|
142
142
|
content: Reactive<string>
|
|
143
143
|
/** Text alignment: 'left' | 'center' | 'right' */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - When Primitive
|
|
3
|
+
*
|
|
4
|
+
* Async rendering. Shows loading/error/success states based on promise state.
|
|
5
|
+
* When the promise resolves or rejects, the appropriate components are shown.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* when(() => fetchData(), {
|
|
10
|
+
* pending: () => text({ content: 'Loading...' }),
|
|
11
|
+
* then: (data) => text({ content: `Got: ${data}` }),
|
|
12
|
+
* catch: (err) => text({ content: `Error: ${err.message}` })
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
|
|
18
|
+
import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
|
|
19
|
+
import type { Cleanup } from './types'
|
|
20
|
+
|
|
21
|
+
interface WhenOptions<T> {
|
|
22
|
+
pending?: () => Cleanup
|
|
23
|
+
then: (value: T) => Cleanup
|
|
24
|
+
catch?: (error: Error) => Cleanup
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render based on async promise state.
|
|
29
|
+
*
|
|
30
|
+
* @param promiseGetter - Getter that returns a promise (creates dependency)
|
|
31
|
+
* @param options - Handlers for pending, then, and catch states
|
|
32
|
+
*/
|
|
33
|
+
export function when<T>(
|
|
34
|
+
promiseGetter: () => Promise<T>,
|
|
35
|
+
options: WhenOptions<T>
|
|
36
|
+
): Cleanup {
|
|
37
|
+
let cleanup: Cleanup | null = null
|
|
38
|
+
let currentPromise: Promise<T> | null = null
|
|
39
|
+
const parentIndex = getCurrentParentIndex()
|
|
40
|
+
const scope = effectScope()
|
|
41
|
+
|
|
42
|
+
const render = (fn: () => Cleanup) => {
|
|
43
|
+
if (cleanup) {
|
|
44
|
+
cleanup()
|
|
45
|
+
cleanup = null
|
|
46
|
+
}
|
|
47
|
+
pushParentContext(parentIndex)
|
|
48
|
+
try {
|
|
49
|
+
cleanup = fn()
|
|
50
|
+
} finally {
|
|
51
|
+
popParentContext()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handlePromise = (promise: Promise<T>) => {
|
|
56
|
+
if (promise !== currentPromise) return
|
|
57
|
+
|
|
58
|
+
promise
|
|
59
|
+
.then((value) => {
|
|
60
|
+
if (promise !== currentPromise) return
|
|
61
|
+
render(() => options.then(value))
|
|
62
|
+
})
|
|
63
|
+
.catch((error) => {
|
|
64
|
+
if (promise !== currentPromise) return
|
|
65
|
+
if (options.catch) {
|
|
66
|
+
render(() => options.catch!(error))
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
scope.run(() => {
|
|
72
|
+
// Initial setup
|
|
73
|
+
const initialPromise = promiseGetter()
|
|
74
|
+
currentPromise = initialPromise
|
|
75
|
+
if (options.pending) {
|
|
76
|
+
render(options.pending)
|
|
77
|
+
}
|
|
78
|
+
handlePromise(initialPromise)
|
|
79
|
+
|
|
80
|
+
// Effect for updates - skip first run
|
|
81
|
+
let initialized = false
|
|
82
|
+
effect(() => {
|
|
83
|
+
const promise = promiseGetter()
|
|
84
|
+
if (initialized) {
|
|
85
|
+
if (promise === currentPromise) return
|
|
86
|
+
currentPromise = promise
|
|
87
|
+
if (options.pending) {
|
|
88
|
+
render(options.pending)
|
|
89
|
+
}
|
|
90
|
+
handlePromise(promise)
|
|
91
|
+
}
|
|
92
|
+
initialized = true
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
onScopeDispose(() => {
|
|
96
|
+
currentPromise = null
|
|
97
|
+
if (cleanup) cleanup()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return () => scope.stop()
|
|
102
|
+
}
|