@cel-tui/core 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.
package/src/paint.ts ADDED
@@ -0,0 +1,646 @@
1
+ import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
2
+ import type { Cell } from "./cell-buffer.js";
3
+ import { CellBuffer } from "./cell-buffer.js";
4
+ import type { LayoutNode, Rect } from "./layout.js";
5
+ import { visibleWidth } from "./width.js";
6
+
7
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
+
9
+ /** Empty inherited style — no values set. */
10
+ const EMPTY_STYLE: StyleProps = {};
11
+
12
+ /**
13
+ * Paint a laid-out tree into a cell buffer.
14
+ *
15
+ * Walks the {@link LayoutNode} tree and writes styled cells into the
16
+ * buffer within each node's computed rect. Content is clipped at
17
+ * rect boundaries. Container styles are inherited by descendants.
18
+ *
19
+ * @param root - The root of the laid-out tree (from {@link layout}).
20
+ * @param buf - Target cell buffer.
21
+ */
22
+ export function paint(root: LayoutNode, buf: CellBuffer): void {
23
+ paintLayoutNode(root, buf, root.rect, EMPTY_STYLE);
24
+ }
25
+
26
+ /**
27
+ * Intersect two rectangles. Returns a rect with zero area if they don't overlap.
28
+ */
29
+ function intersectRect(a: Rect, b: Rect): Rect {
30
+ const x = Math.max(a.x, b.x);
31
+ const y = Math.max(a.y, b.y);
32
+ const right = Math.min(a.x + a.width, b.x + b.width);
33
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
34
+ return {
35
+ x,
36
+ y,
37
+ width: Math.max(0, right - x),
38
+ height: Math.max(0, bottom - y),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Resolve the effective style for a container, accounting for focusStyle.
44
+ * When the container is focused and has focusStyle, those values override
45
+ * the normal style props.
46
+ */
47
+ function resolveContainerStyle(
48
+ props: { focused?: boolean; focusStyle?: StyleProps } & StyleProps,
49
+ ): StyleProps {
50
+ const isFocused = props.focused === true;
51
+ if (!isFocused || !props.focusStyle) {
52
+ return props;
53
+ }
54
+ return {
55
+ bold: props.focusStyle.bold ?? props.bold,
56
+ italic: props.focusStyle.italic ?? props.italic,
57
+ underline: props.focusStyle.underline ?? props.underline,
58
+ fgColor: props.focusStyle.fgColor ?? props.fgColor,
59
+ bgColor: props.focusStyle.bgColor ?? props.bgColor,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Merge a container's resolved style into the inherited style.
65
+ * Only values explicitly set on the container override inherited ones.
66
+ */
67
+ function mergeInherited(
68
+ inherited: StyleProps,
69
+ resolved: StyleProps,
70
+ ): StyleProps {
71
+ return {
72
+ bold: resolved.bold ?? inherited.bold,
73
+ italic: resolved.italic ?? inherited.italic,
74
+ underline: resolved.underline ?? inherited.underline,
75
+ fgColor: resolved.fgColor ?? inherited.fgColor,
76
+ bgColor: resolved.bgColor ?? inherited.bgColor,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Fill a rectangle with opaque background cells, respecting the clip rect.
82
+ * Writes space characters with the given bgColor, making the container
83
+ * fully opaque. This ensures upper layers properly occlude lower layers.
84
+ */
85
+ function fillBackground(
86
+ rect: Rect,
87
+ clipRect: Rect,
88
+ bgColor: Color,
89
+ buf: CellBuffer,
90
+ ): void {
91
+ const fill = intersectRect(rect, clipRect);
92
+ if (fill.width <= 0 || fill.height <= 0) return;
93
+ for (let y = fill.y; y < fill.y + fill.height; y++) {
94
+ for (let x = fill.x; x < fill.x + fill.width; x++) {
95
+ buf.set(x, y, {
96
+ char: " ",
97
+ fgColor: null,
98
+ bgColor,
99
+ bold: false,
100
+ italic: false,
101
+ underline: false,
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ function paintLayoutNode(
108
+ ln: LayoutNode,
109
+ buf: CellBuffer,
110
+ clipRect: Rect,
111
+ inherited: StyleProps,
112
+ ): void {
113
+ const { node, rect } = ln;
114
+
115
+ // Clip this node's rect against the parent clip rect
116
+ const clipped = intersectRect(rect, clipRect);
117
+ if (clipped.width <= 0 || clipped.height <= 0) return;
118
+
119
+ // Compute inherited style for this subtree
120
+ let childInherited = inherited;
121
+
122
+ switch (node.type) {
123
+ case "text": {
124
+ // Resolve text styles: own props override inherited
125
+ const effective = {
126
+ fgColor: node.props.fgColor ?? inherited.fgColor,
127
+ bgColor: node.props.bgColor ?? inherited.bgColor,
128
+ bold: node.props.bold ?? inherited.bold,
129
+ italic: node.props.italic ?? inherited.italic,
130
+ underline: node.props.underline ?? inherited.underline,
131
+ };
132
+ paintText(
133
+ node.content,
134
+ { ...node.props, ...effective },
135
+ rect,
136
+ clipped,
137
+ buf,
138
+ );
139
+ break;
140
+ }
141
+ case "textinput": {
142
+ // TextInput: resolve own styles with inheritance
143
+ const tiResolved = resolveContainerStyle(node.props);
144
+ const tiEffective = mergeInherited(inherited, tiResolved);
145
+ const tiProps = {
146
+ ...node.props,
147
+ fgColor: node.props.fgColor ?? tiEffective.fgColor,
148
+ bgColor: node.props.bgColor ?? tiEffective.bgColor,
149
+ bold: node.props.bold ?? tiEffective.bold,
150
+ italic: node.props.italic ?? tiEffective.italic,
151
+ underline: node.props.underline ?? tiEffective.underline,
152
+ };
153
+ paintTextInput(tiProps, rect, clipped, buf);
154
+ break;
155
+ }
156
+ case "vstack":
157
+ case "hstack": {
158
+ // Resolve container style (applies focusStyle if focused)
159
+ const resolved = resolveContainerStyle(node.props);
160
+ childInherited = mergeInherited(inherited, resolved);
161
+
162
+ // Fill container background
163
+ const effectiveBg = childInherited.bgColor;
164
+ if (effectiveBg) {
165
+ fillBackground(rect, clipped, effectiveBg, buf);
166
+ }
167
+ break;
168
+ }
169
+ }
170
+
171
+ // Determine scroll offset for scrollable containers
172
+ const isContainer = node.type === "vstack" || node.type === "hstack";
173
+ const containerProps = isContainer ? node.props : null;
174
+ const isScrollable = containerProps?.overflow === "scroll";
175
+ const scrollOffset = isScrollable
176
+ ? (containerProps.scrollOffset ?? getContainerScroll(containerProps))
177
+ : 0;
178
+ const isVertical = node.type === "vstack";
179
+
180
+ // Recurse into children, using this node's clipped rect as the clip for children
181
+ for (const child of ln.children) {
182
+ if (isScrollable && scrollOffset !== 0) {
183
+ // Paint child with shifted position
184
+ const shifted = shiftLayoutNode(child, isVertical, -scrollOffset);
185
+ paintLayoutNode(shifted, buf, clipped, childInherited);
186
+ } else {
187
+ paintLayoutNode(child, buf, clipped, childInherited);
188
+ }
189
+ }
190
+
191
+ // Paint scrollbar if enabled
192
+ if (isScrollable && containerProps.scrollbar) {
193
+ paintScrollbar(ln, scrollOffset, buf, clipped);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Create a copy of a layout node (and all descendants) with positions
199
+ * shifted along the given axis.
200
+ */
201
+ function shiftLayoutNode(
202
+ ln: LayoutNode,
203
+ isVertical: boolean,
204
+ offset: number,
205
+ ): LayoutNode {
206
+ const newRect: Rect = {
207
+ x: ln.rect.x + (isVertical ? 0 : offset),
208
+ y: ln.rect.y + (isVertical ? offset : 0),
209
+ width: ln.rect.width,
210
+ height: ln.rect.height,
211
+ };
212
+ return {
213
+ node: ln.node,
214
+ rect: newRect,
215
+ children: ln.children.map((c) => shiftLayoutNode(c, isVertical, offset)),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Paint a scrollbar indicator for a scrollable container.
221
+ */
222
+ function paintScrollbar(
223
+ ln: LayoutNode,
224
+ scrollOffset: number,
225
+ buf: CellBuffer,
226
+ clipRect: Rect,
227
+ ): void {
228
+ const { rect, children } = ln;
229
+ const isVertical = ln.node.type === "vstack";
230
+
231
+ if (isVertical) {
232
+ // Compute total content height from children
233
+ let contentHeight = 0;
234
+ for (const child of children) {
235
+ const childBottom = child.rect.y + child.rect.height - rect.y;
236
+ if (childBottom > contentHeight) contentHeight = childBottom;
237
+ }
238
+ const viewportH = rect.height;
239
+ if (contentHeight <= viewportH) return; // no scrollbar needed
240
+
241
+ // Thumb size and position
242
+ const thumbSize = Math.max(
243
+ 1,
244
+ Math.round((viewportH / contentHeight) * viewportH),
245
+ );
246
+ const maxOffset = contentHeight - viewportH;
247
+ const thumbPos =
248
+ maxOffset > 0
249
+ ? Math.round((scrollOffset / maxOffset) * (viewportH - thumbSize))
250
+ : 0;
251
+
252
+ const barX = rect.x + rect.width - 1;
253
+ for (let row = 0; row < viewportH; row++) {
254
+ const absY = rect.y + row;
255
+ if (absY < clipRect.y || absY >= clipRect.y + clipRect.height) continue;
256
+ if (barX < clipRect.x || barX >= clipRect.x + clipRect.width) continue;
257
+ const isThumb = row >= thumbPos && row < thumbPos + thumbSize;
258
+ buf.set(barX, absY, {
259
+ char: isThumb ? "┃" : "│",
260
+ fgColor: isThumb ? "white" : "brightBlack",
261
+ bgColor: null,
262
+ bold: false,
263
+ italic: false,
264
+ underline: false,
265
+ });
266
+ }
267
+ } else {
268
+ // Horizontal scrollbar
269
+ let contentWidth = 0;
270
+ for (const child of children) {
271
+ const childRight = child.rect.x + child.rect.width - rect.x;
272
+ if (childRight > contentWidth) contentWidth = childRight;
273
+ }
274
+ const viewportW = rect.width;
275
+ if (contentWidth <= viewportW) return;
276
+
277
+ const thumbSize = Math.max(
278
+ 1,
279
+ Math.round((viewportW / contentWidth) * viewportW),
280
+ );
281
+ const maxOffset = contentWidth - viewportW;
282
+ const thumbPos =
283
+ maxOffset > 0
284
+ ? Math.round((scrollOffset / maxOffset) * (viewportW - thumbSize))
285
+ : 0;
286
+
287
+ const barY = rect.y + rect.height - 1;
288
+ for (let col = 0; col < viewportW; col++) {
289
+ const absX = rect.x + col;
290
+ if (absX < clipRect.x || absX >= clipRect.x + clipRect.width) continue;
291
+ if (barY < clipRect.y || barY >= clipRect.y + clipRect.height) continue;
292
+ const isThumb = col >= thumbPos && col < thumbPos + thumbSize;
293
+ buf.set(absX, barY, {
294
+ char: isThumb ? "━" : "─",
295
+ fgColor: isThumb ? "white" : "brightBlack",
296
+ bgColor: null,
297
+ bold: false,
298
+ italic: false,
299
+ underline: false,
300
+ });
301
+ }
302
+ }
303
+ }
304
+
305
+ function makeCell(
306
+ char: string,
307
+ props: {
308
+ fgColor?: Color;
309
+ bgColor?: Color;
310
+ bold?: boolean;
311
+ italic?: boolean;
312
+ underline?: boolean;
313
+ },
314
+ ): Cell {
315
+ return {
316
+ char,
317
+ fgColor: props.fgColor ?? null,
318
+ bgColor: props.bgColor ?? null,
319
+ bold: props.bold ?? false,
320
+ italic: props.italic ?? false,
321
+ underline: props.underline ?? false,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Paint a single line of text into the buffer using grapheme segmentation.
327
+ * Correctly handles wide characters (CJK, emoji) by advancing the column
328
+ * by the grapheme's visible width.
329
+ */
330
+ /**
331
+ * Paint a single line of text into the buffer using grapheme segmentation.
332
+ * Correctly handles wide characters (CJK, emoji) by advancing the column
333
+ * by the grapheme's visible width. Respects the clip rect.
334
+ */
335
+ function paintLineGraphemes(
336
+ line: string,
337
+ x: number,
338
+ y: number,
339
+ maxWidth: number,
340
+ clipRect: Rect,
341
+ props: {
342
+ fgColor?: Color;
343
+ bgColor?: Color;
344
+ bold?: boolean;
345
+ italic?: boolean;
346
+ underline?: boolean;
347
+ },
348
+ buf: CellBuffer,
349
+ ): void {
350
+ if (y < clipRect.y || y >= clipRect.y + clipRect.height) return;
351
+ const clipLeft = clipRect.x;
352
+ const clipRight = clipRect.x + clipRect.width;
353
+
354
+ let col = 0;
355
+ for (const { segment } of segmenter.segment(line)) {
356
+ const gw = visibleWidth(segment);
357
+ if (gw === 0) continue;
358
+ if (col + gw > maxWidth) break; // clip: grapheme doesn't fit in rect
359
+ const absX = x + col;
360
+ if (absX >= clipRight) break; // past clip right edge
361
+ if (absX + gw > clipLeft) {
362
+ // At least partially visible in clip rect
363
+ buf.set(absX, y, makeCell(segment, props));
364
+ }
365
+ col += gw;
366
+ }
367
+ }
368
+
369
+ function paintText(
370
+ content: string,
371
+ props: {
372
+ repeat?: number | "fill";
373
+ wrap?: "none" | "word";
374
+ fgColor?: Color;
375
+ bgColor?: Color;
376
+ bold?: boolean;
377
+ italic?: boolean;
378
+ underline?: boolean;
379
+ },
380
+ rect: Rect,
381
+ clipRect: Rect,
382
+ buf: CellBuffer,
383
+ ): void {
384
+ const { x, y, width: w, height: h } = rect;
385
+ if (w <= 0 || h <= 0) return;
386
+
387
+ // Resolve repeat
388
+ let text = content;
389
+ if (props.repeat === "fill" && content.length > 0) {
390
+ const contentW = visibleWidth(content);
391
+ if (contentW > 0) {
392
+ text = content.repeat(Math.ceil(w / contentW));
393
+ }
394
+ } else if (typeof props.repeat === "number" && props.repeat > 0) {
395
+ text = content.repeat(props.repeat);
396
+ }
397
+
398
+ // Split into lines
399
+ const rawLines = text.split("\n");
400
+
401
+ // Word-wrap if enabled
402
+ const lines: string[] = [];
403
+ if (props.wrap === "word") {
404
+ for (const rawLine of rawLines) {
405
+ if (visibleWidth(rawLine) <= w) {
406
+ lines.push(rawLine);
407
+ } else {
408
+ wrapLine(rawLine, w, lines);
409
+ }
410
+ }
411
+ } else {
412
+ lines.push(...rawLines);
413
+ }
414
+
415
+ // Paint lines, clipped to rect (grapheme-aware)
416
+ for (let row = 0; row < lines.length && row < h; row++) {
417
+ const line = lines[row]!;
418
+ paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
419
+ }
420
+ }
421
+
422
+ function paintTextInput(
423
+ props: TextInputProps,
424
+ rect: Rect,
425
+ clipRect: Rect,
426
+ buf: CellBuffer,
427
+ ): void {
428
+ const { x, y, width: w, height: h } = rect;
429
+ if (w <= 0 || h <= 0) return;
430
+
431
+ const value = props.value;
432
+ const showPlaceholder = value.length === 0 && props.placeholder;
433
+
434
+ if (showPlaceholder && props.placeholder) {
435
+ // Paint placeholder text
436
+ paintText(
437
+ props.placeholder.content,
438
+ props.placeholder.props,
439
+ rect,
440
+ clipRect,
441
+ buf,
442
+ );
443
+ return;
444
+ }
445
+
446
+ // Word-wrap value (always on for TextInput)
447
+ const lines: string[] = [];
448
+ for (const rawLine of value.split("\n")) {
449
+ if (visibleWidth(rawLine) <= w) {
450
+ lines.push(rawLine);
451
+ } else {
452
+ wrapLine(rawLine, w, lines);
453
+ }
454
+ }
455
+
456
+ // Framework-managed scroll: auto-scroll to keep cursor visible
457
+ let scrollOffset = getTextInputScroll(props);
458
+
459
+ if (props.focused) {
460
+ const cursorOffset = getTextInputCursor(props);
461
+ const cursorPos = offsetToWrappedPos(value, cursorOffset, w);
462
+ // Scroll down if cursor is below viewport
463
+ if (cursorPos.line >= scrollOffset + h) {
464
+ scrollOffset = cursorPos.line - h + 1;
465
+ }
466
+ // Scroll up if cursor is above viewport
467
+ if (cursorPos.line < scrollOffset) {
468
+ scrollOffset = cursorPos.line;
469
+ }
470
+ setTextInputScroll(props, scrollOffset);
471
+ }
472
+
473
+ // Paint visible lines (grapheme-aware)
474
+ for (let row = 0; row < h; row++) {
475
+ const lineIdx = scrollOffset + row;
476
+ if (lineIdx >= lines.length) break;
477
+ const line = lines[lineIdx]!;
478
+ paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
479
+ }
480
+
481
+ // Paint cursor if focused
482
+ if (props.focused) {
483
+ const cursorOffset = getTextInputCursor(props);
484
+ const pos = offsetToWrappedPos(value, cursorOffset, w);
485
+ const screenRow = pos.line - scrollOffset;
486
+ if (screenRow >= 0 && screenRow < h && pos.col < w) {
487
+ const existing = buf.get(x + pos.col, y + screenRow);
488
+ // Invert colors for cursor visibility
489
+ buf.set(x + pos.col, y + screenRow, {
490
+ char: existing.char === " " && !existing.bgColor ? " " : existing.char,
491
+ fgColor: existing.bgColor ?? "black",
492
+ bgColor: existing.fgColor ?? "white",
493
+ bold: existing.bold,
494
+ italic: existing.italic,
495
+ underline: existing.underline,
496
+ });
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Map a cursor offset in the raw value to a (line, col) position
503
+ * in the word-wrapped output.
504
+ */
505
+ function offsetToWrappedPos(
506
+ value: string,
507
+ cursor: number,
508
+ width: number,
509
+ ): { line: number; col: number } {
510
+ const rawLines = value.split("\n");
511
+ let offset = 0;
512
+ let wrappedLine = 0;
513
+
514
+ for (const rawLine of rawLines) {
515
+ if (cursor <= offset + rawLine.length) {
516
+ // Cursor is in this raw line
517
+ const colInRaw = cursor - offset;
518
+ if (width <= 0) return { line: wrappedLine, col: colInRaw };
519
+ // Compute visible width of text before cursor
520
+ const textBeforeCursor = rawLine.slice(0, colInRaw);
521
+ const vw = visibleWidth(textBeforeCursor);
522
+ const extraLines = Math.floor(vw / width);
523
+ return { line: wrappedLine + extraLines, col: vw % width };
524
+ }
525
+ // Count wrapped lines for this raw line
526
+ const lineVW = visibleWidth(rawLine);
527
+ if (lineVW <= width || width <= 0) {
528
+ wrappedLine += 1;
529
+ } else {
530
+ wrappedLine += Math.ceil(lineVW / width);
531
+ }
532
+ offset += rawLine.length + 1; // +1 for \n
533
+ }
534
+
535
+ return { line: wrappedLine, col: 0 };
536
+ }
537
+
538
+ // --- Framework-managed state ---
539
+
540
+ import type { ContainerProps } from "@cel-tui/types";
541
+
542
+ const containerScrolls = new WeakMap<ContainerProps, number>();
543
+
544
+ /** Get the scroll offset for an uncontrolled scrollable container. */
545
+ export function getContainerScroll(props: ContainerProps): number {
546
+ return containerScrolls.get(props) ?? 0;
547
+ }
548
+
549
+ /** Set the scroll offset for an uncontrolled scrollable container. */
550
+ export function setContainerScroll(
551
+ props: ContainerProps,
552
+ scroll: number,
553
+ ): void {
554
+ containerScrolls.set(props, scroll);
555
+ }
556
+
557
+ /**
558
+ * TextInput state is keyed on the `onChange` function reference, which is
559
+ * a stable identity across re-renders (the app provides the same closure).
560
+ * This avoids losing cursor/scroll position when props objects are recreated
561
+ * each frame.
562
+ */
563
+ type OnChangeFn = (value: string) => void;
564
+ const textInputCursors = new WeakMap<OnChangeFn, number>();
565
+ const textInputScrolls = new WeakMap<OnChangeFn, number>();
566
+
567
+ /** Get the cursor offset for a TextInput (framework-managed). */
568
+ export function getTextInputCursor(props: TextInputProps): number {
569
+ return textInputCursors.get(props.onChange) ?? props.value.length;
570
+ }
571
+
572
+ /** Set the cursor offset for a TextInput. */
573
+ export function setTextInputCursor(
574
+ props: TextInputProps,
575
+ cursor: number,
576
+ ): void {
577
+ textInputCursors.set(props.onChange, cursor);
578
+ }
579
+
580
+ /** Get the scroll offset for a TextInput (framework-managed). */
581
+ export function getTextInputScroll(props: TextInputProps): number {
582
+ return textInputScrolls.get(props.onChange) ?? 0;
583
+ }
584
+
585
+ /** Set the scroll offset for a TextInput. */
586
+ export function setTextInputScroll(
587
+ props: TextInputProps,
588
+ scroll: number,
589
+ ): void {
590
+ textInputScrolls.set(props.onChange, scroll);
591
+ }
592
+
593
+ /**
594
+ * Simple word-wrap: break a line into multiple lines at word boundaries.
595
+ */
596
+ function wrapLine(line: string, width: number, out: string[]): void {
597
+ if (width <= 0) return;
598
+
599
+ let current = "";
600
+ let currentW = 0;
601
+ const words = line.split(" ");
602
+
603
+ for (const word of words) {
604
+ const wordW = visibleWidth(word);
605
+ if (currentW === 0) {
606
+ current = word;
607
+ currentW = wordW;
608
+ } else if (currentW + 1 + wordW <= width) {
609
+ current += " " + word;
610
+ currentW += 1 + wordW;
611
+ } else {
612
+ out.push(current);
613
+ current = word;
614
+ currentW = wordW;
615
+ }
616
+
617
+ // Handle words longer than width (break by grapheme)
618
+ while (currentW > width) {
619
+ let taken = "";
620
+ let takenW = 0;
621
+ let rest = "";
622
+ let inRest = false;
623
+ for (const { segment } of segmenter.segment(current)) {
624
+ if (inRest) {
625
+ rest += segment;
626
+ continue;
627
+ }
628
+ const gw = visibleWidth(segment);
629
+ if (takenW + gw > width) {
630
+ rest += segment;
631
+ inRest = true;
632
+ } else {
633
+ taken += segment;
634
+ takenW += gw;
635
+ }
636
+ }
637
+ out.push(taken);
638
+ current = rest;
639
+ currentW = visibleWidth(rest);
640
+ }
641
+ }
642
+
643
+ if (current.length > 0) {
644
+ out.push(current);
645
+ }
646
+ }
@@ -0,0 +1,42 @@
1
+ import type { ContainerNode, ContainerProps, Node } from "@cel-tui/types";
2
+
3
+ /**
4
+ * Create a vertical stack container — children laid out top to bottom.
5
+ *
6
+ * Equivalent to CSS `flex-direction: column`. Main axis is vertical,
7
+ * cross axis is horizontal.
8
+ *
9
+ * @param props - Layout, sizing, scrolling, focus, and interaction props.
10
+ * @param children - Ordered child nodes.
11
+ * @returns A container node for the UI tree.
12
+ *
13
+ * @example
14
+ * VStack({ flex: 1, gap: 1 }, [
15
+ * Text("Hello"),
16
+ * Text("World"),
17
+ * ])
18
+ */
19
+ export function VStack(props: ContainerProps, children: Node[]): ContainerNode {
20
+ return { type: "vstack", props, children };
21
+ }
22
+
23
+ /**
24
+ * Create a horizontal stack container — children laid out left to right.
25
+ *
26
+ * Equivalent to CSS `flex-direction: row`. Main axis is horizontal,
27
+ * cross axis is vertical.
28
+ *
29
+ * @param props - Layout, sizing, scrolling, focus, and interaction props.
30
+ * @param children - Ordered child nodes.
31
+ * @returns A container node for the UI tree.
32
+ *
33
+ * @example
34
+ * HStack({ height: 1, gap: 1 }, [
35
+ * Text("Name", { bold: true }),
36
+ * VStack({ flex: 1 }, []),
37
+ * Text("value", { fgColor: "brightBlack" }),
38
+ * ])
39
+ */
40
+ export function HStack(props: ContainerProps, children: Node[]): ContainerNode {
41
+ return { type: "hstack", props, children };
42
+ }