@rlabs-inc/tui 0.1.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 (44) hide show
  1. package/README.md +141 -0
  2. package/index.ts +45 -0
  3. package/package.json +59 -0
  4. package/src/api/index.ts +7 -0
  5. package/src/api/mount.ts +230 -0
  6. package/src/engine/arrays/core.ts +60 -0
  7. package/src/engine/arrays/dimensions.ts +68 -0
  8. package/src/engine/arrays/index.ts +166 -0
  9. package/src/engine/arrays/interaction.ts +112 -0
  10. package/src/engine/arrays/layout.ts +175 -0
  11. package/src/engine/arrays/spacing.ts +100 -0
  12. package/src/engine/arrays/text.ts +55 -0
  13. package/src/engine/arrays/visual.ts +140 -0
  14. package/src/engine/index.ts +25 -0
  15. package/src/engine/inheritance.ts +138 -0
  16. package/src/engine/registry.ts +180 -0
  17. package/src/pipeline/frameBuffer.ts +473 -0
  18. package/src/pipeline/layout/index.ts +105 -0
  19. package/src/pipeline/layout/titan-engine.ts +798 -0
  20. package/src/pipeline/layout/types.ts +194 -0
  21. package/src/pipeline/layout/utils/hierarchy.ts +202 -0
  22. package/src/pipeline/layout/utils/math.ts +134 -0
  23. package/src/pipeline/layout/utils/text-measure.ts +160 -0
  24. package/src/pipeline/layout.ts +30 -0
  25. package/src/primitives/box.ts +312 -0
  26. package/src/primitives/index.ts +12 -0
  27. package/src/primitives/text.ts +199 -0
  28. package/src/primitives/types.ts +222 -0
  29. package/src/primitives/utils.ts +37 -0
  30. package/src/renderer/ansi.ts +625 -0
  31. package/src/renderer/buffer.ts +667 -0
  32. package/src/renderer/index.ts +40 -0
  33. package/src/renderer/input.ts +518 -0
  34. package/src/renderer/output.ts +451 -0
  35. package/src/state/cursor.ts +176 -0
  36. package/src/state/focus.ts +241 -0
  37. package/src/state/index.ts +43 -0
  38. package/src/state/keyboard.ts +771 -0
  39. package/src/state/mouse.ts +524 -0
  40. package/src/state/scroll.ts +341 -0
  41. package/src/state/theme.ts +687 -0
  42. package/src/types/color.ts +401 -0
  43. package/src/types/index.ts +316 -0
  44. package/src/utils/text.ts +471 -0
@@ -0,0 +1,30 @@
1
+ /**
2
+ * TUI Framework - Layout Module
3
+ *
4
+ * RE-EXPORTS from the new terminal-native layout engine.
5
+ *
6
+ * The Yoga-based implementation has been replaced with our custom engine:
7
+ * - O(n) flat array iteration (no recursion)
8
+ * - 1000+ nested levels without breaking a sweat
9
+ * - Integer-only math for terminal cells
10
+ * - Full Flexbox + positioning support
11
+ *
12
+ * See src/pipeline/layout/ for the implementation.
13
+ */
14
+
15
+ // Re-export everything from the new layout module
16
+ export {
17
+ layoutDerived,
18
+ terminalWidth,
19
+ terminalHeight,
20
+ updateTerminalSize,
21
+ renderMode,
22
+ type ComputedLayout,
23
+ FlexDirection,
24
+ FlexWrap,
25
+ JustifyContent,
26
+ AlignItems,
27
+ Position,
28
+ Display,
29
+ Overflow,
30
+ } from './layout/index'
@@ -0,0 +1,312 @@
1
+ /**
2
+ * TUI Framework - Box Primitive
3
+ *
4
+ * Container component with flexbox layout, borders, and background.
5
+ * Children inherit parent context automatically.
6
+ *
7
+ * REACTIVITY: Props are passed directly to bind() to preserve reactive links.
8
+ * Don't extract values before binding - that breaks the connection!
9
+ *
10
+ * Usage:
11
+ * ```ts
12
+ * const width = signal(40)
13
+ * box({
14
+ * width, // Reactive! Changes to width.value update the UI
15
+ * height: 10, // Static
16
+ * border: 1,
17
+ * children: () => {
18
+ * text({ content: 'Hello!' })
19
+ * }
20
+ * })
21
+ * ```
22
+ */
23
+
24
+ import { bind, BINDING_SYMBOL } from '@rlabs-inc/signals'
25
+ import { ComponentType } from '../types'
26
+ import {
27
+ allocateIndex,
28
+ releaseIndex,
29
+ getCurrentParentIndex,
30
+ pushParentContext,
31
+ popParentContext,
32
+ } from '../engine/registry'
33
+ import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
34
+ import { getVariantStyle } from '../state/theme'
35
+
36
+ // Import arrays
37
+ import * as core from '../engine/arrays/core'
38
+ import * as dimensions from '../engine/arrays/dimensions'
39
+ import * as spacing from '../engine/arrays/spacing'
40
+ import * as layout from '../engine/arrays/layout'
41
+ import * as visual from '../engine/arrays/visual'
42
+ import * as interaction from '../engine/arrays/interaction'
43
+
44
+ // Import types
45
+ import type { BoxProps, Cleanup } from './types'
46
+
47
+ // =============================================================================
48
+ // HELPERS - For enum conversions only, not for extracting values!
49
+ // =============================================================================
50
+
51
+ /** Convert flex direction string to number */
52
+ function flexDirectionToNum(dir: string | undefined): number {
53
+ switch (dir) {
54
+ case 'row': return 1
55
+ case 'column-reverse': return 2
56
+ case 'row-reverse': return 3
57
+ default: return 0 // column
58
+ }
59
+ }
60
+
61
+ /** Convert flex wrap string to number */
62
+ function flexWrapToNum(wrap: string | undefined): number {
63
+ switch (wrap) {
64
+ case 'wrap': return 1
65
+ case 'wrap-reverse': return 2
66
+ default: return 0 // nowrap
67
+ }
68
+ }
69
+
70
+ /** Convert justify content string to number */
71
+ function justifyToNum(justify: string | undefined): number {
72
+ switch (justify) {
73
+ case 'center': return 1
74
+ case 'flex-end': return 2
75
+ case 'space-between': return 3
76
+ case 'space-around': return 4
77
+ case 'space-evenly': return 5
78
+ default: return 0 // flex-start
79
+ }
80
+ }
81
+
82
+ /** Convert align items string to number */
83
+ function alignToNum(align: string | undefined): number {
84
+ switch (align) {
85
+ case 'flex-start': return 1
86
+ case 'center': return 2
87
+ case 'flex-end': return 3
88
+ case 'baseline': return 4
89
+ default: return 0 // stretch
90
+ }
91
+ }
92
+
93
+ /** Convert overflow string to number */
94
+ function overflowToNum(overflow: string | undefined): number {
95
+ switch (overflow) {
96
+ case 'hidden': return 1
97
+ case 'scroll': return 2
98
+ case 'auto': return 3
99
+ default: return 0 // visible
100
+ }
101
+ }
102
+
103
+ /** Convert align-self string to number (0 = auto) */
104
+ function alignSelfToNum(align: string | undefined): number {
105
+ switch (align) {
106
+ case 'stretch': return 1
107
+ case 'flex-start': return 2
108
+ case 'center': return 3
109
+ case 'flex-end': return 4
110
+ case 'baseline': return 5
111
+ default: return 0 // auto (use parent's alignItems)
112
+ }
113
+ }
114
+
115
+ /**
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.
119
+ */
120
+ function bindEnumProp<T extends string>(
121
+ prop: T | { value: T } | undefined,
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
125
+ if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
126
+ 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>>
136
+ }
137
+ // Static value - just convert and bind
138
+ return bind(converter(prop as T | undefined))
139
+ }
140
+
141
+ /** Get static boolean for visible prop */
142
+ function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
143
+ if (prop === undefined) return defaultVal
144
+ if (typeof prop === 'boolean') return prop
145
+ if (typeof prop === 'object' && prop !== null && 'value' in prop) {
146
+ return (prop as { value: boolean }).value
147
+ }
148
+ return defaultVal
149
+ }
150
+
151
+ // =============================================================================
152
+ // BOX COMPONENT
153
+ // =============================================================================
154
+
155
+ /**
156
+ * Create a box container component.
157
+ *
158
+ * Boxes are the building blocks of layouts. They can:
159
+ * - Have borders and backgrounds
160
+ * - Use flexbox for child layout
161
+ * - Contain other components as children
162
+ * - Scroll their content
163
+ *
164
+ * Pass signals directly for reactive props - they stay connected!
165
+ */
166
+ export function box(props: BoxProps = {}): Cleanup {
167
+ const index = allocateIndex(props.id)
168
+
169
+ // ==========================================================================
170
+ // CORE - Always needed
171
+ // ==========================================================================
172
+ core.componentType[index] = ComponentType.BOX
173
+ core.parentIndex[index] = bind(getCurrentParentIndex())
174
+
175
+ // Visible - only bind if passed (default is visible, handled by TITAN)
176
+ if (props.visible !== undefined) {
177
+ core.visible[index] = bind(props.visible)
178
+ }
179
+
180
+ // ==========================================================================
181
+ // DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
182
+ // ==========================================================================
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)
189
+
190
+ // ==========================================================================
191
+ // PADDING - Shorthand support: padding sets all 4, individual overrides
192
+ // ==========================================================================
193
+ if (props.padding !== undefined) {
194
+ // 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)
199
+ } else {
200
+ // 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)
205
+ }
206
+
207
+ // ==========================================================================
208
+ // MARGIN - Shorthand support: margin sets all 4, individual overrides
209
+ // ==========================================================================
210
+ if (props.margin !== undefined) {
211
+ // 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)
216
+ } else {
217
+ // 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)
222
+ }
223
+
224
+ // Gap - only bind if passed
225
+ if (props.gap !== undefined) spacing.gap[index] = bind(props.gap)
226
+
227
+ // ==========================================================================
228
+ // LAYOUT - Only bind what's passed (TITAN uses sensible defaults)
229
+ // ==========================================================================
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)
240
+
241
+ // ==========================================================================
242
+ // INTERACTION - Only bind if focusable
243
+ // ==========================================================================
244
+ if (props.focusable) {
245
+ interaction.focusable[index] = bind(1)
246
+ if (props.tabIndex !== undefined) interaction.tabIndex[index] = bind(props.tabIndex)
247
+ }
248
+
249
+ // ==========================================================================
250
+ // VISUAL - Colors and borders (only bind what's passed)
251
+ // ==========================================================================
252
+ if (props.variant && props.variant !== 'default') {
253
+ // Variant colors - inline bindings that read theme at read time (no deriveds!)
254
+ const variant = props.variant
255
+ if (props.fg !== undefined) {
256
+ visual.fgColor[index] = bind(props.fg)
257
+ } else {
258
+ visual.fgColor[index] = {
259
+ [BINDING_SYMBOL]: true,
260
+ get value() { return getVariantStyle(variant).fg },
261
+ set value(_) {},
262
+ } as any
263
+ }
264
+ if (props.bg !== undefined) {
265
+ visual.bgColor[index] = bind(props.bg)
266
+ } else {
267
+ visual.bgColor[index] = {
268
+ [BINDING_SYMBOL]: true,
269
+ get value() { return getVariantStyle(variant).bg },
270
+ set value(_) {},
271
+ } as any
272
+ }
273
+ if (props.borderColor !== undefined) {
274
+ visual.borderColor[index] = bind(props.borderColor)
275
+ } else {
276
+ visual.borderColor[index] = {
277
+ [BINDING_SYMBOL]: true,
278
+ get value() { return getVariantStyle(variant).border },
279
+ set value(_) {},
280
+ } as any
281
+ }
282
+ } else {
283
+ // 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)
287
+ }
288
+ if (props.opacity !== undefined) visual.opacity[index] = bind(props.opacity)
289
+
290
+ // 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)
296
+
297
+ // Render children with this box as parent context
298
+ if (props.children) {
299
+ pushParentContext(index)
300
+ try {
301
+ props.children()
302
+ } finally {
303
+ popParentContext()
304
+ }
305
+ }
306
+
307
+ // Cleanup function
308
+ return () => {
309
+ cleanupKeyboardListeners(index) // Remove any focused key handlers
310
+ releaseIndex(index)
311
+ }
312
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * TUI Framework - Primitives
3
+ *
4
+ * The building blocks for creating terminal UIs.
5
+ * All primitives use bind() for reactive props - no effects needed!
6
+ */
7
+
8
+ export { box } from './box'
9
+ export { text } from './text'
10
+
11
+ // Types
12
+ export type { BoxProps, TextProps, Cleanup } from './types'
@@ -0,0 +1,199 @@
1
+ /**
2
+ * TUI Framework - Text Primitive
3
+ *
4
+ * Display text with styling, alignment, and wrapping.
5
+ *
6
+ * REACTIVITY: Props are passed directly to bind() to preserve reactive links.
7
+ * Don't extract values before binding - that breaks the connection!
8
+ *
9
+ * Usage:
10
+ * ```ts
11
+ * // Static text
12
+ * text({ content: 'Hello, World!' })
13
+ *
14
+ * // Reactive text - pass the signal directly!
15
+ * const message = signal('Hello')
16
+ * text({ content: message })
17
+ * message.value = 'Updated!' // UI reacts automatically!
18
+ *
19
+ * // Reactive with derived
20
+ * const count = signal(0)
21
+ * const countText = derived(() => `Count: ${count.value}`)
22
+ * text({ content: countText })
23
+ * ```
24
+ */
25
+
26
+ import { bind, BINDING_SYMBOL } from '@rlabs-inc/signals'
27
+ import { ComponentType, Attr } from '../types'
28
+ import { allocateIndex, releaseIndex, getCurrentParentIndex } from '../engine/registry'
29
+ import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
30
+ import { getVariantStyle } from '../state/theme'
31
+
32
+ // Import arrays
33
+ import * as core from '../engine/arrays/core'
34
+ import * as dimensions from '../engine/arrays/dimensions'
35
+ import * as spacing from '../engine/arrays/spacing'
36
+ import * as layout from '../engine/arrays/layout'
37
+ import * as visual from '../engine/arrays/visual'
38
+ import * as textArrays from '../engine/arrays/text'
39
+
40
+ // Import types
41
+ import type { TextProps, Cleanup } from './types'
42
+
43
+ // =============================================================================
44
+ // HELPERS - For enum conversions with reactive support!
45
+ // =============================================================================
46
+
47
+ /** Convert align string to number */
48
+ function alignToNum(align: string | undefined): number {
49
+ switch (align) {
50
+ case 'center': return 1
51
+ case 'right': return 2
52
+ default: return 0 // left
53
+ }
54
+ }
55
+
56
+ /** Convert wrap string to number */
57
+ function wrapToNum(wrap: string | undefined): number {
58
+ switch (wrap) {
59
+ case 'nowrap': return 0
60
+ case 'truncate': return 2
61
+ default: return 1 // wrap
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create a binding for enum props that converts at read time.
67
+ * No derived needed - reads signal directly and converts inline.
68
+ * This creates dependency directly on user's signal, no intermediate objects.
69
+ */
70
+ function bindEnumProp<T extends string>(
71
+ prop: T | { value: T } | undefined,
72
+ converter: (val: T | undefined) => number
73
+ ): ReturnType<typeof bind<number>> {
74
+ // If it's reactive (has .value), create binding that converts at read time
75
+ if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
76
+ 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>>
86
+ }
87
+ // Static value - just convert
88
+ return bind(converter(prop as T | undefined))
89
+ }
90
+
91
+ // =============================================================================
92
+ // TEXT COMPONENT
93
+ // =============================================================================
94
+
95
+ /**
96
+ * Create a text display component.
97
+ *
98
+ * Pass signals directly for reactive content - they stay connected!
99
+ * The pipeline reads via unwrap() which tracks dependencies.
100
+ *
101
+ * Supports all theme variants:
102
+ * - Core: default, primary, secondary, tertiary, accent
103
+ * - Status: success, warning, error, info
104
+ * - Surface: muted, surface, elevated, ghost, outline
105
+ */
106
+ export function text(props: TextProps): Cleanup {
107
+ const index = allocateIndex()
108
+
109
+ // ==========================================================================
110
+ // CORE - Always needed
111
+ // ==========================================================================
112
+ core.componentType[index] = ComponentType.TEXT
113
+ core.parentIndex[index] = bind(getCurrentParentIndex())
114
+
115
+ // Visible - only bind if passed
116
+ if (props.visible !== undefined) {
117
+ core.visible[index] = bind(props.visible)
118
+ }
119
+
120
+ // ==========================================================================
121
+ // TEXT CONTENT - Always needed (this is a text component!)
122
+ // ==========================================================================
123
+ textArrays.textContent[index] = bind(props.content)
124
+
125
+ // Text styling - only bind if passed
126
+ if (props.attrs !== undefined) textArrays.textAttrs[index] = bind(props.attrs)
127
+ if (props.align !== undefined) textArrays.textAlign[index] = bindEnumProp(props.align, alignToNum)
128
+ if (props.wrap !== undefined) textArrays.textWrap[index] = bindEnumProp(props.wrap, wrapToNum)
129
+
130
+ // ==========================================================================
131
+ // DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
132
+ // ==========================================================================
133
+ if (props.width !== undefined) dimensions.width[index] = bind(props.width)
134
+ if (props.height !== undefined) dimensions.height[index] = bind(props.height)
135
+ if (props.minWidth !== undefined) dimensions.minWidth[index] = bind(props.minWidth)
136
+ if (props.maxWidth !== undefined) dimensions.maxWidth[index] = bind(props.maxWidth)
137
+ if (props.minHeight !== undefined) dimensions.minHeight[index] = bind(props.minHeight)
138
+ if (props.maxHeight !== undefined) dimensions.maxHeight[index] = bind(props.maxHeight)
139
+
140
+ // ==========================================================================
141
+ // PADDING - Shorthand support
142
+ // ==========================================================================
143
+ if (props.padding !== undefined) {
144
+ spacing.paddingTop[index] = bind(props.paddingTop ?? props.padding)
145
+ spacing.paddingRight[index] = bind(props.paddingRight ?? props.padding)
146
+ spacing.paddingBottom[index] = bind(props.paddingBottom ?? props.padding)
147
+ spacing.paddingLeft[index] = bind(props.paddingLeft ?? props.padding)
148
+ } else {
149
+ if (props.paddingTop !== undefined) spacing.paddingTop[index] = bind(props.paddingTop)
150
+ if (props.paddingRight !== undefined) spacing.paddingRight[index] = bind(props.paddingRight)
151
+ if (props.paddingBottom !== undefined) spacing.paddingBottom[index] = bind(props.paddingBottom)
152
+ if (props.paddingLeft !== undefined) spacing.paddingLeft[index] = bind(props.paddingLeft)
153
+ }
154
+
155
+ // ==========================================================================
156
+ // FLEX ITEM - Only bind if passed (text can be a flex item)
157
+ // ==========================================================================
158
+ if (props.grow !== undefined) layout.flexGrow[index] = bind(props.grow)
159
+ if (props.shrink !== undefined) layout.flexShrink[index] = bind(props.shrink)
160
+ if (props.flexBasis !== undefined) layout.flexBasis[index] = bind(props.flexBasis)
161
+ if (props.alignSelf !== undefined) layout.alignSelf[index] = bind(props.alignSelf)
162
+
163
+ // ==========================================================================
164
+ // VISUAL - Colors with variant support (only bind what's needed)
165
+ // ==========================================================================
166
+ if (props.variant && props.variant !== 'default') {
167
+ // Variant colors - inline bindings that read theme at read time (no deriveds!)
168
+ const variant = props.variant
169
+ if (props.fg !== undefined) {
170
+ visual.fgColor[index] = bind(props.fg)
171
+ } else {
172
+ visual.fgColor[index] = {
173
+ [BINDING_SYMBOL]: true,
174
+ get value() { return getVariantStyle(variant).fg },
175
+ set value(_) {},
176
+ } as any
177
+ }
178
+ if (props.bg !== undefined) {
179
+ visual.bgColor[index] = bind(props.bg)
180
+ } else {
181
+ visual.bgColor[index] = {
182
+ [BINDING_SYMBOL]: true,
183
+ get value() { return getVariantStyle(variant).bg },
184
+ set value(_) {},
185
+ } as any
186
+ }
187
+ } else {
188
+ // Direct colors - only bind if passed
189
+ if (props.fg !== undefined) visual.fgColor[index] = bind(props.fg)
190
+ if (props.bg !== undefined) visual.bgColor[index] = bind(props.bg)
191
+ }
192
+ if (props.opacity !== undefined) visual.opacity[index] = bind(props.opacity)
193
+
194
+ // Cleanup function
195
+ return () => {
196
+ cleanupKeyboardListeners(index)
197
+ releaseIndex(index)
198
+ }
199
+ }