take4-console 0.15.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 (131) hide show
  1. package/CHANGELOG.md +287 -0
  2. package/LICENSE +21 -0
  3. package/README.md +986 -0
  4. package/dist/Screen/InterfaceBuilder.d.mts +43 -0
  5. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -0
  6. package/dist/Screen/InterfaceBuilder.mjs +355 -0
  7. package/dist/Screen/InterfaceBuilder.mjs.map +1 -0
  8. package/dist/Screen/Pos.d.mts +52 -0
  9. package/dist/Screen/Pos.d.mts.map +1 -0
  10. package/dist/Screen/Pos.mjs +105 -0
  11. package/dist/Screen/Pos.mjs.map +1 -0
  12. package/dist/Screen/Region.d.mts +31 -0
  13. package/dist/Screen/Region.d.mts.map +1 -0
  14. package/dist/Screen/Region.mjs +73 -0
  15. package/dist/Screen/Region.mjs.map +1 -0
  16. package/dist/Screen/RegistryHolder.d.mts +6 -0
  17. package/dist/Screen/RegistryHolder.d.mts.map +1 -0
  18. package/dist/Screen/RegistryHolder.mjs +14 -0
  19. package/dist/Screen/RegistryHolder.mjs.map +1 -0
  20. package/dist/Screen/Screen.d.mts +25 -0
  21. package/dist/Screen/Screen.d.mts.map +1 -0
  22. package/dist/Screen/Screen.mjs +100 -0
  23. package/dist/Screen/Screen.mjs.map +1 -0
  24. package/dist/Screen/Size.d.mts +23 -0
  25. package/dist/Screen/Size.d.mts.map +1 -0
  26. package/dist/Screen/Size.mjs +48 -0
  27. package/dist/Screen/Size.mjs.map +1 -0
  28. package/dist/Screen/StyleRegistry.d.mts +32 -0
  29. package/dist/Screen/StyleRegistry.d.mts.map +1 -0
  30. package/dist/Screen/StyleRegistry.mjs +80 -0
  31. package/dist/Screen/StyleRegistry.mjs.map +1 -0
  32. package/dist/Screen/Window.d.mts +121 -0
  33. package/dist/Screen/Window.d.mts.map +1 -0
  34. package/dist/Screen/Window.mjs +407 -0
  35. package/dist/Screen/Window.mjs.map +1 -0
  36. package/dist/Screen/WindowManager.d.mts +86 -0
  37. package/dist/Screen/WindowManager.d.mts.map +1 -0
  38. package/dist/Screen/WindowManager.mjs +399 -0
  39. package/dist/Screen/WindowManager.mjs.map +1 -0
  40. package/dist/Screen/controls/BarChart.d.mts +29 -0
  41. package/dist/Screen/controls/BarChart.d.mts.map +1 -0
  42. package/dist/Screen/controls/BarChart.mjs +90 -0
  43. package/dist/Screen/controls/BarChart.mjs.map +1 -0
  44. package/dist/Screen/controls/Button.d.mts +16 -0
  45. package/dist/Screen/controls/Button.d.mts.map +1 -0
  46. package/dist/Screen/controls/Button.mjs +34 -0
  47. package/dist/Screen/controls/Button.mjs.map +1 -0
  48. package/dist/Screen/controls/Checkbox.d.mts +23 -0
  49. package/dist/Screen/controls/Checkbox.d.mts.map +1 -0
  50. package/dist/Screen/controls/Checkbox.mjs +55 -0
  51. package/dist/Screen/controls/Checkbox.mjs.map +1 -0
  52. package/dist/Screen/controls/LineChart.d.mts +29 -0
  53. package/dist/Screen/controls/LineChart.d.mts.map +1 -0
  54. package/dist/Screen/controls/LineChart.mjs +172 -0
  55. package/dist/Screen/controls/LineChart.mjs.map +1 -0
  56. package/dist/Screen/controls/ListBox.d.mts +34 -0
  57. package/dist/Screen/controls/ListBox.d.mts.map +1 -0
  58. package/dist/Screen/controls/ListBox.mjs +138 -0
  59. package/dist/Screen/controls/ListBox.mjs.map +1 -0
  60. package/dist/Screen/controls/ProgressBar.d.mts +26 -0
  61. package/dist/Screen/controls/ProgressBar.d.mts.map +1 -0
  62. package/dist/Screen/controls/ProgressBar.mjs +70 -0
  63. package/dist/Screen/controls/ProgressBar.mjs.map +1 -0
  64. package/dist/Screen/controls/ProgressBarV.d.mts +22 -0
  65. package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -0
  66. package/dist/Screen/controls/ProgressBarV.mjs +61 -0
  67. package/dist/Screen/controls/ProgressBarV.mjs.map +1 -0
  68. package/dist/Screen/controls/Radio.d.mts +23 -0
  69. package/dist/Screen/controls/Radio.d.mts.map +1 -0
  70. package/dist/Screen/controls/Radio.mjs +55 -0
  71. package/dist/Screen/controls/Radio.mjs.map +1 -0
  72. package/dist/Screen/controls/Sparkline.d.mts +29 -0
  73. package/dist/Screen/controls/Sparkline.d.mts.map +1 -0
  74. package/dist/Screen/controls/Sparkline.mjs +82 -0
  75. package/dist/Screen/controls/Sparkline.mjs.map +1 -0
  76. package/dist/Screen/controls/Spinner.d.mts +37 -0
  77. package/dist/Screen/controls/Spinner.d.mts.map +1 -0
  78. package/dist/Screen/controls/Spinner.mjs +87 -0
  79. package/dist/Screen/controls/Spinner.mjs.map +1 -0
  80. package/dist/Screen/controls/StatusLED.d.mts +22 -0
  81. package/dist/Screen/controls/StatusLED.d.mts.map +1 -0
  82. package/dist/Screen/controls/StatusLED.mjs +51 -0
  83. package/dist/Screen/controls/StatusLED.mjs.map +1 -0
  84. package/dist/Screen/controls/Tabs.d.mts +42 -0
  85. package/dist/Screen/controls/Tabs.d.mts.map +1 -0
  86. package/dist/Screen/controls/Tabs.mjs +126 -0
  87. package/dist/Screen/controls/Tabs.mjs.map +1 -0
  88. package/dist/Screen/controls/TextArea.d.mts +41 -0
  89. package/dist/Screen/controls/TextArea.d.mts.map +1 -0
  90. package/dist/Screen/controls/TextArea.mjs +197 -0
  91. package/dist/Screen/controls/TextArea.mjs.map +1 -0
  92. package/dist/Screen/controls/TextBox.d.mts +35 -0
  93. package/dist/Screen/controls/TextBox.d.mts.map +1 -0
  94. package/dist/Screen/controls/TextBox.mjs +135 -0
  95. package/dist/Screen/controls/TextBox.mjs.map +1 -0
  96. package/dist/Screen/types.d.mts +399 -0
  97. package/dist/Screen/types.d.mts.map +1 -0
  98. package/dist/Screen/types.mjs +22 -0
  99. package/dist/Screen/types.mjs.map +1 -0
  100. package/dist/index.d.mts +26 -0
  101. package/dist/index.d.mts.map +1 -0
  102. package/dist/index.mjs +41 -0
  103. package/dist/index.mjs.map +1 -0
  104. package/package.json +72 -0
  105. package/src/Screen/InterfaceBuilder.mts +403 -0
  106. package/src/Screen/Pos.mts +119 -0
  107. package/src/Screen/Region.mts +88 -0
  108. package/src/Screen/RegistryHolder.mts +16 -0
  109. package/src/Screen/Screen.mts +103 -0
  110. package/src/Screen/Size.mts +55 -0
  111. package/src/Screen/StyleRegistry.mts +95 -0
  112. package/src/Screen/Window.mts +439 -0
  113. package/src/Screen/WindowManager.mts +472 -0
  114. package/src/Screen/controls/BarChart.mts +109 -0
  115. package/src/Screen/controls/Button.mts +40 -0
  116. package/src/Screen/controls/Checkbox.mts +66 -0
  117. package/src/Screen/controls/LineChart.mts +202 -0
  118. package/src/Screen/controls/ListBox.mts +154 -0
  119. package/src/Screen/controls/ProgressBar.mts +88 -0
  120. package/src/Screen/controls/ProgressBarV.mts +77 -0
  121. package/src/Screen/controls/Radio.mts +66 -0
  122. package/src/Screen/controls/Sparkline.mts +101 -0
  123. package/src/Screen/controls/Spinner.mts +102 -0
  124. package/src/Screen/controls/StatusLED.mts +65 -0
  125. package/src/Screen/controls/Tabs.mts +140 -0
  126. package/src/Screen/controls/TextArea.mts +194 -0
  127. package/src/Screen/controls/TextBox.mts +139 -0
  128. package/src/Screen/types.mts +416 -0
  129. package/src/demo.mts +171 -0
  130. package/src/index.mts +105 -0
  131. package/src/layout.yaml +236 -0
@@ -0,0 +1,403 @@
1
+ import { parse } from 'yaml';
2
+ import { readFile } from 'node:fs/promises';
3
+ import type {
4
+ YamlLayout,
5
+ YamlWindowDef,
6
+ YamlPosSpec,
7
+ YamlSizeSpec,
8
+ YamlAxisValue,
9
+ StyleId,
10
+ Focusable,
11
+ WindowProperties,
12
+ } from './types.mjs';
13
+ import { Window } from './Window.mjs';
14
+ import { Screen } from './Screen.mjs';
15
+ import { WindowManager } from './WindowManager.mjs';
16
+ import { Pos, Pct, pct } from './Pos.mjs';
17
+ import { Size } from './Size.mjs';
18
+ import { getRegistry } from './RegistryHolder.mjs';
19
+ import { Button } from './controls/Button.mjs';
20
+ import { TextBox } from './controls/TextBox.mjs';
21
+ import { TextArea } from './controls/TextArea.mjs';
22
+ import { Checkbox } from './controls/Checkbox.mjs';
23
+ import { Radio } from './controls/Radio.mjs';
24
+ import { StatusLED } from './controls/StatusLED.mjs';
25
+ import { ProgressBar } from './controls/ProgressBar.mjs';
26
+ import { ProgressBarV } from './controls/ProgressBarV.mjs';
27
+ import { LineChart } from './controls/LineChart.mjs';
28
+ import { BarChart } from './controls/BarChart.mjs';
29
+ import { ListBox } from './controls/ListBox.mjs';
30
+ import { Tabs } from './controls/Tabs.mjs';
31
+ import { Sparkline } from './controls/Sparkline.mjs';
32
+ import { Spinner } from './controls/Spinner.mjs';
33
+
34
+ // ── Internal helpers ──────────────────────────────────────────────────────────
35
+
36
+ /** A focusable control with its resolved parent chain, queued for WM registration. */
37
+ interface PendingRegistration {
38
+ control: Focusable & Window;
39
+ parents: Window[];
40
+ }
41
+
42
+ /** Converts a YamlAxisValue ("N%", or a number) to a number or Pct instance. */
43
+ function parseAxisValue(v: YamlAxisValue): number | Pct {
44
+ if (typeof v === 'string') {
45
+ const m = v.match(/^(-?\d+(?:\.\d+)?)%$/);
46
+ if (!m) throw new Error(`Invalid axis value: "${v}". Expected a number or "N%" string.`);
47
+ return pct(parseFloat(m[1]));
48
+ }
49
+ return v;
50
+ }
51
+
52
+ /** Converts a YamlPosSpec to a Pos instance. */
53
+ function parsePos(spec: YamlPosSpec | undefined): Pos {
54
+ if (!spec) return new Pos(0, 0);
55
+ if (spec === 'center') return Pos.center();
56
+ if (spec === 'topLeft') return Pos.topLeft();
57
+ if (spec === 'topRight') return Pos.topRight();
58
+ if (spec === 'bottomLeft') return Pos.bottomLeft();
59
+ if (spec === 'bottomRight') return Pos.bottomRight();
60
+ if (typeof spec === 'object') {
61
+ if ('preset' in spec) {
62
+ const off = spec.offset !== undefined ? parseAxisValue(spec.offset) : 0;
63
+ switch (spec.preset) {
64
+ case 'top': return Pos.top(off);
65
+ case 'left': return Pos.left(off);
66
+ case 'right': return Pos.right(off);
67
+ case 'bottom': return Pos.bottom(off);
68
+ }
69
+ }
70
+ if ('x' in spec && 'y' in spec) {
71
+ return new Pos(parseAxisValue(spec.x), parseAxisValue(spec.y));
72
+ }
73
+ }
74
+ throw new Error(`Invalid pos spec: ${JSON.stringify(spec)}`);
75
+ }
76
+
77
+ /** Converts a YamlSizeSpec to a Size instance. */
78
+ function parseSize(spec: YamlSizeSpec): Size {
79
+ if (spec === 'fill') return Size.fill();
80
+ if (typeof spec === 'object') {
81
+ if ('fillWidth' in spec) return Size.fillWidth(parseAxisValue(spec.fillWidth));
82
+ if ('fillHeight' in spec) return Size.fillHeight(parseAxisValue(spec.fillHeight));
83
+ if ('width' in spec && 'height' in spec) {
84
+ return new Size(parseAxisValue(spec.width), parseAxisValue(spec.height));
85
+ }
86
+ }
87
+ throw new Error(`Invalid size spec: ${JSON.stringify(spec)}`);
88
+ }
89
+
90
+ /** Resolves a YAML background value (style name or numeric StyleId) to a StyleId.
91
+ * String values are looked up by name in the global registry (returns 0 if not found).
92
+ * Numeric values are passed through as-is. Undefined becomes undefined (transparent). */
93
+ function resolveBackground(bg: string | number | undefined): StyleId | undefined {
94
+ if (bg === undefined) return undefined;
95
+ if (typeof bg === 'string') return getRegistry().getNamed(bg) ?? 0;
96
+ return bg;
97
+ }
98
+
99
+ // ── InterfaceBuilder ──────────────────────────────────────────────────────────
100
+
101
+ /** Builds a window hierarchy from a YAML description.
102
+ *
103
+ * Usage:
104
+ * 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.
108
+ *
109
+ * YAML schema:
110
+ * windows:
111
+ * - id: myBtn
112
+ * type: button # window | button | textbox | textarea | checkbox | radio
113
+ * pos: center # {x, y} | center | topLeft | topRight | bottomLeft | bottomRight | {preset: top|left|right|bottom, offset?}
114
+ * size: { width: 20, height: 3 } # {width, height} | fill | {fillWidth: N} | {fillHeight: N}
115
+ * label: "Click me"
116
+ * onPress: my_callback
117
+ * children:
118
+ * - ...
119
+ */
120
+ export class InterfaceBuilder {
121
+ private callbacks: Map<string, (...args: unknown[]) => void>;
122
+
123
+ /** Creates an InterfaceBuilder with an empty callback registry. */
124
+ public constructor() {
125
+ this.callbacks = new Map();
126
+ }
127
+
128
+ /** Registers a named callback for use with onPress or onChange in YAML definitions. */
129
+ public registerCallback(id: string, fn: (...args: unknown[]) => void): void {
130
+ this.callbacks.set(id, fn);
131
+ }
132
+
133
+ /** Builds the UI from a YAML string, adds all top-level windows to Screen,
134
+ * and registers focusable controls with WindowManager if provided.
135
+ * Styles defined in the `styles:` section are registered before any windows are built.
136
+ * Returns a map of all windows and controls keyed by their YAML id. */
137
+ public build(yamlText: string, screen: Screen, wm?: WindowManager): Map<string, Window> {
138
+ const layout = parse(yamlText) as YamlLayout;
139
+ const result = new Map<string, Window>();
140
+ const pending: PendingRegistration[] = [];
141
+ const contentWrites: Array<{ win: Window; text: string }> = [];
142
+
143
+ // Register YAML-defined named styles before building the window tree.
144
+ if (layout.styles) {
145
+ const registry = getRegistry();
146
+ for (const styleDef of layout.styles) {
147
+ const { name, ...attrs } = styleDef;
148
+ registry.registerNamed(name, attrs);
149
+ }
150
+ }
151
+
152
+ for (const def of layout.windows) {
153
+ const win = this.buildNode(def, result, pending, contentWrites, []);
154
+ // Adding to screen resolves percentage-based sizes for the entire subtree.
155
+ screen.addChild(win);
156
+ }
157
+
158
+ // Write static content after sizing so text lands in correctly-sized regions.
159
+ for (const { win, text } of contentWrites) {
160
+ win.writeText(text);
161
+ }
162
+
163
+ if (wm) {
164
+ for (const { control, parents } of pending) {
165
+ wm.register(control, ...parents);
166
+ }
167
+ }
168
+
169
+ return result;
170
+ }
171
+
172
+ /** Builds the UI from a YAML file. See build() for details. */
173
+ public async buildFromFile(filePath: string, screen: Screen, wm?: WindowManager): Promise<Map<string, Window>> {
174
+ const text = await readFile(filePath, 'utf8');
175
+ return this.build(text, screen, wm);
176
+ }
177
+
178
+ // ── Private helpers ───────────────────────────────────────────────────────────
179
+
180
+ /** Recursively creates a single widget and all of its children from a YamlWindowDef.
181
+ * Static content strings are collected into contentWrites instead of being written
182
+ * immediately, so they are applied after all sizes are resolved. */
183
+ private buildNode(
184
+ def: YamlWindowDef,
185
+ result: Map<string, Window>,
186
+ pending: PendingRegistration[],
187
+ contentWrites: Array<{ win: Window; text: string }>,
188
+ parentChain: Window[],
189
+ ): Window {
190
+ const pos = parsePos(def.pos);
191
+ const bgId = resolveBackground(def.background);
192
+
193
+ /** Common window properties shared by all control types. */
194
+ const wp: WindowProperties = {
195
+ 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,
203
+ };
204
+
205
+ let win: Window;
206
+
207
+ switch (def.type ?? 'window') {
208
+ case 'button': {
209
+ const pressCb = def.onPress ? this.callbacks.get(def.onPress) : undefined;
210
+ wp.size = wp.size ?? this.requireSize(def);
211
+ const btn = new Button(wp, {
212
+ onPress: pressCb ? () => pressCb() : undefined,
213
+ });
214
+ pending.push({ control: btn, parents: [...parentChain] });
215
+ win = btn;
216
+ break;
217
+ }
218
+
219
+ case 'textbox': {
220
+ wp.size = wp.size ?? this.requireSize(def);
221
+ const tb = new TextBox(wp, {
222
+ value: def.value,
223
+ placeholder: def.placeholder,
224
+ });
225
+ pending.push({ control: tb, parents: [...parentChain] });
226
+ win = tb;
227
+ break;
228
+ }
229
+
230
+ case 'textarea': {
231
+ wp.size = wp.size ?? this.requireSize(def);
232
+ const ta = new TextArea(wp, {
233
+ value: def.value,
234
+ placeholder: def.placeholder,
235
+ });
236
+ pending.push({ control: ta, parents: [...parentChain] });
237
+ win = ta;
238
+ break;
239
+ }
240
+
241
+ case 'checkbox': {
242
+ const changeCb = def.onChange ? this.callbacks.get(def.onChange) : undefined;
243
+ const cb = new Checkbox(wp, {
244
+ checked: def.checked,
245
+ onChange: changeCb ? (checked: boolean) => changeCb(checked) : undefined,
246
+ });
247
+ pending.push({ control: cb, parents: [...parentChain] });
248
+ win = cb;
249
+ break;
250
+ }
251
+
252
+ case 'radio': {
253
+ const changeCb = def.onChange ? this.callbacks.get(def.onChange) : undefined;
254
+ const r = new Radio(wp, {
255
+ checked: def.checked,
256
+ onChange: changeCb ? (checked: boolean) => changeCb(checked) : undefined,
257
+ });
258
+ pending.push({ control: r, parents: [...parentChain] });
259
+ win = r;
260
+ break;
261
+ }
262
+
263
+ case 'statusled': {
264
+ const led = new StatusLED(wp, {
265
+ state: def.state,
266
+ });
267
+ win = led;
268
+ break;
269
+ }
270
+
271
+ case 'progressbar': {
272
+ wp.size = wp.size ?? this.requireSize(def);
273
+ const pb = new ProgressBar(wp, {
274
+ value: def.barValue,
275
+ max: def.max,
276
+ showLabel: def.showLabel,
277
+ fillColor: def.fillColor,
278
+ emptyColor: def.emptyColor,
279
+ });
280
+ win = pb;
281
+ break;
282
+ }
283
+
284
+ case 'progressbarv': {
285
+ wp.size = wp.size ?? this.requireSize(def);
286
+ const pbv = new ProgressBarV(wp, {
287
+ value: def.barValue,
288
+ max: def.max,
289
+ fillColor: def.fillColor,
290
+ emptyColor: def.emptyColor,
291
+ });
292
+ win = pbv;
293
+ break;
294
+ }
295
+
296
+ case 'linechart': {
297
+ wp.size = wp.size ?? this.requireSize(def);
298
+ const lc = new LineChart(wp, {
299
+ data: def.data,
300
+ min: def.min,
301
+ max: def.max,
302
+ color: def.chartColor,
303
+ });
304
+ win = lc;
305
+ break;
306
+ }
307
+
308
+ case 'barchart': {
309
+ wp.size = wp.size ?? this.requireSize(def);
310
+ const bc = new BarChart(wp, {
311
+ data: def.data,
312
+ labels: def.barLabels,
313
+ max: def.max,
314
+ barColor: def.chartColor,
315
+ barWidth: def.barWidth,
316
+ });
317
+ win = bc;
318
+ break;
319
+ }
320
+
321
+ case 'listbox': {
322
+ const changeCb = def.onChange ? this.callbacks.get(def.onChange) : undefined;
323
+ wp.size = wp.size ?? this.requireSize(def);
324
+ const lb = new ListBox(wp, {
325
+ items: def.items,
326
+ selectedIndex: def.selectedIndex,
327
+ onChange: changeCb ? (idx: number, item: string) => changeCb(idx, item) : undefined,
328
+ });
329
+ pending.push({ control: lb, parents: [...parentChain] });
330
+ win = lb;
331
+ break;
332
+ }
333
+
334
+ case 'tabs': {
335
+ const changeCb = def.onChange ? this.callbacks.get(def.onChange) : undefined;
336
+ wp.size = wp.size ?? this.requireSize(def);
337
+ const tabs = new Tabs(wp, {
338
+ titles: def.titles,
339
+ activeIndex: def.activeIndex,
340
+ onChange: changeCb ? (idx: number, title: string) => changeCb(idx, title) : undefined,
341
+ });
342
+ pending.push({ control: tabs, parents: [...parentChain] });
343
+ win = tabs;
344
+ break;
345
+ }
346
+
347
+ case 'sparkline': {
348
+ wp.size = wp.size ?? this.requireSize(def);
349
+ const sp = new Sparkline(wp, {
350
+ data: def.data,
351
+ min: def.min,
352
+ max: def.max,
353
+ color: def.chartColor,
354
+ });
355
+ win = sp;
356
+ break;
357
+ }
358
+
359
+ case 'spinner': {
360
+ const sp = new Spinner(wp, {
361
+ style: def.spinnerStyle,
362
+ frame: def.frame,
363
+ running: def.running,
364
+ color: def.chartColor,
365
+ });
366
+ win = sp;
367
+ break;
368
+ }
369
+
370
+ default: {
371
+ wp.size = wp.size ?? this.requireSize(def);
372
+ win = new Window(wp);
373
+ break;
374
+ }
375
+ }
376
+
377
+ if (def.id) result.set(def.id, win);
378
+ // Defer content write – the window may not have its final size yet.
379
+ if (def.content) contentWrites.push({ win, text: def.content });
380
+
381
+ if (def.children) {
382
+ for (const childDef of def.children) {
383
+ const child = this.buildNode(childDef, result, pending, contentWrites, [win, ...parentChain]);
384
+ if (win instanceof Tabs && childDef.tab !== undefined) {
385
+ win.addChildToTab(childDef.tab, child);
386
+ } else {
387
+ win.addChild(child);
388
+ }
389
+ }
390
+ }
391
+
392
+ return win;
393
+ }
394
+
395
+ /** Returns a parsed Size from the definition or throws if size is missing. */
396
+ private requireSize(def: YamlWindowDef): Size {
397
+ if (!def.size) {
398
+ const label = def.id ? `"${def.id}"` : (def.type ?? 'window');
399
+ throw new Error(`Missing required "size" for ${label}.`);
400
+ }
401
+ return parseSize(def.size);
402
+ }
403
+ }
@@ -0,0 +1,119 @@
1
+ import type { AxisSpec } from './types.mjs';
2
+
3
+ /** Wraps a percentage value (0–100) for use in Pos and Size constructors. */
4
+ export class Pct {
5
+ public constructor(public readonly value: number) {}
6
+ }
7
+
8
+ /** Creates a Pct instance representing the given percentage. */
9
+ export function pct(value: number): Pct {
10
+ return new Pct(value);
11
+ }
12
+
13
+ /** Converts a user-supplied coordinate to an internal AxisSpec. */
14
+ function toAxisSpec(v: number | Pct): AxisSpec {
15
+ if (v instanceof Pct) return { mode: 'pct', value: v.value };
16
+ if (v < 0) return { mode: 'end', value: -v };
17
+ return { mode: 'start', value: v };
18
+ }
19
+
20
+ /** Resolves a single axis spec to a pixel offset. */
21
+ function resolveAxis(spec: AxisSpec, parentSize: number, ownSize: number): number {
22
+ switch (spec.mode) {
23
+ case 'start': return spec.value;
24
+ case 'end': return parentSize - ownSize - spec.value;
25
+ case 'pct': return Math.floor(parentSize * spec.value / 100);
26
+ case 'center': return Math.floor((parentSize - ownSize) / 2);
27
+ }
28
+ }
29
+
30
+ /** Encodes a window position. Supports absolute coordinates, edge-relative (negative),
31
+ * percentage-based, center alignment, and named edge presets. */
32
+ export class Pos {
33
+ private xSpec: AxisSpec;
34
+ private ySpec: AxisSpec;
35
+
36
+ /** Creates a position.
37
+ * - Positive number: absolute distance from left/top.
38
+ * - Negative number: own right/bottom edge at that distance from parent's right/bottom.
39
+ * - Pct instance: percentage of parent dimension from left/top. */
40
+ public constructor(x: number | Pct, y: number | Pct) {
41
+ this.xSpec = toAxisSpec(x);
42
+ this.ySpec = toAxisSpec(y);
43
+ }
44
+
45
+ /** Builds a Pos directly from raw AxisSpec values (used by static factories). */
46
+ private static fromSpecs(x: AxisSpec, y: AxisSpec): Pos {
47
+ const p = Object.create(Pos.prototype) as Pos;
48
+ p.xSpec = x;
49
+ p.ySpec = y;
50
+ return p;
51
+ }
52
+
53
+ /** Aligns the top-left corner with the parent's top-left corner (0, 0). */
54
+ public static topLeft(): Pos {
55
+ return Pos.fromSpecs({ mode: 'start', value: 0 }, { mode: 'start', value: 0 });
56
+ }
57
+
58
+ /** Aligns the top-right corner with the parent's top-right corner. */
59
+ public static topRight(): Pos {
60
+ return Pos.fromSpecs({ mode: 'end', value: 0 }, { mode: 'start', value: 0 });
61
+ }
62
+
63
+ /** Aligns the bottom-left corner with the parent's bottom-left corner. */
64
+ public static bottomLeft(): Pos {
65
+ return Pos.fromSpecs({ mode: 'start', value: 0 }, { mode: 'end', value: 0 });
66
+ }
67
+
68
+ /** Aligns the bottom-right corner with the parent's bottom-right corner. */
69
+ public static bottomRight(): Pos {
70
+ return Pos.fromSpecs({ mode: 'end', value: 0 }, { mode: 'end', value: 0 });
71
+ }
72
+
73
+ /** Centers the window within the parent on both axes. */
74
+ public static center(): Pos {
75
+ return Pos.fromSpecs({ mode: 'center' }, { mode: 'center' });
76
+ }
77
+
78
+ /** Aligns the top edge with the parent's top edge; x sets horizontal position. */
79
+ public static top(x: number | Pct = 0): Pos {
80
+ return Pos.fromSpecs(toAxisSpec(x), { mode: 'start', value: 0 });
81
+ }
82
+
83
+ /** Aligns the left edge with the parent's left edge; y sets vertical position. */
84
+ public static left(y: number | Pct = 0): Pos {
85
+ return Pos.fromSpecs({ mode: 'start', value: 0 }, toAxisSpec(y));
86
+ }
87
+
88
+ /** Aligns the right edge with the parent's right edge; y sets vertical position. */
89
+ public static right(y: number | Pct = 0): Pos {
90
+ return Pos.fromSpecs({ mode: 'end', value: 0 }, toAxisSpec(y));
91
+ }
92
+
93
+ /** Aligns the bottom edge with the parent's bottom edge; x sets horizontal position. */
94
+ public static bottom(x: number | Pct = 0): Pos {
95
+ return Pos.fromSpecs(toAxisSpec(x), { mode: 'end', value: 0 });
96
+ }
97
+
98
+ /** Returns true when both axes are absolute-from-start values (no parent/own-size needed). */
99
+ public isAbsolute(): boolean {
100
+ return this.xSpec.mode === 'start' && this.ySpec.mode === 'start';
101
+ }
102
+
103
+ /** Resolves absolute position without needing parent or own dimensions.
104
+ * Only call when isAbsolute() returns true. */
105
+ public resolveAbsolute(): { x: number; y: number } {
106
+ return {
107
+ x: (this.xSpec as { mode: 'start'; value: number }).value,
108
+ y: (this.ySpec as { mode: 'start'; value: number }).value,
109
+ };
110
+ }
111
+
112
+ /** Resolves this position to pixel coordinates given parent and own dimensions. */
113
+ public resolve(parentW: number, parentH: number, ownW: number, ownH: number): { x: number; y: number } {
114
+ return {
115
+ x: resolveAxis(this.xSpec, parentW, ownW),
116
+ y: resolveAxis(this.ySpec, parentH, ownH),
117
+ };
118
+ }
119
+ }
@@ -0,0 +1,88 @@
1
+ import type { StyleId, TerminalSize } from './types.mjs';
2
+
3
+ export class Region {
4
+ private chars: string[];
5
+ private styleIds: number[];
6
+ private size: TerminalSize;
7
+
8
+ /** Allocates a cell buffer for the given dimensions. All cells start with style ID 0 (empty). */
9
+ public constructor(width: number, height: number) {
10
+ this.size = { width, height };
11
+ const len = width * height;
12
+ this.chars = Array(len).fill(' ');
13
+ this.styleIds = new Array(len).fill(0);
14
+ }
15
+
16
+ /** Returns the region dimensions (columns × rows). */
17
+ public getSize(): TerminalSize {
18
+ return { ...this.size };
19
+ }
20
+
21
+ /** Returns the style ID stored at (x, y). Throws RangeError if out of bounds. */
22
+ public getStyleId(x: number, y: number): StyleId {
23
+ this.assertBounds(x, y);
24
+ return this.styleIds[this.index(x, y)];
25
+ }
26
+
27
+ /** Sets only the character at (x, y) without modifying the style ID. Throws RangeError if out of bounds. */
28
+ public setChar(x: number, y: number, char: string): void {
29
+ this.assertBounds(x, y);
30
+ this.chars[this.index(x, y)] = char;
31
+ }
32
+
33
+ /** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
34
+ public setCell(x: number, y: number, char: string, styleId: StyleId = 0): void {
35
+ this.assertBounds(x, y);
36
+ const i = this.index(x, y);
37
+ this.chars[i] = char;
38
+ this.styleIds[i] = styleId;
39
+ }
40
+
41
+ /** Replaces only the style ID at (x, y) without changing the character. Throws RangeError if out of bounds. */
42
+ public setStyleId(x: number, y: number, styleId: StyleId): void {
43
+ this.assertBounds(x, y);
44
+ this.styleIds[this.index(x, y)] = styleId;
45
+ }
46
+
47
+ /** Resets every cell to a blank space with style ID 0. */
48
+ public clear(): void {
49
+ const len = this.chars.length;
50
+ for (let i = 0; i < len; i++) {
51
+ this.chars[i] = ' ';
52
+ this.styleIds[i] = 0;
53
+ }
54
+ }
55
+
56
+ /** Fills every cell with the given character and style ID. */
57
+ public fill(char: string, styleId: StyleId = 0): void {
58
+ const len = this.chars.length;
59
+ for (let i = 0; i < len; i++) {
60
+ this.chars[i] = char;
61
+ this.styleIds[i] = styleId;
62
+ }
63
+ }
64
+
65
+ /** Returns a readonly view of the character buffer for rendering. */
66
+ public getChars(): readonly string[] {
67
+ return this.chars;
68
+ }
69
+
70
+ /** Returns a readonly view of the style-ID buffer for rendering. */
71
+ public getStyleIds(): readonly number[] {
72
+ return this.styleIds;
73
+ }
74
+
75
+ /** Returns the flat buffer index for the given (x, y) coordinates. */
76
+ private index(x: number, y: number): number {
77
+ return y * this.size.width + x;
78
+ }
79
+
80
+ /** Throws RangeError if the coordinates (x, y) are outside the region boundaries. */
81
+ private assertBounds(x: number, y: number): void {
82
+ if (x < 0 || x >= this.size.width || y < 0 || y >= this.size.height) {
83
+ throw new RangeError(
84
+ `Cell (${x}, ${y}) is out of bounds for region ${this.size.width}×${this.size.height}`
85
+ );
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,16 @@
1
+ import { StyleRegistry } from './StyleRegistry.mjs';
2
+
3
+ /** Module-level singleton holding the active StyleRegistry.
4
+ * Initialized with an empty registry so Window and controls work
5
+ * in isolation (e.g. in tests) before any Screen is constructed. */
6
+ let activeRegistry: StyleRegistry = new StyleRegistry();
7
+
8
+ /** Returns the currently active StyleRegistry. */
9
+ export function getRegistry(): StyleRegistry {
10
+ return activeRegistry;
11
+ }
12
+
13
+ /** Sets the active StyleRegistry. Called by the Screen constructor. */
14
+ export function setRegistry(registry: StyleRegistry): void {
15
+ activeRegistry = registry;
16
+ }