@rlabs-inc/tui 0.1.0 → 0.2.1

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 (39) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/history.ts +451 -0
  5. package/src/api/mount.ts +66 -31
  6. package/src/engine/arrays/core.ts +13 -21
  7. package/src/engine/arrays/dimensions.ts +22 -32
  8. package/src/engine/arrays/index.ts +88 -86
  9. package/src/engine/arrays/interaction.ts +34 -48
  10. package/src/engine/arrays/layout.ts +67 -92
  11. package/src/engine/arrays/spacing.ts +37 -52
  12. package/src/engine/arrays/text.ts +23 -31
  13. package/src/engine/arrays/visual.ts +56 -75
  14. package/src/engine/inheritance.ts +18 -18
  15. package/src/engine/registry.ts +15 -0
  16. package/src/pipeline/frameBuffer.ts +26 -26
  17. package/src/pipeline/layout/index.ts +2 -2
  18. package/src/pipeline/layout/titan-engine.ts +112 -84
  19. package/src/primitives/animation.ts +194 -0
  20. package/src/primitives/box.ts +74 -86
  21. package/src/primitives/each.ts +87 -0
  22. package/src/primitives/index.ts +7 -0
  23. package/src/primitives/scope.ts +215 -0
  24. package/src/primitives/show.ts +77 -0
  25. package/src/primitives/text.ts +63 -59
  26. package/src/primitives/types.ts +1 -1
  27. package/src/primitives/when.ts +102 -0
  28. package/src/renderer/append-region.ts +159 -0
  29. package/src/renderer/index.ts +4 -2
  30. package/src/renderer/output.ts +11 -34
  31. package/src/state/focus.ts +16 -5
  32. package/src/state/global-keys.ts +184 -0
  33. package/src/state/index.ts +44 -8
  34. package/src/state/input.ts +534 -0
  35. package/src/state/keyboard.ts +98 -674
  36. package/src/state/mouse.ts +163 -340
  37. package/src/state/scroll.ts +7 -9
  38. package/src/types/index.ts +23 -2
  39. package/src/renderer/input.ts +0 -518
@@ -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
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * TUI Framework - Animation Primitives
3
+ *
4
+ * Reusable animation utilities for spinners, progress indicators, etc.
5
+ * Handles frame cycling with proper lifecycle management.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10
+ *
11
+ * function LoadingSpinner(props): Cleanup {
12
+ * return scoped(() => {
13
+ * const frame = useAnimation(SPINNER, {
14
+ * fps: 12,
15
+ * active: () => props.loading.value,
16
+ * })
17
+ *
18
+ * text({ content: frame, fg: t.warning })
19
+ * })
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { signal, derived, effect } from '@rlabs-inc/signals'
25
+ import type { DerivedSignal } from '@rlabs-inc/signals'
26
+ import { onCleanup, getActiveScope } from './scope'
27
+
28
+ export interface AnimationOptions {
29
+ /** Frames per second (default: 12) */
30
+ fps?: number
31
+ /** Whether animation is active - can be reactive */
32
+ active?: boolean | (() => boolean) | { readonly value: boolean }
33
+ /** Start at a specific frame index (default: 0) */
34
+ startFrame?: number
35
+ }
36
+
37
+ // Global animation registry - shared intervals for same FPS
38
+ interface AnimationRegistry {
39
+ frameIndex: ReturnType<typeof signal<number>>
40
+ interval: ReturnType<typeof setInterval> | null
41
+ subscribers: number
42
+ }
43
+
44
+ const animationRegistry = new Map<number, AnimationRegistry>()
45
+
46
+ /**
47
+ * Get or create a shared animation clock for the given FPS.
48
+ */
49
+ function getAnimationClock(fps: number): AnimationRegistry {
50
+ let registry = animationRegistry.get(fps)
51
+
52
+ if (!registry) {
53
+ registry = {
54
+ frameIndex: signal(0),
55
+ interval: null,
56
+ subscribers: 0,
57
+ }
58
+ animationRegistry.set(fps, registry)
59
+ }
60
+
61
+ return registry
62
+ }
63
+
64
+ /**
65
+ * Subscribe to an animation clock.
66
+ */
67
+ function subscribeToAnimation(fps: number): () => void {
68
+ const registry = getAnimationClock(fps)
69
+ registry.subscribers++
70
+
71
+ // Start interval if this is the first subscriber
72
+ if (registry.subscribers === 1 && !registry.interval) {
73
+ const ms = Math.floor(1000 / fps)
74
+ registry.interval = setInterval(() => {
75
+ registry.frameIndex.value++
76
+ }, ms)
77
+ }
78
+
79
+ // Return unsubscribe function
80
+ return () => {
81
+ registry.subscribers = Math.max(0, registry.subscribers - 1)
82
+
83
+ // Stop interval if no more subscribers
84
+ if (registry.subscribers === 0 && registry.interval) {
85
+ clearInterval(registry.interval)
86
+ registry.interval = null
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create an animated signal that cycles through frames.
93
+ *
94
+ * @example Basic spinner
95
+ * ```ts
96
+ * const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
97
+ * const frame = useAnimation(SPINNER)
98
+ * text({ content: frame }) // Animates automatically
99
+ * ```
100
+ *
101
+ * @example Conditional animation
102
+ * ```ts
103
+ * const frame = useAnimation(SPINNER, {
104
+ * fps: 12,
105
+ * active: () => isLoading.value,
106
+ * })
107
+ * // Animation only runs when isLoading is true
108
+ * ```
109
+ *
110
+ * @example With scope (auto-cleanup)
111
+ * ```ts
112
+ * function Spinner(): Cleanup {
113
+ * return scoped(() => {
114
+ * const frame = useAnimation(SPINNER, { active: () => loading.value })
115
+ * text({ content: frame, fg: t.warning })
116
+ * })
117
+ * }
118
+ * ```
119
+ */
120
+ export function useAnimation<T>(
121
+ frames: readonly T[],
122
+ options: AnimationOptions = {}
123
+ ): DerivedSignal<T> {
124
+ const { fps = 12, active = true, startFrame = 0 } = options
125
+
126
+ // Get shared animation clock
127
+ const clock = getAnimationClock(fps)
128
+
129
+ // Track whether we're subscribed
130
+ let unsubscribe: (() => void) | null = null
131
+
132
+ // Unwrap active prop
133
+ const isActive = (): boolean => {
134
+ if (typeof active === 'function') return active()
135
+ if (typeof active === 'object' && 'value' in active) return active.value
136
+ return active
137
+ }
138
+
139
+ // Effect to manage subscription based on active state
140
+ const stopEffect = effect(() => {
141
+ const shouldBeActive = isActive()
142
+
143
+ if (shouldBeActive && !unsubscribe) {
144
+ unsubscribe = subscribeToAnimation(fps)
145
+ } else if (!shouldBeActive && unsubscribe) {
146
+ unsubscribe()
147
+ unsubscribe = null
148
+ }
149
+ })
150
+
151
+ // Auto-register cleanup with active scope
152
+ const scope = getActiveScope()
153
+ if (scope) {
154
+ scope.cleanups.push(() => {
155
+ stopEffect()
156
+ if (unsubscribe) {
157
+ unsubscribe()
158
+ unsubscribe = null
159
+ }
160
+ })
161
+ }
162
+
163
+ // Return derived that computes current frame
164
+ return derived(() => {
165
+ const index = (clock.frameIndex.value + startFrame) % frames.length
166
+ return frames[index]!
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Common animation frame sets.
172
+ */
173
+ export const AnimationFrames = {
174
+ /** Braille spinner (smooth) */
175
+ spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const,
176
+
177
+ /** Braille dots (vertical) */
178
+ dots: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const,
179
+
180
+ /** Simple line spinner */
181
+ line: ['|', '/', '-', '\\'] as const,
182
+
183
+ /** Growing bar */
184
+ bar: ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] as const,
185
+
186
+ /** Bouncing ball */
187
+ bounce: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'] as const,
188
+
189
+ /** Clock */
190
+ clock: ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛'] as const,
191
+
192
+ /** Pulse */
193
+ pulse: ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'] as const,
194
+ } as const