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