take4-console 0.15.0 → 0.25.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 (64) hide show
  1. package/CHANGELOG.md +365 -0
  2. package/README.md +1 -1
  3. package/dist/Screen/InterfaceBuilder.d.mts +15 -4
  4. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  5. package/dist/Screen/InterfaceBuilder.mjs +104 -8
  6. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  7. package/dist/Screen/Pos.d.mts +12 -0
  8. package/dist/Screen/Pos.d.mts.map +1 -1
  9. package/dist/Screen/Pos.mjs +23 -1
  10. package/dist/Screen/Pos.mjs.map +1 -1
  11. package/dist/Screen/Screen.d.mts +77 -3
  12. package/dist/Screen/Screen.d.mts.map +1 -1
  13. package/dist/Screen/Screen.mjs +168 -3
  14. package/dist/Screen/Screen.mjs.map +1 -1
  15. package/dist/Screen/Size.d.mts +49 -6
  16. package/dist/Screen/Size.d.mts.map +1 -1
  17. package/dist/Screen/Size.mjs +81 -7
  18. package/dist/Screen/Size.mjs.map +1 -1
  19. package/dist/Screen/Window.d.mts +131 -20
  20. package/dist/Screen/Window.d.mts.map +1 -1
  21. package/dist/Screen/Window.mjs +474 -57
  22. package/dist/Screen/Window.mjs.map +1 -1
  23. package/dist/Screen/WindowManager.d.mts +85 -5
  24. package/dist/Screen/WindowManager.d.mts.map +1 -1
  25. package/dist/Screen/WindowManager.mjs +279 -26
  26. package/dist/Screen/WindowManager.mjs.map +1 -1
  27. package/dist/Screen/controls/ListBox.d.mts +34 -12
  28. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  29. package/dist/Screen/controls/ListBox.mjs +127 -25
  30. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  31. package/dist/Screen/controls/TextArea.d.mts +15 -1
  32. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  33. package/dist/Screen/controls/TextArea.mjs +74 -1
  34. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  35. package/dist/Screen/controls/TextBox.d.mts +13 -1
  36. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  37. package/dist/Screen/controls/TextBox.mjs +36 -1
  38. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  39. package/dist/Screen/textWidth.d.mts +13 -0
  40. package/dist/Screen/textWidth.d.mts.map +1 -0
  41. package/dist/Screen/textWidth.mjs +188 -0
  42. package/dist/Screen/textWidth.mjs.map +1 -0
  43. package/dist/Screen/types.d.mts +336 -20
  44. package/dist/Screen/types.d.mts.map +1 -1
  45. package/dist/Screen/types.mjs.map +1 -1
  46. package/dist/index.d.mts +3 -2
  47. package/dist/index.d.mts.map +1 -1
  48. package/dist/index.mjs +3 -1
  49. package/dist/index.mjs.map +1 -1
  50. package/package.json +4 -4
  51. package/src/Screen/InterfaceBuilder.mts +116 -20
  52. package/src/Screen/Pos.mts +24 -1
  53. package/src/Screen/Screen.mts +192 -4
  54. package/src/Screen/Size.mts +97 -12
  55. package/src/Screen/Window.mts +463 -63
  56. package/src/Screen/WindowManager.mts +301 -29
  57. package/src/Screen/controls/ListBox.mts +151 -32
  58. package/src/Screen/controls/TextArea.mts +82 -1
  59. package/src/Screen/controls/TextBox.mts +40 -1
  60. package/src/Screen/textWidth.mts +186 -0
  61. package/src/Screen/types.mts +328 -23
  62. package/src/demo.mts +232 -20
  63. package/src/index.mts +23 -3
  64. package/src/layout.yaml +56 -24
@@ -1,16 +1,27 @@
1
- import type { Cell, StyleId, BorderStyle, WindowBorder, WindowProperties, WriteTextOptions, TerminalSize } from './types.mjs';
1
+ import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, DimSpec } from './types.mjs';
2
2
  import { BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED, BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED } from './types.mjs';
3
3
  import { Region } from './Region.mjs';
4
4
  import { StyleRegistry } from './StyleRegistry.mjs';
5
5
  import { getRegistry } from './RegistryHolder.mjs';
6
+ import { charWidth, stringWidth } from './textWidth.mjs';
6
7
  import type { Pos } from './Pos.mjs';
7
8
  import { Size } from './Size.mjs';
8
9
 
9
- /** Characters used for each border style. */
10
- const BORDER_CHARS: Record<BorderStyle, { h: string; v: string; tl: string; tr: string; bl: string; br: string }> = {
11
- single: { h: '─', v: '', tl: '┌', tr: '┐', bl: '└', br: '┘' },
12
- double: { h: '', v: '', tl: '', tr: '', bl: '', br: '' },
13
- rounded: { h: '', v: '', tl: '', tr: '', bl: '', br: '╯' },
10
+ /** Glyph table for each visual border style. The 'none' style is handled
11
+ * separately (paintBorder bails out early), so it does not appear here. */
12
+ const BORDER_CHARS: Record<Exclude<BorderStyle, 'none'>, Required<BorderChars>> = {
13
+ single: { horizontal: '', vertical: '', topLeft: '', topRight: '', bottomLeft: '', bottomRight: '',
14
+ verticalLeft: '', verticalRight: '', horizontalTop: '', horizontalBottom: '', cross: '' },
15
+ double: { horizontal: '═', vertical: '║', topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
16
+ verticalLeft: '╣', verticalRight: '╠', horizontalTop: '╩', horizontalBottom: '╦', cross: '╬' },
17
+ rounded: { horizontal: '─', vertical: '│', topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
18
+ verticalLeft: '┤', verticalRight: '├', horizontalTop: '┴', horizontalBottom: '┬', cross: '┼' },
19
+ thick: { horizontal: '━', vertical: '┃', topLeft: '┏', topRight: '┓', bottomLeft: '┗', bottomRight: '┛',
20
+ verticalLeft: '┫', verticalRight: '┣', horizontalTop: '┻', horizontalBottom: '┳', cross: '╋' },
21
+ dashed: { horizontal: '╌', vertical: '╎', topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘',
22
+ verticalLeft: '┤', verticalRight: '├', horizontalTop: '┴', horizontalBottom: '┬', cross: '┼' },
23
+ ascii: { horizontal: '-', vertical: '|', topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+',
24
+ verticalLeft: '+', verticalRight: '+', horizontalTop: '+', horizontalBottom: '+', cross: '+' },
14
25
  };
15
26
 
16
27
  /** Resolves a border option (true / object / false) to a full WindowBorder or false. */
@@ -20,6 +31,51 @@ const resolveBorder = (border: WindowBorder | boolean | undefined): WindowBorder
20
31
  return border;
21
32
  };
22
33
 
34
+ /** Internal per-child bookkeeping during a row/column flex layout pass. */
35
+ interface FlexItem {
36
+ child: Window;
37
+ mainSpec: DimSpec;
38
+ crossSpec: DimSpec;
39
+ mainSize: number;
40
+ crossSize: number;
41
+ }
42
+
43
+ /** Resolves a flex-item's initial main- or cross-axis size from its DimSpec.
44
+ * - 'abs' → the literal pixel value.
45
+ * - 'pct' → the computed fraction of the parent's inner dimension.
46
+ * - 'flex' → the basis (abs or pct of parent) before grow/shrink distribution.
47
+ * - 'content' → the child's natural size (its current Region dimension on the
48
+ * requested axis), which defaults to 1 for an uninitialised child.
49
+ * `naturalSize` must come from the child's current getSize() on the relevant
50
+ * axis so content-sized children pick up the most up-to-date measurement. */
51
+ function resolveFlexBasis(spec: DimSpec, parent: number, naturalSize: number): number {
52
+ switch (spec.mode) {
53
+ case 'abs': return spec.value;
54
+ case 'pct': return Math.floor(parent * spec.value / 100);
55
+ case 'content': return naturalSize;
56
+ case 'flex':
57
+ return spec.basis.kind === 'pct'
58
+ ? Math.floor(parent * spec.basis.value / 100)
59
+ : spec.basis.value;
60
+ }
61
+ }
62
+
63
+ /** Normalises a PaddingSpec to a full Padding record (missing sides → 0). */
64
+ const resolvePadding = (spec: PaddingSpec | undefined): Padding => {
65
+ if (spec === undefined) return { top: 0, right: 0, bottom: 0, left: 0 };
66
+ if (typeof spec === 'number') return { top: spec, right: spec, bottom: spec, left: spec };
67
+ if (Array.isArray(spec)) {
68
+ const [v = 0, h = 0] = spec;
69
+ return { top: v, right: h, bottom: v, left: h };
70
+ }
71
+ return {
72
+ top: spec.top ?? 0,
73
+ right: spec.right ?? 0,
74
+ bottom: spec.bottom ?? 0,
75
+ left: spec.left ?? 0,
76
+ };
77
+ };
78
+
23
79
  export class Window {
24
80
  public x: number;
25
81
  public y: number;
@@ -50,6 +106,25 @@ export class Window {
50
106
  private active: boolean;
51
107
  private posSpec: Pos;
52
108
  private sizeSpec: Size;
109
+ /** Whether this window participates in rendering. When false, the window's own
110
+ * render() is a no-op, its region is not blitted onto the parent, and
111
+ * `getCell()` throws. Focus-cycle in WindowManager skips invisible focusable
112
+ * controls. Default: true. */
113
+ private visible: boolean = true;
114
+ /** Layout algorithm applied to direct children. 'absolute' keeps the pre-flex
115
+ * behaviour; 'row' / 'column' / 'grid' activate the flex engine. */
116
+ private layoutMode: LayoutMode;
117
+ /** Spacing (in cells) between adjacent children for flex / grid layouts. */
118
+ private gap: number;
119
+ /** Padding applied inside the border (in addition to the border inset).
120
+ * Influences `getInnerSize()` / `getInnerOffset()`. */
121
+ private padding: Padding;
122
+ /** Number of columns for `layout: 'grid'`. */
123
+ private gridColumns: number;
124
+ /** Cross-axis alignment for row/column layouts. */
125
+ private alignItems: AlignItems;
126
+ /** Main-axis distribution of leftover space when no flex-grow child consumes it. */
127
+ private justifyContent: JustifyContent;
53
128
 
54
129
  /** Creates a window from the given properties.
55
130
  * For percentage-based sizes, call addChild() before writing content to the window.
@@ -72,6 +147,12 @@ export class Window {
72
147
  this.background = wp.background ?? 0;
73
148
  this.border = resolveBorder(wp.border ?? wp.defaultBorder);
74
149
  this.borderColorExplicit = this.border !== false && this.border.color !== undefined;
150
+ this.layoutMode = wp.layout ?? 'absolute';
151
+ this.gap = wp.gap ?? 0;
152
+ this.padding = resolvePadding(wp.padding);
153
+ this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
154
+ this.alignItems = wp.alignItems ?? 'stretch';
155
+ this.justifyContent = wp.justifyContent ?? 'start';
75
156
 
76
157
  const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
77
158
  this.region = new Region(w, h);
@@ -92,10 +173,11 @@ export class Window {
92
173
  return this.region.getSize();
93
174
  }
94
175
 
95
- /** Returns the number of cells consumed by decorations on each edge. */
176
+ /** Returns the number of cells consumed by decorations on each edge.
177
+ * The explicit 'none' border style is treated as no border (no insets). */
96
178
  private borderInset(): { top: number; right: number; bottom: number; left: number } {
97
179
  const b = this.border;
98
- if (!b) return { top: 0, right: 0, bottom: 0, left: 0 };
180
+ if (!b || b.style === 'none') return { top: 0, right: 0, bottom: 0, left: 0 };
99
181
  return {
100
182
  top: (b.top ?? false) ? 1 : 0,
101
183
  right: (b.right ?? false) ? 1 : 0,
@@ -104,16 +186,30 @@ export class Window {
104
186
  };
105
187
  }
106
188
 
107
- /** Returns the top-left offset of the content area, accounting for decorations such as borders. */
189
+ /** Returns the combined per-side inset (border + padding) that defines the
190
+ * inner content area. Padding stacks on top of the border inset. */
191
+ private innerInset(): Padding {
192
+ const b = this.borderInset();
193
+ return {
194
+ top: b.top + this.padding.top,
195
+ right: b.right + this.padding.right,
196
+ bottom: b.bottom + this.padding.bottom,
197
+ left: b.left + this.padding.left,
198
+ };
199
+ }
200
+
201
+ /** Returns the top-left offset of the content area, accounting for
202
+ * decorations (border) and layout insets (padding). */
108
203
  public getInnerOffset(): { x: number; y: number } {
109
- const { left, top } = this.borderInset();
204
+ const { left, top } = this.innerInset();
110
205
  return { x: left, y: top };
111
206
  }
112
207
 
113
- /** Returns the dimensions of the content area, accounting for decorations such as borders. */
208
+ /** Returns the dimensions of the content area, accounting for decorations
209
+ * (border) and layout insets (padding). */
114
210
  public getInnerSize(): TerminalSize {
115
211
  const { width, height } = this.getSize();
116
- const { top, right, bottom, left } = this.borderInset();
212
+ const { top, right, bottom, left } = this.innerInset();
117
213
  return {
118
214
  width: Math.max(0, width - left - right),
119
215
  height: Math.max(0, height - top - bottom),
@@ -146,6 +242,20 @@ export class Window {
146
242
  return this.disabled;
147
243
  }
148
244
 
245
+ /** Shows or hides the window. A hidden window does not paint itself, is not
246
+ * blitted onto its parent, and its focusable descendants are skipped by
247
+ * `WindowManager.moveFocus / setFocus`. Toggling visibility does not mutate
248
+ * the content buffer — previously written cells reappear verbatim on the
249
+ * next render after the window is shown again. */
250
+ public setVisible(visible: boolean): void {
251
+ this.visible = visible;
252
+ }
253
+
254
+ /** Returns whether this window is currently visible. Default: true. */
255
+ public isVisible(): boolean {
256
+ return this.visible;
257
+ }
258
+
149
259
  /** Sets the label text displayed by the control. */
150
260
  public setLabel(label: string): void {
151
261
  this.label = label;
@@ -174,26 +284,25 @@ export class Window {
174
284
  : this.registry.getNamedForeground(BUILTIN_BORDER, 240);
175
285
  }
176
286
 
177
- /** Adds a child window. If the child uses percentage-based sizes they are resolved immediately
178
- * against this window's inner dimensions (excluding decorations such as borders).
179
- * Position is resolved relative to the inner area and stored on child.x/y. */
287
+ /** Adds a child window. The final position and (for non-absolute sizes) the
288
+ * final dimensions are computed by the parent's layout engine: in
289
+ * 'absolute' layout each child resolves its own Pos/Size; in 'row',
290
+ * 'column', or 'grid' layouts the engine distributes the inner area
291
+ * across every visible child. Re-laying out on addChild keeps sibling
292
+ * geometry consistent when more children join the stack. */
180
293
  public addChild(child: Window): void {
181
- const { width: pw, height: ph } = this.getInnerSize();
182
- const { x: ox, y: oy } = this.getInnerOffset();
183
- if (!child.sizeSpec.isAbsolute()) {
184
- const { w, h } = child.sizeSpec.resolve(pw, ph);
185
- child.resizeRegions(w, h);
186
- }
187
- const { width: cw, height: ch } = child.getSize();
188
- const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
189
- child.x = x + ox;
190
- child.y = y + oy;
191
294
  this.children.push(child);
295
+ this.runLayout();
192
296
  }
193
297
 
194
298
  /** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
195
- * Throws RangeError if out of bounds. */
299
+ * Throws RangeError if out of bounds. Throws Error when the window is
300
+ * currently hidden (setVisible(false)) — callers should check isVisible()
301
+ * first when the visibility state is uncertain. */
196
302
  public getCell(x: number, y: number): Cell {
303
+ if (!this.visible) {
304
+ throw new Error('Window.getCell called on a hidden window (setVisible(false))');
305
+ }
197
306
  const char = this.region.getChars()[this.flatIndex(x, y)];
198
307
  const styleId = this.region.getStyleId(x, y);
199
308
  const attributes = { ...this.registry.get(styleId) };
@@ -235,45 +344,142 @@ export class Window {
235
344
 
236
345
  /** Writes text into the window's content area starting at (x, y) (default 0, 0).
237
346
  * Coordinates are relative to the inner content area (i.e. decorations such as borders are excluded).
238
- * Newline characters move to the next row, resetting x to startX.
239
- * Characters outside the inner bounds are silently clipped.
240
- * When no style is provided, automatically picks disabledStyleId, focusedStyleId, or normalStyleId
241
- * based on the current disabled/focused state. */
242
- public writeText(text: string, options?: WriteTextOptions): void {
347
+ *
348
+ * Accepts either a plain string (one base style for the whole text) or an array of
349
+ * `WriteTextSegment`s so inline rich text can be laid out without separate writeText()
350
+ * calls — the cursor flows from one segment into the next on the same row. Each segment
351
+ * may pin its own style either as a pre-registered `style: StyleId` or as inline
352
+ * `attrs: CellAttributes` (registered on the fly). The segment style is merged on top
353
+ * of the base style so global defaults (e.g. foreground) keep applying unless overridden.
354
+ *
355
+ * Behaviour shared with the single-string form:
356
+ * - Newline characters move to the next row, resetting x to startX.
357
+ * - Characters outside the inner bounds are silently clipped.
358
+ * - Wide characters (CJK, emoji, double-width NerdFonts) occupy two consecutive cells;
359
+ * the second cell stores '' as a continuation sentinel so terminal cursor advancement
360
+ * during render() stays aligned with the buffer indices. Wide chars whose right half
361
+ * would overflow the inner area are skipped entirely.
362
+ * - Zero-width codepoints (combining marks, format chars, control chars) are skipped.
363
+ * - When no style is provided, the base style is auto-picked from disabled / focused /
364
+ * normal state. Pass `options.style = 0` to suppress the state-based base style. */
365
+ public writeText(input: WriteTextInput, options?: WriteTextOptions): void {
243
366
  const { x: ox, y: oy } = this.getInnerOffset();
244
367
  const { width: iw, height: ih } = this.getInnerSize();
245
- const startX = (options?.x ?? 0) + ox;
246
- const startY = (options?.y ?? 0) + oy;
247
- const styleId = options?.style ?? (
368
+ const startX = (options?.x ?? 0) + ox;
369
+ const startY = (options?.y ?? 0) + oy;
370
+ const baseStyle = options?.style ?? (
248
371
  this.disabled ? this.disabledStyleId :
249
372
  this.focused ? this.focusedStyleId :
250
373
  this.normalStyleId
251
374
  );
375
+ const segments: WriteTextSegment[] = typeof input === 'string'
376
+ ? [{ text: input }]
377
+ : input;
378
+
252
379
  let cx = startX;
253
380
  let cy = startY;
254
- for (const ch of text) {
255
- if (ch === '\n') {
256
- cx = startX;
257
- cy++;
258
- continue;
381
+ for (const seg of segments) {
382
+ let segId: StyleId = baseStyle;
383
+ if (seg.style !== undefined) {
384
+ segId = this.registry.merge(baseStyle, seg.style);
385
+ } else if (seg.attrs !== undefined) {
386
+ segId = this.registry.merge(baseStyle, this.registry.register(seg.attrs));
259
387
  }
260
- if (cx >= ox && cx < ox + iw && cy >= oy && cy < oy + ih) {
261
- this.setCell(cx, cy, ch, styleId);
388
+ for (const ch of seg.text) {
389
+ if (ch === '\n') {
390
+ cx = startX;
391
+ cy++;
392
+ continue;
393
+ }
394
+ const w = charWidth(ch.codePointAt(0)!);
395
+ if (w === 0) continue;
396
+ const inRow = cy >= oy && cy < oy + ih;
397
+ if (w === 2) {
398
+ if (inRow && cx >= ox && cx + 1 < ox + iw) {
399
+ this.setCell(cx, cy, ch, segId);
400
+ this.setCell(cx + 1, cy, '', segId);
401
+ }
402
+ } else {
403
+ if (inRow && cx >= ox && cx < ox + iw) {
404
+ this.setCell(cx, cy, ch, segId);
405
+ }
406
+ }
407
+ cx += w;
262
408
  }
263
- cx++;
264
409
  }
265
410
  }
266
411
 
412
+ /** Writes a template that embeds named styles from the StyleRegistry.
413
+ * Syntax is a mini-markup: `{name}…{/}` wraps an inline region in the style
414
+ * registered under `name` (e.g. a built-in name like `builtin:text-focused` or a
415
+ * user-registered custom name). Nested tags inherit attributes from their parent
416
+ * — the inner style is merged on top of the outer one, so `{red}a{bold}b{/}c{/}`
417
+ * renders `a` in red, `b` in red+bold, `c` in red. `{/}` closes the most recently
418
+ * opened tag. Unknown names keep the surrounding style unchanged. `{{` and `}}`
419
+ * escape literal braces. The template is compiled to an array of
420
+ * `WriteTextSegment`s and written via writeText(), so line-wrap, width, and
421
+ * clipping rules are inherited. */
422
+ public writeMarkup(template: string, options?: WriteTextOptions): void {
423
+ const segments: WriteTextSegment[] = [];
424
+ const stack: StyleId[] = [];
425
+ let buf = '';
426
+ const flush = (): void => {
427
+ if (buf.length === 0) return;
428
+ const top = stack[stack.length - 1];
429
+ segments.push(top !== undefined ? { text: buf, style: top } : { text: buf });
430
+ buf = '';
431
+ };
432
+ let i = 0;
433
+ while (i < template.length) {
434
+ const ch = template[i]!;
435
+ if (ch === '{' && template[i + 1] === '{') { buf += '{'; i += 2; continue; }
436
+ if (ch === '}' && template[i + 1] === '}') { buf += '}'; i += 2; continue; }
437
+ if (ch === '{') {
438
+ const close = template.indexOf('}', i + 1);
439
+ if (close === -1) { buf += ch; i++; continue; }
440
+ const tag = template.slice(i + 1, close);
441
+ flush();
442
+ if (tag === '/') {
443
+ stack.pop();
444
+ } else {
445
+ const id = this.registry.getNamed(tag);
446
+ const top = stack[stack.length - 1] ?? 0;
447
+ const composed = id !== undefined ? this.registry.merge(top, id) : top;
448
+ stack.push(composed);
449
+ }
450
+ i = close + 1;
451
+ continue;
452
+ }
453
+ buf += ch;
454
+ i++;
455
+ }
456
+ flush();
457
+ this.writeText(segments, options);
458
+ }
459
+
460
+ /** Returns the display width (in terminal cells) of a string,
461
+ * honouring wide characters (CJK, emoji, double-width NerdFonts) and
462
+ * ignoring zero-width codepoints. Useful for sizing labels or aligning
463
+ * text in custom controls. */
464
+ public getTextWidth(text: string): number {
465
+ return stringWidth(text);
466
+ }
467
+
267
468
  /**
268
469
  * Builds the display buffer: background → user content → border → children.
269
470
  * The result is stored in region and used by blitChild / Screen.render().
471
+ * A hidden window (setVisible(false)) returns immediately so neither its
472
+ * own paint stages nor its children contribute to the frame; hidden
473
+ * children are also skipped in the loop below.
270
474
  */
271
475
  public render(): void {
476
+ if (!this.visible) return;
272
477
  this.syncBorderColor();
273
478
  this.paintBackground();
274
479
  this.blitContent();
275
480
  this.paintBorder();
276
481
  for (const child of this.children) {
482
+ if (!child.visible) continue;
277
483
  child.render();
278
484
  this.blitChild(child);
279
485
  }
@@ -305,13 +511,18 @@ export class Window {
305
511
  }
306
512
  }
307
513
 
308
- /** Draws border characters on the display buffer edges. When inactive, adds dim to border cells. */
514
+ /** Draws border characters on the display buffer edges. When inactive, adds dim to border cells.
515
+ * Uses the glyph table for `border.style`, with optional per-character overrides
516
+ * via `border.chars`. The 'none' style is a no-op placeholder. */
309
517
  private paintBorder(): void {
310
518
  if (!this.border) return;
311
519
  const b = this.border;
520
+ const style = b.style ?? 'single';
521
+ if (style === 'none') return;
312
522
  const { width, height } = this.region.getSize();
313
523
  if (width < 2 && height < 2) return;
314
- const chars = BORDER_CHARS[b.style ?? 'single'];
524
+ const baseChars = BORDER_CHARS[style];
525
+ const chars: Required<BorderChars> = b.chars ? { ...baseChars, ...b.chars } : baseChars;
315
526
 
316
527
  const bgColor = this.background !== 0
317
528
  ? this.registry.get(this.background).background
@@ -334,9 +545,9 @@ export class Window {
334
545
  const isLeft = x === 0;
335
546
  const isRight = x === width - 1;
336
547
  let ch: string;
337
- if (isLeft && left) ch = chars.tl;
338
- else if (isRight && right) ch = chars.tr;
339
- else ch = chars.h;
548
+ if (isLeft && left) ch = chars.topLeft;
549
+ else if (isRight && right) ch = chars.topRight;
550
+ else ch = chars.horizontal;
340
551
  this.region.setCell(x, 0, ch, baseId);
341
552
  }
342
553
  }
@@ -347,9 +558,9 @@ export class Window {
347
558
  const isLeft = x === 0;
348
559
  const isRight = x === width - 1;
349
560
  let ch: string;
350
- if (isLeft && left) ch = chars.bl;
351
- else if (isRight && right) ch = chars.br;
352
- else ch = chars.h;
561
+ if (isLeft && left) ch = chars.bottomLeft;
562
+ else if (isRight && right) ch = chars.bottomRight;
563
+ else ch = chars.horizontal;
353
564
  this.region.setCell(x, height - 1, ch, baseId);
354
565
  }
355
566
  }
@@ -359,7 +570,7 @@ export class Window {
359
570
  const rowStart = top ? 1 : 0;
360
571
  const rowEnd = bottom ? height - 2 : height - 1;
361
572
  for (let y = rowStart; y <= rowEnd; y++) {
362
- this.region.setCell(0, y, chars.v, baseId);
573
+ this.region.setCell(0, y, chars.vertical, baseId);
363
574
  }
364
575
  }
365
576
 
@@ -368,23 +579,22 @@ export class Window {
368
579
  const rowStart = top ? 1 : 0;
369
580
  const rowEnd = bottom ? height - 2 : height - 1;
370
581
  for (let y = rowStart; y <= rowEnd; y++) {
371
- this.region.setCell(width - 1, y, chars.v, baseId);
582
+ this.region.setCell(width - 1, y, chars.vertical, baseId);
372
583
  }
373
584
  }
374
585
  }
375
586
 
376
- /** Copies a child's display buffer onto this window's display buffer, clipping to this window's bounds.
377
- * Styles are transferred from the child's registry into this window's registry.
378
- * Position is re-resolved from the child's Pos spec relative to the inner content area on every render. */
587
+ /** Copies a child's display buffer onto this window's display buffer,
588
+ * clipping to this window's bounds. Styles are transferred from the child's
589
+ * registry into this window's registry. The child's (x, y) was computed by
590
+ * the layout engine on addChild / reflowChildren / setSize, so blit can
591
+ * read it directly — this keeps absolute and flex paths on the same code. */
379
592
  private blitChild(child: Window): void {
380
593
  const chars = child.region.getChars();
381
594
  const childStyleIds = child.region.getStyleIds();
382
595
  const { width: cw, height: ch } = child.getSize();
383
- const { width: pw, height: ph } = this.getInnerSize();
384
- const { x: ox, y: oy } = this.getInnerOffset();
385
- const { x: cx, y: cy } = child.posSpec.resolve(pw, ph, cw, ch);
386
- const ax = cx + ox;
387
- const ay = cy + oy;
596
+ const ax = child.x;
597
+ const ay = child.y;
388
598
  const { width: totalW, height: totalH } = this.getSize();
389
599
 
390
600
  for (let childY = 0; childY < ch; childY++) {
@@ -409,17 +619,46 @@ export class Window {
409
619
 
410
620
  /** Replaces both internal regions with new ones of the given dimensions,
411
621
  * then re-resolves sizes and positions of all direct children against the updated inner area. */
412
- private resizeRegions(w: number, h: number): void {
622
+ protected resizeRegions(w: number, h: number): void {
413
623
  this.region = new Region(w, h);
414
624
  this.content = new Region(w, h);
415
625
  this.reflowChildren();
416
626
  }
417
627
 
418
- /** Re-resolves sizes and absolute positions for every direct child against the current inner area.
419
- * Called after this window is resized so that percentage-based children get correct dimensions. */
628
+ /** Resizes this window to the given absolute dimensions and reflows every
629
+ * descendant whose size or position is percentage-based against the new
630
+ * inner area. Existing absolute children keep their declared geometry but
631
+ * have their stored x/y refreshed by reflowChildren() so the parent's
632
+ * border insets still apply. The previously written content is discarded
633
+ * — callers that need to preserve it must redraw after setSize(). */
634
+ public setSize(width: number, height: number): void {
635
+ this.resizeRegions(width, height);
636
+ }
637
+
638
+ /** Re-resolves sizes and absolute positions for every direct child against
639
+ * the current inner area. Delegates to the layout engine so percentage
640
+ * children, flex children, and grid cells are all updated from the same
641
+ * code path. Called after this window is resized or a new child is added. */
420
642
  private reflowChildren(): void {
643
+ this.runLayout();
644
+ }
645
+
646
+ /** Runs the layout engine against the current inner area. Dispatches to
647
+ * absolute / row / column / grid implementations based on `layoutMode`. */
648
+ private runLayout(): void {
421
649
  const { width: pw, height: ph } = this.getInnerSize();
422
650
  const { x: ox, y: oy } = this.getInnerOffset();
651
+ switch (this.layoutMode) {
652
+ case 'absolute': this.layoutAbsolute(pw, ph, ox, oy); return;
653
+ case 'row':
654
+ case 'column': this.layoutFlex(this.layoutMode, pw, ph, ox, oy); return;
655
+ case 'grid': this.layoutGrid(pw, ph, ox, oy); return;
656
+ }
657
+ }
658
+
659
+ /** Absolute layout: each child independently resolves its Pos/Size against
660
+ * the parent's inner area — the pre-flex behaviour. */
661
+ private layoutAbsolute(pw: number, ph: number, ox: number, oy: number): void {
423
662
  for (const child of this.children) {
424
663
  if (!child.sizeSpec.isAbsolute()) {
425
664
  const { w, h } = child.sizeSpec.resolve(pw, ph);
@@ -432,6 +671,167 @@ export class Window {
432
671
  }
433
672
  }
434
673
 
674
+ /** Row / column flex layout. The main axis (width for 'row', height for
675
+ * 'column') is distributed across children: each child starts with its
676
+ * basis (abs → value, pct → fraction of parent, flex → flex basis,
677
+ * content → measured natural size), then remaining slack is distributed
678
+ * pro rata by `grow`, and negative slack by `shrink`. The cross axis is
679
+ * aligned via `alignItems` — 'stretch' expands flex/content children to
680
+ * the full inner cross dimension. `justifyContent` governs leftover
681
+ * distribution only when no child consumes slack via flex-grow. Invisible
682
+ * children are skipped so `setVisible(false)` effectively removes them
683
+ * from the stack. Children are ordered by `Pos.flex(order)` first, then
684
+ * by addChild insertion (stable). */
685
+ private layoutFlex(mode: 'row' | 'column', pw: number, ph: number, ox: number, oy: number): void {
686
+ const ordered = this.orderedVisibleChildren();
687
+ if (ordered.length === 0) return;
688
+ const isRow = mode === 'row';
689
+ const mainParent = isRow ? pw : ph;
690
+ const crossParent = isRow ? ph : pw;
691
+ const gap = this.gap;
692
+
693
+ // First pass: basis sizes per child.
694
+ const items = ordered.map((c): FlexItem => {
695
+ const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
696
+ const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
697
+ return {
698
+ child: c,
699
+ mainSpec,
700
+ crossSpec,
701
+ mainSize: resolveFlexBasis(mainSpec, mainParent, isRow ? c.getSize().width : c.getSize().height),
702
+ crossSize: resolveFlexBasis(crossSpec, crossParent, isRow ? c.getSize().height : c.getSize().width),
703
+ };
704
+ });
705
+
706
+ // Distribute leftover main-axis space.
707
+ const totalGap = Math.max(0, items.length - 1) * gap;
708
+ const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
709
+ let remainder = mainParent - totalMain - totalGap;
710
+ const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
711
+ if (remainder > 0 && totalGrow > 0) {
712
+ let distributed = 0;
713
+ for (const it of items) {
714
+ if (it.mainSpec.mode !== 'flex') continue;
715
+ const share = Math.floor(remainder * (it.mainSpec.grow / totalGrow));
716
+ it.mainSize += share;
717
+ distributed += share;
718
+ }
719
+ // Hand the integer-truncation leftover to the last flex child.
720
+ const leftover = remainder - distributed;
721
+ if (leftover > 0) {
722
+ for (let i = items.length - 1; i >= 0; i--) {
723
+ if (items[i]!.mainSpec.mode === 'flex') { items[i]!.mainSize += leftover; break; }
724
+ }
725
+ }
726
+ remainder = 0;
727
+ } else if (remainder < 0) {
728
+ const totalShrink = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.shrink : 0), 0);
729
+ if (totalShrink > 0) {
730
+ for (const it of items) {
731
+ if (it.mainSpec.mode !== 'flex') continue;
732
+ const take = Math.ceil(Math.abs(remainder) * (it.mainSpec.shrink / totalShrink));
733
+ it.mainSize = Math.max(0, it.mainSize - take);
734
+ }
735
+ const consumed = items.reduce((s, it) => s + it.mainSize, 0);
736
+ remainder = mainParent - consumed - totalGap;
737
+ }
738
+ }
739
+
740
+ // Apply cross-axis stretch where allowed.
741
+ for (const it of items) {
742
+ const mode = it.crossSpec.mode;
743
+ if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
744
+ it.crossSize = crossParent;
745
+ } else {
746
+ it.crossSize = Math.min(it.crossSize, crossParent);
747
+ }
748
+ }
749
+
750
+ // justifyContent kicks in only when there is positive slack with no grow.
751
+ let mainStart = 0;
752
+ let itemSpacing = gap;
753
+ if (remainder > 0) {
754
+ switch (this.justifyContent) {
755
+ case 'center': mainStart = Math.floor(remainder / 2); break;
756
+ case 'end': mainStart = remainder; break;
757
+ case 'space-between':
758
+ if (items.length > 1) itemSpacing = gap + Math.floor(remainder / (items.length - 1));
759
+ break;
760
+ case 'space-around':
761
+ if (items.length > 0) {
762
+ const pad = Math.floor(remainder / (items.length * 2));
763
+ mainStart = pad;
764
+ itemSpacing = gap + pad * 2;
765
+ }
766
+ break;
767
+ }
768
+ }
769
+
770
+ // Write final sizes and positions.
771
+ let cursor = mainStart;
772
+ for (const it of items) {
773
+ const mainSize = Math.max(0, it.mainSize);
774
+ const crossSize = Math.max(0, it.crossSize);
775
+ const newW = isRow ? mainSize : crossSize;
776
+ const newH = isRow ? crossSize : mainSize;
777
+ if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
778
+ it.child.resizeRegions(newW, newH);
779
+ }
780
+ let crossPos = 0;
781
+ if (this.alignItems !== 'stretch') {
782
+ const free = crossParent - crossSize;
783
+ if (this.alignItems === 'center') crossPos = Math.floor(free / 2);
784
+ else if (this.alignItems === 'end') crossPos = free;
785
+ }
786
+ if (isRow) {
787
+ it.child.x = ox + cursor;
788
+ it.child.y = oy + crossPos;
789
+ } else {
790
+ it.child.x = ox + crossPos;
791
+ it.child.y = oy + cursor;
792
+ }
793
+ cursor += mainSize + itemSpacing;
794
+ }
795
+ }
796
+
797
+ /** Grid layout: children are placed row-major into equally sized cells.
798
+ * Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
799
+ * heights use the same formula with the derived row count. Children are
800
+ * resized to the cell dimensions and positioned at the cell's top-left.
801
+ * Invisible children are skipped. */
802
+ private layoutGrid(pw: number, ph: number, ox: number, oy: number): void {
803
+ const ordered = this.orderedVisibleChildren();
804
+ if (ordered.length === 0) return;
805
+ const cols = this.gridColumns;
806
+ const rows = Math.max(1, Math.ceil(ordered.length / cols));
807
+ const gap = this.gap;
808
+ const cellW = Math.max(0, Math.floor((pw - gap * Math.max(0, cols - 1)) / cols));
809
+ const cellH = Math.max(0, Math.floor((ph - gap * Math.max(0, rows - 1)) / rows));
810
+ for (let i = 0; i < ordered.length; i++) {
811
+ const child = ordered[i]!;
812
+ const c = i % cols;
813
+ const r = Math.floor(i / cols);
814
+ if (cellW !== child.getSize().width || cellH !== child.getSize().height) {
815
+ child.resizeRegions(cellW, cellH);
816
+ }
817
+ child.x = ox + c * (cellW + gap);
818
+ child.y = oy + r * (cellH + gap);
819
+ }
820
+ }
821
+
822
+ /** Returns visible children sorted by Pos.flex(order) ascending; children
823
+ * without Pos.flex default to order 0. Stable w.r.t. addChild insertion. */
824
+ private orderedVisibleChildren(): Window[] {
825
+ const visible = this.children.filter(c => c.visible);
826
+ const withIndex = visible.map((child, idx) => ({
827
+ child,
828
+ order: child.posSpec.getFlexOrder() ?? 0,
829
+ idx,
830
+ }));
831
+ withIndex.sort((a, b) => (a.order - b.order) || (a.idx - b.idx));
832
+ return withIndex.map(e => e.child);
833
+ }
834
+
435
835
  /** Returns the flat index for (x, y) in the region buffer (used by getCell). */
436
836
  private flatIndex(x: number, y: number): number {
437
837
  return y * this.region.getSize().width + x;