@rlabs-inc/tui 0.3.2 → 0.6.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/index.ts CHANGED
@@ -9,13 +9,32 @@
9
9
  export { mount } from './src/api'
10
10
 
11
11
  // Primitives - UI building blocks
12
- export { box, text, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
13
- export type { BoxProps, TextProps, Cleanup, AnimationOptions } from './src/primitives'
12
+ export { box, text, input, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
13
+ export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, Cleanup, AnimationOptions } from './src/primitives'
14
+
15
+ // Lifecycle hooks - Component mount/destroy callbacks
16
+ export { onMount, onDestroy } from './src/engine/lifecycle'
17
+
18
+ // Context - Reactive dependency injection
19
+ export { createContext, provide, useContext, hasContext, clearContext } from './src/state/context'
20
+ export type { Context } from './src/state/context'
14
21
 
15
22
  // State modules - Input handling
16
23
  export { keyboard, lastKey, lastEvent } from './src/state/keyboard'
17
- export { mouse, hitGrid, lastMouseEvent, mouseX, mouseY, isMouseDown } from './src/state/mouse'
18
- export { focusManager, focusedIndex } from './src/state/focus'
24
+ export {
25
+ mouse,
26
+ hitGrid,
27
+ lastMouseEvent,
28
+ mouseX,
29
+ mouseY,
30
+ isMouseDown,
31
+ onMouseDown,
32
+ onMouseUp,
33
+ onClick,
34
+ onScroll,
35
+ onComponent,
36
+ } from './src/state/mouse'
37
+ export { focusManager, focusedIndex, pushFocusTrap, popFocusTrap, isFocusTrapped, getFocusTrapContainer } from './src/state/focus'
19
38
  export { scroll } from './src/state/scroll'
20
39
  export { globalKeys } from './src/state/global-keys'
21
40
  export { cursor } from './src/state/cursor'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/tui",
3
- "version": "0.3.2",
3
+ "version": "0.6.0",
4
4
  "description": "The Terminal UI Framework for TypeScript/Bun - Blazing-fast, fine-grained reactive terminal UI with complete flexbox layout",
5
5
  "module": "index.ts",
6
6
  "main": "index.ts",
@@ -54,6 +54,6 @@
54
54
  "typescript": "^5.0.0"
55
55
  },
56
56
  "dependencies": {
57
- "@rlabs-inc/signals": "^1.8.2"
57
+ "@rlabs-inc/signals": "^1.9.0"
58
58
  }
59
59
  }
package/src/api/mount.ts CHANGED
@@ -37,6 +37,13 @@ import { resetRegistry } from '../engine/registry'
37
37
  import { hitGrid, clearHitGrid, mouse } from '../state/mouse'
38
38
  import { globalKeys } from '../state/global-keys'
39
39
 
40
+ // =============================================================================
41
+ // MODULE STATE
42
+ // =============================================================================
43
+
44
+ // Track if global error handlers have been registered (only once per process)
45
+ let globalErrorHandlersRegistered = false
46
+
40
47
  // =============================================================================
41
48
  // MOUNT
42
49
  // =============================================================================
@@ -149,13 +156,16 @@ export async function mount(
149
156
  // Create the component tree
150
157
  root()
151
158
 
152
- // Global error handlers for debugging
153
- process.on('uncaughtException', (err) => {
154
- console.error('[TUI] Uncaught exception:', err)
155
- })
156
- process.on('unhandledRejection', (err) => {
157
- console.error('[TUI] Unhandled rejection:', err)
158
- })
159
+ // Global error handlers for debugging (register only once per process)
160
+ if (!globalErrorHandlersRegistered) {
161
+ globalErrorHandlersRegistered = true
162
+ process.on('uncaughtException', (err) => {
163
+ console.error('[TUI] Uncaught exception:', err)
164
+ })
165
+ process.on('unhandledRejection', (err) => {
166
+ console.error('[TUI] Unhandled rejection:', err)
167
+ })
168
+ }
159
169
 
160
170
  // THE ONE RENDER EFFECT
161
171
  // This is where the magic happens - reactive rendering!
@@ -65,6 +65,48 @@ export const selectionStart: SlotArray<number> = slotArray<number>(-1)
65
65
  /** Selection end position */
66
66
  export const selectionEnd: SlotArray<number> = slotArray<number>(-1)
67
67
 
68
+ // =============================================================================
69
+ // CURSOR STYLING (for customizable cursor appearance)
70
+ // =============================================================================
71
+
72
+ /**
73
+ * Cursor character codepoint.
74
+ * 0 = use inverse block (default), >0 = custom character
75
+ * Presets: bar=0x2502 (│), underline=0x5F (_)
76
+ */
77
+ export const cursorChar: SlotArray<number> = slotArray<number>(0)
78
+
79
+ /**
80
+ * Cursor alternate character for blink "off" phase.
81
+ * 0 = space (invisible), >0 = custom character
82
+ */
83
+ export const cursorAltChar: SlotArray<number> = slotArray<number>(0)
84
+
85
+ /**
86
+ * Cursor blink rate in FPS.
87
+ * 0 = no blink, >0 = blink at this FPS (default would be 2 = 500ms cycle)
88
+ */
89
+ export const cursorBlinkFps: SlotArray<number> = slotArray<number>(0)
90
+
91
+ /**
92
+ * Custom cursor foreground color (packed RGBA or 0 for default).
93
+ * When 0, uses inverted colors from component's bg.
94
+ */
95
+ export const cursorFg: SlotArray<number> = slotArray<number>(0)
96
+
97
+ /**
98
+ * Custom cursor background color (packed RGBA or 0 for default).
99
+ * When 0, uses component's fg color.
100
+ */
101
+ export const cursorBg: SlotArray<number> = slotArray<number>(0)
102
+
103
+ /**
104
+ * Cursor visibility state for blink animation.
105
+ * 1 = visible (default), 0 = hidden
106
+ * Managed by input component's animation, read by frameBuffer.
107
+ */
108
+ export const cursorVisible: SlotArray<number> = slotArray<number>(1)
109
+
68
110
  // =============================================================================
69
111
  // CAPACITY MANAGEMENT
70
112
  // =============================================================================
@@ -81,6 +123,12 @@ export function ensureCapacity(index: number): void {
81
123
  cursorPosition.ensureCapacity(index)
82
124
  selectionStart.ensureCapacity(index)
83
125
  selectionEnd.ensureCapacity(index)
126
+ cursorChar.ensureCapacity(index)
127
+ cursorAltChar.ensureCapacity(index)
128
+ cursorBlinkFps.ensureCapacity(index)
129
+ cursorFg.ensureCapacity(index)
130
+ cursorBg.ensureCapacity(index)
131
+ cursorVisible.ensureCapacity(index)
84
132
  }
85
133
 
86
134
  /** Clear slot at index (reset to default) */
@@ -95,4 +143,10 @@ export function clearAtIndex(index: number): void {
95
143
  cursorPosition.clear(index)
96
144
  selectionStart.clear(index)
97
145
  selectionEnd.clear(index)
146
+ cursorChar.clear(index)
147
+ cursorAltChar.clear(index)
148
+ cursorBlinkFps.clear(index)
149
+ cursorFg.clear(index)
150
+ cursorBg.clear(index)
151
+ cursorVisible.clear(index)
98
152
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * TUI Framework - Component Lifecycle Hooks
3
+ *
4
+ * Provides onMount and onDestroy hooks for components.
5
+ * Zero overhead when not used - callbacks only stored for components that opt-in.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * function Timer() {
10
+ * const interval = setInterval(() => tick(), 1000)
11
+ * onDestroy(() => clearInterval(interval))
12
+ * return text({ content: 'Timer running...' })
13
+ * }
14
+ * ```
15
+ */
16
+
17
+ // =============================================================================
18
+ // Current Component Tracking
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Stack of component indices currently being created.
23
+ * Needed because children are created synchronously inside parent's children() callback.
24
+ */
25
+ const componentStack: number[] = []
26
+
27
+ /**
28
+ * Push a component index onto the creation stack.
29
+ * Called by primitives (box, text) at the start of creation.
30
+ */
31
+ export function pushCurrentComponent(index: number): void {
32
+ componentStack.push(index)
33
+ }
34
+
35
+ /**
36
+ * Pop a component index from the creation stack.
37
+ * Called by primitives (box, text) after setup is complete.
38
+ */
39
+ export function popCurrentComponent(): void {
40
+ componentStack.pop()
41
+ }
42
+
43
+ /**
44
+ * Get the current component index (the one being created).
45
+ * Returns -1 if not inside a component creation.
46
+ */
47
+ export function getCurrentComponentIndex(): number {
48
+ return componentStack.length > 0 ? componentStack[componentStack.length - 1]! : -1
49
+ }
50
+
51
+ // =============================================================================
52
+ // Lifecycle Callbacks Storage
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Mount callbacks by component index.
57
+ * Called after component is fully set up in arrays.
58
+ */
59
+ const mountCallbacks = new Map<number, Array<() => void>>()
60
+
61
+ /**
62
+ * Destroy callbacks by component index.
63
+ * Called when component is released.
64
+ */
65
+ const destroyCallbacks = new Map<number, Array<() => void>>()
66
+
67
+ // =============================================================================
68
+ // Lifecycle Hook APIs
69
+ // =============================================================================
70
+
71
+ /**
72
+ * Register a callback to run after the current component is mounted.
73
+ * The callback runs synchronously after the component setup is complete.
74
+ *
75
+ * @param fn - Callback to run on mount
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * function MyComponent() {
80
+ * onMount(() => {
81
+ * console.log('Component mounted!')
82
+ * fetchInitialData()
83
+ * })
84
+ * return box({ ... })
85
+ * }
86
+ * ```
87
+ */
88
+ export function onMount(fn: () => void): void {
89
+ const index = getCurrentComponentIndex()
90
+ if (index === -1) {
91
+ console.warn('onMount called outside of component creation')
92
+ return
93
+ }
94
+
95
+ let callbacks = mountCallbacks.get(index)
96
+ if (!callbacks) {
97
+ callbacks = []
98
+ mountCallbacks.set(index, callbacks)
99
+ }
100
+ callbacks.push(fn)
101
+ }
102
+
103
+ /**
104
+ * Register a callback to run when the current component is destroyed.
105
+ * Use for cleanup: clearing intervals, removing event listeners, etc.
106
+ *
107
+ * @param fn - Cleanup callback
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * function Timer() {
112
+ * const interval = setInterval(() => tick(), 1000)
113
+ * onDestroy(() => clearInterval(interval))
114
+ * return text({ content: 'Timer' })
115
+ * }
116
+ * ```
117
+ */
118
+ export function onDestroy(fn: () => void): void {
119
+ const index = getCurrentComponentIndex()
120
+ if (index === -1) {
121
+ console.warn('onDestroy called outside of component creation')
122
+ return
123
+ }
124
+
125
+ let callbacks = destroyCallbacks.get(index)
126
+ if (!callbacks) {
127
+ callbacks = []
128
+ destroyCallbacks.set(index, callbacks)
129
+ }
130
+ callbacks.push(fn)
131
+ }
132
+
133
+ // =============================================================================
134
+ // Internal: Run Lifecycle Callbacks
135
+ // =============================================================================
136
+
137
+ /**
138
+ * Run all mount callbacks for a component.
139
+ * Called by primitives after setup is complete.
140
+ */
141
+ export function runMountCallbacks(index: number): void {
142
+ const callbacks = mountCallbacks.get(index)
143
+ if (callbacks) {
144
+ for (const fn of callbacks) {
145
+ try {
146
+ fn()
147
+ } catch (err) {
148
+ console.error(`Error in onMount callback for component ${index}:`, err)
149
+ }
150
+ }
151
+ // Don't delete - keep for potential re-mount scenarios
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Run all destroy callbacks for a component.
157
+ * Called by releaseIndex before cleanup.
158
+ */
159
+ export function runDestroyCallbacks(index: number): void {
160
+ const callbacks = destroyCallbacks.get(index)
161
+ if (callbacks) {
162
+ for (const fn of callbacks) {
163
+ try {
164
+ fn()
165
+ } catch (err) {
166
+ console.error(`Error in onDestroy callback for component ${index}:`, err)
167
+ }
168
+ }
169
+ // Clean up storage
170
+ destroyCallbacks.delete(index)
171
+ mountCallbacks.delete(index)
172
+ }
173
+ }
174
+
175
+ // =============================================================================
176
+ // Reset (for testing)
177
+ // =============================================================================
178
+
179
+ /**
180
+ * Reset all lifecycle state (for testing)
181
+ */
182
+ export function resetLifecycle(): void {
183
+ componentStack.length = 0
184
+ mountCallbacks.clear()
185
+ destroyCallbacks.clear()
186
+ }
@@ -14,6 +14,7 @@ import { ReactiveSet } from '@rlabs-inc/signals'
14
14
  import { ensureAllCapacity, clearAllAtIndex, resetAllArrays } from './arrays'
15
15
  import { parentIndex as parentIndexArray } from './arrays/core'
16
16
  import { resetTitanArrays } from '../pipeline/layout/titan-engine'
17
+ import { runDestroyCallbacks, resetLifecycle } from './lifecycle'
17
18
 
18
19
  // =============================================================================
19
20
  // Registry State
@@ -123,6 +124,9 @@ export function releaseIndex(index: number): void {
123
124
  releaseIndex(childIndex)
124
125
  }
125
126
 
127
+ // Run destroy callbacks before cleanup
128
+ runDestroyCallbacks(index)
129
+
126
130
  // Clean up mappings
127
131
  idToIndex.delete(id)
128
132
  indexToId.delete(index)
@@ -192,4 +196,5 @@ export function resetRegistry(): void {
192
196
  nextIndex = 0
193
197
  idCounter = 0
194
198
  parentStack.length = 0
199
+ resetLifecycle()
195
200
  }
@@ -16,7 +16,7 @@
16
16
  * HitGrid updates are returned as data to be applied by the render effect.
17
17
  */
18
18
 
19
- import { derived, neverEquals } from '@rlabs-inc/signals'
19
+ import { derived, neverEquals, signal } from '@rlabs-inc/signals'
20
20
  import type { FrameBuffer, RGBA } from '../types'
21
21
  import { ComponentType } from '../types'
22
22
  import { Colors, TERMINAL_DEFAULT, rgbaBlend, rgbaLerp } from '../types/color'
@@ -39,7 +39,6 @@ import {
39
39
  getInheritedBg,
40
40
  getBorderColors,
41
41
  getBorderStyles,
42
- hasBorder,
43
42
  getEffectiveOpacity,
44
43
  } from '../engine/inheritance'
45
44
 
@@ -222,7 +221,8 @@ function renderComponent(
222
221
  // Get border configuration
223
222
  const borderStyles = getBorderStyles(index)
224
223
  const borderColors = getBorderColors(index)
225
- const hasAnyBorder = hasBorder(index)
224
+ // Inline hasAnyBorder check - avoids redundant getBorderStyles call (was 12 array reads, now 8)
225
+ const hasAnyBorder = borderStyles.top > 0 || borderStyles.right > 0 || borderStyles.bottom > 0 || borderStyles.left > 0
226
226
 
227
227
  // Draw borders
228
228
  if (hasAnyBorder && w >= 2 && h >= 2) {
@@ -376,7 +376,9 @@ function renderInput(
376
376
  fg: RGBA,
377
377
  clip: ClipRect
378
378
  ): void {
379
- const content = text.textContent[index] || '' // SlotArray auto-unwraps
379
+ // Read through slotArray proxy - same pattern as renderText
380
+ const rawValue = text.textContent[index]
381
+ const content = rawValue == null ? '' : String(rawValue)
380
382
  const attrs = text.textAttrs[index] || 0
381
383
  const cursorPos = interaction.cursorPosition[index] || 0
382
384
 
@@ -401,14 +403,38 @@ function renderInput(
401
403
  if (interaction.focusedIndex.value === index) {
402
404
  const cursorX = x + Math.min(cursorPos - displayOffset, w - 1)
403
405
  if (cursorX >= clip.x && cursorX < clip.x + clip.width && y >= clip.y && y < clip.y + clip.height) {
404
- // Draw cursor as inverse block
406
+ // Read cursor configuration from arrays
407
+ const cursorCharCode = interaction.cursorChar[index] ?? 0
408
+ const cursorAltCharCode = interaction.cursorAltChar[index] ?? 0
409
+ const cursorVisible = interaction.cursorVisible[index] ?? 1
410
+
405
411
  const cell = buffer.cells[y]?.[cursorX]
406
412
  if (cell) {
407
- const cursorChar = content[cursorPos] || ' '
408
- // Swap fg/bg for cursor visibility
409
- cell.char = cursorChar.codePointAt(0) ?? 32
410
- cell.fg = getInheritedBg(index)
411
- cell.bg = fg
413
+ const charUnderCursor = content[cursorPos] || ' '
414
+
415
+ if (cursorVisible === 0) {
416
+ // Blink "off" phase
417
+ if (cursorAltCharCode > 0) {
418
+ // Custom alt character for "off" phase
419
+ cell.char = cursorAltCharCode
420
+ cell.fg = fg
421
+ cell.bg = getInheritedBg(index)
422
+ }
423
+ // else: leave cell unchanged (original text shows through)
424
+ } else {
425
+ // Cursor visible
426
+ if (cursorCharCode === 0) {
427
+ // Block cursor (inverse) - swap fg/bg
428
+ cell.char = charUnderCursor.codePointAt(0) ?? 32
429
+ cell.fg = getInheritedBg(index)
430
+ cell.bg = fg
431
+ } else {
432
+ // Custom cursor character (bar, underline, or user-defined)
433
+ cell.char = cursorCharCode
434
+ cell.fg = fg
435
+ cell.bg = getInheritedBg(index)
436
+ }
437
+ }
412
438
  }
413
439
  }
414
440
  }
@@ -127,9 +127,10 @@ const lineMainUsed: number[] = []
127
127
  // =============================================================================
128
128
  // INTRINSIC CACHE - Skip recomputation when inputs unchanged
129
129
  // =============================================================================
130
- // For TEXT components: cache based on text content hash + available width
130
+ // For TEXT components: cache based on text content hash + available width + length
131
131
  // For BOX components: cache based on children intrinsics + layout props
132
132
  const cachedTextHash: bigint[] = []
133
+ const cachedTextLength: number[] = [] // Length check prevents hash collisions
133
134
  const cachedAvailW: number[] = []
134
135
  const cachedIntrinsicW: number[] = []
135
136
  const cachedIntrinsicH: number[] = []
@@ -166,6 +167,7 @@ export function resetTitanArrays(): void {
166
167
  lineMainUsed.length = 0
167
168
  // Intrinsic cache
168
169
  cachedTextHash.length = 0
170
+ cachedTextLength.length = 0
169
171
  cachedAvailW.length = 0
170
172
  cachedIntrinsicW.length = 0
171
173
  cachedIntrinsicH.length = 0
@@ -215,7 +217,8 @@ export function computeLayoutTitan(
215
217
  if (firstChild[parent] === -1) {
216
218
  firstChild[parent] = i
217
219
  } else {
218
- nextSibling[lastChild[parent]!] = i
220
+ const last = lastChild[parent] ?? -1
221
+ if (last !== -1) nextSibling[last] = i
219
222
  }
220
223
  lastChild[parent] = i
221
224
  } else {
@@ -229,10 +232,10 @@ export function computeLayoutTitan(
229
232
  let head = 0
230
233
  while (head < bfsQueue.length) {
231
234
  const parent = bfsQueue[head++]!
232
- let child = firstChild[parent]!
235
+ let child = firstChild[parent] ?? -1
233
236
  while (child !== -1) {
234
237
  bfsQueue.push(child)
235
- child = nextSibling[child]!
238
+ child = nextSibling[child] ?? -1
236
239
  }
237
240
  }
238
241
 
@@ -269,8 +272,9 @@ export function computeLayoutTitan(
269
272
 
270
273
  // CACHE CHECK: Hash text content, compare with cached
271
274
  // Only recompute stringWidth/measureTextHeight if content or availableW changed
275
+ // Length check prevents hash collisions (two strings with same hash must also have same length)
272
276
  const textHash = BigInt(Bun.hash(str))
273
- if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i]) {
277
+ if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i] && str.length === cachedTextLength[i]) {
274
278
  // Cache hit - reuse cached intrinsics (skip expensive computation!)
275
279
  intrinsicW[i] = cachedIntrinsicW[i]!
276
280
  intrinsicH[i] = cachedIntrinsicH[i]!
@@ -279,6 +283,7 @@ export function computeLayoutTitan(
279
283
  intrinsicW[i] = stringWidth(str)
280
284
  intrinsicH[i] = measureTextHeight(str, availableW)
281
285
  cachedTextHash[i] = textHash
286
+ cachedTextLength[i] = str.length
282
287
  cachedAvailW[i] = availableW
283
288
  cachedIntrinsicW[i] = intrinsicW[i]
284
289
  cachedIntrinsicH[i] = intrinsicH[i]
@@ -288,6 +293,26 @@ export function computeLayoutTitan(
288
293
  intrinsicH[i] = 0
289
294
  }
290
295
  }
296
+ } else if (type === ComponentType.INPUT) {
297
+ // INPUT: Single-line, intrinsic width from content, height always 1
298
+ const content = text.textContent[i] // SlotArray auto-unwraps & tracks
299
+ const str = content != null ? String(content) : ''
300
+
301
+ // Get borders and padding for this input
302
+ const borderStyle = visual.borderStyle[i] ?? 0
303
+ const borderT = borderStyle > 0 || (visual.borderTop[i] ?? 0) > 0 ? 1 : 0
304
+ const borderR = borderStyle > 0 || (visual.borderRight[i] ?? 0) > 0 ? 1 : 0
305
+ const borderB = borderStyle > 0 || (visual.borderBottom[i] ?? 0) > 0 ? 1 : 0
306
+ const borderL = borderStyle > 0 || (visual.borderLeft[i] ?? 0) > 0 ? 1 : 0
307
+ const padT = spacing.paddingTop[i] ?? 0
308
+ const padR = spacing.paddingRight[i] ?? 0
309
+ const padB = spacing.paddingBottom[i] ?? 0
310
+ const padL = spacing.paddingLeft[i] ?? 0
311
+
312
+ // Intrinsic width: text width + padding + borders
313
+ intrinsicW[i] = stringWidth(str) + padL + padR + borderL + borderR
314
+ // Intrinsic height: 1 line + padding + borders
315
+ intrinsicH[i] = 1 + padT + padB + borderT + borderB
291
316
  } else {
292
317
  // BOX/Container - calculate intrinsic from children + padding + borders
293
318
  // EXCEPTION: Scrollable containers should have minimal intrinsic height
@@ -295,7 +320,7 @@ export function computeLayoutTitan(
295
320
  const overflow = layout.overflow[i] ?? Overflow.VISIBLE
296
321
  const isScrollable = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
297
322
 
298
- let kid = firstChild[i]!
323
+ let kid = firstChild[i] ?? -1
299
324
  if (kid !== -1 && !isScrollable) {
300
325
  // Normal containers: intrinsic size includes all children
301
326
  const dir = layout.flexDirection[i] ?? FLEX_COLUMN
@@ -344,7 +369,7 @@ export function computeLayoutTitan(
344
369
  sumMain += kidH + kidMarginMain + gap
345
370
  maxCross = Math.max(maxCross, kidW)
346
371
  }
347
- kid = nextSibling[kid]!
372
+ kid = nextSibling[kid] ?? -1
348
373
  }
349
374
 
350
375
  if (childCount > 0) sumMain -= gap
@@ -405,12 +430,12 @@ export function computeLayoutTitan(
405
430
  let childrenMaxCross = 0
406
431
 
407
432
  // Collect flow children
408
- let kid = firstChild[parent]!
433
+ let kid = firstChild[parent] ?? -1
409
434
  while (kid !== -1) {
410
435
  if ((layout.position[kid] ?? POS_RELATIVE) !== POS_ABSOLUTE) {
411
436
  flowKids.push(kid)
412
437
  }
413
- kid = nextSibling[kid]!
438
+ kid = nextSibling[kid] ?? -1
414
439
  }
415
440
 
416
441
  if (flowKids.length === 0) return
@@ -655,6 +680,17 @@ export function computeLayoutTitan(
655
680
  }
656
681
  }
657
682
 
683
+ // INPUT: Single-line, always height 1 (content scrolls horizontally)
684
+ if (core.componentType[fkid] === ComponentType.INPUT) {
685
+ // Add border height if borders are present
686
+ const borderStyle = visual.borderStyle[fkid] ?? 0
687
+ const borderT = borderStyle > 0 || (visual.borderTop[fkid] ?? 0) > 0 ? 1 : 0
688
+ const borderB = borderStyle > 0 || (visual.borderBottom[fkid] ?? 0) > 0 ? 1 : 0
689
+ const padT = spacing.paddingTop[fkid] ?? 0
690
+ const padB = spacing.paddingBottom[fkid] ?? 0
691
+ outH[fkid] = 1 + borderT + borderB + padT + padB
692
+ }
693
+
658
694
  // Track max extent inline (zero overhead) - include margins
659
695
  if (isRow) {
660
696
  childrenMaxMain = Math.max(childrenMaxMain, mainOffset + mLeft + outW[fkid]! + mRight)
@@ -33,7 +33,8 @@ export function measureTextHeight(content: string, maxWidth: number): number {
33
33
  // Split by existing newlines first
34
34
  const paragraphs = content.split('\n')
35
35
 
36
- for (const paragraph of paragraphs) {
36
+ for (let i = 0; i < paragraphs.length; i++) {
37
+ const paragraph = paragraphs[i]!
37
38
  if (paragraph === '') {
38
39
  lines++
39
40
  continue
@@ -53,7 +54,7 @@ export function measureTextHeight(content: string, maxWidth: number): number {
53
54
 
54
55
  // Reset for next paragraph
55
56
  currentLineWidth = 0
56
- if (paragraphs.indexOf(paragraph) < paragraphs.length - 1) {
57
+ if (i < paragraphs.length - 1) {
57
58
  lines++
58
59
  }
59
60
  }
@@ -30,9 +30,15 @@ import {
30
30
  pushParentContext,
31
31
  popParentContext,
32
32
  } from '../engine/registry'
33
+ import {
34
+ pushCurrentComponent,
35
+ popCurrentComponent,
36
+ runMountCallbacks,
37
+ } from '../engine/lifecycle'
33
38
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
34
39
  import { getVariantStyle } from '../state/theme'
35
40
  import { getActiveScope } from './scope'
41
+ import { enumSource } from './utils'
36
42
 
37
43
  // Import arrays
38
44
  import * as core from '../engine/arrays/core'
@@ -113,28 +119,6 @@ function alignSelfToNum(align: string | undefined): number {
113
119
  }
114
120
  }
115
121
 
116
- /**
117
- * Create a slot source for enum props - returns getter for reactive, value for static.
118
- * For use with slotArray.setSource()
119
- * Handles: static values, signals/bindings ({ value: T }), and getter functions (() => T)
120
- */
121
- function enumSource<T extends string>(
122
- prop: T | { value: T } | (() => T) | undefined,
123
- converter: (val: T | undefined) => number
124
- ): number | (() => number) {
125
- // Handle getter function (inline derived)
126
- if (typeof prop === 'function') {
127
- return () => converter(prop())
128
- }
129
- // Handle object with .value (signal/binding/derived)
130
- if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
131
- const reactiveSource = prop as { value: T }
132
- return () => converter(reactiveSource.value)
133
- }
134
- // Static value
135
- return converter(prop as T | undefined)
136
- }
137
-
138
122
  /** Get static boolean for visible prop */
139
123
  function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
140
124
  if (prop === undefined) return defaultVal
@@ -163,6 +147,9 @@ function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
163
147
  export function box(props: BoxProps = {}): Cleanup {
164
148
  const index = allocateIndex(props.id)
165
149
 
150
+ // Track current component for lifecycle hooks
151
+ pushCurrentComponent(index)
152
+
166
153
  // ==========================================================================
167
154
  // CORE - Always needed
168
155
  // ==========================================================================
@@ -291,6 +278,10 @@ export function box(props: BoxProps = {}): Cleanup {
291
278
  }
292
279
  }
293
280
 
281
+ // Component setup complete - run lifecycle callbacks
282
+ popCurrentComponent()
283
+ runMountCallbacks(index)
284
+
294
285
  // Cleanup function
295
286
  const cleanup = () => {
296
287
  cleanupKeyboardListeners(index) // Remove any focused key handlers