take4-console 0.15.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +365 -0
- package/README.md +1 -1
- 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 +4 -4
- 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/src/Screen/Window.mts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
import type { Cell, StyleId, BorderStyle, WindowBorder, WindowProperties, WriteTextOptions, TerminalSize } from './types.mjs';
|
|
1
|
+
import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, DimSpec } from './types.mjs';
|
|
2
2
|
import { BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED, BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED } from './types.mjs';
|
|
3
3
|
import { Region } from './Region.mjs';
|
|
4
4
|
import { StyleRegistry } from './StyleRegistry.mjs';
|
|
5
5
|
import { getRegistry } from './RegistryHolder.mjs';
|
|
6
|
+
import { charWidth, stringWidth } from './textWidth.mjs';
|
|
6
7
|
import type { Pos } from './Pos.mjs';
|
|
7
8
|
import { Size } from './Size.mjs';
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
/** Glyph table for each visual border style. The 'none' style is handled
|
|
11
|
+
* separately (paintBorder bails out early), so it does not appear here. */
|
|
12
|
+
const BORDER_CHARS: Record<Exclude<BorderStyle, 'none'>, Required<BorderChars>> = {
|
|
13
|
+
single: { horizontal: '─', vertical: '│', topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘',
|
|
14
|
+
verticalLeft: '┤', verticalRight: '├', horizontalTop: '┴', horizontalBottom: '┬', cross: '┼' },
|
|
15
|
+
double: { horizontal: '═', vertical: '║', topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
|
|
16
|
+
verticalLeft: '╣', verticalRight: '╠', horizontalTop: '╩', horizontalBottom: '╦', cross: '╬' },
|
|
17
|
+
rounded: { horizontal: '─', vertical: '│', topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
|
|
18
|
+
verticalLeft: '┤', verticalRight: '├', horizontalTop: '┴', horizontalBottom: '┬', cross: '┼' },
|
|
19
|
+
thick: { horizontal: '━', vertical: '┃', topLeft: '┏', topRight: '┓', bottomLeft: '┗', bottomRight: '┛',
|
|
20
|
+
verticalLeft: '┫', verticalRight: '┣', horizontalTop: '┻', horizontalBottom: '┳', cross: '╋' },
|
|
21
|
+
dashed: { horizontal: '╌', vertical: '╎', topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘',
|
|
22
|
+
verticalLeft: '┤', verticalRight: '├', horizontalTop: '┴', horizontalBottom: '┬', cross: '┼' },
|
|
23
|
+
ascii: { horizontal: '-', vertical: '|', topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+',
|
|
24
|
+
verticalLeft: '+', verticalRight: '+', horizontalTop: '+', horizontalBottom: '+', cross: '+' },
|
|
14
25
|
};
|
|
15
26
|
|
|
16
27
|
/** Resolves a border option (true / object / false) to a full WindowBorder or false. */
|
|
@@ -20,6 +31,51 @@ const resolveBorder = (border: WindowBorder | boolean | undefined): WindowBorder
|
|
|
20
31
|
return border;
|
|
21
32
|
};
|
|
22
33
|
|
|
34
|
+
/** Internal per-child bookkeeping during a row/column flex layout pass. */
|
|
35
|
+
interface FlexItem {
|
|
36
|
+
child: Window;
|
|
37
|
+
mainSpec: DimSpec;
|
|
38
|
+
crossSpec: DimSpec;
|
|
39
|
+
mainSize: number;
|
|
40
|
+
crossSize: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolves a flex-item's initial main- or cross-axis size from its DimSpec.
|
|
44
|
+
* - 'abs' → the literal pixel value.
|
|
45
|
+
* - 'pct' → the computed fraction of the parent's inner dimension.
|
|
46
|
+
* - 'flex' → the basis (abs or pct of parent) before grow/shrink distribution.
|
|
47
|
+
* - 'content' → the child's natural size (its current Region dimension on the
|
|
48
|
+
* requested axis), which defaults to 1 for an uninitialised child.
|
|
49
|
+
* `naturalSize` must come from the child's current getSize() on the relevant
|
|
50
|
+
* axis so content-sized children pick up the most up-to-date measurement. */
|
|
51
|
+
function resolveFlexBasis(spec: DimSpec, parent: number, naturalSize: number): number {
|
|
52
|
+
switch (spec.mode) {
|
|
53
|
+
case 'abs': return spec.value;
|
|
54
|
+
case 'pct': return Math.floor(parent * spec.value / 100);
|
|
55
|
+
case 'content': return naturalSize;
|
|
56
|
+
case 'flex':
|
|
57
|
+
return spec.basis.kind === 'pct'
|
|
58
|
+
? Math.floor(parent * spec.basis.value / 100)
|
|
59
|
+
: spec.basis.value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalises a PaddingSpec to a full Padding record (missing sides → 0). */
|
|
64
|
+
const resolvePadding = (spec: PaddingSpec | undefined): Padding => {
|
|
65
|
+
if (spec === undefined) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
66
|
+
if (typeof spec === 'number') return { top: spec, right: spec, bottom: spec, left: spec };
|
|
67
|
+
if (Array.isArray(spec)) {
|
|
68
|
+
const [v = 0, h = 0] = spec;
|
|
69
|
+
return { top: v, right: h, bottom: v, left: h };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
top: spec.top ?? 0,
|
|
73
|
+
right: spec.right ?? 0,
|
|
74
|
+
bottom: spec.bottom ?? 0,
|
|
75
|
+
left: spec.left ?? 0,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
23
79
|
export class Window {
|
|
24
80
|
public x: number;
|
|
25
81
|
public y: number;
|
|
@@ -50,6 +106,25 @@ export class Window {
|
|
|
50
106
|
private active: boolean;
|
|
51
107
|
private posSpec: Pos;
|
|
52
108
|
private sizeSpec: Size;
|
|
109
|
+
/** Whether this window participates in rendering. When false, the window's own
|
|
110
|
+
* render() is a no-op, its region is not blitted onto the parent, and
|
|
111
|
+
* `getCell()` throws. Focus-cycle in WindowManager skips invisible focusable
|
|
112
|
+
* controls. Default: true. */
|
|
113
|
+
private visible: boolean = true;
|
|
114
|
+
/** Layout algorithm applied to direct children. 'absolute' keeps the pre-flex
|
|
115
|
+
* behaviour; 'row' / 'column' / 'grid' activate the flex engine. */
|
|
116
|
+
private layoutMode: LayoutMode;
|
|
117
|
+
/** Spacing (in cells) between adjacent children for flex / grid layouts. */
|
|
118
|
+
private gap: number;
|
|
119
|
+
/** Padding applied inside the border (in addition to the border inset).
|
|
120
|
+
* Influences `getInnerSize()` / `getInnerOffset()`. */
|
|
121
|
+
private padding: Padding;
|
|
122
|
+
/** Number of columns for `layout: 'grid'`. */
|
|
123
|
+
private gridColumns: number;
|
|
124
|
+
/** Cross-axis alignment for row/column layouts. */
|
|
125
|
+
private alignItems: AlignItems;
|
|
126
|
+
/** Main-axis distribution of leftover space when no flex-grow child consumes it. */
|
|
127
|
+
private justifyContent: JustifyContent;
|
|
53
128
|
|
|
54
129
|
/** Creates a window from the given properties.
|
|
55
130
|
* For percentage-based sizes, call addChild() before writing content to the window.
|
|
@@ -72,6 +147,12 @@ export class Window {
|
|
|
72
147
|
this.background = wp.background ?? 0;
|
|
73
148
|
this.border = resolveBorder(wp.border ?? wp.defaultBorder);
|
|
74
149
|
this.borderColorExplicit = this.border !== false && this.border.color !== undefined;
|
|
150
|
+
this.layoutMode = wp.layout ?? 'absolute';
|
|
151
|
+
this.gap = wp.gap ?? 0;
|
|
152
|
+
this.padding = resolvePadding(wp.padding);
|
|
153
|
+
this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
|
|
154
|
+
this.alignItems = wp.alignItems ?? 'stretch';
|
|
155
|
+
this.justifyContent = wp.justifyContent ?? 'start';
|
|
75
156
|
|
|
76
157
|
const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
|
|
77
158
|
this.region = new Region(w, h);
|
|
@@ -92,10 +173,11 @@ export class Window {
|
|
|
92
173
|
return this.region.getSize();
|
|
93
174
|
}
|
|
94
175
|
|
|
95
|
-
/** Returns the number of cells consumed by decorations on each edge.
|
|
176
|
+
/** Returns the number of cells consumed by decorations on each edge.
|
|
177
|
+
* The explicit 'none' border style is treated as no border (no insets). */
|
|
96
178
|
private borderInset(): { top: number; right: number; bottom: number; left: number } {
|
|
97
179
|
const b = this.border;
|
|
98
|
-
if (!b) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
180
|
+
if (!b || b.style === 'none') return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
99
181
|
return {
|
|
100
182
|
top: (b.top ?? false) ? 1 : 0,
|
|
101
183
|
right: (b.right ?? false) ? 1 : 0,
|
|
@@ -104,16 +186,30 @@ export class Window {
|
|
|
104
186
|
};
|
|
105
187
|
}
|
|
106
188
|
|
|
107
|
-
/** Returns the
|
|
189
|
+
/** Returns the combined per-side inset (border + padding) that defines the
|
|
190
|
+
* inner content area. Padding stacks on top of the border inset. */
|
|
191
|
+
private innerInset(): Padding {
|
|
192
|
+
const b = this.borderInset();
|
|
193
|
+
return {
|
|
194
|
+
top: b.top + this.padding.top,
|
|
195
|
+
right: b.right + this.padding.right,
|
|
196
|
+
bottom: b.bottom + this.padding.bottom,
|
|
197
|
+
left: b.left + this.padding.left,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Returns the top-left offset of the content area, accounting for
|
|
202
|
+
* decorations (border) and layout insets (padding). */
|
|
108
203
|
public getInnerOffset(): { x: number; y: number } {
|
|
109
|
-
const { left, top } = this.
|
|
204
|
+
const { left, top } = this.innerInset();
|
|
110
205
|
return { x: left, y: top };
|
|
111
206
|
}
|
|
112
207
|
|
|
113
|
-
/** Returns the dimensions of the content area, accounting for decorations
|
|
208
|
+
/** Returns the dimensions of the content area, accounting for decorations
|
|
209
|
+
* (border) and layout insets (padding). */
|
|
114
210
|
public getInnerSize(): TerminalSize {
|
|
115
211
|
const { width, height } = this.getSize();
|
|
116
|
-
const { top, right, bottom, left } = this.
|
|
212
|
+
const { top, right, bottom, left } = this.innerInset();
|
|
117
213
|
return {
|
|
118
214
|
width: Math.max(0, width - left - right),
|
|
119
215
|
height: Math.max(0, height - top - bottom),
|
|
@@ -146,6 +242,20 @@ export class Window {
|
|
|
146
242
|
return this.disabled;
|
|
147
243
|
}
|
|
148
244
|
|
|
245
|
+
/** Shows or hides the window. A hidden window does not paint itself, is not
|
|
246
|
+
* blitted onto its parent, and its focusable descendants are skipped by
|
|
247
|
+
* `WindowManager.moveFocus / setFocus`. Toggling visibility does not mutate
|
|
248
|
+
* the content buffer — previously written cells reappear verbatim on the
|
|
249
|
+
* next render after the window is shown again. */
|
|
250
|
+
public setVisible(visible: boolean): void {
|
|
251
|
+
this.visible = visible;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Returns whether this window is currently visible. Default: true. */
|
|
255
|
+
public isVisible(): boolean {
|
|
256
|
+
return this.visible;
|
|
257
|
+
}
|
|
258
|
+
|
|
149
259
|
/** Sets the label text displayed by the control. */
|
|
150
260
|
public setLabel(label: string): void {
|
|
151
261
|
this.label = label;
|
|
@@ -174,26 +284,25 @@ export class Window {
|
|
|
174
284
|
: this.registry.getNamedForeground(BUILTIN_BORDER, 240);
|
|
175
285
|
}
|
|
176
286
|
|
|
177
|
-
/** Adds a child window.
|
|
178
|
-
*
|
|
179
|
-
*
|
|
287
|
+
/** Adds a child window. The final position and (for non-absolute sizes) the
|
|
288
|
+
* final dimensions are computed by the parent's layout engine: in
|
|
289
|
+
* 'absolute' layout each child resolves its own Pos/Size; in 'row',
|
|
290
|
+
* 'column', or 'grid' layouts the engine distributes the inner area
|
|
291
|
+
* across every visible child. Re-laying out on addChild keeps sibling
|
|
292
|
+
* geometry consistent when more children join the stack. */
|
|
180
293
|
public addChild(child: Window): void {
|
|
181
|
-
const { width: pw, height: ph } = this.getInnerSize();
|
|
182
|
-
const { x: ox, y: oy } = this.getInnerOffset();
|
|
183
|
-
if (!child.sizeSpec.isAbsolute()) {
|
|
184
|
-
const { w, h } = child.sizeSpec.resolve(pw, ph);
|
|
185
|
-
child.resizeRegions(w, h);
|
|
186
|
-
}
|
|
187
|
-
const { width: cw, height: ch } = child.getSize();
|
|
188
|
-
const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
|
|
189
|
-
child.x = x + ox;
|
|
190
|
-
child.y = y + oy;
|
|
191
294
|
this.children.push(child);
|
|
295
|
+
this.runLayout();
|
|
192
296
|
}
|
|
193
297
|
|
|
194
298
|
/** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
|
|
195
|
-
* Throws RangeError if out of bounds.
|
|
299
|
+
* Throws RangeError if out of bounds. Throws Error when the window is
|
|
300
|
+
* currently hidden (setVisible(false)) — callers should check isVisible()
|
|
301
|
+
* first when the visibility state is uncertain. */
|
|
196
302
|
public getCell(x: number, y: number): Cell {
|
|
303
|
+
if (!this.visible) {
|
|
304
|
+
throw new Error('Window.getCell called on a hidden window (setVisible(false))');
|
|
305
|
+
}
|
|
197
306
|
const char = this.region.getChars()[this.flatIndex(x, y)];
|
|
198
307
|
const styleId = this.region.getStyleId(x, y);
|
|
199
308
|
const attributes = { ...this.registry.get(styleId) };
|
|
@@ -235,45 +344,142 @@ export class Window {
|
|
|
235
344
|
|
|
236
345
|
/** Writes text into the window's content area starting at (x, y) (default 0, 0).
|
|
237
346
|
* Coordinates are relative to the inner content area (i.e. decorations such as borders are excluded).
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
|
|
347
|
+
*
|
|
348
|
+
* Accepts either a plain string (one base style for the whole text) or an array of
|
|
349
|
+
* `WriteTextSegment`s so inline rich text can be laid out without separate writeText()
|
|
350
|
+
* calls — the cursor flows from one segment into the next on the same row. Each segment
|
|
351
|
+
* may pin its own style either as a pre-registered `style: StyleId` or as inline
|
|
352
|
+
* `attrs: CellAttributes` (registered on the fly). The segment style is merged on top
|
|
353
|
+
* of the base style so global defaults (e.g. foreground) keep applying unless overridden.
|
|
354
|
+
*
|
|
355
|
+
* Behaviour shared with the single-string form:
|
|
356
|
+
* - Newline characters move to the next row, resetting x to startX.
|
|
357
|
+
* - Characters outside the inner bounds are silently clipped.
|
|
358
|
+
* - Wide characters (CJK, emoji, double-width NerdFonts) occupy two consecutive cells;
|
|
359
|
+
* the second cell stores '' as a continuation sentinel so terminal cursor advancement
|
|
360
|
+
* during render() stays aligned with the buffer indices. Wide chars whose right half
|
|
361
|
+
* would overflow the inner area are skipped entirely.
|
|
362
|
+
* - Zero-width codepoints (combining marks, format chars, control chars) are skipped.
|
|
363
|
+
* - When no style is provided, the base style is auto-picked from disabled / focused /
|
|
364
|
+
* normal state. Pass `options.style = 0` to suppress the state-based base style. */
|
|
365
|
+
public writeText(input: WriteTextInput, options?: WriteTextOptions): void {
|
|
243
366
|
const { x: ox, y: oy } = this.getInnerOffset();
|
|
244
367
|
const { width: iw, height: ih } = this.getInnerSize();
|
|
245
|
-
const startX
|
|
246
|
-
const startY
|
|
247
|
-
const
|
|
368
|
+
const startX = (options?.x ?? 0) + ox;
|
|
369
|
+
const startY = (options?.y ?? 0) + oy;
|
|
370
|
+
const baseStyle = options?.style ?? (
|
|
248
371
|
this.disabled ? this.disabledStyleId :
|
|
249
372
|
this.focused ? this.focusedStyleId :
|
|
250
373
|
this.normalStyleId
|
|
251
374
|
);
|
|
375
|
+
const segments: WriteTextSegment[] = typeof input === 'string'
|
|
376
|
+
? [{ text: input }]
|
|
377
|
+
: input;
|
|
378
|
+
|
|
252
379
|
let cx = startX;
|
|
253
380
|
let cy = startY;
|
|
254
|
-
for (const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
381
|
+
for (const seg of segments) {
|
|
382
|
+
let segId: StyleId = baseStyle;
|
|
383
|
+
if (seg.style !== undefined) {
|
|
384
|
+
segId = this.registry.merge(baseStyle, seg.style);
|
|
385
|
+
} else if (seg.attrs !== undefined) {
|
|
386
|
+
segId = this.registry.merge(baseStyle, this.registry.register(seg.attrs));
|
|
259
387
|
}
|
|
260
|
-
|
|
261
|
-
|
|
388
|
+
for (const ch of seg.text) {
|
|
389
|
+
if (ch === '\n') {
|
|
390
|
+
cx = startX;
|
|
391
|
+
cy++;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const w = charWidth(ch.codePointAt(0)!);
|
|
395
|
+
if (w === 0) continue;
|
|
396
|
+
const inRow = cy >= oy && cy < oy + ih;
|
|
397
|
+
if (w === 2) {
|
|
398
|
+
if (inRow && cx >= ox && cx + 1 < ox + iw) {
|
|
399
|
+
this.setCell(cx, cy, ch, segId);
|
|
400
|
+
this.setCell(cx + 1, cy, '', segId);
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
if (inRow && cx >= ox && cx < ox + iw) {
|
|
404
|
+
this.setCell(cx, cy, ch, segId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
cx += w;
|
|
262
408
|
}
|
|
263
|
-
cx++;
|
|
264
409
|
}
|
|
265
410
|
}
|
|
266
411
|
|
|
412
|
+
/** Writes a template that embeds named styles from the StyleRegistry.
|
|
413
|
+
* Syntax is a mini-markup: `{name}…{/}` wraps an inline region in the style
|
|
414
|
+
* registered under `name` (e.g. a built-in name like `builtin:text-focused` or a
|
|
415
|
+
* user-registered custom name). Nested tags inherit attributes from their parent
|
|
416
|
+
* — the inner style is merged on top of the outer one, so `{red}a{bold}b{/}c{/}`
|
|
417
|
+
* renders `a` in red, `b` in red+bold, `c` in red. `{/}` closes the most recently
|
|
418
|
+
* opened tag. Unknown names keep the surrounding style unchanged. `{{` and `}}`
|
|
419
|
+
* escape literal braces. The template is compiled to an array of
|
|
420
|
+
* `WriteTextSegment`s and written via writeText(), so line-wrap, width, and
|
|
421
|
+
* clipping rules are inherited. */
|
|
422
|
+
public writeMarkup(template: string, options?: WriteTextOptions): void {
|
|
423
|
+
const segments: WriteTextSegment[] = [];
|
|
424
|
+
const stack: StyleId[] = [];
|
|
425
|
+
let buf = '';
|
|
426
|
+
const flush = (): void => {
|
|
427
|
+
if (buf.length === 0) return;
|
|
428
|
+
const top = stack[stack.length - 1];
|
|
429
|
+
segments.push(top !== undefined ? { text: buf, style: top } : { text: buf });
|
|
430
|
+
buf = '';
|
|
431
|
+
};
|
|
432
|
+
let i = 0;
|
|
433
|
+
while (i < template.length) {
|
|
434
|
+
const ch = template[i]!;
|
|
435
|
+
if (ch === '{' && template[i + 1] === '{') { buf += '{'; i += 2; continue; }
|
|
436
|
+
if (ch === '}' && template[i + 1] === '}') { buf += '}'; i += 2; continue; }
|
|
437
|
+
if (ch === '{') {
|
|
438
|
+
const close = template.indexOf('}', i + 1);
|
|
439
|
+
if (close === -1) { buf += ch; i++; continue; }
|
|
440
|
+
const tag = template.slice(i + 1, close);
|
|
441
|
+
flush();
|
|
442
|
+
if (tag === '/') {
|
|
443
|
+
stack.pop();
|
|
444
|
+
} else {
|
|
445
|
+
const id = this.registry.getNamed(tag);
|
|
446
|
+
const top = stack[stack.length - 1] ?? 0;
|
|
447
|
+
const composed = id !== undefined ? this.registry.merge(top, id) : top;
|
|
448
|
+
stack.push(composed);
|
|
449
|
+
}
|
|
450
|
+
i = close + 1;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
buf += ch;
|
|
454
|
+
i++;
|
|
455
|
+
}
|
|
456
|
+
flush();
|
|
457
|
+
this.writeText(segments, options);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Returns the display width (in terminal cells) of a string,
|
|
461
|
+
* honouring wide characters (CJK, emoji, double-width NerdFonts) and
|
|
462
|
+
* ignoring zero-width codepoints. Useful for sizing labels or aligning
|
|
463
|
+
* text in custom controls. */
|
|
464
|
+
public getTextWidth(text: string): number {
|
|
465
|
+
return stringWidth(text);
|
|
466
|
+
}
|
|
467
|
+
|
|
267
468
|
/**
|
|
268
469
|
* Builds the display buffer: background → user content → border → children.
|
|
269
470
|
* The result is stored in region and used by blitChild / Screen.render().
|
|
471
|
+
* A hidden window (setVisible(false)) returns immediately so neither its
|
|
472
|
+
* own paint stages nor its children contribute to the frame; hidden
|
|
473
|
+
* children are also skipped in the loop below.
|
|
270
474
|
*/
|
|
271
475
|
public render(): void {
|
|
476
|
+
if (!this.visible) return;
|
|
272
477
|
this.syncBorderColor();
|
|
273
478
|
this.paintBackground();
|
|
274
479
|
this.blitContent();
|
|
275
480
|
this.paintBorder();
|
|
276
481
|
for (const child of this.children) {
|
|
482
|
+
if (!child.visible) continue;
|
|
277
483
|
child.render();
|
|
278
484
|
this.blitChild(child);
|
|
279
485
|
}
|
|
@@ -305,13 +511,18 @@ export class Window {
|
|
|
305
511
|
}
|
|
306
512
|
}
|
|
307
513
|
|
|
308
|
-
/** Draws border characters on the display buffer edges. When inactive, adds dim to border cells.
|
|
514
|
+
/** Draws border characters on the display buffer edges. When inactive, adds dim to border cells.
|
|
515
|
+
* Uses the glyph table for `border.style`, with optional per-character overrides
|
|
516
|
+
* via `border.chars`. The 'none' style is a no-op placeholder. */
|
|
309
517
|
private paintBorder(): void {
|
|
310
518
|
if (!this.border) return;
|
|
311
519
|
const b = this.border;
|
|
520
|
+
const style = b.style ?? 'single';
|
|
521
|
+
if (style === 'none') return;
|
|
312
522
|
const { width, height } = this.region.getSize();
|
|
313
523
|
if (width < 2 && height < 2) return;
|
|
314
|
-
const
|
|
524
|
+
const baseChars = BORDER_CHARS[style];
|
|
525
|
+
const chars: Required<BorderChars> = b.chars ? { ...baseChars, ...b.chars } : baseChars;
|
|
315
526
|
|
|
316
527
|
const bgColor = this.background !== 0
|
|
317
528
|
? this.registry.get(this.background).background
|
|
@@ -334,9 +545,9 @@ export class Window {
|
|
|
334
545
|
const isLeft = x === 0;
|
|
335
546
|
const isRight = x === width - 1;
|
|
336
547
|
let ch: string;
|
|
337
|
-
if (isLeft && left) ch = chars.
|
|
338
|
-
else if (isRight && right) ch = chars.
|
|
339
|
-
else ch = chars.
|
|
548
|
+
if (isLeft && left) ch = chars.topLeft;
|
|
549
|
+
else if (isRight && right) ch = chars.topRight;
|
|
550
|
+
else ch = chars.horizontal;
|
|
340
551
|
this.region.setCell(x, 0, ch, baseId);
|
|
341
552
|
}
|
|
342
553
|
}
|
|
@@ -347,9 +558,9 @@ export class Window {
|
|
|
347
558
|
const isLeft = x === 0;
|
|
348
559
|
const isRight = x === width - 1;
|
|
349
560
|
let ch: string;
|
|
350
|
-
if (isLeft && left) ch = chars.
|
|
351
|
-
else if (isRight && right) ch = chars.
|
|
352
|
-
else ch = chars.
|
|
561
|
+
if (isLeft && left) ch = chars.bottomLeft;
|
|
562
|
+
else if (isRight && right) ch = chars.bottomRight;
|
|
563
|
+
else ch = chars.horizontal;
|
|
353
564
|
this.region.setCell(x, height - 1, ch, baseId);
|
|
354
565
|
}
|
|
355
566
|
}
|
|
@@ -359,7 +570,7 @@ export class Window {
|
|
|
359
570
|
const rowStart = top ? 1 : 0;
|
|
360
571
|
const rowEnd = bottom ? height - 2 : height - 1;
|
|
361
572
|
for (let y = rowStart; y <= rowEnd; y++) {
|
|
362
|
-
this.region.setCell(0, y, chars.
|
|
573
|
+
this.region.setCell(0, y, chars.vertical, baseId);
|
|
363
574
|
}
|
|
364
575
|
}
|
|
365
576
|
|
|
@@ -368,23 +579,22 @@ export class Window {
|
|
|
368
579
|
const rowStart = top ? 1 : 0;
|
|
369
580
|
const rowEnd = bottom ? height - 2 : height - 1;
|
|
370
581
|
for (let y = rowStart; y <= rowEnd; y++) {
|
|
371
|
-
this.region.setCell(width - 1, y, chars.
|
|
582
|
+
this.region.setCell(width - 1, y, chars.vertical, baseId);
|
|
372
583
|
}
|
|
373
584
|
}
|
|
374
585
|
}
|
|
375
586
|
|
|
376
|
-
/** Copies a child's display buffer onto this window's display buffer,
|
|
377
|
-
* Styles are transferred from the child's
|
|
378
|
-
*
|
|
587
|
+
/** Copies a child's display buffer onto this window's display buffer,
|
|
588
|
+
* clipping to this window's bounds. Styles are transferred from the child's
|
|
589
|
+
* registry into this window's registry. The child's (x, y) was computed by
|
|
590
|
+
* the layout engine on addChild / reflowChildren / setSize, so blit can
|
|
591
|
+
* read it directly — this keeps absolute and flex paths on the same code. */
|
|
379
592
|
private blitChild(child: Window): void {
|
|
380
593
|
const chars = child.region.getChars();
|
|
381
594
|
const childStyleIds = child.region.getStyleIds();
|
|
382
595
|
const { width: cw, height: ch } = child.getSize();
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
const { x: cx, y: cy } = child.posSpec.resolve(pw, ph, cw, ch);
|
|
386
|
-
const ax = cx + ox;
|
|
387
|
-
const ay = cy + oy;
|
|
596
|
+
const ax = child.x;
|
|
597
|
+
const ay = child.y;
|
|
388
598
|
const { width: totalW, height: totalH } = this.getSize();
|
|
389
599
|
|
|
390
600
|
for (let childY = 0; childY < ch; childY++) {
|
|
@@ -409,17 +619,46 @@ export class Window {
|
|
|
409
619
|
|
|
410
620
|
/** Replaces both internal regions with new ones of the given dimensions,
|
|
411
621
|
* then re-resolves sizes and positions of all direct children against the updated inner area. */
|
|
412
|
-
|
|
622
|
+
protected resizeRegions(w: number, h: number): void {
|
|
413
623
|
this.region = new Region(w, h);
|
|
414
624
|
this.content = new Region(w, h);
|
|
415
625
|
this.reflowChildren();
|
|
416
626
|
}
|
|
417
627
|
|
|
418
|
-
/**
|
|
419
|
-
*
|
|
628
|
+
/** Resizes this window to the given absolute dimensions and reflows every
|
|
629
|
+
* descendant whose size or position is percentage-based against the new
|
|
630
|
+
* inner area. Existing absolute children keep their declared geometry but
|
|
631
|
+
* have their stored x/y refreshed by reflowChildren() so the parent's
|
|
632
|
+
* border insets still apply. The previously written content is discarded
|
|
633
|
+
* — callers that need to preserve it must redraw after setSize(). */
|
|
634
|
+
public setSize(width: number, height: number): void {
|
|
635
|
+
this.resizeRegions(width, height);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Re-resolves sizes and absolute positions for every direct child against
|
|
639
|
+
* the current inner area. Delegates to the layout engine so percentage
|
|
640
|
+
* children, flex children, and grid cells are all updated from the same
|
|
641
|
+
* code path. Called after this window is resized or a new child is added. */
|
|
420
642
|
private reflowChildren(): void {
|
|
643
|
+
this.runLayout();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Runs the layout engine against the current inner area. Dispatches to
|
|
647
|
+
* absolute / row / column / grid implementations based on `layoutMode`. */
|
|
648
|
+
private runLayout(): void {
|
|
421
649
|
const { width: pw, height: ph } = this.getInnerSize();
|
|
422
650
|
const { x: ox, y: oy } = this.getInnerOffset();
|
|
651
|
+
switch (this.layoutMode) {
|
|
652
|
+
case 'absolute': this.layoutAbsolute(pw, ph, ox, oy); return;
|
|
653
|
+
case 'row':
|
|
654
|
+
case 'column': this.layoutFlex(this.layoutMode, pw, ph, ox, oy); return;
|
|
655
|
+
case 'grid': this.layoutGrid(pw, ph, ox, oy); return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Absolute layout: each child independently resolves its Pos/Size against
|
|
660
|
+
* the parent's inner area — the pre-flex behaviour. */
|
|
661
|
+
private layoutAbsolute(pw: number, ph: number, ox: number, oy: number): void {
|
|
423
662
|
for (const child of this.children) {
|
|
424
663
|
if (!child.sizeSpec.isAbsolute()) {
|
|
425
664
|
const { w, h } = child.sizeSpec.resolve(pw, ph);
|
|
@@ -432,6 +671,167 @@ export class Window {
|
|
|
432
671
|
}
|
|
433
672
|
}
|
|
434
673
|
|
|
674
|
+
/** Row / column flex layout. The main axis (width for 'row', height for
|
|
675
|
+
* 'column') is distributed across children: each child starts with its
|
|
676
|
+
* basis (abs → value, pct → fraction of parent, flex → flex basis,
|
|
677
|
+
* content → measured natural size), then remaining slack is distributed
|
|
678
|
+
* pro rata by `grow`, and negative slack by `shrink`. The cross axis is
|
|
679
|
+
* aligned via `alignItems` — 'stretch' expands flex/content children to
|
|
680
|
+
* the full inner cross dimension. `justifyContent` governs leftover
|
|
681
|
+
* distribution only when no child consumes slack via flex-grow. Invisible
|
|
682
|
+
* children are skipped so `setVisible(false)` effectively removes them
|
|
683
|
+
* from the stack. Children are ordered by `Pos.flex(order)` first, then
|
|
684
|
+
* by addChild insertion (stable). */
|
|
685
|
+
private layoutFlex(mode: 'row' | 'column', pw: number, ph: number, ox: number, oy: number): void {
|
|
686
|
+
const ordered = this.orderedVisibleChildren();
|
|
687
|
+
if (ordered.length === 0) return;
|
|
688
|
+
const isRow = mode === 'row';
|
|
689
|
+
const mainParent = isRow ? pw : ph;
|
|
690
|
+
const crossParent = isRow ? ph : pw;
|
|
691
|
+
const gap = this.gap;
|
|
692
|
+
|
|
693
|
+
// First pass: basis sizes per child.
|
|
694
|
+
const items = ordered.map((c): FlexItem => {
|
|
695
|
+
const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
|
|
696
|
+
const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
|
|
697
|
+
return {
|
|
698
|
+
child: c,
|
|
699
|
+
mainSpec,
|
|
700
|
+
crossSpec,
|
|
701
|
+
mainSize: resolveFlexBasis(mainSpec, mainParent, isRow ? c.getSize().width : c.getSize().height),
|
|
702
|
+
crossSize: resolveFlexBasis(crossSpec, crossParent, isRow ? c.getSize().height : c.getSize().width),
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// Distribute leftover main-axis space.
|
|
707
|
+
const totalGap = Math.max(0, items.length - 1) * gap;
|
|
708
|
+
const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
|
|
709
|
+
let remainder = mainParent - totalMain - totalGap;
|
|
710
|
+
const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
|
|
711
|
+
if (remainder > 0 && totalGrow > 0) {
|
|
712
|
+
let distributed = 0;
|
|
713
|
+
for (const it of items) {
|
|
714
|
+
if (it.mainSpec.mode !== 'flex') continue;
|
|
715
|
+
const share = Math.floor(remainder * (it.mainSpec.grow / totalGrow));
|
|
716
|
+
it.mainSize += share;
|
|
717
|
+
distributed += share;
|
|
718
|
+
}
|
|
719
|
+
// Hand the integer-truncation leftover to the last flex child.
|
|
720
|
+
const leftover = remainder - distributed;
|
|
721
|
+
if (leftover > 0) {
|
|
722
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
723
|
+
if (items[i]!.mainSpec.mode === 'flex') { items[i]!.mainSize += leftover; break; }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
remainder = 0;
|
|
727
|
+
} else if (remainder < 0) {
|
|
728
|
+
const totalShrink = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.shrink : 0), 0);
|
|
729
|
+
if (totalShrink > 0) {
|
|
730
|
+
for (const it of items) {
|
|
731
|
+
if (it.mainSpec.mode !== 'flex') continue;
|
|
732
|
+
const take = Math.ceil(Math.abs(remainder) * (it.mainSpec.shrink / totalShrink));
|
|
733
|
+
it.mainSize = Math.max(0, it.mainSize - take);
|
|
734
|
+
}
|
|
735
|
+
const consumed = items.reduce((s, it) => s + it.mainSize, 0);
|
|
736
|
+
remainder = mainParent - consumed - totalGap;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Apply cross-axis stretch where allowed.
|
|
741
|
+
for (const it of items) {
|
|
742
|
+
const mode = it.crossSpec.mode;
|
|
743
|
+
if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
|
|
744
|
+
it.crossSize = crossParent;
|
|
745
|
+
} else {
|
|
746
|
+
it.crossSize = Math.min(it.crossSize, crossParent);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// justifyContent kicks in only when there is positive slack with no grow.
|
|
751
|
+
let mainStart = 0;
|
|
752
|
+
let itemSpacing = gap;
|
|
753
|
+
if (remainder > 0) {
|
|
754
|
+
switch (this.justifyContent) {
|
|
755
|
+
case 'center': mainStart = Math.floor(remainder / 2); break;
|
|
756
|
+
case 'end': mainStart = remainder; break;
|
|
757
|
+
case 'space-between':
|
|
758
|
+
if (items.length > 1) itemSpacing = gap + Math.floor(remainder / (items.length - 1));
|
|
759
|
+
break;
|
|
760
|
+
case 'space-around':
|
|
761
|
+
if (items.length > 0) {
|
|
762
|
+
const pad = Math.floor(remainder / (items.length * 2));
|
|
763
|
+
mainStart = pad;
|
|
764
|
+
itemSpacing = gap + pad * 2;
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Write final sizes and positions.
|
|
771
|
+
let cursor = mainStart;
|
|
772
|
+
for (const it of items) {
|
|
773
|
+
const mainSize = Math.max(0, it.mainSize);
|
|
774
|
+
const crossSize = Math.max(0, it.crossSize);
|
|
775
|
+
const newW = isRow ? mainSize : crossSize;
|
|
776
|
+
const newH = isRow ? crossSize : mainSize;
|
|
777
|
+
if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
|
|
778
|
+
it.child.resizeRegions(newW, newH);
|
|
779
|
+
}
|
|
780
|
+
let crossPos = 0;
|
|
781
|
+
if (this.alignItems !== 'stretch') {
|
|
782
|
+
const free = crossParent - crossSize;
|
|
783
|
+
if (this.alignItems === 'center') crossPos = Math.floor(free / 2);
|
|
784
|
+
else if (this.alignItems === 'end') crossPos = free;
|
|
785
|
+
}
|
|
786
|
+
if (isRow) {
|
|
787
|
+
it.child.x = ox + cursor;
|
|
788
|
+
it.child.y = oy + crossPos;
|
|
789
|
+
} else {
|
|
790
|
+
it.child.x = ox + crossPos;
|
|
791
|
+
it.child.y = oy + cursor;
|
|
792
|
+
}
|
|
793
|
+
cursor += mainSize + itemSpacing;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Grid layout: children are placed row-major into equally sized cells.
|
|
798
|
+
* Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
|
|
799
|
+
* heights use the same formula with the derived row count. Children are
|
|
800
|
+
* resized to the cell dimensions and positioned at the cell's top-left.
|
|
801
|
+
* Invisible children are skipped. */
|
|
802
|
+
private layoutGrid(pw: number, ph: number, ox: number, oy: number): void {
|
|
803
|
+
const ordered = this.orderedVisibleChildren();
|
|
804
|
+
if (ordered.length === 0) return;
|
|
805
|
+
const cols = this.gridColumns;
|
|
806
|
+
const rows = Math.max(1, Math.ceil(ordered.length / cols));
|
|
807
|
+
const gap = this.gap;
|
|
808
|
+
const cellW = Math.max(0, Math.floor((pw - gap * Math.max(0, cols - 1)) / cols));
|
|
809
|
+
const cellH = Math.max(0, Math.floor((ph - gap * Math.max(0, rows - 1)) / rows));
|
|
810
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
811
|
+
const child = ordered[i]!;
|
|
812
|
+
const c = i % cols;
|
|
813
|
+
const r = Math.floor(i / cols);
|
|
814
|
+
if (cellW !== child.getSize().width || cellH !== child.getSize().height) {
|
|
815
|
+
child.resizeRegions(cellW, cellH);
|
|
816
|
+
}
|
|
817
|
+
child.x = ox + c * (cellW + gap);
|
|
818
|
+
child.y = oy + r * (cellH + gap);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Returns visible children sorted by Pos.flex(order) ascending; children
|
|
823
|
+
* without Pos.flex default to order 0. Stable w.r.t. addChild insertion. */
|
|
824
|
+
private orderedVisibleChildren(): Window[] {
|
|
825
|
+
const visible = this.children.filter(c => c.visible);
|
|
826
|
+
const withIndex = visible.map((child, idx) => ({
|
|
827
|
+
child,
|
|
828
|
+
order: child.posSpec.getFlexOrder() ?? 0,
|
|
829
|
+
idx,
|
|
830
|
+
}));
|
|
831
|
+
withIndex.sort((a, b) => (a.order - b.order) || (a.idx - b.idx));
|
|
832
|
+
return withIndex.map(e => e.child);
|
|
833
|
+
}
|
|
834
|
+
|
|
435
835
|
/** Returns the flat index for (x, y) in the region buffer (used by getCell). */
|
|
436
836
|
private flatIndex(x: number, y: number): number {
|
|
437
837
|
return y * this.region.getSize().width + x;
|