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.
- package/CHANGELOG.md +360 -0
- package/dist/Screen/InterfaceBuilder.d.mts +15 -4
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +104 -8
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Pos.d.mts +12 -0
- package/dist/Screen/Pos.d.mts.map +1 -1
- package/dist/Screen/Pos.mjs +23 -1
- package/dist/Screen/Pos.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +77 -3
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +168 -3
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/Size.d.mts +49 -6
- package/dist/Screen/Size.d.mts.map +1 -1
- package/dist/Screen/Size.mjs +81 -7
- package/dist/Screen/Size.mjs.map +1 -1
- package/dist/Screen/Window.d.mts +131 -20
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +474 -57
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +85 -5
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +279 -26
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/ListBox.d.mts +34 -12
- package/dist/Screen/controls/ListBox.d.mts.map +1 -1
- package/dist/Screen/controls/ListBox.mjs +127 -25
- package/dist/Screen/controls/ListBox.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +15 -1
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +74 -1
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +13 -1
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +36 -1
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/textWidth.d.mts +13 -0
- package/dist/Screen/textWidth.d.mts.map +1 -0
- package/dist/Screen/textWidth.mjs +188 -0
- package/dist/Screen/textWidth.mjs.map +1 -0
- package/dist/Screen/types.d.mts +336 -20
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Screen/InterfaceBuilder.mts +116 -20
- package/src/Screen/Pos.mts +24 -1
- package/src/Screen/Screen.mts +192 -4
- package/src/Screen/Size.mts +97 -12
- package/src/Screen/Window.mts +463 -63
- package/src/Screen/WindowManager.mts +301 -29
- package/src/Screen/controls/ListBox.mts +151 -32
- package/src/Screen/controls/TextArea.mts +82 -1
- package/src/Screen/controls/TextBox.mts +40 -1
- package/src/Screen/textWidth.mts +186 -0
- package/src/Screen/types.mts +328 -23
- package/src/demo.mts +232 -20
- package/src/index.mts +23 -3
- 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')
|
|
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(
|
|
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.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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:
|
|
169
|
+
private callbacks: Map<string, (...args: unknown[]) => void>;
|
|
170
|
+
private customTypes: Map<string, CustomTypeFactory>;
|
|
122
171
|
|
|
123
|
-
/** Creates an InterfaceBuilder with
|
|
172
|
+
/** Creates an InterfaceBuilder with empty callback and custom-type registries. */
|
|
124
173
|
public constructor() {
|
|
125
|
-
this.callbacks
|
|
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:
|
|
197
|
-
background:
|
|
198
|
-
border:
|
|
199
|
-
active:
|
|
200
|
-
focused:
|
|
201
|
-
disabled:
|
|
202
|
-
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:
|
|
234
|
-
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
|
-
|
|
372
|
-
|
|
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
|
}
|
package/src/Screen/Pos.mts
CHANGED
|
@@ -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';
|
package/src/Screen/Screen.mts
CHANGED
|
@@ -1,20 +1,61 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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 +=
|
|
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). */
|
package/src/Screen/Size.mts
CHANGED
|
@@ -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:
|
|
6
|
-
if (v instanceof Pct)
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
*
|
|
23
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|