@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
@@ -4,97 +4,82 @@
4
4
  * Margin, padding, and gap values.
5
5
  * All values are in terminal cells (integers).
6
6
  *
7
- * CRITICAL: Use regular arrays (NOT state!) to preserve binding getters.
8
- * state() proxies snapshot getter values, breaking reactivity.
7
+ * Uses slotArray for stable reactive cells that NEVER get replaced.
9
8
  */
10
9
 
11
- import { bind, disconnectBinding, type Binding } from '@rlabs-inc/signals'
10
+ import { slotArray, type SlotArray } from '@rlabs-inc/signals'
12
11
 
13
12
  // =============================================================================
14
13
  // MARGIN - Space outside the component (offsets position in parent)
15
14
  // =============================================================================
16
15
 
17
16
  /** Top margin - adds space above the component (in cells) */
18
- export const marginTop: Binding<number>[] = []
17
+ export const marginTop: SlotArray<number> = slotArray<number>(0)
19
18
 
20
19
  /** Right margin - adds space to the right of the component (in cells) */
21
- export const marginRight: Binding<number>[] = []
20
+ export const marginRight: SlotArray<number> = slotArray<number>(0)
22
21
 
23
22
  /** Bottom margin - adds space below the component (in cells) */
24
- export const marginBottom: Binding<number>[] = []
23
+ export const marginBottom: SlotArray<number> = slotArray<number>(0)
25
24
 
26
25
  /** Left margin - adds space to the left of the component (in cells) */
27
- export const marginLeft: Binding<number>[] = []
26
+ export const marginLeft: SlotArray<number> = slotArray<number>(0)
28
27
 
29
28
  // =============================================================================
30
29
  // PADDING - Space inside the component (reduces content area)
31
30
  // =============================================================================
32
31
 
33
32
  /** Top padding - pushes content down from top edge (in cells) */
34
- export const paddingTop: Binding<number>[] = []
33
+ export const paddingTop: SlotArray<number> = slotArray<number>(0)
35
34
 
36
35
  /** Right padding - pushes content left from right edge (in cells) */
37
- export const paddingRight: Binding<number>[] = []
36
+ export const paddingRight: SlotArray<number> = slotArray<number>(0)
38
37
 
39
38
  /** Bottom padding - pushes content up from bottom edge (in cells) */
40
- export const paddingBottom: Binding<number>[] = []
39
+ export const paddingBottom: SlotArray<number> = slotArray<number>(0)
41
40
 
42
41
  /** Left padding - pushes content right from left edge (in cells) */
43
- export const paddingLeft: Binding<number>[] = []
42
+ export const paddingLeft: SlotArray<number> = slotArray<number>(0)
44
43
 
45
44
  // =============================================================================
46
45
  // GAP - Space between flex items (CSS gap property)
47
46
  // =============================================================================
48
47
 
49
48
  /** Gap between flex items in both directions (in cells) */
50
- export const gap: Binding<number>[] = []
49
+ export const gap: SlotArray<number> = slotArray<number>(0)
51
50
 
52
51
  /** Row gap - vertical space between wrapped lines (in cells) */
53
- export const rowGap: Binding<number>[] = []
52
+ export const rowGap: SlotArray<number> = slotArray<number>(0)
54
53
 
55
54
  /** Column gap - horizontal space between items in a row (in cells) */
56
- export const columnGap: Binding<number>[] = []
55
+ export const columnGap: SlotArray<number> = slotArray<number>(0)
57
56
 
58
- /** LAZY BINDING: Push undefined, primitives create bindings for used props only */
57
+ /** Ensure capacity for all spacing arrays */
59
58
  export function ensureCapacity(index: number): void {
60
- while (marginTop.length <= index) {
61
- marginTop.push(undefined as any)
62
- marginRight.push(undefined as any)
63
- marginBottom.push(undefined as any)
64
- marginLeft.push(undefined as any)
65
- paddingTop.push(undefined as any)
66
- paddingRight.push(undefined as any)
67
- paddingBottom.push(undefined as any)
68
- paddingLeft.push(undefined as any)
69
- gap.push(undefined as any)
70
- rowGap.push(undefined as any)
71
- columnGap.push(undefined as any)
72
- }
59
+ marginTop.ensureCapacity(index)
60
+ marginRight.ensureCapacity(index)
61
+ marginBottom.ensureCapacity(index)
62
+ marginLeft.ensureCapacity(index)
63
+ paddingTop.ensureCapacity(index)
64
+ paddingRight.ensureCapacity(index)
65
+ paddingBottom.ensureCapacity(index)
66
+ paddingLeft.ensureCapacity(index)
67
+ gap.ensureCapacity(index)
68
+ rowGap.ensureCapacity(index)
69
+ columnGap.ensureCapacity(index)
73
70
  }
74
71
 
72
+ /** Clear slot at index (reset to default) */
75
73
  export function clearAtIndex(index: number): void {
76
- if (index < marginTop.length) {
77
- disconnectBinding(marginTop[index])
78
- disconnectBinding(marginRight[index])
79
- disconnectBinding(marginBottom[index])
80
- disconnectBinding(marginLeft[index])
81
- disconnectBinding(paddingTop[index])
82
- disconnectBinding(paddingRight[index])
83
- disconnectBinding(paddingBottom[index])
84
- disconnectBinding(paddingLeft[index])
85
- disconnectBinding(gap[index])
86
- disconnectBinding(rowGap[index])
87
- disconnectBinding(columnGap[index])
88
- marginTop[index] = undefined as any
89
- marginRight[index] = undefined as any
90
- marginBottom[index] = undefined as any
91
- marginLeft[index] = undefined as any
92
- paddingTop[index] = undefined as any
93
- paddingRight[index] = undefined as any
94
- paddingBottom[index] = undefined as any
95
- paddingLeft[index] = undefined as any
96
- gap[index] = undefined as any
97
- rowGap[index] = undefined as any
98
- columnGap[index] = undefined as any
99
- }
74
+ marginTop.clear(index)
75
+ marginRight.clear(index)
76
+ marginBottom.clear(index)
77
+ marginLeft.clear(index)
78
+ paddingTop.clear(index)
79
+ paddingRight.clear(index)
80
+ paddingBottom.clear(index)
81
+ paddingLeft.clear(index)
82
+ gap.clear(index)
83
+ rowGap.clear(index)
84
+ columnGap.clear(index)
100
85
  }
@@ -3,53 +3,45 @@
3
3
  *
4
4
  * Text content and styling for text/input components.
5
5
  *
6
- * CRITICAL: Use regular arrays (NOT state!) to preserve binding getters.
7
- * state() proxies snapshot getter values, breaking reactivity.
6
+ * Uses slotArray for stable reactive cells that NEVER get replaced.
7
+ * This fixes the bind() tracking bug where deriveds miss updates
8
+ * when binding objects are replaced.
8
9
  *
9
- * Flow: user signal → bind() → Binding storedunwrap() reads .value → dependency!
10
+ * Flow: user signal → setSource() → Slot tracksarr[i] reads value → dependency!
10
11
  */
11
12
 
12
- import { bind, disconnectBinding, type Binding } from '@rlabs-inc/signals'
13
+ import { slotArray, type SlotArray } from '@rlabs-inc/signals'
13
14
  import type { CellAttrs } from '../../types'
14
- import { Attr } from '../../types'
15
15
 
16
- // Text content - Regular array to preserve binding getters
17
- export const textContent: Binding<string>[] = []
16
+ // Text content - SlotArray auto-tracks and auto-unwraps
17
+ export const textContent: SlotArray<string> = slotArray<string>('')
18
18
 
19
19
  // Text styling (CellAttrs bitfield)
20
- export const textAttrs: Binding<CellAttrs>[] = []
20
+ export const textAttrs: SlotArray<CellAttrs> = slotArray<CellAttrs>(0)
21
21
 
22
22
  // Text alignment: 0=left, 1=center, 2=right
23
- export const textAlign: Binding<number>[] = []
23
+ export const textAlign: SlotArray<number> = slotArray<number>(0)
24
24
 
25
25
  // Text wrapping: 0=nowrap, 1=wrap, 2=truncate
26
- export const textWrap: Binding<number>[] = []
26
+ export const textWrap: SlotArray<number> = slotArray<number>(1) // wrap by default
27
27
 
28
28
  // Ellipsis for truncated text
29
- export const ellipsis: Binding<string>[] = []
29
+ export const ellipsis: SlotArray<string> = slotArray<string>('...')
30
30
 
31
- /** LAZY BINDING: Push undefined, primitives create bindings for used props only */
31
+ /** Ensure capacity for all text arrays */
32
32
  export function ensureCapacity(index: number): void {
33
- while (textContent.length <= index) {
34
- textContent.push(undefined as any)
35
- textAttrs.push(undefined as any)
36
- textAlign.push(undefined as any)
37
- textWrap.push(undefined as any)
38
- ellipsis.push(undefined as any)
39
- }
33
+ textContent.ensureCapacity(index)
34
+ textAttrs.ensureCapacity(index)
35
+ textAlign.ensureCapacity(index)
36
+ textWrap.ensureCapacity(index)
37
+ ellipsis.ensureCapacity(index)
40
38
  }
41
39
 
40
+ /** Clear slot at index (reset to default) */
42
41
  export function clearAtIndex(index: number): void {
43
- if (index < textContent.length) {
44
- disconnectBinding(textContent[index])
45
- disconnectBinding(textAttrs[index])
46
- disconnectBinding(textAlign[index])
47
- disconnectBinding(textWrap[index])
48
- disconnectBinding(ellipsis[index])
49
- textContent[index] = undefined as any
50
- textAttrs[index] = undefined as any
51
- textAlign[index] = undefined as any
52
- textWrap[index] = undefined as any
53
- ellipsis[index] = undefined as any
54
- }
42
+ textContent.clear(index)
43
+ textAttrs.clear(index)
44
+ textAlign.clear(index)
45
+ textWrap.clear(index)
46
+ ellipsis.clear(index)
55
47
  }
@@ -4,8 +4,7 @@
4
4
  * Colors, borders, and visual styling.
5
5
  * Colors stored as RGBA objects for alpha blending support.
6
6
  *
7
- * CRITICAL: Use regular arrays (NOT state!) to preserve binding getters.
8
- * state() proxies snapshot getter values, breaking reactivity.
7
+ * Uses slotArray for stable reactive cells that NEVER get replaced.
9
8
  *
10
9
  * Border styles:
11
10
  * 0 = none
@@ -23,118 +22,100 @@
23
22
  * Per-side borders can have independent styles.
24
23
  */
25
24
 
26
- import { bind, disconnectBinding, type Binding } from '@rlabs-inc/signals'
25
+ import { slotArray, type SlotArray } from '@rlabs-inc/signals'
27
26
  import type { RGBA } from '../../types'
28
27
 
29
28
  // =============================================================================
30
- // COLORS - Regular arrays to preserve binding reactivity
29
+ // DEFAULT VALUES
30
+ // =============================================================================
31
+
32
+ const DEFAULT_FOCUS_COLOR: RGBA = { r: 100, g: 149, b: 237, a: 255 } // cornflowerblue
33
+
34
+ // =============================================================================
35
+ // COLORS - SlotArrays for stable reactive cells
31
36
  // =============================================================================
32
37
 
33
38
  /** Foreground color (text) - null means inherit from parent */
34
- export const fgColor: Binding<RGBA | null>[] = []
39
+ export const fgColor: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
35
40
 
36
41
  /** Background color - null means transparent/inherit */
37
- export const bgColor: Binding<RGBA | null>[] = []
42
+ export const bgColor: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
38
43
 
39
44
  /** Opacity 0-1 (1 = fully opaque) */
40
- export const opacity: Binding<number>[] = []
45
+ export const opacity: SlotArray<number> = slotArray<number>(1)
41
46
 
42
47
  // =============================================================================
43
48
  // BORDERS - Per-side independent styles
44
49
  // =============================================================================
45
50
 
46
51
  /** Default border style for all sides (0-10) */
47
- export const borderStyle: Binding<number>[] = []
52
+ export const borderStyle: SlotArray<number> = slotArray<number>(0)
48
53
 
49
54
  /** Default border color for all sides - null means use foreground */
50
- export const borderColor: Binding<RGBA | null>[] = []
55
+ export const borderColor: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
51
56
 
52
57
  /** Top border style (0=none or inherit from borderStyle, 1-10=specific style) */
53
- export const borderTop: Binding<number>[] = []
58
+ export const borderTop: SlotArray<number> = slotArray<number>(0)
54
59
 
55
60
  /** Right border style */
56
- export const borderRight: Binding<number>[] = []
61
+ export const borderRight: SlotArray<number> = slotArray<number>(0)
57
62
 
58
63
  /** Bottom border style */
59
- export const borderBottom: Binding<number>[] = []
64
+ export const borderBottom: SlotArray<number> = slotArray<number>(0)
60
65
 
61
66
  /** Left border style */
62
- export const borderLeft: Binding<number>[] = []
67
+ export const borderLeft: SlotArray<number> = slotArray<number>(0)
63
68
 
64
69
  /** Per-side border colors - null means use borderColor or foreground */
65
- export const borderColorTop: Binding<RGBA | null>[] = []
66
- export const borderColorRight: Binding<RGBA | null>[] = []
67
- export const borderColorBottom: Binding<RGBA | null>[] = []
68
- export const borderColorLeft: Binding<RGBA | null>[] = []
70
+ export const borderColorTop: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
71
+ export const borderColorRight: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
72
+ export const borderColorBottom: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
73
+ export const borderColorLeft: SlotArray<RGBA | null> = slotArray<RGBA | null>(null)
69
74
 
70
75
  // =============================================================================
71
76
  // FOCUS RING
72
77
  // =============================================================================
73
78
 
74
79
  /** Show focus ring when focused (1=yes, 0=no) */
75
- export const showFocusRing: Binding<number>[] = []
80
+ export const showFocusRing: SlotArray<number> = slotArray<number>(0)
76
81
 
77
82
  /** Focus ring color */
78
- export const focusRingColor: Binding<RGBA>[] = []
79
-
80
- // =============================================================================
81
- // DEFAULT VALUES
82
- // =============================================================================
83
-
84
- const DEFAULT_FOCUS_COLOR: RGBA = { r: 100, g: 149, b: 237, a: 255 } // cornflowerblue
83
+ export const focusRingColor: SlotArray<RGBA> = slotArray<RGBA>(DEFAULT_FOCUS_COLOR)
85
84
 
86
- /** LAZY BINDING: Push undefined, primitives create bindings for used props only */
85
+ /** Ensure capacity for all visual arrays */
87
86
  export function ensureCapacity(index: number): void {
88
- while (fgColor.length <= index) {
89
- fgColor.push(undefined as any)
90
- bgColor.push(undefined as any)
91
- opacity.push(undefined as any)
92
- borderStyle.push(undefined as any)
93
- borderColor.push(undefined as any)
94
- borderTop.push(undefined as any)
95
- borderRight.push(undefined as any)
96
- borderBottom.push(undefined as any)
97
- borderLeft.push(undefined as any)
98
- borderColorTop.push(undefined as any)
99
- borderColorRight.push(undefined as any)
100
- borderColorBottom.push(undefined as any)
101
- borderColorLeft.push(undefined as any)
102
- showFocusRing.push(undefined as any)
103
- focusRingColor.push(undefined as any)
104
- }
87
+ fgColor.ensureCapacity(index)
88
+ bgColor.ensureCapacity(index)
89
+ opacity.ensureCapacity(index)
90
+ borderStyle.ensureCapacity(index)
91
+ borderColor.ensureCapacity(index)
92
+ borderTop.ensureCapacity(index)
93
+ borderRight.ensureCapacity(index)
94
+ borderBottom.ensureCapacity(index)
95
+ borderLeft.ensureCapacity(index)
96
+ borderColorTop.ensureCapacity(index)
97
+ borderColorRight.ensureCapacity(index)
98
+ borderColorBottom.ensureCapacity(index)
99
+ borderColorLeft.ensureCapacity(index)
100
+ showFocusRing.ensureCapacity(index)
101
+ focusRingColor.ensureCapacity(index)
105
102
  }
106
103
 
104
+ /** Clear slot at index (reset to default) */
107
105
  export function clearAtIndex(index: number): void {
108
- if (index < fgColor.length) {
109
- disconnectBinding(fgColor[index])
110
- disconnectBinding(bgColor[index])
111
- disconnectBinding(opacity[index])
112
- disconnectBinding(borderStyle[index])
113
- disconnectBinding(borderColor[index])
114
- disconnectBinding(borderTop[index])
115
- disconnectBinding(borderRight[index])
116
- disconnectBinding(borderBottom[index])
117
- disconnectBinding(borderLeft[index])
118
- disconnectBinding(borderColorTop[index])
119
- disconnectBinding(borderColorRight[index])
120
- disconnectBinding(borderColorBottom[index])
121
- disconnectBinding(borderColorLeft[index])
122
- disconnectBinding(showFocusRing[index])
123
- disconnectBinding(focusRingColor[index])
124
- fgColor[index] = undefined as any
125
- bgColor[index] = undefined as any
126
- opacity[index] = undefined as any
127
- borderStyle[index] = undefined as any
128
- borderColor[index] = undefined as any
129
- borderTop[index] = undefined as any
130
- borderRight[index] = undefined as any
131
- borderBottom[index] = undefined as any
132
- borderLeft[index] = undefined as any
133
- borderColorTop[index] = undefined as any
134
- borderColorRight[index] = undefined as any
135
- borderColorBottom[index] = undefined as any
136
- borderColorLeft[index] = undefined as any
137
- showFocusRing[index] = undefined as any
138
- focusRingColor[index] = undefined as any
139
- }
106
+ fgColor.clear(index)
107
+ bgColor.clear(index)
108
+ opacity.clear(index)
109
+ borderStyle.clear(index)
110
+ borderColor.clear(index)
111
+ borderTop.clear(index)
112
+ borderRight.clear(index)
113
+ borderBottom.clear(index)
114
+ borderLeft.clear(index)
115
+ borderColorTop.clear(index)
116
+ borderColorRight.clear(index)
117
+ borderColorBottom.clear(index)
118
+ borderColorLeft.clear(index)
119
+ showFocusRing.clear(index)
120
+ focusRingColor.clear(index)
140
121
  }
@@ -19,9 +19,9 @@ export function getInheritedFg(index: number): RGBA {
19
19
  let current: number = index
20
20
 
21
21
  while (current >= 0) {
22
- const fg = unwrap(visual.fgColor[current])
22
+ const fg = visual.fgColor[current]
23
23
  if (fg !== null && fg !== undefined) return fg
24
- const parent = unwrap(core.parentIndex[current])
24
+ const parent = core.parentIndex[current]
25
25
  if (parent === undefined || parent < 0) break
26
26
  current = parent
27
27
  }
@@ -37,9 +37,9 @@ export function getInheritedBg(index: number): RGBA {
37
37
  let current: number = index
38
38
 
39
39
  while (current >= 0) {
40
- const bg = unwrap(visual.bgColor[current])
40
+ const bg = visual.bgColor[current]
41
41
  if (bg !== null && bg !== undefined) return bg
42
- const parent = unwrap(core.parentIndex[current])
42
+ const parent = core.parentIndex[current]
43
43
  if (parent === undefined || parent < 0) break
44
44
  current = parent
45
45
  }
@@ -59,11 +59,11 @@ export function getInheritedBorderColor(index: number, side: 'top' | 'right' | '
59
59
  left: visual.borderColorLeft,
60
60
  }[side]
61
61
 
62
- const color = unwrap(colorArray[index])
62
+ const color = colorArray[index]
63
63
  if (color !== null && color !== undefined) return color
64
64
 
65
65
  // Try unified border color
66
- const unifiedColor = unwrap(visual.borderColor[index])
66
+ const unifiedColor = visual.borderColor[index]
67
67
  if (unifiedColor !== null && unifiedColor !== undefined) return unifiedColor
68
68
 
69
69
  // Fall back to foreground color
@@ -80,14 +80,14 @@ export function getBorderColors(index: number): {
80
80
  left: RGBA
81
81
  } {
82
82
  const fg = getInheritedFg(index)
83
- const unified = unwrap(visual.borderColor[index])
83
+ const unified = visual.borderColor[index]
84
84
  const fallback = unified ?? fg
85
85
 
86
86
  return {
87
- top: unwrap(visual.borderColorTop[index]) ?? fallback,
88
- right: unwrap(visual.borderColorRight[index]) ?? fallback,
89
- bottom: unwrap(visual.borderColorBottom[index]) ?? fallback,
90
- left: unwrap(visual.borderColorLeft[index]) ?? fallback,
87
+ top: visual.borderColorTop[index] ?? fallback,
88
+ right: visual.borderColorRight[index] ?? fallback,
89
+ bottom: visual.borderColorBottom[index] ?? fallback,
90
+ left: visual.borderColorLeft[index] ?? fallback,
91
91
  }
92
92
  }
93
93
 
@@ -101,13 +101,13 @@ export function getBorderStyles(index: number): {
101
101
  bottom: number
102
102
  left: number
103
103
  } {
104
- const unified = unwrap(visual.borderStyle[index]) || 0
104
+ const unified = visual.borderStyle[index] || 0
105
105
 
106
106
  return {
107
- top: unwrap(visual.borderTop[index]) || unified,
108
- right: unwrap(visual.borderRight[index]) || unified,
109
- bottom: unwrap(visual.borderBottom[index]) || unified,
110
- left: unwrap(visual.borderLeft[index]) || unified,
107
+ top: visual.borderTop[index] || unified,
108
+ right: visual.borderRight[index] || unified,
109
+ bottom: visual.borderBottom[index] || unified,
110
+ left: visual.borderLeft[index] || unified,
111
111
  }
112
112
  }
113
113
 
@@ -127,11 +127,11 @@ export function getEffectiveOpacity(index: number): number {
127
127
  let current: number | undefined = index
128
128
 
129
129
  while (current !== undefined && current >= 0) {
130
- const nodeOpacity = unwrap(visual.opacity[current])
130
+ const nodeOpacity = visual.opacity[current]
131
131
  if (nodeOpacity !== undefined && nodeOpacity !== 1) {
132
132
  opacity *= nodeOpacity
133
133
  }
134
- current = unwrap(core.parentIndex[current])
134
+ current = core.parentIndex[current]
135
135
  }
136
136
 
137
137
  return opacity
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { ReactiveSet } from '@rlabs-inc/signals'
14
14
  import { ensureAllCapacity, clearAllAtIndex, resetAllArrays } from './arrays'
15
+ import { parentIndex as parentIndexArray } from './arrays/core'
15
16
  import { resetTitanArrays } from '../pipeline/layout/titan-engine'
16
17
 
17
18
  // =============================================================================
@@ -101,6 +102,7 @@ export function allocateIndex(id?: string): number {
101
102
 
102
103
  /**
103
104
  * Release an index back to the pool.
105
+ * Also recursively releases all children!
104
106
  *
105
107
  * @param index - The index to release.
106
108
  */
@@ -108,6 +110,19 @@ export function releaseIndex(index: number): void {
108
110
  const id = indexToId.get(index)
109
111
  if (id === undefined) return
110
112
 
113
+ // FIRST: Find and release all children (recursive!)
114
+ // We collect children first to avoid modifying while iterating
115
+ const children: number[] = []
116
+ for (const childIndex of allocatedIndices) {
117
+ if (parentIndexArray[childIndex] === index) {
118
+ children.push(childIndex)
119
+ }
120
+ }
121
+ // Release children recursively
122
+ for (const childIndex of children) {
123
+ releaseIndex(childIndex)
124
+ }
125
+
111
126
  // Clean up mappings
112
127
  idToIndex.delete(id)
113
128
  indexToId.delete(index)