@rlabs-inc/tui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/mount.ts +42 -27
  5. package/src/engine/arrays/core.ts +13 -21
  6. package/src/engine/arrays/dimensions.ts +22 -32
  7. package/src/engine/arrays/index.ts +88 -86
  8. package/src/engine/arrays/interaction.ts +34 -48
  9. package/src/engine/arrays/layout.ts +67 -92
  10. package/src/engine/arrays/spacing.ts +37 -52
  11. package/src/engine/arrays/text.ts +23 -31
  12. package/src/engine/arrays/visual.ts +56 -75
  13. package/src/engine/inheritance.ts +18 -18
  14. package/src/engine/registry.ts +15 -0
  15. package/src/pipeline/frameBuffer.ts +26 -26
  16. package/src/pipeline/layout/index.ts +2 -2
  17. package/src/pipeline/layout/titan-engine.ts +112 -84
  18. package/src/primitives/animation.ts +194 -0
  19. package/src/primitives/box.ts +74 -86
  20. package/src/primitives/each.ts +87 -0
  21. package/src/primitives/index.ts +7 -0
  22. package/src/primitives/scope.ts +215 -0
  23. package/src/primitives/show.ts +77 -0
  24. package/src/primitives/text.ts +63 -59
  25. package/src/primitives/types.ts +1 -1
  26. package/src/primitives/when.ts +102 -0
  27. package/src/renderer/append-region.ts +303 -0
  28. package/src/renderer/index.ts +4 -2
  29. package/src/renderer/output.ts +11 -34
  30. package/src/state/focus.ts +16 -5
  31. package/src/state/global-keys.ts +184 -0
  32. package/src/state/index.ts +44 -8
  33. package/src/state/input.ts +534 -0
  34. package/src/state/keyboard.ts +98 -674
  35. package/src/state/mouse.ts +163 -340
  36. package/src/state/scroll.ts +7 -9
  37. package/src/types/index.ts +6 -0
  38. package/src/renderer/input.ts +0 -518
@@ -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, unwrap } from '@rlabs-inc/signals'
19
+ import { derived, neverEquals } 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'
@@ -46,10 +46,10 @@ import {
46
46
  // Import arrays
47
47
  import * as core from '../engine/arrays/core'
48
48
  import * as visual from '../engine/arrays/visual'
49
- import * as text from '../engine/arrays/text'
50
- import * as spacing from '../engine/arrays/spacing'
51
- import * as layout from '../engine/arrays/layout'
52
- import * as interaction from '../engine/arrays/interaction'
49
+ import *as text from '../engine/arrays/text'
50
+ import *as spacing from '../engine/arrays/spacing'
51
+ import *as layout from '../engine/arrays/layout'
52
+ import *as interaction from '../engine/arrays/interaction'
53
53
 
54
54
  // Import layout derived
55
55
  import { layoutDerived, terminalWidth, terminalHeight, renderMode } from './layout'
@@ -119,10 +119,10 @@ export const frameBufferDerived = derived((): FrameBufferResult => {
119
119
 
120
120
  for (const i of indices) {
121
121
  if (core.componentType[i] === ComponentType.NONE) continue
122
- const vis = unwrap(core.visible[i])
122
+ const vis = core.visible[i]
123
123
  if (vis === 0 || vis === false) continue
124
124
 
125
- const parent = unwrap(core.parentIndex[i]) ?? -1
125
+ const parent = core.parentIndex[i] ?? -1
126
126
  if (parent === -1) {
127
127
  rootIndices.push(i)
128
128
  } else {
@@ -136,11 +136,11 @@ export const frameBufferDerived = derived((): FrameBufferResult => {
136
136
  }
137
137
 
138
138
  // Sort roots by zIndex
139
- rootIndices.sort((a, b) => (unwrap(layout.zIndex[a]) || 0) - (unwrap(layout.zIndex[b]) || 0))
139
+ rootIndices.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
140
140
 
141
141
  // Sort children by zIndex
142
142
  for (const children of childMap.values()) {
143
- children.sort((a, b) => (unwrap(layout.zIndex[a]) || 0) - (unwrap(layout.zIndex[b]) || 0))
143
+ children.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
144
144
  }
145
145
 
146
146
  // Render tree recursively
@@ -180,7 +180,7 @@ function renderComponent(
180
180
  parentScrollX: number
181
181
  ): void {
182
182
  // Skip invisible/invalid components
183
- const vis = unwrap(core.visible[index])
183
+ const vis = core.visible[index]
184
184
  if (vis === 0 || vis === false) return
185
185
  if (core.componentType[index] === ComponentType.NONE) return
186
186
 
@@ -239,10 +239,10 @@ function renderComponent(
239
239
  }
240
240
 
241
241
  // Calculate content area (inside borders and padding)
242
- const padTop = (unwrap(spacing.paddingTop[index]) || 0) + (hasAnyBorder && borderStyles.top > 0 ? 1 : 0)
243
- const padRight = (unwrap(spacing.paddingRight[index]) || 0) + (hasAnyBorder && borderStyles.right > 0 ? 1 : 0)
244
- const padBottom = (unwrap(spacing.paddingBottom[index]) || 0) + (hasAnyBorder && borderStyles.bottom > 0 ? 1 : 0)
245
- const padLeft = (unwrap(spacing.paddingLeft[index]) || 0) + (hasAnyBorder && borderStyles.left > 0 ? 1 : 0)
242
+ const padTop = (spacing.paddingTop[index] || 0) + (hasAnyBorder && borderStyles.top > 0 ? 1 : 0)
243
+ const padRight = (spacing.paddingRight[index] || 0) + (hasAnyBorder && borderStyles.right > 0 ? 1 : 0)
244
+ const padBottom = (spacing.paddingBottom[index] || 0) + (hasAnyBorder && borderStyles.bottom > 0 ? 1 : 0)
245
+ const padLeft = (spacing.paddingLeft[index] || 0) + (hasAnyBorder && borderStyles.left > 0 ? 1 : 0)
246
246
 
247
247
  const contentX = x + padLeft
248
248
  const contentY = y + padTop
@@ -292,8 +292,8 @@ function renderComponent(
292
292
 
293
293
  // Get this component's scroll offset (scrollable comes from layout, offset from interaction)
294
294
  const isScrollable = (computedLayout.scrollable[index] ?? 0) === 1
295
- const scrollY = isScrollable ? (unwrap(interaction.scrollOffsetY[index]) || 0) : 0
296
- const scrollX = isScrollable ? (unwrap(interaction.scrollOffsetX[index]) || 0) : 0
295
+ const scrollY = isScrollable ? (interaction.scrollOffsetY[index] || 0) : 0
296
+ const scrollX = isScrollable ? (interaction.scrollOffsetX[index] || 0) : 0
297
297
 
298
298
  // Accumulated scroll for children
299
299
  const childScrollY = parentScrollY + scrollY
@@ -331,13 +331,13 @@ function renderText(
331
331
  fg: RGBA,
332
332
  clip: ClipRect
333
333
  ): void {
334
+ // Read through slotArray proxy - same pattern as color reads in inheritance.ts
334
335
  const rawValue = text.textContent[index]
335
- const unwrapped = unwrap(rawValue)
336
- const content = unwrapped == null ? '' : String(unwrapped)
336
+ const content = rawValue == null ? '' : String(rawValue)
337
337
  if (!content) return
338
338
 
339
- const attrs = unwrap(text.textAttrs[index]) || 0
340
- const align = unwrap(text.textAlign[index]) || 0
339
+ const attrs = text.textAttrs[index] || 0
340
+ const align = text.textAlign[index] || 0
341
341
 
342
342
  // Word wrap the text
343
343
  const lines = wrapText(content, w)
@@ -376,9 +376,9 @@ function renderInput(
376
376
  fg: RGBA,
377
377
  clip: ClipRect
378
378
  ): void {
379
- const content = unwrap(text.textContent[index]) || ''
380
- const attrs = unwrap(text.textAttrs[index]) || 0
381
- const cursorPos = unwrap(interaction.cursorPosition[index]) || 0
379
+ const content = text.textContent[index] || '' // SlotArray auto-unwraps
380
+ const attrs = text.textAttrs[index] || 0
381
+ const cursorPos = interaction.cursorPosition[index] || 0
382
382
 
383
383
  if (w <= 0) return
384
384
 
@@ -427,7 +427,7 @@ function renderProgress(
427
427
  fg: RGBA,
428
428
  clip?: ClipRect
429
429
  ): void {
430
- const valueStr = unwrap(text.textContent[index]) || '0'
430
+ const valueStr = text.textContent[index] || '0' // SlotArray auto-unwraps
431
431
  const progress = Math.max(0, Math.min(1, parseFloat(valueStr) || 0))
432
432
  const filled = Math.round(progress * w)
433
433
 
@@ -458,8 +458,8 @@ function renderSelect(
458
458
  fg: RGBA,
459
459
  clip: ClipRect
460
460
  ): void {
461
- const content = unwrap(text.textContent[index]) || ''
462
- const attrs = unwrap(text.textAttrs[index]) || 0
461
+ const content = text.textContent[index] || '' // SlotArray auto-unwraps
462
+ const attrs = text.textAttrs[index] || 0
463
463
 
464
464
  // For now, just show current selection
465
465
  const displayText = truncateText(content, w - 2) // Leave room for dropdown indicator
@@ -15,7 +15,7 @@
15
15
  * - Return plain output arrays
16
16
  */
17
17
 
18
- import { derived, signal } from '@rlabs-inc/signals'
18
+ import { derived, signal, neverEquals } from '@rlabs-inc/signals'
19
19
  import { getAllocatedIndices } from '../../engine/registry'
20
20
  import { computeLayoutTitan, resetTitanArrays } from './titan-engine'
21
21
  import type { ComputedLayout } from './types'
@@ -87,7 +87,7 @@ export const layoutDerived = derived((): ComputedLayout => {
87
87
  // TITAN ENGINE: Read arrays, compute, return.
88
88
  // Reactivity tracks dependencies as we read - no manual tracking needed.
89
89
  return computeLayoutTitan(tw, th, indices, constrainHeight)
90
- })
90
+ }, { equals: neverEquals })
91
91
 
92
92
  // =============================================================================
93
93
  // EXPORTS
@@ -206,10 +206,10 @@ export function computeLayoutTitan(
206
206
 
207
207
  for (const i of indices) {
208
208
  // Skip invisible components - they don't participate in layout
209
- const vis = unwrap(core.visible[i])
209
+ const vis = core.visible[i]
210
210
  if (vis === 0 || vis === false) continue
211
211
 
212
- const parent = unwrap(core.parentIndex[i]) ?? -1
212
+ const parent = core.parentIndex[i] ?? -1
213
213
 
214
214
  if (parent >= 0 && indices.has(parent)) {
215
215
  if (firstChild[parent] === -1) {
@@ -244,32 +244,32 @@ export function computeLayoutTitan(
244
244
  const type = core.componentType[i]
245
245
 
246
246
  if (type === ComponentType.TEXT) {
247
- const content = unwrap(text.textContent[i])
247
+ const content = text.textContent[i] // SlotArray auto-unwraps & tracks
248
248
  // Check for null/undefined, NOT truthiness (0 and '' are valid content!)
249
249
  if (content != null) {
250
250
  const str = String(content)
251
251
 
252
252
  if (str.length > 0) {
253
253
  // TEXT WRAPPING: Calculate available width for height measurement
254
- const parentIdx = unwrap(core.parentIndex[i]) ?? -1
254
+ const parentIdx = core.parentIndex[i] ?? -1
255
255
  let availableW = terminalWidth
256
256
 
257
257
  if (parentIdx >= 0) {
258
- const rawParentW = unwrap(dimensions.width[parentIdx])
258
+ const rawParentW = dimensions.width[parentIdx]
259
259
  const parentExplicitW = typeof rawParentW === 'number' ? rawParentW : 0
260
260
  if (parentExplicitW > 0) {
261
- const pPadL = unwrap(spacing.paddingLeft[parentIdx]) ?? 0
262
- const pPadR = unwrap(spacing.paddingRight[parentIdx]) ?? 0
263
- const pBorderStyle = unwrap(visual.borderStyle[parentIdx]) ?? 0
264
- const pBorderL = pBorderStyle > 0 || (unwrap(visual.borderLeft[parentIdx]) ?? 0) > 0 ? 1 : 0
265
- const pBorderR = pBorderStyle > 0 || (unwrap(visual.borderRight[parentIdx]) ?? 0) > 0 ? 1 : 0
261
+ const pPadL = spacing.paddingLeft[parentIdx] ?? 0
262
+ const pPadR = spacing.paddingRight[parentIdx] ?? 0
263
+ const pBorderStyle = visual.borderStyle[parentIdx] ?? 0
264
+ const pBorderL = pBorderStyle > 0 || (visual.borderLeft[parentIdx] ?? 0) > 0 ? 1 : 0
265
+ const pBorderR = pBorderStyle > 0 || (visual.borderRight[parentIdx] ?? 0) > 0 ? 1 : 0
266
266
  availableW = Math.max(1, parentExplicitW - pPadL - pPadR - pBorderL - pBorderR)
267
267
  }
268
268
  }
269
269
 
270
270
  // CACHE CHECK: Hash text content, compare with cached
271
271
  // Only recompute stringWidth/measureTextHeight if content or availableW changed
272
- const textHash = Bun.hash(str)
272
+ const textHash = BigInt(Bun.hash(str))
273
273
  if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i]) {
274
274
  // Cache hit - reuse cached intrinsics (skip expensive computation!)
275
275
  intrinsicW[i] = cachedIntrinsicW[i]!
@@ -290,11 +290,17 @@ export function computeLayoutTitan(
290
290
  }
291
291
  } else {
292
292
  // BOX/Container - calculate intrinsic from children + padding + borders
293
+ // EXCEPTION: Scrollable containers should have minimal intrinsic height
294
+ // so they don't force parents to expand - content scrolls instead
295
+ const overflow = layout.overflow[i] ?? Overflow.VISIBLE
296
+ const isScrollable = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
297
+
293
298
  let kid = firstChild[i]!
294
- if (kid !== -1) {
295
- const dir = unwrap(layout.flexDirection[i]) ?? FLEX_COLUMN
299
+ if (kid !== -1 && !isScrollable) {
300
+ // Normal containers: intrinsic size includes all children
301
+ const dir = layout.flexDirection[i] ?? FLEX_COLUMN
296
302
  const isRow = dir === FLEX_ROW || dir === FLEX_ROW_REVERSE
297
- const gap = unwrap(spacing.gap[i]) ?? 0
303
+ const gap = spacing.gap[i] ?? 0
298
304
 
299
305
  let sumMain = 0
300
306
  let maxCross = 0
@@ -306,8 +312,8 @@ export function computeLayoutTitan(
306
312
  // This ensures children with explicit sizes contribute correctly
307
313
  // Note: Percentage dimensions (strings) → 0 for intrinsic calculation
308
314
  // They'll be resolved against parent computed size in layout phase
309
- const rawKidW = unwrap(dimensions.width[kid])
310
- const rawKidH = unwrap(dimensions.height[kid])
315
+ const rawKidW = dimensions.width[kid]
316
+ const rawKidH = dimensions.height[kid]
311
317
  const kidExplicitW = typeof rawKidW === 'number' ? rawKidW : 0
312
318
  const kidExplicitH = typeof rawKidH === 'number' ? rawKidH : 0
313
319
  const kidW = kidExplicitW > 0 ? kidExplicitW : intrinsicW[kid]!
@@ -318,19 +324,24 @@ export function computeLayoutTitan(
318
324
  // Adding borders here was DOUBLE-COUNTING and inflating contentHeight.
319
325
  //
320
326
  // OLD CODE (double-counted borders):
321
- // const kidBs = unwrap(visual.borderStyle[kid]) ?? 0
322
- // const kidBordT = kidBs > 0 || (unwrap(visual.borderTop[kid]) ?? 0) > 0 ? 1 : 0
323
- // const kidBordB = kidBs > 0 || (unwrap(visual.borderBottom[kid]) ?? 0) > 0 ? 1 : 0
324
- // const kidBordL = kidBs > 0 || (unwrap(visual.borderLeft[kid]) ?? 0) > 0 ? 1 : 0
325
- // const kidBordR = kidBs > 0 || (unwrap(visual.borderRight[kid]) ?? 0) > 0 ? 1 : 0
327
+ // const kidBs = visual.borderStyle[kid] ?? 0
328
+ // const kidBordT = kidBs > 0 || (visual.borderTop[kid] ?? 0) > 0 ? 1 : 0
329
+ // const kidBordB = kidBs > 0 || (visual.borderBottom[kid] ?? 0) > 0 ? 1 : 0
330
+ // const kidBordL = kidBs > 0 || (visual.borderLeft[kid] ?? 0) > 0 ? 1 : 0
331
+ // const kidBordR = kidBs > 0 || (visual.borderRight[kid] ?? 0) > 0 ? 1 : 0
326
332
  // const kidTotalW = kidW + kidBordL + kidBordR
327
333
  // const kidTotalH = kidH + kidBordT + kidBordB
328
334
 
335
+ // Include child margins in intrinsic size (matches layout pass behavior)
336
+ const kidMarginMain = isRow
337
+ ? (spacing.marginLeft[kid] ?? 0) + (spacing.marginRight[kid] ?? 0)
338
+ : (spacing.marginTop[kid] ?? 0) + (spacing.marginBottom[kid] ?? 0)
339
+
329
340
  if (isRow) {
330
- sumMain += kidW + gap
341
+ sumMain += kidW + kidMarginMain + gap
331
342
  maxCross = Math.max(maxCross, kidH)
332
343
  } else {
333
- sumMain += kidH + gap
344
+ sumMain += kidH + kidMarginMain + gap
334
345
  maxCross = Math.max(maxCross, kidW)
335
346
  }
336
347
  kid = nextSibling[kid]!
@@ -339,15 +350,15 @@ export function computeLayoutTitan(
339
350
  if (childCount > 0) sumMain -= gap
340
351
 
341
352
  // Add padding and borders to intrinsic size
342
- const padTop = unwrap(spacing.paddingTop[i]) ?? 0
343
- const padRight = unwrap(spacing.paddingRight[i]) ?? 0
344
- const padBottom = unwrap(spacing.paddingBottom[i]) ?? 0
345
- const padLeft = unwrap(spacing.paddingLeft[i]) ?? 0
346
- const borderStyle = unwrap(visual.borderStyle[i]) ?? 0
347
- const borderT = borderStyle > 0 || (unwrap(visual.borderTop[i]) ?? 0) > 0 ? 1 : 0
348
- const borderR = borderStyle > 0 || (unwrap(visual.borderRight[i]) ?? 0) > 0 ? 1 : 0
349
- const borderB = borderStyle > 0 || (unwrap(visual.borderBottom[i]) ?? 0) > 0 ? 1 : 0
350
- const borderL = borderStyle > 0 || (unwrap(visual.borderLeft[i]) ?? 0) > 0 ? 1 : 0
353
+ const padTop = spacing.paddingTop[i] ?? 0
354
+ const padRight = spacing.paddingRight[i] ?? 0
355
+ const padBottom = spacing.paddingBottom[i] ?? 0
356
+ const padLeft = spacing.paddingLeft[i] ?? 0
357
+ const borderStyle = visual.borderStyle[i] ?? 0
358
+ const borderT = borderStyle > 0 || (visual.borderTop[i] ?? 0) > 0 ? 1 : 0
359
+ const borderR = borderStyle > 0 || (visual.borderRight[i] ?? 0) > 0 ? 1 : 0
360
+ const borderB = borderStyle > 0 || (visual.borderBottom[i] ?? 0) > 0 ? 1 : 0
361
+ const borderL = borderStyle > 0 || (visual.borderLeft[i] ?? 0) > 0 ? 1 : 0
351
362
 
352
363
  const extraWidth = padLeft + padRight + borderL + borderR
353
364
  const extraHeight = padTop + padBottom + borderT + borderB
@@ -359,6 +370,21 @@ export function computeLayoutTitan(
359
370
  intrinsicW[i] = maxCross + extraWidth
360
371
  intrinsicH[i] = sumMain + extraHeight
361
372
  }
373
+ } else if (isScrollable) {
374
+ // Scrollable containers: minimal intrinsic size (just padding + borders)
375
+ // Children will overflow and be scrollable, not force container to expand
376
+ const padTop = spacing.paddingTop[i] ?? 0
377
+ const padRight = spacing.paddingRight[i] ?? 0
378
+ const padBottom = spacing.paddingBottom[i] ?? 0
379
+ const padLeft = spacing.paddingLeft[i] ?? 0
380
+ const borderStyle = visual.borderStyle[i] ?? 0
381
+ const borderT = borderStyle > 0 || (visual.borderTop[i] ?? 0) > 0 ? 1 : 0
382
+ const borderR = borderStyle > 0 || (visual.borderRight[i] ?? 0) > 0 ? 1 : 0
383
+ const borderB = borderStyle > 0 || (visual.borderBottom[i] ?? 0) > 0 ? 1 : 0
384
+ const borderL = borderStyle > 0 || (visual.borderLeft[i] ?? 0) > 0 ? 1 : 0
385
+
386
+ intrinsicW[i] = padLeft + padRight + borderL + borderR
387
+ intrinsicH[i] = padTop + padBottom + borderT + borderB
362
388
  }
363
389
  }
364
390
  }
@@ -381,7 +407,7 @@ export function computeLayoutTitan(
381
407
  // Collect flow children
382
408
  let kid = firstChild[parent]!
383
409
  while (kid !== -1) {
384
- if ((unwrap(layout.position[kid]) ?? POS_RELATIVE) !== POS_ABSOLUTE) {
410
+ if ((layout.position[kid] ?? POS_RELATIVE) !== POS_ABSOLUTE) {
385
411
  flowKids.push(kid)
386
412
  }
387
413
  kid = nextSibling[kid]!
@@ -390,16 +416,16 @@ export function computeLayoutTitan(
390
416
  if (flowKids.length === 0) return
391
417
 
392
418
  // Parent's content area
393
- const pPadT = unwrap(spacing.paddingTop[parent]) ?? 0
394
- const pPadR = unwrap(spacing.paddingRight[parent]) ?? 0
395
- const pPadB = unwrap(spacing.paddingBottom[parent]) ?? 0
396
- const pPadL = unwrap(spacing.paddingLeft[parent]) ?? 0
419
+ const pPadT = spacing.paddingTop[parent] ?? 0
420
+ const pPadR = spacing.paddingRight[parent] ?? 0
421
+ const pPadB = spacing.paddingBottom[parent] ?? 0
422
+ const pPadL = spacing.paddingLeft[parent] ?? 0
397
423
 
398
- const pBs = unwrap(visual.borderStyle[parent]) ?? 0
399
- const pBordT = pBs > 0 || (unwrap(visual.borderTop[parent]) ?? 0) > 0 ? 1 : 0
400
- const pBordR = pBs > 0 || (unwrap(visual.borderRight[parent]) ?? 0) > 0 ? 1 : 0
401
- const pBordB = pBs > 0 || (unwrap(visual.borderBottom[parent]) ?? 0) > 0 ? 1 : 0
402
- const pBordL = pBs > 0 || (unwrap(visual.borderLeft[parent]) ?? 0) > 0 ? 1 : 0
424
+ const pBs = visual.borderStyle[parent] ?? 0
425
+ const pBordT = pBs > 0 || (visual.borderTop[parent] ?? 0) > 0 ? 1 : 0
426
+ const pBordR = pBs > 0 || (visual.borderRight[parent] ?? 0) > 0 ? 1 : 0
427
+ const pBordB = pBs > 0 || (visual.borderBottom[parent] ?? 0) > 0 ? 1 : 0
428
+ const pBordL = pBs > 0 || (visual.borderLeft[parent] ?? 0) > 0 ? 1 : 0
403
429
 
404
430
  const contentX = outX[parent]! + pPadL + pBordL
405
431
  const contentY = outY[parent]! + pPadT + pBordT
@@ -407,17 +433,19 @@ export function computeLayoutTitan(
407
433
  const contentH = Math.max(0, outH[parent]! - pPadT - pPadB - pBordT - pBordB)
408
434
 
409
435
  // Flex properties
410
- const dir = unwrap(layout.flexDirection[parent]) ?? FLEX_COLUMN
411
- const wrap = unwrap(layout.flexWrap[parent]) ?? WRAP_NOWRAP
412
- const justify = unwrap(layout.justifyContent[parent]) ?? JUSTIFY_START
413
- const alignItems = unwrap(layout.alignItems[parent]) ?? ALIGN_STRETCH
414
- const gap = unwrap(spacing.gap[parent]) ?? 0
415
- const overflow = unwrap(layout.overflow[parent]) ?? Overflow.VISIBLE
436
+ const dir = layout.flexDirection[parent] ?? FLEX_COLUMN
437
+ const wrap = layout.flexWrap[parent] ?? WRAP_NOWRAP
438
+ const justify = layout.justifyContent[parent] ?? JUSTIFY_START
439
+ const alignItems = layout.alignItems[parent] ?? ALIGN_STRETCH
440
+ const gap = spacing.gap[parent] ?? 0
441
+ const overflow = layout.overflow[parent] ?? Overflow.VISIBLE
416
442
 
417
443
  const isRow = dir === FLEX_ROW || dir === FLEX_ROW_REVERSE
418
444
  const isReverse = dir === FLEX_ROW_REVERSE || dir === FLEX_COLUMN_REVERSE
419
445
  // Scrollable containers should NOT shrink children - content scrolls instead
420
- const isScrollableParent = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
446
+ // In fullscreen mode, root boxes (parentIndex === -1) are auto-scrollable
447
+ const isRoot = (core.parentIndex[parent] ?? -1) < 0
448
+ const isScrollableParent = overflow === Overflow.SCROLL || overflow === Overflow.AUTO || (isRoot && constrainHeight)
421
449
 
422
450
  const mainSize = isRow ? contentW : contentH
423
451
  const crossSize = isRow ? contentH : contentW
@@ -429,8 +457,8 @@ export function computeLayoutTitan(
429
457
 
430
458
  for (let fi = 0; fi < flowKids.length; fi++) {
431
459
  const fkid = flowKids[fi]!
432
- const ew = resolveDim(unwrap(dimensions.width[fkid]), contentW)
433
- const eh = resolveDim(unwrap(dimensions.height[fkid]), contentH)
460
+ const ew = resolveDim(dimensions.width[fkid], contentW)
461
+ const eh = resolveDim(dimensions.height[fkid], contentH)
434
462
  const kidMain = isRow
435
463
  ? (ew > 0 ? ew : intrinsicW[fkid]!)
436
464
  : (eh > 0 ? eh : intrinsicH[fkid]!)
@@ -464,17 +492,17 @@ export function computeLayoutTitan(
464
492
 
465
493
  for (let fi = lStart; fi <= lEnd; fi++) {
466
494
  const fkid = flowKids[fi]!
467
- totalGrow += unwrap(layout.flexGrow[fkid]) ?? 0
468
- totalShrink += unwrap(layout.flexShrink[fkid]) ?? 1
495
+ totalGrow += layout.flexGrow[fkid] ?? 0
496
+ totalShrink += layout.flexShrink[fkid] ?? 1
469
497
  }
470
498
 
471
499
  for (let fi = lStart; fi <= lEnd; fi++) {
472
500
  const fkid = flowKids[fi]!
473
- const ew = resolveDim(unwrap(dimensions.width[fkid]), contentW)
474
- const eh = resolveDim(unwrap(dimensions.height[fkid]), contentH)
501
+ const ew = resolveDim(dimensions.width[fkid], contentW)
502
+ const eh = resolveDim(dimensions.height[fkid], contentH)
475
503
 
476
504
  // flex-basis takes priority over width/height for main axis size
477
- const basis = unwrap(layout.flexBasis[fkid]) ?? 0
505
+ const basis = layout.flexBasis[fkid] ?? 0
478
506
  let kidMain = basis > 0
479
507
  ? basis
480
508
  : (isRow
@@ -482,17 +510,17 @@ export function computeLayoutTitan(
482
510
  : (eh > 0 ? eh : intrinsicH[fkid]!))
483
511
 
484
512
  if (freeSpace > 0 && totalGrow > 0) {
485
- kidMain += ((unwrap(layout.flexGrow[fkid]) ?? 0) / totalGrow) * freeSpace
513
+ kidMain += ((layout.flexGrow[fkid] ?? 0) / totalGrow) * freeSpace
486
514
  } else if (freeSpace < 0 && totalShrink > 0 && !isScrollableParent) {
487
515
  // Only shrink if parent is NOT scrollable
488
516
  // Scrollable containers let content overflow and scroll instead
489
- kidMain += ((unwrap(layout.flexShrink[fkid]) ?? 1) / totalShrink) * freeSpace
517
+ kidMain += ((layout.flexShrink[fkid] ?? 1) / totalShrink) * freeSpace
490
518
  }
491
519
  kidMain = Math.max(0, Math.floor(kidMain))
492
520
 
493
521
  // Apply min/max constraints for main axis
494
- const minMain = isRow ? unwrap(dimensions.minWidth[fkid]) : unwrap(dimensions.minHeight[fkid])
495
- const maxMain = isRow ? unwrap(dimensions.maxWidth[fkid]) : unwrap(dimensions.maxHeight[fkid])
522
+ const minMain = isRow ? dimensions.minWidth[fkid] : dimensions.minHeight[fkid]
523
+ const maxMain = isRow ? dimensions.maxWidth[fkid] : dimensions.maxHeight[fkid]
496
524
  kidMain = clampDim(kidMain, minMain, maxMain, isRow ? contentW : contentH)
497
525
 
498
526
  let kidCross = isRow
@@ -500,8 +528,8 @@ export function computeLayoutTitan(
500
528
  : (ew > 0 ? ew : (alignItems === ALIGN_STRETCH ? crossSize / lineCount : intrinsicW[fkid]!))
501
529
 
502
530
  // Apply min/max constraints for cross axis
503
- const minCross = isRow ? unwrap(dimensions.minHeight[fkid]) : unwrap(dimensions.minWidth[fkid])
504
- const maxCross = isRow ? unwrap(dimensions.maxHeight[fkid]) : unwrap(dimensions.maxWidth[fkid])
531
+ const minCross = isRow ? dimensions.minHeight[fkid] : dimensions.minWidth[fkid]
532
+ const maxCross = isRow ? dimensions.maxHeight[fkid] : dimensions.maxWidth[fkid]
505
533
  kidCross = clampDim(Math.max(0, Math.floor(kidCross)), minCross, maxCross, isRow ? contentH : contentW)
506
534
 
507
535
  itemMain[fkid] = kidMain
@@ -525,8 +553,8 @@ export function computeLayoutTitan(
525
553
  const kid = flowKids[fi]!
526
554
  // Include margins in line size calculation (CSS box model)
527
555
  const mMain = isRow
528
- ? (unwrap(spacing.marginLeft[kid]) ?? 0) + (unwrap(spacing.marginRight[kid]) ?? 0)
529
- : (unwrap(spacing.marginTop[kid]) ?? 0) + (unwrap(spacing.marginBottom[kid]) ?? 0)
556
+ ? (spacing.marginLeft[kid] ?? 0) + (spacing.marginRight[kid] ?? 0)
557
+ : (spacing.marginTop[kid] ?? 0) + (spacing.marginBottom[kid] ?? 0)
530
558
  lineMain += itemMain[kid]! + mMain + gap
531
559
  }
532
560
  lineMain -= gap
@@ -568,16 +596,16 @@ export function computeLayoutTitan(
568
596
  const sizeCross = itemCross[fkid]!
569
597
 
570
598
  // Read margins for CSS-compliant positioning
571
- const mTop = unwrap(spacing.marginTop[fkid]) ?? 0
572
- const mRight = unwrap(spacing.marginRight[fkid]) ?? 0
573
- const mBottom = unwrap(spacing.marginBottom[fkid]) ?? 0
574
- const mLeft = unwrap(spacing.marginLeft[fkid]) ?? 0
599
+ const mTop = spacing.marginTop[fkid] ?? 0
600
+ const mRight = spacing.marginRight[fkid] ?? 0
601
+ const mBottom = spacing.marginBottom[fkid] ?? 0
602
+ const mLeft = spacing.marginLeft[fkid] ?? 0
575
603
 
576
604
  // align-self overrides parent's align-items for individual items
577
605
  // alignSelf: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end, 5=baseline
578
606
  // alignItems: 0=stretch, 1=flex-start, 2=center, 3=flex-end
579
607
  // When alignSelf != 0, we subtract 1 to map to alignItems values
580
- const selfAlign = unwrap(layout.alignSelf[fkid]) ?? ALIGN_SELF_AUTO
608
+ const selfAlign = layout.alignSelf[fkid] ?? ALIGN_SELF_AUTO
581
609
  const effectiveAlign = selfAlign !== ALIGN_SELF_AUTO ? (selfAlign - 1) : alignItems
582
610
 
583
611
  let crossPos = crossOffset
@@ -617,7 +645,7 @@ export function computeLayoutTitan(
617
645
  // TEXT WRAPPING: Now that we know the width, recalculate height for TEXT
618
646
  // This fixes the intrinsicH=1 assumption - text wraps to actual width
619
647
  if (core.componentType[fkid] === ComponentType.TEXT) {
620
- const content = unwrap(text.textContent[fkid])
648
+ const content = text.textContent[fkid] // SlotArray auto-unwraps & tracks
621
649
  if (content != null) {
622
650
  const str = String(content)
623
651
  if (str.length > 0) {
@@ -665,10 +693,10 @@ export function computeLayoutTitan(
665
693
  // HELPER: Absolute positioning
666
694
  // ─────────────────────────────────────────────────────────────────────────
667
695
  function layoutAbsolute(i: number): void {
668
- let container = unwrap(core.parentIndex[i]) ?? -1
696
+ let container = core.parentIndex[i] ?? -1
669
697
  while (container >= 0 && indices.has(container)) {
670
- if ((unwrap(layout.position[container]) ?? POS_RELATIVE) !== POS_RELATIVE) break
671
- container = unwrap(core.parentIndex[container]) ?? -1
698
+ if ((layout.position[container] ?? POS_RELATIVE) !== POS_RELATIVE) break
699
+ container = core.parentIndex[container] ?? -1
672
700
  }
673
701
 
674
702
  let containerX = 0, containerY = 0, containerW = outW[0] ?? 80, containerH = outH[0] ?? 24
@@ -680,21 +708,21 @@ export function computeLayoutTitan(
680
708
  }
681
709
 
682
710
  // Resolve dimensions against containing block
683
- const ew = resolveDim(unwrap(dimensions.width[i]), containerW)
684
- const eh = resolveDim(unwrap(dimensions.height[i]), containerH)
711
+ const ew = resolveDim(dimensions.width[i], containerW)
712
+ const eh = resolveDim(dimensions.height[i], containerH)
685
713
  let absW = ew > 0 ? ew : intrinsicW[i]!
686
714
  let absH = eh > 0 ? eh : intrinsicH[i]!
687
715
 
688
716
  // Apply min/max constraints
689
- absW = clampDim(absW, unwrap(dimensions.minWidth[i]), unwrap(dimensions.maxWidth[i]), containerW)
690
- absH = clampDim(absH, unwrap(dimensions.minHeight[i]), unwrap(dimensions.maxHeight[i]), containerH)
717
+ absW = clampDim(absW, dimensions.minWidth[i], dimensions.maxWidth[i], containerW)
718
+ absH = clampDim(absH, dimensions.minHeight[i], dimensions.maxHeight[i], containerH)
691
719
  outW[i] = absW
692
720
  outH[i] = absH
693
721
 
694
- const t = unwrap(layout.top[i])
695
- const r = unwrap(layout.right[i])
696
- const b = unwrap(layout.bottom[i])
697
- const l = unwrap(layout.left[i])
722
+ const t = layout.top[i]
723
+ const r = layout.right[i]
724
+ const b = layout.bottom[i]
725
+ const l = layout.left[i]
698
726
 
699
727
  if (l !== undefined && l !== 0) {
700
728
  outX[i] = containerX + l
@@ -724,8 +752,8 @@ export function computeLayoutTitan(
724
752
  // Root elements resolve percentage dimensions against terminal size
725
753
  for (let ri = 0; ri < rootCount; ri++) {
726
754
  const root = bfsQueue[ri]!
727
- const rawW = unwrap(dimensions.width[root])
728
- const rawH = unwrap(dimensions.height[root])
755
+ const rawW = dimensions.width[root]
756
+ const rawH = dimensions.height[root]
729
757
  const ew = resolveDim(rawW, terminalWidth)
730
758
  const eh = resolveDim(rawH, terminalHeight)
731
759
 
@@ -758,7 +786,7 @@ export function computeLayoutTitan(
758
786
  // PASS 5: Absolute positioning
759
787
  // ─────────────────────────────────────────────────────────────────────────
760
788
  for (const i of indices) {
761
- if ((unwrap(layout.position[i]) ?? POS_RELATIVE) === POS_ABSOLUTE) {
789
+ if ((layout.position[i] ?? POS_RELATIVE) === POS_ABSOLUTE) {
762
790
  layoutAbsolute(i)
763
791
  }
764
792
  }