take4-console 0.25.0 → 0.31.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 +253 -0
- package/README.md +3 -2
- package/dist/Screen/ErrorHolder.d.mts +10 -0
- package/dist/Screen/ErrorHolder.d.mts.map +1 -0
- package/dist/Screen/ErrorHolder.mjs +14 -0
- package/dist/Screen/ErrorHolder.mjs.map +1 -0
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +7 -0
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +90 -1
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +300 -1
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/StyleRegistry.d.mts.map +1 -1
- package/dist/Screen/StyleRegistry.mjs +3 -1
- package/dist/Screen/StyleRegistry.mjs.map +1 -1
- package/dist/Screen/VirtualCursor.d.mts +57 -0
- package/dist/Screen/VirtualCursor.d.mts.map +1 -0
- package/dist/Screen/VirtualCursor.mjs +148 -0
- package/dist/Screen/VirtualCursor.mjs.map +1 -0
- package/dist/Screen/Window.d.mts +116 -6
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +359 -34
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +78 -2
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +197 -3
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/BarChart.d.mts.map +1 -1
- package/dist/Screen/controls/BarChart.mjs +3 -0
- package/dist/Screen/controls/BarChart.mjs.map +1 -1
- package/dist/Screen/controls/Checkbox.d.mts.map +1 -1
- package/dist/Screen/controls/Checkbox.mjs +4 -0
- package/dist/Screen/controls/Checkbox.mjs.map +1 -1
- package/dist/Screen/controls/LineChart.d.mts.map +1 -1
- package/dist/Screen/controls/LineChart.mjs +3 -0
- package/dist/Screen/controls/LineChart.mjs.map +1 -1
- package/dist/Screen/controls/ListBox.d.mts.map +1 -1
- package/dist/Screen/controls/ListBox.mjs +9 -0
- package/dist/Screen/controls/ListBox.mjs.map +1 -1
- package/dist/Screen/controls/ProgressBar.d.mts.map +1 -1
- package/dist/Screen/controls/ProgressBar.mjs +2 -0
- package/dist/Screen/controls/ProgressBar.mjs.map +1 -1
- package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -1
- package/dist/Screen/controls/ProgressBarV.mjs +2 -0
- package/dist/Screen/controls/ProgressBarV.mjs.map +1 -1
- package/dist/Screen/controls/Radio.d.mts.map +1 -1
- package/dist/Screen/controls/Radio.mjs +7 -1
- package/dist/Screen/controls/Radio.mjs.map +1 -1
- package/dist/Screen/controls/Sparkline.d.mts.map +1 -1
- package/dist/Screen/controls/Sparkline.mjs +3 -0
- package/dist/Screen/controls/Sparkline.mjs.map +1 -1
- package/dist/Screen/controls/Spinner.d.mts.map +1 -1
- package/dist/Screen/controls/Spinner.mjs +8 -0
- package/dist/Screen/controls/Spinner.mjs.map +1 -1
- package/dist/Screen/controls/StatusLED.d.mts.map +1 -1
- package/dist/Screen/controls/StatusLED.mjs +3 -0
- package/dist/Screen/controls/StatusLED.mjs.map +1 -1
- package/dist/Screen/controls/Tabs.d.mts.map +1 -1
- package/dist/Screen/controls/Tabs.mjs +2 -0
- package/dist/Screen/controls/Tabs.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +68 -2
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +291 -46
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +52 -5
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +192 -10
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/controls/Toast.d.mts +72 -0
- package/dist/Screen/controls/Toast.d.mts.map +1 -0
- package/dist/Screen/controls/Toast.mjs +112 -0
- package/dist/Screen/controls/Toast.mjs.map +1 -0
- package/dist/Screen/types.d.mts +169 -0
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs +8 -0
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +4 -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/ErrorHolder.mts +22 -0
- package/src/Screen/InterfaceBuilder.mts +12 -5
- package/src/Screen/Screen.mts +313 -2
- package/src/Screen/StyleRegistry.mts +4 -0
- package/src/Screen/VirtualCursor.mts +175 -0
- package/src/Screen/Window.mts +352 -34
- package/src/Screen/WindowManager.mts +203 -3
- package/src/Screen/controls/BarChart.mts +3 -0
- package/src/Screen/controls/Checkbox.mts +3 -0
- package/src/Screen/controls/LineChart.mts +3 -0
- package/src/Screen/controls/ListBox.mts +8 -0
- package/src/Screen/controls/ProgressBar.mts +2 -0
- package/src/Screen/controls/ProgressBarV.mts +2 -0
- package/src/Screen/controls/Radio.mts +6 -1
- package/src/Screen/controls/Sparkline.mts +3 -0
- package/src/Screen/controls/Spinner.mts +6 -0
- package/src/Screen/controls/StatusLED.mts +2 -0
- package/src/Screen/controls/Tabs.mts +2 -0
- package/src/Screen/controls/TextArea.mts +290 -41
- package/src/Screen/controls/TextBox.mts +193 -10
- package/src/Screen/controls/Toast.mts +138 -0
- package/src/Screen/types.mts +167 -0
- package/src/demo.mts +131 -0
- package/src/index.mts +13 -0
- package/src/layout.yaml +16 -0
package/src/Screen/Window.mts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, DimSpec } from './types.mjs';
|
|
1
|
+
import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, Margin, MarginSpec, DimSpec, DirtyRect } 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 { getErrorHandler } from './ErrorHolder.mjs';
|
|
6
7
|
import { charWidth, stringWidth } from './textWidth.mjs';
|
|
7
8
|
import type { Pos } from './Pos.mjs';
|
|
8
9
|
import { Size } from './Size.mjs';
|
|
@@ -76,6 +77,23 @@ const resolvePadding = (spec: PaddingSpec | undefined): Padding => {
|
|
|
76
77
|
};
|
|
77
78
|
};
|
|
78
79
|
|
|
80
|
+
/** Normalises a MarginSpec to a full Margin record (missing sides → 0).
|
|
81
|
+
* Shares the PaddingSpec shape so the two helpers are interchangeable. */
|
|
82
|
+
const resolveMargin = (spec: MarginSpec | undefined): Margin => {
|
|
83
|
+
if (spec === undefined) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
84
|
+
if (typeof spec === 'number') return { top: spec, right: spec, bottom: spec, left: spec };
|
|
85
|
+
if (Array.isArray(spec)) {
|
|
86
|
+
const [v = 0, h = 0] = spec;
|
|
87
|
+
return { top: v, right: h, bottom: v, left: h };
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
top: spec.top ?? 0,
|
|
91
|
+
right: spec.right ?? 0,
|
|
92
|
+
bottom: spec.bottom ?? 0,
|
|
93
|
+
left: spec.left ?? 0,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
79
97
|
export class Window {
|
|
80
98
|
public x: number;
|
|
81
99
|
public y: number;
|
|
@@ -119,12 +137,44 @@ export class Window {
|
|
|
119
137
|
/** Padding applied inside the border (in addition to the border inset).
|
|
120
138
|
* Influences `getInnerSize()` / `getInnerOffset()`. */
|
|
121
139
|
private padding: Padding;
|
|
140
|
+
/** Outer spacing reserved around this window inside its parent's layout.
|
|
141
|
+
* Consumed by the parent's layout engine (absolute/flex/grid); does not
|
|
142
|
+
* influence this window's inner area. */
|
|
143
|
+
private margin: Margin;
|
|
122
144
|
/** Number of columns for `layout: 'grid'`. */
|
|
123
145
|
private gridColumns: number;
|
|
124
146
|
/** Cross-axis alignment for row/column layouts. */
|
|
125
147
|
private alignItems: AlignItems;
|
|
126
148
|
/** Main-axis distribution of leftover space when no flex-grow child consumes it. */
|
|
127
149
|
private justifyContent: JustifyContent;
|
|
150
|
+
/** Optional identifier — copied from WindowProperties.id. Used by
|
|
151
|
+
* `WindowManager.focusById` and by diagnostic tooling. */
|
|
152
|
+
private id: string | undefined;
|
|
153
|
+
/** Stacking order among siblings (higher z renders on top). Default: 0. */
|
|
154
|
+
private zIndex: number;
|
|
155
|
+
/** Fires once when focused state flips from false to true. */
|
|
156
|
+
private onFocusHandler: (() => void) | undefined;
|
|
157
|
+
/** Fires once when focused state flips from true to false. */
|
|
158
|
+
private onBlurHandler: (() => void) | undefined;
|
|
159
|
+
/** Parent window (set by `addChild`, cleared by `removeChild`). Damage
|
|
160
|
+
* tracking walks up this chain to notify the root `Screen` of geometry
|
|
161
|
+
* or topology changes that need a full repaint. */
|
|
162
|
+
protected parent: Window | null = null;
|
|
163
|
+
/** Accumulated dirty region in this window's local coordinates.
|
|
164
|
+
* - `null` — nothing changed since the last emit.
|
|
165
|
+
* - `'all'` — the whole window area needs to be re-emitted.
|
|
166
|
+
* - `DirtyRect` — bounding box of localised writes; `markDirty()` eagerly
|
|
167
|
+
* unions new rects so we only ever carry one rect per window.
|
|
168
|
+
* Windows start as `'all'` so the very first frame re-emits every cell. */
|
|
169
|
+
protected dirtyRect: DirtyRect | 'all' | null = 'all';
|
|
170
|
+
/** Depth of nested `Window.render()` calls currently executing. Increments
|
|
171
|
+
* at the top of every `render()` and decrements in a `finally`, so
|
|
172
|
+
* controls whose `render()` override re-writes content via `this.clear()`
|
|
173
|
+
* / `this.writeText(...)` do not re-mark themselves dirty every frame —
|
|
174
|
+
* render-time writes deterministically rebuild the display buffer from
|
|
175
|
+
* existing state, so marking them as "user-driven dirty" would defeat the
|
|
176
|
+
* "skip frame when nothing changed" optimisation. */
|
|
177
|
+
protected static renderingDepth: number = 0;
|
|
128
178
|
|
|
129
179
|
/** Creates a window from the given properties.
|
|
130
180
|
* For percentage-based sizes, call addChild() before writing content to the window.
|
|
@@ -150,9 +200,14 @@ export class Window {
|
|
|
150
200
|
this.layoutMode = wp.layout ?? 'absolute';
|
|
151
201
|
this.gap = wp.gap ?? 0;
|
|
152
202
|
this.padding = resolvePadding(wp.padding);
|
|
203
|
+
this.margin = resolveMargin(wp.margin);
|
|
153
204
|
this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
|
|
154
205
|
this.alignItems = wp.alignItems ?? 'stretch';
|
|
155
206
|
this.justifyContent = wp.justifyContent ?? 'start';
|
|
207
|
+
this.id = wp.id;
|
|
208
|
+
this.zIndex = wp.zIndex ?? 0;
|
|
209
|
+
this.onFocusHandler = wp.onFocus;
|
|
210
|
+
this.onBlurHandler = wp.onBlur;
|
|
156
211
|
|
|
157
212
|
const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
|
|
158
213
|
this.region = new Region(w, h);
|
|
@@ -173,6 +228,94 @@ export class Window {
|
|
|
173
228
|
return this.region.getSize();
|
|
174
229
|
}
|
|
175
230
|
|
|
231
|
+
// ── Damage tracking ─────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** Marks this window (or a sub-rectangle in its local coordinates) as
|
|
234
|
+
* dirty. Subsequent `Screen.render()` calls emit ANSI only for cells
|
|
235
|
+
* inside the collected dirty rects, skipping untouched regions. When
|
|
236
|
+
* `rect` is omitted, the entire window is flagged. Render-time writes
|
|
237
|
+
* are ignored so controls' `render()` overrides do not re-mark
|
|
238
|
+
* themselves every frame. Safe to call many times between renders —
|
|
239
|
+
* new rects are unioned into the existing bounding box. */
|
|
240
|
+
public markDirty(rect?: DirtyRect): void {
|
|
241
|
+
if (Window.renderingDepth > 0) return;
|
|
242
|
+
if (this.dirtyRect === 'all') return;
|
|
243
|
+
if (rect === undefined) { this.dirtyRect = 'all'; return; }
|
|
244
|
+
if (rect.w <= 0 || rect.h <= 0) return;
|
|
245
|
+
if (this.dirtyRect === null) {
|
|
246
|
+
this.dirtyRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const ax0 = this.dirtyRect.x;
|
|
250
|
+
const ay0 = this.dirtyRect.y;
|
|
251
|
+
const ax1 = ax0 + this.dirtyRect.w;
|
|
252
|
+
const ay1 = ay0 + this.dirtyRect.h;
|
|
253
|
+
const bx0 = rect.x;
|
|
254
|
+
const by0 = rect.y;
|
|
255
|
+
const bx1 = bx0 + rect.w;
|
|
256
|
+
const by1 = by0 + rect.h;
|
|
257
|
+
const nx0 = Math.min(ax0, bx0);
|
|
258
|
+
const ny0 = Math.min(ay0, by0);
|
|
259
|
+
const nx1 = Math.max(ax1, bx1);
|
|
260
|
+
const ny1 = Math.max(ay1, by1);
|
|
261
|
+
this.dirtyRect = { x: nx0, y: ny0, w: nx1 - nx0, h: ny1 - ny0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Convenience alias — flags the entire window as dirty. Equivalent to
|
|
265
|
+
* calling `markDirty()` with no argument; kept as a named entry point
|
|
266
|
+
* so custom controls can invalidate themselves without depending on
|
|
267
|
+
* the default-argument behaviour. */
|
|
268
|
+
public invalidate(): void {
|
|
269
|
+
this.markDirty();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Walks the parent chain up to the root window. Damage tracking uses
|
|
273
|
+
* this to bubble a full-invalidation signal to the root `Screen` when
|
|
274
|
+
* geometry or tree topology changes, because the cells previously
|
|
275
|
+
* occupied by a now-moved / resized / hidden window need to be repainted
|
|
276
|
+
* from the content underneath them. `Screen` overrides it to toggle its
|
|
277
|
+
* internal full-repaint flag; plain `Window`s forward the call up. */
|
|
278
|
+
protected markFullInvalidation(): void {
|
|
279
|
+
this.parent?.markFullInvalidation();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Appends this window's dirty rect (translated into screen coordinates
|
|
283
|
+
* via the running offset) and every descendant's dirty rects to `acc`.
|
|
284
|
+
* Hidden subtrees are skipped — invisible windows don't contribute to
|
|
285
|
+
* the emit phase, and any state change that flips visibility already
|
|
286
|
+
* escalated to a full invalidation so the vacated area repaints. */
|
|
287
|
+
protected collectDirtyRects(offsetX: number, offsetY: number, acc: DirtyRect[]): void {
|
|
288
|
+
if (!this.visible) return;
|
|
289
|
+
if (this.dirtyRect === 'all') {
|
|
290
|
+
const { width, height } = this.getSize();
|
|
291
|
+
acc.push({ x: offsetX, y: offsetY, w: width, h: height });
|
|
292
|
+
} else if (this.dirtyRect !== null) {
|
|
293
|
+
acc.push({
|
|
294
|
+
x: offsetX + this.dirtyRect.x,
|
|
295
|
+
y: offsetY + this.dirtyRect.y,
|
|
296
|
+
w: this.dirtyRect.w,
|
|
297
|
+
h: this.dirtyRect.h,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
for (const child of this.children) {
|
|
301
|
+
child.collectDirtyRects(offsetX + child.x, offsetY + child.y, acc);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Clears the dirty flag on this window and every descendant. Called by
|
|
306
|
+
* `Screen.render()` after the emit phase so the next frame starts from
|
|
307
|
+
* a clean slate. */
|
|
308
|
+
protected clearDirtyRecursive(): void {
|
|
309
|
+
this.dirtyRect = null;
|
|
310
|
+
for (const child of this.children) child.clearDirtyRecursive();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Returns a snapshot of the resolved per-side margin (in cells). The parent's
|
|
314
|
+
* layout engine reads this to reserve outer spacing around the child. */
|
|
315
|
+
public getMargin(): Readonly<Margin> {
|
|
316
|
+
return { ...this.margin };
|
|
317
|
+
}
|
|
318
|
+
|
|
176
319
|
/** Returns the number of cells consumed by decorations on each edge.
|
|
177
320
|
* The explicit 'none' border style is treated as no border (no insets). */
|
|
178
321
|
private borderInset(): { top: number; right: number; bottom: number; left: number } {
|
|
@@ -218,12 +361,70 @@ export class Window {
|
|
|
218
361
|
|
|
219
362
|
/** Sets the active state. Affects border and background appearance on next render(). */
|
|
220
363
|
public setActive(active: boolean): void {
|
|
364
|
+
if (this.active === active) return;
|
|
221
365
|
this.active = active;
|
|
366
|
+
this.markDirty();
|
|
222
367
|
}
|
|
223
368
|
|
|
224
|
-
/** Sets the focused state. Controls use this to change visual appearance on focus.
|
|
369
|
+
/** Sets the focused state. Controls use this to change visual appearance on focus.
|
|
370
|
+
* Fires `onFocus` / `onBlur` (configured via `WindowProperties`) when the state
|
|
371
|
+
* actually changes; a no-op call with the current value does not re-fire them. */
|
|
225
372
|
public setFocused(focused: boolean): void {
|
|
373
|
+
if (this.focused === focused) return;
|
|
226
374
|
this.focused = focused;
|
|
375
|
+
this.markDirty();
|
|
376
|
+
if (focused) this.onFocusHandler?.();
|
|
377
|
+
else this.onBlurHandler?.();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Registers (or replaces) the onFocus handler at runtime. Pass `undefined`
|
|
381
|
+
* to detach. Fires in `setFocused` on every false → true transition. */
|
|
382
|
+
public setOnFocus(handler: (() => void) | undefined): void {
|
|
383
|
+
this.onFocusHandler = handler;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Registers (or replaces) the onBlur handler at runtime. Pass `undefined`
|
|
387
|
+
* to detach. Fires in `setFocused` on every true → false transition. */
|
|
388
|
+
public setOnBlur(handler: (() => void) | undefined): void {
|
|
389
|
+
this.onBlurHandler = handler;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Returns the optional identifier set via WindowProperties.id. */
|
|
393
|
+
public getId(): string | undefined {
|
|
394
|
+
return this.id;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Sets (or clears with `undefined`) the window identifier used by
|
|
398
|
+
* `WindowManager.focusById` and diagnostic tooling. */
|
|
399
|
+
public setId(id: string | undefined): void {
|
|
400
|
+
this.id = id;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Returns the current stacking order. Higher values render on top of
|
|
404
|
+
* lower ones among siblings. Default: 0. */
|
|
405
|
+
public getZIndex(): number {
|
|
406
|
+
return this.zIndex;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Updates the stacking order. Siblings are re-sorted on the next render()
|
|
410
|
+
* call; call `screen.render()` (or the surrounding window) to see the
|
|
411
|
+
* change. Does not affect layout (flex/absolute coordinates are
|
|
412
|
+
* independent of z). */
|
|
413
|
+
public setZIndex(zIndex: number): void {
|
|
414
|
+
if (this.zIndex === zIndex) return;
|
|
415
|
+
this.zIndex = zIndex;
|
|
416
|
+
// Re-stacking can expose or occlude any cell inside the parent's
|
|
417
|
+
// bounds — escalate to a full repaint so the neighbour(s) underneath
|
|
418
|
+
// get re-emitted along with this window.
|
|
419
|
+
this.markFullInvalidation();
|
|
420
|
+
this.markDirty();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Returns the direct children of this window in insertion order (a
|
|
424
|
+
* read-only view). WindowManager uses this to walk subtrees for
|
|
425
|
+
* `trapFocus` and `focusById` without exposing the internal array. */
|
|
426
|
+
public getChildren(): readonly Window[] {
|
|
427
|
+
return this.children;
|
|
227
428
|
}
|
|
228
429
|
|
|
229
430
|
/** Returns whether this window currently has keyboard focus. */
|
|
@@ -233,6 +434,7 @@ export class Window {
|
|
|
233
434
|
|
|
234
435
|
/** Sets the disabled state and deactivates the window when disabled. */
|
|
235
436
|
public setDisabled(disabled: boolean): void {
|
|
437
|
+
if (this.disabled !== disabled) this.markDirty();
|
|
236
438
|
this.disabled = disabled;
|
|
237
439
|
this.setActive(!disabled);
|
|
238
440
|
}
|
|
@@ -248,7 +450,14 @@ export class Window {
|
|
|
248
450
|
* the content buffer — previously written cells reappear verbatim on the
|
|
249
451
|
* next render after the window is shown again. */
|
|
250
452
|
public setVisible(visible: boolean): void {
|
|
453
|
+
if (this.visible === visible) return;
|
|
251
454
|
this.visible = visible;
|
|
455
|
+
// Showing / hiding a window changes what the root Screen emits in this
|
|
456
|
+
// window's old bounds (the parent below may need to repaint, or the
|
|
457
|
+
// new reveal needs its first emit). Full invalidation is the
|
|
458
|
+
// conservative, always-correct choice.
|
|
459
|
+
this.markFullInvalidation();
|
|
460
|
+
this.markDirty();
|
|
252
461
|
}
|
|
253
462
|
|
|
254
463
|
/** Returns whether this window is currently visible. Default: true. */
|
|
@@ -258,7 +467,9 @@ export class Window {
|
|
|
258
467
|
|
|
259
468
|
/** Sets the label text displayed by the control. */
|
|
260
469
|
public setLabel(label: string): void {
|
|
470
|
+
if (this.label === label) return;
|
|
261
471
|
this.label = label;
|
|
472
|
+
this.markDirty();
|
|
262
473
|
}
|
|
263
474
|
|
|
264
475
|
/** Returns the current label text. */
|
|
@@ -270,6 +481,7 @@ export class Window {
|
|
|
270
481
|
* Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
|
|
271
482
|
protected updateBorder(border: WindowBorder | boolean | undefined): void {
|
|
272
483
|
this.border = resolveBorder(border);
|
|
484
|
+
this.markDirty();
|
|
273
485
|
}
|
|
274
486
|
|
|
275
487
|
/** Recomputes the border color from the current focused/disabled state.
|
|
@@ -292,7 +504,13 @@ export class Window {
|
|
|
292
504
|
* geometry consistent when more children join the stack. */
|
|
293
505
|
public addChild(child: Window): void {
|
|
294
506
|
this.children.push(child);
|
|
507
|
+
child.parent = this;
|
|
295
508
|
this.runLayout();
|
|
509
|
+
// Newly attached subtrees may overlap existing cells, and their own
|
|
510
|
+
// `dirtyRect` already defaults to `'all'` — but the parent region that
|
|
511
|
+
// may be exposed by later removal needs a correct baseline on the root
|
|
512
|
+
// Screen side, so we bubble a full invalidation for the first frame.
|
|
513
|
+
this.markFullInvalidation();
|
|
296
514
|
}
|
|
297
515
|
|
|
298
516
|
/** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
|
|
@@ -313,12 +531,14 @@ export class Window {
|
|
|
313
531
|
public setChar(x: number, y: number, char: string): void {
|
|
314
532
|
this.content.setChar(x, y, char);
|
|
315
533
|
this.region.setChar(x, y, char);
|
|
534
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
316
535
|
}
|
|
317
536
|
|
|
318
537
|
/** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
|
|
319
538
|
public setCell(x: number, y: number, char: string, styleId: StyleId = 0): void {
|
|
320
539
|
this.content.setCell(x, y, char, styleId);
|
|
321
540
|
this.region.setCell(x, y, char, styleId);
|
|
541
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
322
542
|
}
|
|
323
543
|
|
|
324
544
|
/** Merges the given style ID onto the existing style at (x, y) without changing the character.
|
|
@@ -328,18 +548,21 @@ export class Window {
|
|
|
328
548
|
const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
|
|
329
549
|
this.content.setStyleId(x, y, mergedContent);
|
|
330
550
|
this.region.setStyleId(x, y, mergedRegion);
|
|
551
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
331
552
|
}
|
|
332
553
|
|
|
333
554
|
/** Resets every cell to a blank space with style ID 0. */
|
|
334
555
|
public clear(): void {
|
|
335
556
|
this.content.clear();
|
|
336
557
|
this.region.clear();
|
|
558
|
+
this.markDirty();
|
|
337
559
|
}
|
|
338
560
|
|
|
339
561
|
/** Fills every cell with the given character and style ID. */
|
|
340
562
|
public fill(char: string, styleId: StyleId = 0): void {
|
|
341
563
|
this.content.fill(char, styleId);
|
|
342
564
|
this.region.fill(char, styleId);
|
|
565
|
+
this.markDirty();
|
|
343
566
|
}
|
|
344
567
|
|
|
345
568
|
/** Writes text into the window's content area starting at (x, y) (default 0, 0).
|
|
@@ -474,14 +697,62 @@ export class Window {
|
|
|
474
697
|
*/
|
|
475
698
|
public render(): void {
|
|
476
699
|
if (!this.visible) return;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
child.
|
|
700
|
+
Window.renderingDepth++;
|
|
701
|
+
try {
|
|
702
|
+
this.syncBorderColor();
|
|
703
|
+
this.paintBackground();
|
|
704
|
+
this.blitContent();
|
|
705
|
+
this.paintBorder();
|
|
706
|
+
for (const child of this.orderedByZ()) {
|
|
707
|
+
if (!child.visible) continue;
|
|
708
|
+
try {
|
|
709
|
+
child.render();
|
|
710
|
+
this.blitChild(child);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
this.paintErrorPlaceholder(child, err);
|
|
713
|
+
const handler = getErrorHandler();
|
|
714
|
+
if (handler) handler(err, child);
|
|
715
|
+
else throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} finally {
|
|
719
|
+
Window.renderingDepth--;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Returns direct children sorted for rendering — stable by (zIndex asc,
|
|
724
|
+
* insertion order). Children with higher zIndex paint last, so they
|
|
725
|
+
* appear on top of lower-z siblings. Layout is NOT affected: flex and
|
|
726
|
+
* absolute positioning run off the insertion-ordered `children` list. */
|
|
727
|
+
private orderedByZ(): Window[] {
|
|
728
|
+
if (this.children.length < 2) return this.children;
|
|
729
|
+
const stable = this.children.map((child, idx) => ({ child, idx }));
|
|
730
|
+
stable.sort((a, b) => (a.child.zIndex - b.child.zIndex) || (a.idx - b.idx));
|
|
731
|
+
return stable.map(e => e.child);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Renders a single-line "⚠ render error" marker over the child's
|
|
735
|
+
* pre-allocated region so a broken subtree never blanks the rest of the
|
|
736
|
+
* frame. The child is still blitted onto this window so its geometry
|
|
737
|
+
* (borders, siblings) remains visible in the debug layout. */
|
|
738
|
+
private paintErrorPlaceholder(child: Window, err: unknown): void {
|
|
739
|
+
const { width, height } = child.getSize();
|
|
740
|
+
if (width === 0 || height === 0) return;
|
|
741
|
+
const styleId = this.registry.register({ foreground: 196, background: 52, bold: true });
|
|
742
|
+
try {
|
|
743
|
+
child.region = new Region(width, height);
|
|
744
|
+
child.region.fill(' ', styleId);
|
|
745
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
746
|
+
const prefix = '⚠ render error: ';
|
|
747
|
+
const maxLen = Math.max(0, width - prefix.length);
|
|
748
|
+
const text = prefix + message.slice(0, maxLen);
|
|
749
|
+
const chars = [...text];
|
|
750
|
+
for (let i = 0; i < chars.length && i < width; i++) {
|
|
751
|
+
child.region.setCell(i, 0, chars[i]!, styleId);
|
|
752
|
+
}
|
|
484
753
|
this.blitChild(child);
|
|
754
|
+
} catch {
|
|
755
|
+
// Swallow — the placeholder itself must not throw further.
|
|
485
756
|
}
|
|
486
757
|
}
|
|
487
758
|
|
|
@@ -614,7 +885,13 @@ export class Window {
|
|
|
614
885
|
/** Removes a previously added child window. No-op if the child is not found. */
|
|
615
886
|
public removeChild(child: Window): void {
|
|
616
887
|
const idx = this.children.indexOf(child);
|
|
617
|
-
if (idx !== -1)
|
|
888
|
+
if (idx !== -1) {
|
|
889
|
+
this.children.splice(idx, 1);
|
|
890
|
+
child.parent = null;
|
|
891
|
+
// The vacated rectangle has to be re-emitted from the content
|
|
892
|
+
// underneath — escalate so the root Screen repaints everything.
|
|
893
|
+
this.markFullInvalidation();
|
|
894
|
+
}
|
|
618
895
|
}
|
|
619
896
|
|
|
620
897
|
/** Replaces both internal regions with new ones of the given dimensions,
|
|
@@ -622,6 +899,12 @@ export class Window {
|
|
|
622
899
|
protected resizeRegions(w: number, h: number): void {
|
|
623
900
|
this.region = new Region(w, h);
|
|
624
901
|
this.content = new Region(w, h);
|
|
902
|
+
// A new region invalidates any per-cell dirty bookkeeping from the
|
|
903
|
+
// previous size — escalate to a full-window repaint and ask the
|
|
904
|
+
// Screen to emit from scratch because the old footprint on stdout
|
|
905
|
+
// may include cells that are no longer part of this window.
|
|
906
|
+
this.dirtyRect = 'all';
|
|
907
|
+
this.markFullInvalidation();
|
|
625
908
|
this.reflowChildren();
|
|
626
909
|
}
|
|
627
910
|
|
|
@@ -657,7 +940,10 @@ export class Window {
|
|
|
657
940
|
}
|
|
658
941
|
|
|
659
942
|
/** Absolute layout: each child independently resolves its Pos/Size against
|
|
660
|
-
* the parent's inner area — the pre-flex behaviour.
|
|
943
|
+
* the parent's inner area — the pre-flex behaviour. A child's `margin`
|
|
944
|
+
* shifts the resolved position by `(marginLeft, marginTop)` without
|
|
945
|
+
* altering its size, so callers can push a window away from its declared
|
|
946
|
+
* anchor without recomputing coordinates by hand. */
|
|
661
947
|
private layoutAbsolute(pw: number, ph: number, ox: number, oy: number): void {
|
|
662
948
|
for (const child of this.children) {
|
|
663
949
|
if (!child.sizeSpec.isAbsolute()) {
|
|
@@ -666,8 +952,8 @@ export class Window {
|
|
|
666
952
|
}
|
|
667
953
|
const { width: cw, height: ch } = child.getSize();
|
|
668
954
|
const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
|
|
669
|
-
child.x = x + ox;
|
|
670
|
-
child.y = y + oy;
|
|
955
|
+
child.x = x + ox + child.margin.left;
|
|
956
|
+
child.y = y + oy + child.margin.top;
|
|
671
957
|
}
|
|
672
958
|
}
|
|
673
959
|
|
|
@@ -681,7 +967,13 @@ export class Window {
|
|
|
681
967
|
* distribution only when no child consumes slack via flex-grow. Invisible
|
|
682
968
|
* children are skipped so `setVisible(false)` effectively removes them
|
|
683
969
|
* from the stack. Children are ordered by `Pos.flex(order)` first, then
|
|
684
|
-
* by addChild insertion (stable).
|
|
970
|
+
* by addChild insertion (stable).
|
|
971
|
+
*
|
|
972
|
+
* Each child's `margin` reserves extra cells around it: the main axis
|
|
973
|
+
* loses `marginLeft+marginRight` (row) or `marginTop+marginBottom` (column)
|
|
974
|
+
* per child before distribution, and the cross axis loses `margin*` per
|
|
975
|
+
* child before alignment. Grow/shrink still apply to the inner size so the
|
|
976
|
+
* declared margin stays constant even when the child is flex-resized. */
|
|
685
977
|
private layoutFlex(mode: 'row' | 'column', pw: number, ph: number, ox: number, oy: number): void {
|
|
686
978
|
const ordered = this.orderedVisibleChildren();
|
|
687
979
|
if (ordered.length === 0) return;
|
|
@@ -690,7 +982,14 @@ export class Window {
|
|
|
690
982
|
const crossParent = isRow ? ph : pw;
|
|
691
983
|
const gap = this.gap;
|
|
692
984
|
|
|
693
|
-
//
|
|
985
|
+
// Per-child main/cross margin totals captured up-front so they stay
|
|
986
|
+
// constant across grow/shrink passes.
|
|
987
|
+
const mainMargin = (c: Window): number => isRow ? c.margin.left + c.margin.right : c.margin.top + c.margin.bottom;
|
|
988
|
+
const crossMargin = (c: Window): number => isRow ? c.margin.top + c.margin.bottom : c.margin.left + c.margin.right;
|
|
989
|
+
|
|
990
|
+
// First pass: basis sizes per child (inner, i.e. the child's own region
|
|
991
|
+
// without margin). Natural size is taken before margin so content-sized
|
|
992
|
+
// children keep their intrinsic footprint.
|
|
694
993
|
const items = ordered.map((c): FlexItem => {
|
|
695
994
|
const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
|
|
696
995
|
const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
|
|
@@ -703,10 +1002,13 @@ export class Window {
|
|
|
703
1002
|
};
|
|
704
1003
|
});
|
|
705
1004
|
|
|
706
|
-
// Distribute leftover main-axis space.
|
|
1005
|
+
// Distribute leftover main-axis space. The parent sees each child's
|
|
1006
|
+
// slot as `mainSize + mainMargin`, so margins are charged against the
|
|
1007
|
+
// remaining space before grow/shrink.
|
|
707
1008
|
const totalGap = Math.max(0, items.length - 1) * gap;
|
|
1009
|
+
const totalMargin = items.reduce((s, it) => s + mainMargin(it.child), 0);
|
|
708
1010
|
const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
|
|
709
|
-
let remainder = mainParent - totalMain - totalGap;
|
|
1011
|
+
let remainder = mainParent - totalMain - totalMargin - totalGap;
|
|
710
1012
|
const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
|
|
711
1013
|
if (remainder > 0 && totalGrow > 0) {
|
|
712
1014
|
let distributed = 0;
|
|
@@ -733,17 +1035,21 @@ export class Window {
|
|
|
733
1035
|
it.mainSize = Math.max(0, it.mainSize - take);
|
|
734
1036
|
}
|
|
735
1037
|
const consumed = items.reduce((s, it) => s + it.mainSize, 0);
|
|
736
|
-
remainder = mainParent - consumed - totalGap;
|
|
1038
|
+
remainder = mainParent - consumed - totalMargin - totalGap;
|
|
737
1039
|
}
|
|
738
1040
|
}
|
|
739
1041
|
|
|
740
|
-
// Apply cross-axis stretch where allowed.
|
|
1042
|
+
// Apply cross-axis stretch where allowed. Cross-axis margin is charged
|
|
1043
|
+
// before stretch/clamp so a stretched child + its margin fits within
|
|
1044
|
+
// crossParent.
|
|
741
1045
|
for (const it of items) {
|
|
1046
|
+
const cm = crossMargin(it.child);
|
|
1047
|
+
const available = Math.max(0, crossParent - cm);
|
|
742
1048
|
const mode = it.crossSpec.mode;
|
|
743
1049
|
if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
|
|
744
|
-
it.crossSize =
|
|
1050
|
+
it.crossSize = available;
|
|
745
1051
|
} else {
|
|
746
|
-
it.crossSize = Math.min(it.crossSize,
|
|
1052
|
+
it.crossSize = Math.min(it.crossSize, available);
|
|
747
1053
|
}
|
|
748
1054
|
}
|
|
749
1055
|
|
|
@@ -767,7 +1073,10 @@ export class Window {
|
|
|
767
1073
|
}
|
|
768
1074
|
}
|
|
769
1075
|
|
|
770
|
-
// Write final sizes and positions.
|
|
1076
|
+
// Write final sizes and positions. The child's main-axis starting
|
|
1077
|
+
// coordinate is `cursor + marginLeading`; cursor then advances by
|
|
1078
|
+
// `mainSize + mainMargin + itemSpacing` so the next slot is offset by
|
|
1079
|
+
// this child's full footprint.
|
|
771
1080
|
let cursor = mainStart;
|
|
772
1081
|
for (const it of items) {
|
|
773
1082
|
const mainSize = Math.max(0, it.mainSize);
|
|
@@ -777,28 +1086,34 @@ export class Window {
|
|
|
777
1086
|
if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
|
|
778
1087
|
it.child.resizeRegions(newW, newH);
|
|
779
1088
|
}
|
|
1089
|
+
const m = it.child.margin;
|
|
1090
|
+
const mainLead = isRow ? m.left : m.top;
|
|
1091
|
+
const mainTrail = isRow ? m.right : m.bottom;
|
|
1092
|
+
const crossLead = isRow ? m.top : m.left;
|
|
1093
|
+
const cm = crossMargin(it.child);
|
|
780
1094
|
let crossPos = 0;
|
|
781
1095
|
if (this.alignItems !== 'stretch') {
|
|
782
|
-
const free = crossParent - crossSize;
|
|
1096
|
+
const free = Math.max(0, crossParent - crossSize - cm);
|
|
783
1097
|
if (this.alignItems === 'center') crossPos = Math.floor(free / 2);
|
|
784
1098
|
else if (this.alignItems === 'end') crossPos = free;
|
|
785
1099
|
}
|
|
786
1100
|
if (isRow) {
|
|
787
|
-
it.child.x = ox + cursor;
|
|
788
|
-
it.child.y = oy + crossPos;
|
|
1101
|
+
it.child.x = ox + cursor + mainLead;
|
|
1102
|
+
it.child.y = oy + crossPos + crossLead;
|
|
789
1103
|
} else {
|
|
790
|
-
it.child.x = ox + crossPos;
|
|
791
|
-
it.child.y = oy + cursor;
|
|
1104
|
+
it.child.x = ox + crossPos + crossLead;
|
|
1105
|
+
it.child.y = oy + cursor + mainLead;
|
|
792
1106
|
}
|
|
793
|
-
cursor += mainSize + itemSpacing;
|
|
1107
|
+
cursor += mainSize + mainLead + mainTrail + itemSpacing;
|
|
794
1108
|
}
|
|
795
1109
|
}
|
|
796
1110
|
|
|
797
1111
|
/** Grid layout: children are placed row-major into equally sized cells.
|
|
798
1112
|
* Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
|
|
799
1113
|
* heights use the same formula with the derived row count. Children are
|
|
800
|
-
* resized to the cell dimensions
|
|
801
|
-
*
|
|
1114
|
+
* resized to the cell dimensions minus their own margin and positioned
|
|
1115
|
+
* at the cell's top-left offset by `(marginLeft, marginTop)`. Invisible
|
|
1116
|
+
* children are skipped. */
|
|
802
1117
|
private layoutGrid(pw: number, ph: number, ox: number, oy: number): void {
|
|
803
1118
|
const ordered = this.orderedVisibleChildren();
|
|
804
1119
|
if (ordered.length === 0) return;
|
|
@@ -811,11 +1126,14 @@ export class Window {
|
|
|
811
1126
|
const child = ordered[i]!;
|
|
812
1127
|
const c = i % cols;
|
|
813
1128
|
const r = Math.floor(i / cols);
|
|
814
|
-
|
|
815
|
-
|
|
1129
|
+
const m = child.margin;
|
|
1130
|
+
const innerW = Math.max(0, cellW - m.left - m.right);
|
|
1131
|
+
const innerH = Math.max(0, cellH - m.top - m.bottom);
|
|
1132
|
+
if (innerW !== child.getSize().width || innerH !== child.getSize().height) {
|
|
1133
|
+
child.resizeRegions(innerW, innerH);
|
|
816
1134
|
}
|
|
817
|
-
child.x = ox + c * (cellW + gap);
|
|
818
|
-
child.y = oy + r * (cellH + gap);
|
|
1135
|
+
child.x = ox + c * (cellW + gap) + m.left;
|
|
1136
|
+
child.y = oy + r * (cellH + gap) + m.top;
|
|
819
1137
|
}
|
|
820
1138
|
}
|
|
821
1139
|
|