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
@@ -5,16 +5,20 @@ import type {
5
5
  YamlWindowDef,
6
6
  YamlPosSpec,
7
7
  YamlSizeSpec,
8
+ YamlDimValue,
8
9
  YamlAxisValue,
9
10
  StyleId,
10
11
  Focusable,
11
12
  WindowProperties,
13
+ CustomTypeFactory,
14
+ CustomTypeContext,
12
15
  } from './types.mjs';
13
16
  import { Window } from './Window.mjs';
14
17
  import { Screen } from './Screen.mjs';
15
18
  import { WindowManager } from './WindowManager.mjs';
16
19
  import { Pos, Pct, pct } from './Pos.mjs';
17
- import { Size } from './Size.mjs';
20
+ import { Size, flex, content } from './Size.mjs';
21
+ import type { DimValue } from './Size.mjs';
18
22
  import { getRegistry } from './RegistryHolder.mjs';
19
23
  import { Button } from './controls/Button.mjs';
20
24
  import { TextBox } from './controls/TextBox.mjs';
@@ -33,6 +37,22 @@ import { Spinner } from './controls/Spinner.mjs';
33
37
 
34
38
  // ── Internal helpers ──────────────────────────────────────────────────────────
35
39
 
40
+ /** Reserved `type:` tags owned by the built-in widget switch — custom factories
41
+ * cannot override these. */
42
+ /** Structural check: returns true when the window opts into keyboard input by
43
+ * exposing `handleKey` (on top of the focus helpers inherited from Window).
44
+ * Used to auto-register custom-type controls with WindowManager. */
45
+ function isFocusable(win: Window): win is Focusable & Window {
46
+ const w = win as Partial<Focusable>;
47
+ return typeof w.handleKey === 'function';
48
+ }
49
+
50
+ const BUILTIN_TYPE_NAMES = new Set<string>([
51
+ 'window', 'button', 'textbox', 'textarea', 'checkbox', 'radio',
52
+ 'statusled', 'progressbar', 'progressbarv', 'linechart', 'barchart',
53
+ 'listbox', 'tabs', 'sparkline', 'spinner',
54
+ ]);
55
+
36
56
  /** A focusable control with its resolved parent chain, queued for WM registration. */
37
57
  interface PendingRegistration {
38
58
  control: Focusable & Window;
@@ -57,6 +77,7 @@ function parsePos(spec: YamlPosSpec | undefined): Pos {
57
77
  if (spec === 'topRight') return Pos.topRight();
58
78
  if (spec === 'bottomLeft') return Pos.bottomLeft();
59
79
  if (spec === 'bottomRight') return Pos.bottomRight();
80
+ if (spec === 'flex') return Pos.flex();
60
81
  if (typeof spec === 'object') {
61
82
  if ('preset' in spec) {
62
83
  const off = spec.offset !== undefined ? parseAxisValue(spec.offset) : 0;
@@ -67,6 +88,9 @@ function parsePos(spec: YamlPosSpec | undefined): Pos {
67
88
  case 'bottom': return Pos.bottom(off);
68
89
  }
69
90
  }
91
+ if ('flex' in spec) {
92
+ return Pos.flex(spec.flex ?? 0);
93
+ }
70
94
  if ('x' in spec && 'y' in spec) {
71
95
  return new Pos(parseAxisValue(spec.x), parseAxisValue(spec.y));
72
96
  }
@@ -74,14 +98,36 @@ function parsePos(spec: YamlPosSpec | undefined): Pos {
74
98
  throw new Error(`Invalid pos spec: ${JSON.stringify(spec)}`);
75
99
  }
76
100
 
101
+ /** Converts a single axis value within a Size spec — extends the plain-axis
102
+ * parser with the 'flex' / 'content' shorthands and the `{ flex: {...} }` /
103
+ * `{ content: true }` objects so each axis can be chosen independently. */
104
+ function parseDimValue(v: YamlDimValue): DimValue {
105
+ if (v === 'flex') return flex();
106
+ if (v === 'content') return content();
107
+ if (typeof v === 'object' && v !== null) {
108
+ if ('flex' in v) {
109
+ const basis = v.flex.basis !== undefined ? parseAxisValue(v.flex.basis) : 0;
110
+ return flex(v.flex.grow ?? 1, v.flex.shrink ?? 1, basis);
111
+ }
112
+ if ('content' in v && v.content === true) return content();
113
+ }
114
+ return parseAxisValue(v as YamlAxisValue);
115
+ }
116
+
77
117
  /** Converts a YamlSizeSpec to a Size instance. */
78
118
  function parseSize(spec: YamlSizeSpec): Size {
79
- if (spec === 'fill') return Size.fill();
119
+ if (spec === 'fill') return Size.fill();
120
+ if (spec === 'flex') return Size.flex();
121
+ if (spec === 'content') return Size.content();
80
122
  if (typeof spec === 'object') {
81
123
  if ('fillWidth' in spec) return Size.fillWidth(parseAxisValue(spec.fillWidth));
82
124
  if ('fillHeight' in spec) return Size.fillHeight(parseAxisValue(spec.fillHeight));
125
+ if ('flex' in spec) {
126
+ const basis = spec.flex.basis !== undefined ? parseAxisValue(spec.flex.basis) : 0;
127
+ return Size.flex(spec.flex.grow ?? 1, spec.flex.shrink ?? 1, basis);
128
+ }
83
129
  if ('width' in spec && 'height' in spec) {
84
- return new Size(parseAxisValue(spec.width), parseAxisValue(spec.height));
130
+ return new Size(parseDimValue(spec.width), parseDimValue(spec.height));
85
131
  }
86
132
  }
87
133
  throw new Error(`Invalid size spec: ${JSON.stringify(spec)}`);
@@ -102,9 +148,11 @@ function resolveBackground(bg: string | number | undefined): StyleId | undefined
102
148
  *
103
149
  * Usage:
104
150
  * 1. Create an InterfaceBuilder and call registerCallback() for any onPress/onChange IDs.
105
- * 2. Call build(yamlText, screen) or buildFromFile(path, screen).
106
- * 3. Optionally pass a WindowManager to automatically register all focusable controls.
107
- * 4. The returned Map<string, Window> gives access to windows by their YAML id.
151
+ * 2. Optionally call registerType(name, factory) to teach the builder about
152
+ * user-defined controls addressable via `type: <name>` in YAML.
153
+ * 3. Call build(yamlText, screen) or buildFromFile(path, screen).
154
+ * 4. Optionally pass a WindowManager to automatically register all focusable controls.
155
+ * 5. The returned Map<string, Window> gives access to windows by their YAML id.
108
156
  *
109
157
  * YAML schema:
110
158
  * windows:
@@ -118,11 +166,13 @@ function resolveBackground(bg: string | number | undefined): StyleId | undefined
118
166
  * - ...
119
167
  */
120
168
  export class InterfaceBuilder {
121
- private callbacks: Map<string, (...args: unknown[]) => void>;
169
+ private callbacks: Map<string, (...args: unknown[]) => void>;
170
+ private customTypes: Map<string, CustomTypeFactory>;
122
171
 
123
- /** Creates an InterfaceBuilder with an empty callback registry. */
172
+ /** Creates an InterfaceBuilder with empty callback and custom-type registries. */
124
173
  public constructor() {
125
- this.callbacks = new Map();
174
+ this.callbacks = new Map();
175
+ this.customTypes = new Map();
126
176
  }
127
177
 
128
178
  /** Registers a named callback for use with onPress or onChange in YAML definitions. */
@@ -130,6 +180,19 @@ export class InterfaceBuilder {
130
180
  this.callbacks.set(id, fn);
131
181
  }
132
182
 
183
+ /** Registers a custom control factory addressable by `type: <name>` in YAML.
184
+ * The factory receives the raw YAML node plus a context with pre-resolved
185
+ * `wp` (WindowProperties), the style registry, and a callback resolver.
186
+ * Custom names cannot override a built-in type tag. If the returned window
187
+ * implements Focusable, it is auto-registered with WindowManager (when one
188
+ * is supplied to `build()`). */
189
+ public registerType(name: string, factory: CustomTypeFactory): void {
190
+ if (BUILTIN_TYPE_NAMES.has(name)) {
191
+ throw new Error(`Cannot register custom type "${name}": name is reserved by a built-in type.`);
192
+ }
193
+ this.customTypes.set(name, factory);
194
+ }
195
+
133
196
  /** Builds the UI from a YAML string, adds all top-level windows to Screen,
134
197
  * and registers focusable controls with WindowManager if provided.
135
198
  * Styles defined in the `styles:` section are registered before any windows are built.
@@ -193,13 +256,19 @@ export class InterfaceBuilder {
193
256
  /** Common window properties shared by all control types. */
194
257
  const wp: WindowProperties = {
195
258
  pos,
196
- size: def.size ? parseSize(def.size) : undefined,
197
- background: bgId,
198
- border: def.border,
199
- active: def.active,
200
- focused: def.focused,
201
- disabled: def.disabled,
202
- label: def.label,
259
+ size: def.size ? parseSize(def.size) : undefined,
260
+ background: bgId,
261
+ border: def.border,
262
+ active: def.active,
263
+ focused: def.focused,
264
+ disabled: def.disabled,
265
+ label: def.label,
266
+ layout: def.layout,
267
+ gap: def.gap,
268
+ padding: def.padding,
269
+ gridColumns: def.gridColumns,
270
+ alignItems: def.alignItems,
271
+ justifyContent: def.justifyContent,
203
272
  };
204
273
 
205
274
  let win: Window;
@@ -218,9 +287,15 @@ export class InterfaceBuilder {
218
287
 
219
288
  case 'textbox': {
220
289
  wp.size = wp.size ?? this.requireSize(def);
290
+ const tbChange = def.onChange ? this.callbacks.get(def.onChange) : undefined;
291
+ const tbSubmit = def.onSubmit ? this.callbacks.get(def.onSubmit) : undefined;
292
+ const tbKeyDown = def.onKeyDown ? this.callbacks.get(def.onKeyDown) : undefined;
221
293
  const tb = new TextBox(wp, {
222
294
  value: def.value,
223
295
  placeholder: def.placeholder,
296
+ onChange: tbChange as ((value: string) => void) | undefined,
297
+ onSubmit: tbSubmit as ((value: string) => void) | undefined,
298
+ onKeyDown: tbKeyDown as ((key: string) => boolean | void) | undefined,
224
299
  });
225
300
  pending.push({ control: tb, parents: [...parentChain] });
226
301
  win = tb;
@@ -229,9 +304,17 @@ export class InterfaceBuilder {
229
304
 
230
305
  case 'textarea': {
231
306
  wp.size = wp.size ?? this.requireSize(def);
307
+ const taChange = def.onChange ? this.callbacks.get(def.onChange) : undefined;
308
+ const taSubmit = def.onSubmit ? this.callbacks.get(def.onSubmit) : undefined;
309
+ const taKeyDown = def.onKeyDown ? this.callbacks.get(def.onKeyDown) : undefined;
232
310
  const ta = new TextArea(wp, {
233
- value: def.value,
234
- placeholder: def.placeholder,
311
+ value: def.value,
312
+ placeholder: def.placeholder,
313
+ onChange: taChange as ((value: string) => void) | undefined,
314
+ onSubmit: taSubmit as ((value: string) => void) | undefined,
315
+ onKeyDown: taKeyDown as ((key: string) => boolean | void) | undefined,
316
+ insertTabAsSpaces: def.insertTabAsSpaces,
317
+ ctrlDDeletesForward: def.ctrlDDeletesForward,
235
318
  });
236
319
  pending.push({ control: ta, parents: [...parentChain] });
237
320
  win = ta;
@@ -368,8 +451,21 @@ export class InterfaceBuilder {
368
451
  }
369
452
 
370
453
  default: {
371
- wp.size = wp.size ?? this.requireSize(def);
372
- win = new Window(wp);
454
+ const customFactory = def.type ? this.customTypes.get(def.type) : undefined;
455
+ if (customFactory) {
456
+ const ctx: CustomTypeContext = {
457
+ wp,
458
+ registry: getRegistry(),
459
+ resolveCallback: (id) => (id ? this.callbacks.get(id) : undefined),
460
+ };
461
+ win = customFactory(def, ctx);
462
+ if (isFocusable(win)) {
463
+ pending.push({ control: win as Focusable & Window, parents: [...parentChain] });
464
+ }
465
+ } else {
466
+ wp.size = wp.size ?? this.requireSize(def);
467
+ win = new Window(wp);
468
+ }
373
469
  break;
374
470
  }
375
471
  }
@@ -17,13 +17,17 @@ function toAxisSpec(v: number | Pct): AxisSpec {
17
17
  return { mode: 'start', value: v };
18
18
  }
19
19
 
20
- /** Resolves a single axis spec to a pixel offset. */
20
+ /** Resolves a single axis spec to a pixel offset. The 'flex' mode has no
21
+ * meaningful absolute resolution — the parent's layout engine overwrites
22
+ * child.x / child.y before blit, so this returns 0 as a safe fallback that
23
+ * keeps the value inside the region until the layout pass fires. */
21
24
  function resolveAxis(spec: AxisSpec, parentSize: number, ownSize: number): number {
22
25
  switch (spec.mode) {
23
26
  case 'start': return spec.value;
24
27
  case 'end': return parentSize - ownSize - spec.value;
25
28
  case 'pct': return Math.floor(parentSize * spec.value / 100);
26
29
  case 'center': return Math.floor((parentSize - ownSize) / 2);
30
+ case 'flex': return 0;
27
31
  }
28
32
  }
29
33
 
@@ -95,6 +99,25 @@ export class Pos {
95
99
  return Pos.fromSpecs(toAxisSpec(x), { mode: 'end', value: 0 });
96
100
  }
97
101
 
102
+ /** Marks the window as a flex-layout slot on both axes — the parent's layout
103
+ * engine (row / column / grid) decides the final position. The optional
104
+ * `order` controls the relative placement within the flex stack: children
105
+ * with a lower `order` appear earlier, ties broken by addChild insertion.
106
+ * Children without `Pos.flex()` in a flex parent are treated as order 0
107
+ * and fall back to insertion order, so authors can mix ordered and
108
+ * unordered children freely. Default `order`: 0. */
109
+ public static flex(order: number = 0): Pos {
110
+ return Pos.fromSpecs({ mode: 'flex', order }, { mode: 'flex', order });
111
+ }
112
+
113
+ /** Returns the flex order when this Pos was produced via `Pos.flex(order)`,
114
+ * or `undefined` for every other position mode. Used by the layout engine
115
+ * to sort children. */
116
+ public getFlexOrder(): number | undefined {
117
+ if (this.xSpec.mode === 'flex') return (this.xSpec as { mode: 'flex'; order: number }).order;
118
+ return undefined;
119
+ }
120
+
98
121
  /** Returns true when both axes are absolute-from-start values (no parent/own-size needed). */
99
122
  public isAbsolute(): boolean {
100
123
  return this.xSpec.mode === 'start' && this.ySpec.mode === 'start';
@@ -1,20 +1,61 @@
1
- import type { CellAttributes, StyleId } from './types.mjs';
1
+ import { EventEmitter } from 'node:events';
2
+ import type { CellAttributes, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize } from './types.mjs';
2
3
  import { StyleRegistry } from './StyleRegistry.mjs';
3
4
  import { getRegistry, setRegistry } from './RegistryHolder.mjs';
4
5
  import { Window } from './Window.mjs';
5
6
  import { Pos } from './Pos.mjs';
6
7
  import { Size } from './Size.mjs';
7
8
 
9
+ /** ANSI control sequences for the terminal lifecycle features owned by Screen. */
10
+ const ENTER_ALT_SCREEN = '\x1b[?1049h';
11
+ const EXIT_ALT_SCREEN = '\x1b[?1049l';
12
+ const HIDE_CURSOR = '\x1b[?25l';
13
+ const SHOW_CURSOR = '\x1b[?25h';
14
+
8
15
  export class Screen extends Window {
16
+ /** Internal event bus for 'resize' and 'frame' notifications. Composed
17
+ * rather than inherited so the Window class hierarchy stays plain. */
18
+ private events: EventEmitter;
19
+ /** True while the alternate screen buffer is currently active. */
20
+ private altScreenActive: boolean;
21
+ /** True while the hardware cursor is currently hidden. */
22
+ private cursorHidden: boolean;
23
+ /** Soft FPS cap stored for inspection by consumers; full enforcement is P2-47. */
24
+ private targetFps: number | undefined;
25
+ /** Bound SIGWINCH listener (kept so we can detach it on dispose()). */
26
+ private boundSigwinch: () => void;
27
+ /** Bound 'exit' listener that runs synchronous terminal cleanup. */
28
+ private boundExit: () => void;
29
+ /** Whether the SIGWINCH + 'exit' listeners are currently attached. */
30
+ private signalsInstalled: boolean;
31
+ /** Whether dispose() has already restored terminal state. */
32
+ private disposed: boolean;
33
+
9
34
  /** Initializes the root window sized to the current terminal dimensions.
10
35
  * Creates a fresh StyleRegistry (with built-in styles pre-registered)
11
- * and installs it as the global singleton. */
12
- public constructor() {
36
+ * and installs it as the global singleton. The optional ScreenOptions
37
+ * control whether the alternate screen buffer is entered and the cursor
38
+ * hidden up-front; both are off by default so existing code that relies
39
+ * on WindowManager.run/stop for those toggles keeps working unchanged. */
40
+ public constructor(options?: ScreenOptions) {
13
41
  const width = process.stdout.columns ?? 80;
14
42
  const height = process.stdout.rows ?? 24;
15
43
  const registry = new StyleRegistry();
16
44
  setRegistry(registry);
17
45
  super({ pos: Pos.topLeft(), size: new Size(width, height), background: 0 });
46
+
47
+ this.events = new EventEmitter();
48
+ this.altScreenActive = false;
49
+ this.cursorHidden = false;
50
+ this.targetFps = options?.targetFps;
51
+ this.boundSigwinch = (): void => { this.handleSigwinch(); };
52
+ this.boundExit = (): void => { this.restoreTerminalState(); };
53
+ this.signalsInstalled = false;
54
+ this.disposed = false;
55
+
56
+ if (options?.altScreen) this.enterAltScreen();
57
+ if (options?.hideCursor) this.hideHardwareCursor();
58
+ this.installSignalHandlers();
18
59
  }
19
60
 
20
61
  /** Registers a CellAttributes object in the global style registry and returns its stable ID. */
@@ -33,10 +74,107 @@ export class Screen extends Window {
33
74
  return getRegistry().registerNamed(name, attrs);
34
75
  }
35
76
 
77
+ /** Returns the soft FPS cap configured via ScreenOptions, or undefined when uncapped. */
78
+ public getTargetFps(): number | undefined {
79
+ return this.targetFps;
80
+ }
81
+
82
+ // ── Terminal lifecycle helpers ────────────────────────────────────────────
83
+
84
+ /** Switches the terminal into the alternate screen buffer. Idempotent. */
85
+ public enterAltScreen(): void {
86
+ if (this.altScreenActive) return;
87
+ process.stdout.write(ENTER_ALT_SCREEN);
88
+ this.altScreenActive = true;
89
+ }
90
+
91
+ /** Restores the primary screen buffer if Screen previously entered the
92
+ * alternate one. Idempotent. */
93
+ public exitAltScreen(): void {
94
+ if (!this.altScreenActive) return;
95
+ process.stdout.write(EXIT_ALT_SCREEN);
96
+ this.altScreenActive = false;
97
+ }
98
+
99
+ /** Returns true while the alternate screen buffer is active. WindowManager
100
+ * uses this to avoid double-toggling when the consumer constructed the
101
+ * Screen with `altScreen: true`. */
102
+ public isAltScreenActive(): boolean {
103
+ return this.altScreenActive;
104
+ }
105
+
106
+ /** Hides the hardware cursor. Idempotent. */
107
+ public hideHardwareCursor(): void {
108
+ if (this.cursorHidden) return;
109
+ process.stdout.write(HIDE_CURSOR);
110
+ this.cursorHidden = true;
111
+ }
112
+
113
+ /** Restores the hardware cursor if Screen previously hid it. Idempotent. */
114
+ public showHardwareCursor(): void {
115
+ if (!this.cursorHidden) return;
116
+ process.stdout.write(SHOW_CURSOR);
117
+ this.cursorHidden = false;
118
+ }
119
+
120
+ /** Returns true while the hardware cursor is hidden by this Screen. */
121
+ public isCursorHidden(): boolean {
122
+ return this.cursorHidden;
123
+ }
124
+
125
+ /** Restores any terminal state that was modified by ScreenOptions /
126
+ * enter/hide helpers and detaches signal listeners. Idempotent — safe to
127
+ * call from both deliberate teardown and a process 'exit' handler. */
128
+ public dispose(): void {
129
+ if (this.disposed) return;
130
+ this.restoreTerminalState();
131
+ this.uninstallSignalHandlers();
132
+ this.disposed = true;
133
+ }
134
+
135
+ // ── Resize handling ───────────────────────────────────────────────────────
136
+
137
+ /** Recomputes the Screen dimensions from the live `process.stdout` size
138
+ * (or from the explicit overrides), reallocates the internal buffers,
139
+ * reflows all percentage-sized children, and emits a 'resize' event.
140
+ * Called automatically on SIGWINCH; also exposed for tests and consumers
141
+ * that drive resize manually (e.g. when running outside a TTY). */
142
+ public resize(width?: number, height?: number): TerminalSize {
143
+ const w = width ?? process.stdout.columns ?? 80;
144
+ const h = height ?? process.stdout.rows ?? 24;
145
+ this.setSize(w, h);
146
+ const size: TerminalSize = { width: w, height: h };
147
+ this.events.emit('resize', size);
148
+ return size;
149
+ }
150
+
151
+ // ── EventEmitter delegation ───────────────────────────────────────────────
152
+
153
+ /** Subscribes to a Screen lifecycle event.
154
+ * - 'resize' fires after SIGWINCH (or manual resize()) once the buffers
155
+ * have been re-allocated; the listener receives the new TerminalSize.
156
+ * - 'frame' fires at the end of every render() with timing stats. */
157
+ public on(event: 'resize', listener: (size: TerminalSize) => void): this;
158
+ public on(event: 'frame', listener: (stats: ScreenFrameStats) => void): this;
159
+ public on(event: string, listener: (...args: never[]) => void): this {
160
+ this.events.on(event, listener as (...args: unknown[]) => void);
161
+ return this;
162
+ }
163
+
164
+ /** Removes a previously registered listener. */
165
+ public off(event: 'resize', listener: (size: TerminalSize) => void): this;
166
+ public off(event: 'frame', listener: (stats: ScreenFrameStats) => void): this;
167
+ public off(event: string, listener: (...args: never[]) => void): this {
168
+ this.events.off(event, listener as (...args: unknown[]) => void);
169
+ return this;
170
+ }
171
+
36
172
  /**
37
173
  * Composites the full window tree, then writes the result to stdout as a single ANSI string.
174
+ * Emits a 'frame' event with the wall-clock duration of the render after stdout has been written.
38
175
  */
39
176
  public override render(): void {
177
+ const start = Date.now();
40
178
  super.render();
41
179
 
42
180
  const reg = getRegistry();
@@ -44,11 +182,61 @@ export class Screen extends Window {
44
182
  const styleIds = this.region.getStyleIds();
45
183
  let output = '\x1b[H';
46
184
  for (let i = 0; i < chars.length; i++) {
185
+ const ch = chars[i];
186
+ // Empty-string sentinel = continuation cell of a wide character;
187
+ // terminal cursor was already advanced by 2 when its left half was emitted.
188
+ if (ch === '') continue;
47
189
  output += this.buildAnsiSequence(reg.get(styleIds[i]));
48
- output += chars[i];
190
+ output += ch;
49
191
  }
50
192
  output += '\x1b[0m';
51
193
  process.stdout.write(output);
194
+ this.events.emit('frame', { ms: Date.now() - start });
195
+ }
196
+
197
+ // ── Private helpers ───────────────────────────────────────────────────────
198
+
199
+ /** Synchronous teardown of any terminal state we own. Used by both dispose()
200
+ * and the 'exit' listener, where async work would be discarded. */
201
+ private restoreTerminalState(): void {
202
+ // Cursor first so it reappears in the primary buffer rather than blink
203
+ // on the (about-to-be-restored) alt buffer for one frame.
204
+ this.showHardwareCursor();
205
+ this.exitAltScreen();
206
+ }
207
+
208
+ /** Number of distinct process listeners each Screen installs (SIGWINCH + exit).
209
+ * Used to bump process.setMaxListeners() proportionally so test suites that
210
+ * spin up many Screens don't trip Node's default leak warning. */
211
+ private static readonly LISTENERS_PER_INSTANCE = 2;
212
+ /** Live count of Screens that have installed signal listeners but not yet
213
+ * disposed. Drives the dynamic process listener cap. */
214
+ private static liveInstances = 0;
215
+
216
+ /** Attaches the SIGWINCH listener (for autoresize) and an 'exit' listener
217
+ * (for last-chance terminal cleanup). Idempotent. */
218
+ private installSignalHandlers(): void {
219
+ if (this.signalsInstalled) return;
220
+ Screen.liveInstances++;
221
+ const wanted = 10 + Screen.liveInstances * Screen.LISTENERS_PER_INSTANCE;
222
+ if (process.getMaxListeners() < wanted) process.setMaxListeners(wanted);
223
+ process.on('SIGWINCH', this.boundSigwinch);
224
+ process.on('exit', this.boundExit);
225
+ this.signalsInstalled = true;
226
+ }
227
+
228
+ /** Detaches the listeners installed by installSignalHandlers(). Idempotent. */
229
+ private uninstallSignalHandlers(): void {
230
+ if (!this.signalsInstalled) return;
231
+ process.off('SIGWINCH', this.boundSigwinch);
232
+ process.off('exit', this.boundExit);
233
+ Screen.liveInstances = Math.max(0, Screen.liveInstances - 1);
234
+ this.signalsInstalled = false;
235
+ }
236
+
237
+ /** SIGWINCH handler: pulls the new size from process.stdout and triggers a resize. */
238
+ private handleSigwinch(): void {
239
+ this.resize();
52
240
  }
53
241
 
54
242
  /** Converts cell attributes into an ANSI escape sequence (always resets first). */
@@ -1,26 +1,85 @@
1
- import type { DimSpec } from './types.mjs';
1
+ import type { DimSpec, FlexBasis } from './types.mjs';
2
2
  import { Pct } from './Pos.mjs';
3
3
 
4
+ /** Value object describing a flex dimension — `grow` shares positive leftover
5
+ * space between siblings, `shrink` soaks up negative leftover, `basis` is the
6
+ * starting main-axis size before distribution. Produced via the `flex()`
7
+ * factory and accepted anywhere a `Size` constructor expects a dimension. */
8
+ export class FlexDim {
9
+ public readonly grow: number;
10
+ public readonly shrink: number;
11
+ public readonly basis: number | Pct;
12
+
13
+ public constructor(grow: number = 1, shrink: number = 1, basis: number | Pct = 0) {
14
+ this.grow = grow;
15
+ this.shrink = shrink;
16
+ this.basis = basis;
17
+ }
18
+ }
19
+
20
+ /** Singleton marker for content-sized dimensions. Produced via the `content()`
21
+ * factory; the layout engine measures the child's natural size (current
22
+ * Region dimensions) at layout time. */
23
+ export class ContentDim {
24
+ public static readonly INSTANCE: ContentDim = new ContentDim();
25
+ private constructor() {}
26
+ }
27
+
28
+ /** Factory for a flex dimension — shorthand for `new FlexDim(grow, shrink, basis)`. */
29
+ export function flex(grow: number = 1, shrink: number = 1, basis: number | Pct = 0): FlexDim {
30
+ return new FlexDim(grow, shrink, basis);
31
+ }
32
+
33
+ /** Factory for a content-sized dimension. Always returns the singleton. */
34
+ export function content(): ContentDim {
35
+ return ContentDim.INSTANCE;
36
+ }
37
+
38
+ /** Accepted user-supplied values for a single Size dimension. */
39
+ export type DimValue = number | Pct | FlexDim | ContentDim;
40
+
41
+ /** Converts a basis literal to the internal FlexBasis representation. */
42
+ function toFlexBasis(v: number | Pct): FlexBasis {
43
+ if (v instanceof Pct) return { kind: 'pct', value: v.value };
44
+ return { kind: 'abs', value: v };
45
+ }
46
+
4
47
  /** Converts a user-supplied dimension value to an internal DimSpec. */
5
- function toDimSpec(v: number | Pct): DimSpec {
6
- if (v instanceof Pct) return { mode: 'pct', value: v.value };
48
+ function toDimSpec(v: DimValue): DimSpec {
49
+ if (v instanceof Pct) return { mode: 'pct', value: v.value };
50
+ if (v instanceof FlexDim) return { mode: 'flex', grow: v.grow, shrink: v.shrink, basis: toFlexBasis(v.basis) };
51
+ if (v instanceof ContentDim) return { mode: 'content' };
7
52
  return { mode: 'abs', value: v };
8
53
  }
9
54
 
10
- /** Resolves a single dimension spec to a pixel value. */
55
+ /** Resolves a single dimension spec to a pixel value. For modes whose final
56
+ * size is determined by the parent's layout engine (`flex`, `content`) this
57
+ * returns a safe fallback that keeps the region non-empty until the layout
58
+ * pass overwrites it — `flex` falls back to its basis, `content` to 1. */
11
59
  function resolveDim(spec: DimSpec, parentSize: number): number {
12
- if (spec.mode === 'pct') return Math.floor(parentSize * spec.value / 100);
13
- return spec.value;
60
+ switch (spec.mode) {
61
+ case 'abs': return spec.value;
62
+ case 'pct': return Math.floor(parentSize * spec.value / 100);
63
+ case 'flex': return spec.basis.kind === 'pct'
64
+ ? Math.floor(parentSize * spec.basis.value / 100)
65
+ : spec.basis.value;
66
+ case 'content': return 1;
67
+ }
14
68
  }
15
69
 
16
- /** Encodes window dimensions. Supports absolute pixel values and percentage of parent size. */
70
+ /** Encodes window dimensions. Supports absolute pixel values, percentages of
71
+ * the parent, flex slots (`flex(grow, shrink, basis)`), and content-sized
72
+ * dimensions (`content()`). Flex and content modes only take effect inside a
73
+ * flex-layout parent (`WindowProperties.layout` set to 'row', 'column', or
74
+ * 'grid'); in 'absolute' layout they degrade to the fallback resolution. */
17
75
  export class Size {
18
76
  private wSpec: DimSpec;
19
77
  private hSpec: DimSpec;
20
78
 
21
79
  /** Creates a size from width and height values.
22
- * Pass a plain number for absolute pixels, or a Pct instance for a percentage of the parent. */
23
- public constructor(w: number | Pct, h: number | Pct) {
80
+ * Accepts plain numbers (absolute pixels), `Pct` (percentage of parent),
81
+ * `FlexDim` (via `flex()` factory), or `ContentDim` (via `content()`). */
82
+ public constructor(w: DimValue, h: DimValue) {
24
83
  this.wSpec = toDimSpec(w);
25
84
  this.hSpec = toDimSpec(h);
26
85
  }
@@ -31,25 +90,51 @@ export class Size {
31
90
  }
32
91
 
33
92
  /** Creates a size that fills 100% of the parent's width; height is absolute or percentage. */
34
- public static fillWidth(h: number | Pct): Size {
93
+ public static fillWidth(h: DimValue): Size {
35
94
  return new Size(new Pct(100), h);
36
95
  }
37
96
 
38
97
  /** Creates a size that fills 100% of the parent's height; width is absolute or percentage. */
39
- public static fillHeight(w: number | Pct): Size {
98
+ public static fillHeight(w: DimValue): Size {
40
99
  return new Size(w, new Pct(100));
41
100
  }
42
101
 
102
+ /** Creates a size that flexes on both axes — shorthand for
103
+ * `new Size(flex(grow, shrink, basis), flex(grow, shrink, basis))`. The
104
+ * parent's layout engine decides which axis is main vs cross. */
105
+ public static flex(grow: number = 1, shrink: number = 1, basis: number | Pct = 0): Size {
106
+ return new Size(flex(grow, shrink, basis), flex(grow, shrink, basis));
107
+ }
108
+
109
+ /** Creates a size where both axes are content-sized. The layout engine
110
+ * measures the child's current Region dimensions and uses those. */
111
+ public static content(): Size {
112
+ return new Size(content(), content());
113
+ }
114
+
43
115
  /** Returns true when both dimensions are absolute pixel values (no parent size needed). */
44
116
  public isAbsolute(): boolean {
45
117
  return this.wSpec.mode === 'abs' && this.hSpec.mode === 'abs';
46
118
  }
47
119
 
48
- /** Resolves this size to pixel dimensions given the parent's dimensions. */
120
+ /** Resolves this size to pixel dimensions given the parent's dimensions.
121
+ * Flex/content modes fall back to safe defaults; the layout engine takes
122
+ * over when the parent uses a non-'absolute' `layout`. */
49
123
  public resolve(parentW: number, parentH: number): { w: number; h: number } {
50
124
  return {
51
125
  w: resolveDim(this.wSpec, parentW),
52
126
  h: resolveDim(this.hSpec, parentH),
53
127
  };
54
128
  }
129
+
130
+ /** Returns the raw width spec so the layout engine can inspect the mode
131
+ * (abs/pct/flex/content) and route each child accordingly. */
132
+ public getWidthSpec(): DimSpec {
133
+ return this.wSpec;
134
+ }
135
+
136
+ /** Returns the raw height spec — companion to getWidthSpec(). */
137
+ public getHeightSpec(): DimSpec {
138
+ return this.hSpec;
139
+ }
55
140
  }