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