@rlabs-inc/tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +141 -0
  2. package/index.ts +45 -0
  3. package/package.json +59 -0
  4. package/src/api/index.ts +7 -0
  5. package/src/api/mount.ts +230 -0
  6. package/src/engine/arrays/core.ts +60 -0
  7. package/src/engine/arrays/dimensions.ts +68 -0
  8. package/src/engine/arrays/index.ts +166 -0
  9. package/src/engine/arrays/interaction.ts +112 -0
  10. package/src/engine/arrays/layout.ts +175 -0
  11. package/src/engine/arrays/spacing.ts +100 -0
  12. package/src/engine/arrays/text.ts +55 -0
  13. package/src/engine/arrays/visual.ts +140 -0
  14. package/src/engine/index.ts +25 -0
  15. package/src/engine/inheritance.ts +138 -0
  16. package/src/engine/registry.ts +180 -0
  17. package/src/pipeline/frameBuffer.ts +473 -0
  18. package/src/pipeline/layout/index.ts +105 -0
  19. package/src/pipeline/layout/titan-engine.ts +798 -0
  20. package/src/pipeline/layout/types.ts +194 -0
  21. package/src/pipeline/layout/utils/hierarchy.ts +202 -0
  22. package/src/pipeline/layout/utils/math.ts +134 -0
  23. package/src/pipeline/layout/utils/text-measure.ts +160 -0
  24. package/src/pipeline/layout.ts +30 -0
  25. package/src/primitives/box.ts +312 -0
  26. package/src/primitives/index.ts +12 -0
  27. package/src/primitives/text.ts +199 -0
  28. package/src/primitives/types.ts +222 -0
  29. package/src/primitives/utils.ts +37 -0
  30. package/src/renderer/ansi.ts +625 -0
  31. package/src/renderer/buffer.ts +667 -0
  32. package/src/renderer/index.ts +40 -0
  33. package/src/renderer/input.ts +518 -0
  34. package/src/renderer/output.ts +451 -0
  35. package/src/state/cursor.ts +176 -0
  36. package/src/state/focus.ts +241 -0
  37. package/src/state/index.ts +43 -0
  38. package/src/state/keyboard.ts +771 -0
  39. package/src/state/mouse.ts +524 -0
  40. package/src/state/scroll.ts +341 -0
  41. package/src/state/theme.ts +687 -0
  42. package/src/types/color.ts +401 -0
  43. package/src/types/index.ts +316 -0
  44. package/src/utils/text.ts +471 -0
@@ -0,0 +1,798 @@
1
+ /**
2
+ * TUI Framework - TITAN Layout Engine v3
3
+ *
4
+ * Complete terminal layout system using parallel arrays.
5
+ * Module-level arrays for speed, reset function for cleanup.
6
+ *
7
+ * Features:
8
+ * - Block layout (vertical stacking)
9
+ * - Flexbox (grow/shrink/wrap/justify/align)
10
+ * - Absolute/Fixed positioning
11
+ *
12
+ * Memory model:
13
+ * - Arrays are module-level (reused for speed)
14
+ * - Call resetTitanArrays() after destroying all components
15
+ * (in benchmarks, tests, or major UI transitions)
16
+ */
17
+
18
+ import { unwrap } from '@rlabs-inc/signals'
19
+ import { ComponentType } from '../../types'
20
+ import * as core from '../../engine/arrays/core'
21
+ import * as dimensions from '../../engine/arrays/dimensions'
22
+ import * as spacing from '../../engine/arrays/spacing'
23
+ import * as layout from '../../engine/arrays/layout'
24
+ import * as visual from '../../engine/arrays/visual'
25
+ import * as text from '../../engine/arrays/text'
26
+ import { stringWidth } from '../../utils/text'
27
+ import { measureTextHeight } from './utils/text-measure'
28
+ import { getAllocatedIndices } from '../../engine/registry'
29
+
30
+ import type { ComputedLayout } from './types'
31
+ import { Overflow } from './types'
32
+
33
+ // =============================================================================
34
+ // ENUMS (match layout.ts)
35
+ // =============================================================================
36
+ const FLEX_ROW = 1
37
+ const FLEX_ROW_REVERSE = 3
38
+ const FLEX_COLUMN = 0
39
+ const FLEX_COLUMN_REVERSE = 2
40
+
41
+ const WRAP_NOWRAP = 0
42
+ const WRAP_WRAP = 1
43
+ const WRAP_REVERSE = 2
44
+
45
+ const JUSTIFY_START = 0
46
+ const JUSTIFY_CENTER = 1
47
+ const JUSTIFY_END = 2
48
+ const JUSTIFY_BETWEEN = 3
49
+ const JUSTIFY_AROUND = 4
50
+ const JUSTIFY_EVENLY = 5
51
+
52
+ const ALIGN_STRETCH = 0
53
+ const ALIGN_START = 1
54
+ const ALIGN_CENTER = 2
55
+ const ALIGN_END = 3
56
+
57
+ const POS_RELATIVE = 0
58
+ const POS_ABSOLUTE = 1
59
+
60
+ // Align-self: 0 = auto (use parent's alignItems), 1+ = same as ALIGN_* constants
61
+ const ALIGN_SELF_AUTO = 0
62
+
63
+ // =============================================================================
64
+ // DIMENSION RESOLVER - Handles both absolute and percentage values
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Resolve a dimension value against a parent size.
69
+ * - number: Return as-is (absolute value)
70
+ * - string like '50%': Return parentSize * percentage / 100
71
+ *
72
+ * Performance: Inline check, no function call overhead for common case.
73
+ */
74
+ function resolveDim(dim: number | string | null | undefined, parentSize: number): number {
75
+ if (dim == null) return 0
76
+ if (typeof dim === 'number') return dim
77
+ // String percentage like '50%' - parse and resolve
78
+ return Math.floor(parentSize * parseFloat(dim) / 100)
79
+ }
80
+
81
+ /**
82
+ * Apply min/max constraints to a dimension.
83
+ * Resolves percentage constraints against parentSize.
84
+ */
85
+ function clampDim(
86
+ value: number,
87
+ minVal: number | string | null | undefined,
88
+ maxVal: number | string | null | undefined,
89
+ parentSize: number
90
+ ): number {
91
+ const min = resolveDim(minVal, parentSize)
92
+ const max = resolveDim(maxVal, parentSize)
93
+
94
+ let result = value
95
+ if (min > 0 && result < min) result = min
96
+ if (max > 0 && result > max) result = max
97
+ return result
98
+ }
99
+
100
+ // =============================================================================
101
+ // MODULE-LEVEL ARRAYS (reused for speed)
102
+ // =============================================================================
103
+ const outX: number[] = []
104
+ const outY: number[] = []
105
+ const outW: number[] = []
106
+ const outH: number[] = []
107
+ const outScrollable: number[] = []
108
+ const outMaxScrollX: number[] = []
109
+ const outMaxScrollY: number[] = []
110
+
111
+ const firstChild: number[] = []
112
+ const nextSibling: number[] = []
113
+ const lastChild: number[] = []
114
+
115
+ const intrinsicW: number[] = []
116
+ const intrinsicH: number[] = []
117
+
118
+ const itemMain: number[] = []
119
+ const itemCross: number[] = []
120
+
121
+ // Working arrays for layoutChildren (reused to avoid per-call allocation)
122
+ const flowKids: number[] = []
123
+ const lineStarts: number[] = []
124
+ const lineEnds: number[] = []
125
+ const lineMainUsed: number[] = []
126
+
127
+ // =============================================================================
128
+ // INTRINSIC CACHE - Skip recomputation when inputs unchanged
129
+ // =============================================================================
130
+ // For TEXT components: cache based on text content hash + available width
131
+ // For BOX components: cache based on children intrinsics + layout props
132
+ const cachedTextHash: bigint[] = []
133
+ const cachedAvailW: number[] = []
134
+ const cachedIntrinsicW: number[] = []
135
+ const cachedIntrinsicH: number[] = []
136
+
137
+ // =============================================================================
138
+ // RESET FUNCTION - Call after destroying all components
139
+ // =============================================================================
140
+
141
+ /**
142
+ * Reset all TITAN working arrays to release memory.
143
+ * Call this after destroying all components:
144
+ * - In benchmarks between test runs
145
+ * - In tests during cleanup
146
+ * - In runtime after major UI transitions (e.g., switching screens)
147
+ */
148
+ export function resetTitanArrays(): void {
149
+ outX.length = 0
150
+ outY.length = 0
151
+ outW.length = 0
152
+ outH.length = 0
153
+ outScrollable.length = 0
154
+ outMaxScrollX.length = 0
155
+ outMaxScrollY.length = 0
156
+ firstChild.length = 0
157
+ nextSibling.length = 0
158
+ lastChild.length = 0
159
+ intrinsicW.length = 0
160
+ intrinsicH.length = 0
161
+ itemMain.length = 0
162
+ itemCross.length = 0
163
+ flowKids.length = 0
164
+ lineStarts.length = 0
165
+ lineEnds.length = 0
166
+ lineMainUsed.length = 0
167
+ // Intrinsic cache
168
+ cachedTextHash.length = 0
169
+ cachedAvailW.length = 0
170
+ cachedIntrinsicW.length = 0
171
+ cachedIntrinsicH.length = 0
172
+ }
173
+
174
+ // =============================================================================
175
+ // MAIN ENTRY
176
+ // =============================================================================
177
+
178
+ export function computeLayoutTitan(
179
+ terminalWidth: number,
180
+ terminalHeight: number,
181
+ providedIndices?: Set<number>,
182
+ constrainHeight: boolean = true // false for inline/append modes
183
+ ): ComputedLayout {
184
+ const indices = providedIndices ?? getAllocatedIndices()
185
+
186
+ if (indices.size === 0) {
187
+ return { x: [], y: [], width: [], height: [], scrollable: [], maxScrollY: [], maxScrollX: [], contentWidth: 0, contentHeight: 0 }
188
+ }
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+ // PASS 1: Build linked-list tree structure (O(n))
192
+ // ─────────────────────────────────────────────────────────────────────────
193
+ for (const i of indices) {
194
+ firstChild[i] = -1
195
+ nextSibling[i] = -1
196
+ lastChild[i] = -1
197
+ intrinsicW[i] = 0
198
+ intrinsicH[i] = 0
199
+ outScrollable[i] = 0
200
+ outMaxScrollX[i] = 0
201
+ outMaxScrollY[i] = 0
202
+ }
203
+
204
+ const bfsQueue: number[] = []
205
+ let rootCount = 0
206
+
207
+ for (const i of indices) {
208
+ // Skip invisible components - they don't participate in layout
209
+ const vis = unwrap(core.visible[i])
210
+ if (vis === 0 || vis === false) continue
211
+
212
+ const parent = unwrap(core.parentIndex[i]) ?? -1
213
+
214
+ if (parent >= 0 && indices.has(parent)) {
215
+ if (firstChild[parent] === -1) {
216
+ firstChild[parent] = i
217
+ } else {
218
+ nextSibling[lastChild[parent]!] = i
219
+ }
220
+ lastChild[parent] = i
221
+ } else {
222
+ bfsQueue[rootCount++] = i
223
+ }
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────────────────
227
+ // PASS 2: BFS traversal - parents before children order
228
+ // ─────────────────────────────────────────────────────────────────────────
229
+ let head = 0
230
+ while (head < bfsQueue.length) {
231
+ const parent = bfsQueue[head++]!
232
+ let child = firstChild[parent]!
233
+ while (child !== -1) {
234
+ bfsQueue.push(child)
235
+ child = nextSibling[child]!
236
+ }
237
+ }
238
+
239
+ // ─────────────────────────────────────────────────────────────────────────
240
+ // PASS 3: Measure intrinsic sizes (bottom-up)
241
+ // ─────────────────────────────────────────────────────────────────────────
242
+ for (let si = bfsQueue.length - 1; si >= 0; si--) {
243
+ const i = bfsQueue[si]!
244
+ const type = core.componentType[i]
245
+
246
+ if (type === ComponentType.TEXT) {
247
+ const content = unwrap(text.textContent[i])
248
+ // Check for null/undefined, NOT truthiness (0 and '' are valid content!)
249
+ if (content != null) {
250
+ const str = String(content)
251
+
252
+ if (str.length > 0) {
253
+ // TEXT WRAPPING: Calculate available width for height measurement
254
+ const parentIdx = unwrap(core.parentIndex[i]) ?? -1
255
+ let availableW = terminalWidth
256
+
257
+ if (parentIdx >= 0) {
258
+ const rawParentW = unwrap(dimensions.width[parentIdx])
259
+ const parentExplicitW = typeof rawParentW === 'number' ? rawParentW : 0
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
266
+ availableW = Math.max(1, parentExplicitW - pPadL - pPadR - pBorderL - pBorderR)
267
+ }
268
+ }
269
+
270
+ // CACHE CHECK: Hash text content, compare with cached
271
+ // Only recompute stringWidth/measureTextHeight if content or availableW changed
272
+ const textHash = Bun.hash(str)
273
+ if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i]) {
274
+ // Cache hit - reuse cached intrinsics (skip expensive computation!)
275
+ intrinsicW[i] = cachedIntrinsicW[i]!
276
+ intrinsicH[i] = cachedIntrinsicH[i]!
277
+ } else {
278
+ // Cache miss - compute and store
279
+ intrinsicW[i] = stringWidth(str)
280
+ intrinsicH[i] = measureTextHeight(str, availableW)
281
+ cachedTextHash[i] = textHash
282
+ cachedAvailW[i] = availableW
283
+ cachedIntrinsicW[i] = intrinsicW[i]
284
+ cachedIntrinsicH[i] = intrinsicH[i]
285
+ }
286
+ } else {
287
+ intrinsicW[i] = 0
288
+ intrinsicH[i] = 0
289
+ }
290
+ }
291
+ } else {
292
+ // BOX/Container - calculate intrinsic from children + padding + borders
293
+ let kid = firstChild[i]!
294
+ if (kid !== -1) {
295
+ const dir = unwrap(layout.flexDirection[i]) ?? FLEX_COLUMN
296
+ const isRow = dir === FLEX_ROW || dir === FLEX_ROW_REVERSE
297
+ const gap = unwrap(spacing.gap[i]) ?? 0
298
+
299
+ let sumMain = 0
300
+ let maxCross = 0
301
+ let childCount = 0
302
+
303
+ while (kid !== -1) {
304
+ childCount++
305
+ // Use max of explicit dimension and intrinsic size
306
+ // This ensures children with explicit sizes contribute correctly
307
+ // Note: Percentage dimensions (strings) → 0 for intrinsic calculation
308
+ // 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])
311
+ const kidExplicitW = typeof rawKidW === 'number' ? rawKidW : 0
312
+ const kidExplicitH = typeof rawKidH === 'number' ? rawKidH : 0
313
+ const kidW = kidExplicitW > 0 ? kidExplicitW : intrinsicW[kid]!
314
+ const kidH = kidExplicitH > 0 ? kidExplicitH : intrinsicH[kid]!
315
+
316
+ // FIX: Don't add child borders - intrinsicW/H already includes them,
317
+ // and explicit dimensions are total dimensions (including borders).
318
+ // Adding borders here was DOUBLE-COUNTING and inflating contentHeight.
319
+ //
320
+ // 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
326
+ // const kidTotalW = kidW + kidBordL + kidBordR
327
+ // const kidTotalH = kidH + kidBordT + kidBordB
328
+
329
+ if (isRow) {
330
+ sumMain += kidW + gap
331
+ maxCross = Math.max(maxCross, kidH)
332
+ } else {
333
+ sumMain += kidH + gap
334
+ maxCross = Math.max(maxCross, kidW)
335
+ }
336
+ kid = nextSibling[kid]!
337
+ }
338
+
339
+ if (childCount > 0) sumMain -= gap
340
+
341
+ // 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
351
+
352
+ const extraWidth = padLeft + padRight + borderL + borderR
353
+ const extraHeight = padTop + padBottom + borderT + borderB
354
+
355
+ if (isRow) {
356
+ intrinsicW[i] = sumMain + extraWidth
357
+ intrinsicH[i] = maxCross + extraHeight
358
+ } else {
359
+ intrinsicW[i] = maxCross + extraWidth
360
+ intrinsicH[i] = sumMain + extraHeight
361
+ }
362
+ }
363
+ }
364
+ }
365
+
366
+ // ─────────────────────────────────────────────────────────────────────────
367
+ // HELPER: Layout children of a single parent (NON-RECURSIVE)
368
+ // Uses module-level arrays to avoid per-call allocation
369
+ // ─────────────────────────────────────────────────────────────────────────
370
+ function layoutChildrenOf(parent: number): void {
371
+ // Reset working arrays (reuse module-level arrays)
372
+ flowKids.length = 0
373
+ lineStarts.length = 0
374
+ lineEnds.length = 0
375
+ lineMainUsed.length = 0
376
+
377
+ // Track children max extent for scroll detection (zero overhead - updated inline)
378
+ let childrenMaxMain = 0
379
+ let childrenMaxCross = 0
380
+
381
+ // Collect flow children
382
+ let kid = firstChild[parent]!
383
+ while (kid !== -1) {
384
+ if ((unwrap(layout.position[kid]) ?? POS_RELATIVE) !== POS_ABSOLUTE) {
385
+ flowKids.push(kid)
386
+ }
387
+ kid = nextSibling[kid]!
388
+ }
389
+
390
+ if (flowKids.length === 0) return
391
+
392
+ // 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
397
+
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
403
+
404
+ const contentX = outX[parent]! + pPadL + pBordL
405
+ const contentY = outY[parent]! + pPadT + pBordT
406
+ const contentW = Math.max(0, outW[parent]! - pPadL - pPadR - pBordL - pBordR)
407
+ const contentH = Math.max(0, outH[parent]! - pPadT - pPadB - pBordT - pBordB)
408
+
409
+ // 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
416
+
417
+ const isRow = dir === FLEX_ROW || dir === FLEX_ROW_REVERSE
418
+ const isReverse = dir === FLEX_ROW_REVERSE || dir === FLEX_COLUMN_REVERSE
419
+ // Scrollable containers should NOT shrink children - content scrolls instead
420
+ const isScrollableParent = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
421
+
422
+ const mainSize = isRow ? contentW : contentH
423
+ const crossSize = isRow ? contentH : contentW
424
+
425
+ // STEP 1: Collect items into flex lines
426
+ // Child dimensions resolve percentages against parent's content area
427
+ let lineStart = 0
428
+ let currentMain = 0
429
+
430
+ for (let fi = 0; fi < flowKids.length; fi++) {
431
+ const fkid = flowKids[fi]!
432
+ const ew = resolveDim(unwrap(dimensions.width[fkid]), contentW)
433
+ const eh = resolveDim(unwrap(dimensions.height[fkid]), contentH)
434
+ const kidMain = isRow
435
+ ? (ew > 0 ? ew : intrinsicW[fkid]!)
436
+ : (eh > 0 ? eh : intrinsicH[fkid]!)
437
+
438
+ if (wrap !== WRAP_NOWRAP && fi > lineStart && currentMain + kidMain + gap > mainSize) {
439
+ lineStarts.push(lineStart)
440
+ lineEnds.push(fi - 1)
441
+ lineMainUsed.push(currentMain - gap)
442
+ lineStart = fi
443
+ currentMain = 0
444
+ }
445
+ currentMain += kidMain + gap
446
+ }
447
+
448
+ if (flowKids.length > 0) {
449
+ lineStarts.push(lineStart)
450
+ lineEnds.push(flowKids.length - 1)
451
+ lineMainUsed.push(currentMain - gap)
452
+ }
453
+
454
+ const lineCount = lineStarts.length
455
+
456
+ // STEP 2: Resolve flex grow/shrink per line
457
+ for (let li = 0; li < lineCount; li++) {
458
+ const lStart = lineStarts[li]!
459
+ const lEnd = lineEnds[li]!
460
+ const freeSpace = mainSize - lineMainUsed[li]!
461
+
462
+ let totalGrow = 0
463
+ let totalShrink = 0
464
+
465
+ for (let fi = lStart; fi <= lEnd; fi++) {
466
+ const fkid = flowKids[fi]!
467
+ totalGrow += unwrap(layout.flexGrow[fkid]) ?? 0
468
+ totalShrink += unwrap(layout.flexShrink[fkid]) ?? 1
469
+ }
470
+
471
+ for (let fi = lStart; fi <= lEnd; fi++) {
472
+ const fkid = flowKids[fi]!
473
+ const ew = resolveDim(unwrap(dimensions.width[fkid]), contentW)
474
+ const eh = resolveDim(unwrap(dimensions.height[fkid]), contentH)
475
+
476
+ // flex-basis takes priority over width/height for main axis size
477
+ const basis = unwrap(layout.flexBasis[fkid]) ?? 0
478
+ let kidMain = basis > 0
479
+ ? basis
480
+ : (isRow
481
+ ? (ew > 0 ? ew : intrinsicW[fkid]!)
482
+ : (eh > 0 ? eh : intrinsicH[fkid]!))
483
+
484
+ if (freeSpace > 0 && totalGrow > 0) {
485
+ kidMain += ((unwrap(layout.flexGrow[fkid]) ?? 0) / totalGrow) * freeSpace
486
+ } else if (freeSpace < 0 && totalShrink > 0 && !isScrollableParent) {
487
+ // Only shrink if parent is NOT scrollable
488
+ // Scrollable containers let content overflow and scroll instead
489
+ kidMain += ((unwrap(layout.flexShrink[fkid]) ?? 1) / totalShrink) * freeSpace
490
+ }
491
+ kidMain = Math.max(0, Math.floor(kidMain))
492
+
493
+ // 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])
496
+ kidMain = clampDim(kidMain, minMain, maxMain, isRow ? contentW : contentH)
497
+
498
+ let kidCross = isRow
499
+ ? (eh > 0 ? eh : (alignItems === ALIGN_STRETCH ? crossSize / lineCount : intrinsicH[fkid]!))
500
+ : (ew > 0 ? ew : (alignItems === ALIGN_STRETCH ? crossSize / lineCount : intrinsicW[fkid]!))
501
+
502
+ // 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])
505
+ kidCross = clampDim(Math.max(0, Math.floor(kidCross)), minCross, maxCross, isRow ? contentH : contentW)
506
+
507
+ itemMain[fkid] = kidMain
508
+ itemCross[fkid] = kidCross
509
+ }
510
+ }
511
+
512
+ // STEP 3: Position items
513
+ let crossOffset = 0
514
+ // Ensure minimum lineHeight of 1 to prevent all lines stacking at same position
515
+ // when there are more lines than vertical space
516
+ const lineHeight = lineCount > 0 ? Math.max(1, Math.floor(crossSize / lineCount)) : crossSize
517
+
518
+ for (let li = 0; li < lineCount; li++) {
519
+ const lineIdx = isReverse ? lineCount - 1 - li : li
520
+ const lStart = lineStarts[lineIdx]!
521
+ const lEnd = lineEnds[lineIdx]!
522
+
523
+ let lineMain = 0
524
+ for (let fi = lStart; fi <= lEnd; fi++) {
525
+ const kid = flowKids[fi]!
526
+ // Include margins in line size calculation (CSS box model)
527
+ 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)
530
+ lineMain += itemMain[kid]! + mMain + gap
531
+ }
532
+ lineMain -= gap
533
+
534
+ let mainOffset = 0
535
+ let itemGap = gap
536
+ const remainingSpace = mainSize - lineMain
537
+ const itemCount = lEnd - lStart + 1
538
+
539
+ switch (justify) {
540
+ case JUSTIFY_CENTER:
541
+ mainOffset = Math.floor(remainingSpace / 2)
542
+ break
543
+ case JUSTIFY_END:
544
+ mainOffset = remainingSpace
545
+ break
546
+ case JUSTIFY_BETWEEN:
547
+ itemGap = itemCount > 1 ? Math.floor(remainingSpace / (itemCount - 1)) + gap : gap
548
+ break
549
+ case JUSTIFY_AROUND: {
550
+ const around = Math.floor(remainingSpace / itemCount)
551
+ mainOffset = Math.floor(around / 2)
552
+ itemGap = around + gap
553
+ break
554
+ }
555
+ case JUSTIFY_EVENLY: {
556
+ const even = Math.floor(remainingSpace / (itemCount + 1))
557
+ mainOffset = even
558
+ itemGap = even + gap
559
+ break
560
+ }
561
+ }
562
+
563
+ // Always iterate in DOM order - the reversed position calculation
564
+ // handles the visual reversal for row-reverse/column-reverse
565
+ for (let fi = lStart; fi <= lEnd; fi++) {
566
+ const fkid = flowKids[fi]!
567
+ const sizeMain = itemMain[fkid]!
568
+ const sizeCross = itemCross[fkid]!
569
+
570
+ // 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
575
+
576
+ // align-self overrides parent's align-items for individual items
577
+ // alignSelf: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end, 5=baseline
578
+ // alignItems: 0=stretch, 1=flex-start, 2=center, 3=flex-end
579
+ // When alignSelf != 0, we subtract 1 to map to alignItems values
580
+ const selfAlign = unwrap(layout.alignSelf[fkid]) ?? ALIGN_SELF_AUTO
581
+ const effectiveAlign = selfAlign !== ALIGN_SELF_AUTO ? (selfAlign - 1) : alignItems
582
+
583
+ let crossPos = crossOffset
584
+ switch (effectiveAlign) {
585
+ case ALIGN_CENTER:
586
+ crossPos += Math.floor((lineHeight - sizeCross) / 2)
587
+ break
588
+ case ALIGN_END:
589
+ crossPos += lineHeight - sizeCross
590
+ break
591
+ }
592
+
593
+ // CSS Flexbox: margins offset item position and add space between items
594
+ // For row-reverse/column-reverse, position from the end of the axis
595
+ if (isRow) {
596
+ if (dir === FLEX_ROW_REVERSE) {
597
+ // row-reverse: position from right edge
598
+ outX[fkid] = contentX + contentW - mainOffset - sizeMain - mRight
599
+ } else {
600
+ outX[fkid] = contentX + mainOffset + mLeft
601
+ }
602
+ outY[fkid] = contentY + crossPos + mTop
603
+ outW[fkid] = sizeMain
604
+ outH[fkid] = sizeCross
605
+ } else {
606
+ outX[fkid] = contentX + crossPos + mLeft
607
+ if (dir === FLEX_COLUMN_REVERSE) {
608
+ // column-reverse: position from bottom edge
609
+ outY[fkid] = contentY + contentH - mainOffset - sizeMain - mBottom
610
+ } else {
611
+ outY[fkid] = contentY + mainOffset + mTop
612
+ }
613
+ outW[fkid] = sizeCross
614
+ outH[fkid] = sizeMain
615
+ }
616
+
617
+ // TEXT WRAPPING: Now that we know the width, recalculate height for TEXT
618
+ // This fixes the intrinsicH=1 assumption - text wraps to actual width
619
+ if (core.componentType[fkid] === ComponentType.TEXT) {
620
+ const content = unwrap(text.textContent[fkid])
621
+ if (content != null) {
622
+ const str = String(content)
623
+ if (str.length > 0) {
624
+ const wrappedHeight = measureTextHeight(str, outW[fkid]!)
625
+ outH[fkid] = Math.max(1, wrappedHeight)
626
+ }
627
+ }
628
+ }
629
+
630
+ // Track max extent inline (zero overhead) - include margins
631
+ if (isRow) {
632
+ childrenMaxMain = Math.max(childrenMaxMain, mainOffset + mLeft + outW[fkid]! + mRight)
633
+ childrenMaxCross = Math.max(childrenMaxCross, crossPos + mTop + outH[fkid]! + mBottom)
634
+ } else {
635
+ childrenMaxMain = Math.max(childrenMaxMain, mainOffset + mTop + outH[fkid]! + mBottom)
636
+ childrenMaxCross = Math.max(childrenMaxCross, crossPos + mLeft + outW[fkid]! + mRight)
637
+ }
638
+
639
+ // Advance mainOffset including margins (CSS box model)
640
+ const mainMargin = isRow ? (mLeft + mRight) : (mTop + mBottom)
641
+ mainOffset += (isRow ? outW[fkid]! : outH[fkid]!) + mainMargin + itemGap
642
+ }
643
+
644
+ crossOffset += lineHeight
645
+ }
646
+
647
+ // Scroll detection - uses values tracked inline above (no extra loop!)
648
+ // Note: overflow already read above for isScrollableParent check
649
+ if (isScrollableParent) {
650
+ const childrenMaxX = isRow ? childrenMaxMain : childrenMaxCross
651
+ const childrenMaxY = isRow ? childrenMaxCross : childrenMaxMain
652
+ const scrollRangeX = Math.max(0, childrenMaxX - contentW)
653
+ const scrollRangeY = Math.max(0, childrenMaxY - contentH)
654
+
655
+ if (overflow === Overflow.SCROLL || scrollRangeX > 0 || scrollRangeY > 0) {
656
+ // overflow === SCROLL always scrollable; AUTO only if content overflows
657
+ outScrollable[parent] = 1
658
+ outMaxScrollX[parent] = scrollRangeX
659
+ outMaxScrollY[parent] = scrollRangeY
660
+ }
661
+ }
662
+ }
663
+
664
+ // ─────────────────────────────────────────────────────────────────────────
665
+ // HELPER: Absolute positioning
666
+ // ─────────────────────────────────────────────────────────────────────────
667
+ function layoutAbsolute(i: number): void {
668
+ let container = unwrap(core.parentIndex[i]) ?? -1
669
+ while (container >= 0 && indices.has(container)) {
670
+ if ((unwrap(layout.position[container]) ?? POS_RELATIVE) !== POS_RELATIVE) break
671
+ container = unwrap(core.parentIndex[container]) ?? -1
672
+ }
673
+
674
+ let containerX = 0, containerY = 0, containerW = outW[0] ?? 80, containerH = outH[0] ?? 24
675
+ if (container >= 0 && indices.has(container)) {
676
+ containerX = outX[container]!
677
+ containerY = outY[container]!
678
+ containerW = outW[container]!
679
+ containerH = outH[container]!
680
+ }
681
+
682
+ // Resolve dimensions against containing block
683
+ const ew = resolveDim(unwrap(dimensions.width[i]), containerW)
684
+ const eh = resolveDim(unwrap(dimensions.height[i]), containerH)
685
+ let absW = ew > 0 ? ew : intrinsicW[i]!
686
+ let absH = eh > 0 ? eh : intrinsicH[i]!
687
+
688
+ // 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)
691
+ outW[i] = absW
692
+ outH[i] = absH
693
+
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])
698
+
699
+ if (l !== undefined && l !== 0) {
700
+ outX[i] = containerX + l
701
+ } else if (r !== undefined && r !== 0) {
702
+ outX[i] = containerX + containerW - outW[i]! - r
703
+ } else {
704
+ outX[i] = containerX
705
+ }
706
+
707
+ if (t !== undefined && t !== 0) {
708
+ outY[i] = containerY + t
709
+ } else if (b !== undefined && b !== 0) {
710
+ outY[i] = containerY + containerH - outH[i]! - b
711
+ } else {
712
+ outY[i] = containerY
713
+ }
714
+
715
+ // Position the absolute element's own children (non-recursive)
716
+ layoutChildrenOf(i)
717
+ }
718
+
719
+ // ─────────────────────────────────────────────────────────────────────────
720
+ // PASS 4: Layout (top-down, ITERATIVE via BFS order)
721
+ // ─────────────────────────────────────────────────────────────────────────
722
+
723
+ // First, position all roots
724
+ // Root elements resolve percentage dimensions against terminal size
725
+ for (let ri = 0; ri < rootCount; ri++) {
726
+ const root = bfsQueue[ri]!
727
+ const rawW = unwrap(dimensions.width[root])
728
+ const rawH = unwrap(dimensions.height[root])
729
+ const ew = resolveDim(rawW, terminalWidth)
730
+ const eh = resolveDim(rawH, terminalHeight)
731
+
732
+ outX[root] = 0
733
+ outY[root] = 0
734
+ outW[root] = ew > 0 ? ew : terminalWidth
735
+
736
+ // Height handling:
737
+ // - If explicit height set, use it
738
+ // - If constrainHeight (fullscreen), use terminal height
739
+ // - If unconstrained (inline/append), use intrinsic height (content determines size)
740
+ if (eh > 0) {
741
+ outH[root] = eh
742
+ } else if (constrainHeight) {
743
+ outH[root] = terminalHeight
744
+ } else {
745
+ // Inline/append: content determines height
746
+ outH[root] = intrinsicH[root] ?? 1
747
+ }
748
+ }
749
+
750
+ // Then iterate through BFS order - each node positions its children
751
+ // BFS guarantees parents are processed before children, so child positions
752
+ // are always set before we need to process them as parents themselves.
753
+ for (let qi = 0; qi < bfsQueue.length; qi++) {
754
+ layoutChildrenOf(bfsQueue[qi]!)
755
+ }
756
+
757
+ // ─────────────────────────────────────────────────────────────────────────
758
+ // PASS 5: Absolute positioning
759
+ // ─────────────────────────────────────────────────────────────────────────
760
+ for (const i of indices) {
761
+ if ((unwrap(layout.position[i]) ?? POS_RELATIVE) === POS_ABSOLUTE) {
762
+ layoutAbsolute(i)
763
+ }
764
+ }
765
+
766
+ // ─────────────────────────────────────────────────────────────────────────
767
+ // Content bounds: Just use root dimensions (root is at 0,0 and contains all)
768
+ // No need for separate pass - we already have the root's size from PASS 4
769
+ // ─────────────────────────────────────────────────────────────────────────
770
+ // OLD PASS 6 (iterated all components - unnecessary):
771
+ // let contentWidth = 0
772
+ // let contentHeight = 0
773
+ // for (const i of indices) {
774
+ // const right = (outX[i] ?? 0) + (outW[i] ?? 0)
775
+ // const bottom = (outY[i] ?? 0) + (outH[i] ?? 0)
776
+ // if (right > contentWidth) contentWidth = right
777
+ // if (bottom > contentHeight) contentHeight = bottom
778
+ // }
779
+
780
+ // Simple: root is at (0,0), so content bounds = root dimensions
781
+ const contentWidth = rootCount > 0 ? (outW[bfsQueue[0]!] ?? 0) : 0
782
+ const contentHeight = rootCount > 0 ? (outH[bfsQueue[0]!] ?? 0) : 0
783
+
784
+ // ─────────────────────────────────────────────────────────────────────────
785
+ // RETURN
786
+ // ─────────────────────────────────────────────────────────────────────────
787
+ return {
788
+ x: outX,
789
+ y: outY,
790
+ width: outW,
791
+ height: outH,
792
+ scrollable: outScrollable,
793
+ maxScrollY: outMaxScrollY,
794
+ maxScrollX: outMaxScrollX,
795
+ contentWidth,
796
+ contentHeight
797
+ }
798
+ }