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/dist/Screen/Window.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { getErrorHandler } from './ErrorHolder.mjs';
|
|
4
5
|
import { charWidth, stringWidth } from './textWidth.mjs';
|
|
5
6
|
import { Size } from './Size.mjs';
|
|
6
7
|
/** Glyph table for each visual border style. The 'none' style is handled
|
|
@@ -63,6 +64,24 @@ const resolvePadding = (spec) => {
|
|
|
63
64
|
left: spec.left ?? 0,
|
|
64
65
|
};
|
|
65
66
|
};
|
|
67
|
+
/** Normalises a MarginSpec to a full Margin record (missing sides → 0).
|
|
68
|
+
* Shares the PaddingSpec shape so the two helpers are interchangeable. */
|
|
69
|
+
const resolveMargin = (spec) => {
|
|
70
|
+
if (spec === undefined)
|
|
71
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
72
|
+
if (typeof spec === 'number')
|
|
73
|
+
return { top: spec, right: spec, bottom: spec, left: spec };
|
|
74
|
+
if (Array.isArray(spec)) {
|
|
75
|
+
const [v = 0, h = 0] = spec;
|
|
76
|
+
return { top: v, right: h, bottom: v, left: h };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
top: spec.top ?? 0,
|
|
80
|
+
right: spec.right ?? 0,
|
|
81
|
+
bottom: spec.bottom ?? 0,
|
|
82
|
+
left: spec.left ?? 0,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
66
85
|
export class Window {
|
|
67
86
|
x;
|
|
68
87
|
y;
|
|
@@ -105,12 +124,44 @@ export class Window {
|
|
|
105
124
|
/** Padding applied inside the border (in addition to the border inset).
|
|
106
125
|
* Influences `getInnerSize()` / `getInnerOffset()`. */
|
|
107
126
|
padding;
|
|
127
|
+
/** Outer spacing reserved around this window inside its parent's layout.
|
|
128
|
+
* Consumed by the parent's layout engine (absolute/flex/grid); does not
|
|
129
|
+
* influence this window's inner area. */
|
|
130
|
+
margin;
|
|
108
131
|
/** Number of columns for `layout: 'grid'`. */
|
|
109
132
|
gridColumns;
|
|
110
133
|
/** Cross-axis alignment for row/column layouts. */
|
|
111
134
|
alignItems;
|
|
112
135
|
/** Main-axis distribution of leftover space when no flex-grow child consumes it. */
|
|
113
136
|
justifyContent;
|
|
137
|
+
/** Optional identifier — copied from WindowProperties.id. Used by
|
|
138
|
+
* `WindowManager.focusById` and by diagnostic tooling. */
|
|
139
|
+
id;
|
|
140
|
+
/** Stacking order among siblings (higher z renders on top). Default: 0. */
|
|
141
|
+
zIndex;
|
|
142
|
+
/** Fires once when focused state flips from false to true. */
|
|
143
|
+
onFocusHandler;
|
|
144
|
+
/** Fires once when focused state flips from true to false. */
|
|
145
|
+
onBlurHandler;
|
|
146
|
+
/** Parent window (set by `addChild`, cleared by `removeChild`). Damage
|
|
147
|
+
* tracking walks up this chain to notify the root `Screen` of geometry
|
|
148
|
+
* or topology changes that need a full repaint. */
|
|
149
|
+
parent = null;
|
|
150
|
+
/** Accumulated dirty region in this window's local coordinates.
|
|
151
|
+
* - `null` — nothing changed since the last emit.
|
|
152
|
+
* - `'all'` — the whole window area needs to be re-emitted.
|
|
153
|
+
* - `DirtyRect` — bounding box of localised writes; `markDirty()` eagerly
|
|
154
|
+
* unions new rects so we only ever carry one rect per window.
|
|
155
|
+
* Windows start as `'all'` so the very first frame re-emits every cell. */
|
|
156
|
+
dirtyRect = 'all';
|
|
157
|
+
/** Depth of nested `Window.render()` calls currently executing. Increments
|
|
158
|
+
* at the top of every `render()` and decrements in a `finally`, so
|
|
159
|
+
* controls whose `render()` override re-writes content via `this.clear()`
|
|
160
|
+
* / `this.writeText(...)` do not re-mark themselves dirty every frame —
|
|
161
|
+
* render-time writes deterministically rebuild the display buffer from
|
|
162
|
+
* existing state, so marking them as "user-driven dirty" would defeat the
|
|
163
|
+
* "skip frame when nothing changed" optimisation. */
|
|
164
|
+
static renderingDepth = 0;
|
|
114
165
|
/** Creates a window from the given properties.
|
|
115
166
|
* For percentage-based sizes, call addChild() before writing content to the window.
|
|
116
167
|
* Uses the global StyleRegistry set by the Screen constructor. */
|
|
@@ -134,9 +185,14 @@ export class Window {
|
|
|
134
185
|
this.layoutMode = wp.layout ?? 'absolute';
|
|
135
186
|
this.gap = wp.gap ?? 0;
|
|
136
187
|
this.padding = resolvePadding(wp.padding);
|
|
188
|
+
this.margin = resolveMargin(wp.margin);
|
|
137
189
|
this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
|
|
138
190
|
this.alignItems = wp.alignItems ?? 'stretch';
|
|
139
191
|
this.justifyContent = wp.justifyContent ?? 'start';
|
|
192
|
+
this.id = wp.id;
|
|
193
|
+
this.zIndex = wp.zIndex ?? 0;
|
|
194
|
+
this.onFocusHandler = wp.onFocus;
|
|
195
|
+
this.onBlurHandler = wp.onBlur;
|
|
140
196
|
const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
|
|
141
197
|
this.region = new Region(w, h);
|
|
142
198
|
this.content = new Region(w, h);
|
|
@@ -154,6 +210,96 @@ export class Window {
|
|
|
154
210
|
getSize() {
|
|
155
211
|
return this.region.getSize();
|
|
156
212
|
}
|
|
213
|
+
// ── Damage tracking ─────────────────────────────────────────────────────
|
|
214
|
+
/** Marks this window (or a sub-rectangle in its local coordinates) as
|
|
215
|
+
* dirty. Subsequent `Screen.render()` calls emit ANSI only for cells
|
|
216
|
+
* inside the collected dirty rects, skipping untouched regions. When
|
|
217
|
+
* `rect` is omitted, the entire window is flagged. Render-time writes
|
|
218
|
+
* are ignored so controls' `render()` overrides do not re-mark
|
|
219
|
+
* themselves every frame. Safe to call many times between renders —
|
|
220
|
+
* new rects are unioned into the existing bounding box. */
|
|
221
|
+
markDirty(rect) {
|
|
222
|
+
if (Window.renderingDepth > 0)
|
|
223
|
+
return;
|
|
224
|
+
if (this.dirtyRect === 'all')
|
|
225
|
+
return;
|
|
226
|
+
if (rect === undefined) {
|
|
227
|
+
this.dirtyRect = 'all';
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (rect.w <= 0 || rect.h <= 0)
|
|
231
|
+
return;
|
|
232
|
+
if (this.dirtyRect === null) {
|
|
233
|
+
this.dirtyRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const ax0 = this.dirtyRect.x;
|
|
237
|
+
const ay0 = this.dirtyRect.y;
|
|
238
|
+
const ax1 = ax0 + this.dirtyRect.w;
|
|
239
|
+
const ay1 = ay0 + this.dirtyRect.h;
|
|
240
|
+
const bx0 = rect.x;
|
|
241
|
+
const by0 = rect.y;
|
|
242
|
+
const bx1 = bx0 + rect.w;
|
|
243
|
+
const by1 = by0 + rect.h;
|
|
244
|
+
const nx0 = Math.min(ax0, bx0);
|
|
245
|
+
const ny0 = Math.min(ay0, by0);
|
|
246
|
+
const nx1 = Math.max(ax1, bx1);
|
|
247
|
+
const ny1 = Math.max(ay1, by1);
|
|
248
|
+
this.dirtyRect = { x: nx0, y: ny0, w: nx1 - nx0, h: ny1 - ny0 };
|
|
249
|
+
}
|
|
250
|
+
/** Convenience alias — flags the entire window as dirty. Equivalent to
|
|
251
|
+
* calling `markDirty()` with no argument; kept as a named entry point
|
|
252
|
+
* so custom controls can invalidate themselves without depending on
|
|
253
|
+
* the default-argument behaviour. */
|
|
254
|
+
invalidate() {
|
|
255
|
+
this.markDirty();
|
|
256
|
+
}
|
|
257
|
+
/** Walks the parent chain up to the root window. Damage tracking uses
|
|
258
|
+
* this to bubble a full-invalidation signal to the root `Screen` when
|
|
259
|
+
* geometry or tree topology changes, because the cells previously
|
|
260
|
+
* occupied by a now-moved / resized / hidden window need to be repainted
|
|
261
|
+
* from the content underneath them. `Screen` overrides it to toggle its
|
|
262
|
+
* internal full-repaint flag; plain `Window`s forward the call up. */
|
|
263
|
+
markFullInvalidation() {
|
|
264
|
+
this.parent?.markFullInvalidation();
|
|
265
|
+
}
|
|
266
|
+
/** Appends this window's dirty rect (translated into screen coordinates
|
|
267
|
+
* via the running offset) and every descendant's dirty rects to `acc`.
|
|
268
|
+
* Hidden subtrees are skipped — invisible windows don't contribute to
|
|
269
|
+
* the emit phase, and any state change that flips visibility already
|
|
270
|
+
* escalated to a full invalidation so the vacated area repaints. */
|
|
271
|
+
collectDirtyRects(offsetX, offsetY, acc) {
|
|
272
|
+
if (!this.visible)
|
|
273
|
+
return;
|
|
274
|
+
if (this.dirtyRect === 'all') {
|
|
275
|
+
const { width, height } = this.getSize();
|
|
276
|
+
acc.push({ x: offsetX, y: offsetY, w: width, h: height });
|
|
277
|
+
}
|
|
278
|
+
else if (this.dirtyRect !== null) {
|
|
279
|
+
acc.push({
|
|
280
|
+
x: offsetX + this.dirtyRect.x,
|
|
281
|
+
y: offsetY + this.dirtyRect.y,
|
|
282
|
+
w: this.dirtyRect.w,
|
|
283
|
+
h: this.dirtyRect.h,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
for (const child of this.children) {
|
|
287
|
+
child.collectDirtyRects(offsetX + child.x, offsetY + child.y, acc);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Clears the dirty flag on this window and every descendant. Called by
|
|
291
|
+
* `Screen.render()` after the emit phase so the next frame starts from
|
|
292
|
+
* a clean slate. */
|
|
293
|
+
clearDirtyRecursive() {
|
|
294
|
+
this.dirtyRect = null;
|
|
295
|
+
for (const child of this.children)
|
|
296
|
+
child.clearDirtyRecursive();
|
|
297
|
+
}
|
|
298
|
+
/** Returns a snapshot of the resolved per-side margin (in cells). The parent's
|
|
299
|
+
* layout engine reads this to reserve outer spacing around the child. */
|
|
300
|
+
getMargin() {
|
|
301
|
+
return { ...this.margin };
|
|
302
|
+
}
|
|
157
303
|
/** Returns the number of cells consumed by decorations on each edge.
|
|
158
304
|
* The explicit 'none' border style is treated as no border (no insets). */
|
|
159
305
|
borderInset() {
|
|
@@ -196,11 +342,67 @@ export class Window {
|
|
|
196
342
|
}
|
|
197
343
|
/** Sets the active state. Affects border and background appearance on next render(). */
|
|
198
344
|
setActive(active) {
|
|
345
|
+
if (this.active === active)
|
|
346
|
+
return;
|
|
199
347
|
this.active = active;
|
|
348
|
+
this.markDirty();
|
|
200
349
|
}
|
|
201
|
-
/** Sets the focused state. Controls use this to change visual appearance on focus.
|
|
350
|
+
/** Sets the focused state. Controls use this to change visual appearance on focus.
|
|
351
|
+
* Fires `onFocus` / `onBlur` (configured via `WindowProperties`) when the state
|
|
352
|
+
* actually changes; a no-op call with the current value does not re-fire them. */
|
|
202
353
|
setFocused(focused) {
|
|
354
|
+
if (this.focused === focused)
|
|
355
|
+
return;
|
|
203
356
|
this.focused = focused;
|
|
357
|
+
this.markDirty();
|
|
358
|
+
if (focused)
|
|
359
|
+
this.onFocusHandler?.();
|
|
360
|
+
else
|
|
361
|
+
this.onBlurHandler?.();
|
|
362
|
+
}
|
|
363
|
+
/** Registers (or replaces) the onFocus handler at runtime. Pass `undefined`
|
|
364
|
+
* to detach. Fires in `setFocused` on every false → true transition. */
|
|
365
|
+
setOnFocus(handler) {
|
|
366
|
+
this.onFocusHandler = handler;
|
|
367
|
+
}
|
|
368
|
+
/** Registers (or replaces) the onBlur handler at runtime. Pass `undefined`
|
|
369
|
+
* to detach. Fires in `setFocused` on every true → false transition. */
|
|
370
|
+
setOnBlur(handler) {
|
|
371
|
+
this.onBlurHandler = handler;
|
|
372
|
+
}
|
|
373
|
+
/** Returns the optional identifier set via WindowProperties.id. */
|
|
374
|
+
getId() {
|
|
375
|
+
return this.id;
|
|
376
|
+
}
|
|
377
|
+
/** Sets (or clears with `undefined`) the window identifier used by
|
|
378
|
+
* `WindowManager.focusById` and diagnostic tooling. */
|
|
379
|
+
setId(id) {
|
|
380
|
+
this.id = id;
|
|
381
|
+
}
|
|
382
|
+
/** Returns the current stacking order. Higher values render on top of
|
|
383
|
+
* lower ones among siblings. Default: 0. */
|
|
384
|
+
getZIndex() {
|
|
385
|
+
return this.zIndex;
|
|
386
|
+
}
|
|
387
|
+
/** Updates the stacking order. Siblings are re-sorted on the next render()
|
|
388
|
+
* call; call `screen.render()` (or the surrounding window) to see the
|
|
389
|
+
* change. Does not affect layout (flex/absolute coordinates are
|
|
390
|
+
* independent of z). */
|
|
391
|
+
setZIndex(zIndex) {
|
|
392
|
+
if (this.zIndex === zIndex)
|
|
393
|
+
return;
|
|
394
|
+
this.zIndex = zIndex;
|
|
395
|
+
// Re-stacking can expose or occlude any cell inside the parent's
|
|
396
|
+
// bounds — escalate to a full repaint so the neighbour(s) underneath
|
|
397
|
+
// get re-emitted along with this window.
|
|
398
|
+
this.markFullInvalidation();
|
|
399
|
+
this.markDirty();
|
|
400
|
+
}
|
|
401
|
+
/** Returns the direct children of this window in insertion order (a
|
|
402
|
+
* read-only view). WindowManager uses this to walk subtrees for
|
|
403
|
+
* `trapFocus` and `focusById` without exposing the internal array. */
|
|
404
|
+
getChildren() {
|
|
405
|
+
return this.children;
|
|
204
406
|
}
|
|
205
407
|
/** Returns whether this window currently has keyboard focus. */
|
|
206
408
|
isFocused() {
|
|
@@ -208,6 +410,8 @@ export class Window {
|
|
|
208
410
|
}
|
|
209
411
|
/** Sets the disabled state and deactivates the window when disabled. */
|
|
210
412
|
setDisabled(disabled) {
|
|
413
|
+
if (this.disabled !== disabled)
|
|
414
|
+
this.markDirty();
|
|
211
415
|
this.disabled = disabled;
|
|
212
416
|
this.setActive(!disabled);
|
|
213
417
|
}
|
|
@@ -221,7 +425,15 @@ export class Window {
|
|
|
221
425
|
* the content buffer — previously written cells reappear verbatim on the
|
|
222
426
|
* next render after the window is shown again. */
|
|
223
427
|
setVisible(visible) {
|
|
428
|
+
if (this.visible === visible)
|
|
429
|
+
return;
|
|
224
430
|
this.visible = visible;
|
|
431
|
+
// Showing / hiding a window changes what the root Screen emits in this
|
|
432
|
+
// window's old bounds (the parent below may need to repaint, or the
|
|
433
|
+
// new reveal needs its first emit). Full invalidation is the
|
|
434
|
+
// conservative, always-correct choice.
|
|
435
|
+
this.markFullInvalidation();
|
|
436
|
+
this.markDirty();
|
|
225
437
|
}
|
|
226
438
|
/** Returns whether this window is currently visible. Default: true. */
|
|
227
439
|
isVisible() {
|
|
@@ -229,7 +441,10 @@ export class Window {
|
|
|
229
441
|
}
|
|
230
442
|
/** Sets the label text displayed by the control. */
|
|
231
443
|
setLabel(label) {
|
|
444
|
+
if (this.label === label)
|
|
445
|
+
return;
|
|
232
446
|
this.label = label;
|
|
447
|
+
this.markDirty();
|
|
233
448
|
}
|
|
234
449
|
/** Returns the current label text. */
|
|
235
450
|
getLabel() {
|
|
@@ -239,6 +454,7 @@ export class Window {
|
|
|
239
454
|
* Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
|
|
240
455
|
updateBorder(border) {
|
|
241
456
|
this.border = resolveBorder(border);
|
|
457
|
+
this.markDirty();
|
|
242
458
|
}
|
|
243
459
|
/** Recomputes the border color from the current focused/disabled state.
|
|
244
460
|
* Called automatically at the start of render() so subclasses never need to do it manually.
|
|
@@ -260,7 +476,13 @@ export class Window {
|
|
|
260
476
|
* geometry consistent when more children join the stack. */
|
|
261
477
|
addChild(child) {
|
|
262
478
|
this.children.push(child);
|
|
479
|
+
child.parent = this;
|
|
263
480
|
this.runLayout();
|
|
481
|
+
// Newly attached subtrees may overlap existing cells, and their own
|
|
482
|
+
// `dirtyRect` already defaults to `'all'` — but the parent region that
|
|
483
|
+
// may be exposed by later removal needs a correct baseline on the root
|
|
484
|
+
// Screen side, so we bubble a full invalidation for the first frame.
|
|
485
|
+
this.markFullInvalidation();
|
|
264
486
|
}
|
|
265
487
|
/** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
|
|
266
488
|
* Throws RangeError if out of bounds. Throws Error when the window is
|
|
@@ -279,11 +501,13 @@ export class Window {
|
|
|
279
501
|
setChar(x, y, char) {
|
|
280
502
|
this.content.setChar(x, y, char);
|
|
281
503
|
this.region.setChar(x, y, char);
|
|
504
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
282
505
|
}
|
|
283
506
|
/** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
|
|
284
507
|
setCell(x, y, char, styleId = 0) {
|
|
285
508
|
this.content.setCell(x, y, char, styleId);
|
|
286
509
|
this.region.setCell(x, y, char, styleId);
|
|
510
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
287
511
|
}
|
|
288
512
|
/** Merges the given style ID onto the existing style at (x, y) without changing the character.
|
|
289
513
|
* Throws RangeError if out of bounds. */
|
|
@@ -292,16 +516,19 @@ export class Window {
|
|
|
292
516
|
const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
|
|
293
517
|
this.content.setStyleId(x, y, mergedContent);
|
|
294
518
|
this.region.setStyleId(x, y, mergedRegion);
|
|
519
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
295
520
|
}
|
|
296
521
|
/** Resets every cell to a blank space with style ID 0. */
|
|
297
522
|
clear() {
|
|
298
523
|
this.content.clear();
|
|
299
524
|
this.region.clear();
|
|
525
|
+
this.markDirty();
|
|
300
526
|
}
|
|
301
527
|
/** Fills every cell with the given character and style ID. */
|
|
302
528
|
fill(char, styleId = 0) {
|
|
303
529
|
this.content.fill(char, styleId);
|
|
304
530
|
this.region.fill(char, styleId);
|
|
531
|
+
this.markDirty();
|
|
305
532
|
}
|
|
306
533
|
/** Writes text into the window's content area starting at (x, y) (default 0, 0).
|
|
307
534
|
* Coordinates are relative to the inner content area (i.e. decorations such as borders are excluded).
|
|
@@ -447,16 +674,69 @@ export class Window {
|
|
|
447
674
|
render() {
|
|
448
675
|
if (!this.visible)
|
|
449
676
|
return;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
677
|
+
Window.renderingDepth++;
|
|
678
|
+
try {
|
|
679
|
+
this.syncBorderColor();
|
|
680
|
+
this.paintBackground();
|
|
681
|
+
this.blitContent();
|
|
682
|
+
this.paintBorder();
|
|
683
|
+
for (const child of this.orderedByZ()) {
|
|
684
|
+
if (!child.visible)
|
|
685
|
+
continue;
|
|
686
|
+
try {
|
|
687
|
+
child.render();
|
|
688
|
+
this.blitChild(child);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
this.paintErrorPlaceholder(child, err);
|
|
692
|
+
const handler = getErrorHandler();
|
|
693
|
+
if (handler)
|
|
694
|
+
handler(err, child);
|
|
695
|
+
else
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
Window.renderingDepth--;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** Returns direct children sorted for rendering — stable by (zIndex asc,
|
|
705
|
+
* insertion order). Children with higher zIndex paint last, so they
|
|
706
|
+
* appear on top of lower-z siblings. Layout is NOT affected: flex and
|
|
707
|
+
* absolute positioning run off the insertion-ordered `children` list. */
|
|
708
|
+
orderedByZ() {
|
|
709
|
+
if (this.children.length < 2)
|
|
710
|
+
return this.children;
|
|
711
|
+
const stable = this.children.map((child, idx) => ({ child, idx }));
|
|
712
|
+
stable.sort((a, b) => (a.child.zIndex - b.child.zIndex) || (a.idx - b.idx));
|
|
713
|
+
return stable.map(e => e.child);
|
|
714
|
+
}
|
|
715
|
+
/** Renders a single-line "⚠ render error" marker over the child's
|
|
716
|
+
* pre-allocated region so a broken subtree never blanks the rest of the
|
|
717
|
+
* frame. The child is still blitted onto this window so its geometry
|
|
718
|
+
* (borders, siblings) remains visible in the debug layout. */
|
|
719
|
+
paintErrorPlaceholder(child, err) {
|
|
720
|
+
const { width, height } = child.getSize();
|
|
721
|
+
if (width === 0 || height === 0)
|
|
722
|
+
return;
|
|
723
|
+
const styleId = this.registry.register({ foreground: 196, background: 52, bold: true });
|
|
724
|
+
try {
|
|
725
|
+
child.region = new Region(width, height);
|
|
726
|
+
child.region.fill(' ', styleId);
|
|
727
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
728
|
+
const prefix = '⚠ render error: ';
|
|
729
|
+
const maxLen = Math.max(0, width - prefix.length);
|
|
730
|
+
const text = prefix + message.slice(0, maxLen);
|
|
731
|
+
const chars = [...text];
|
|
732
|
+
for (let i = 0; i < chars.length && i < width; i++) {
|
|
733
|
+
child.region.setCell(i, 0, chars[i], styleId);
|
|
734
|
+
}
|
|
458
735
|
this.blitChild(child);
|
|
459
736
|
}
|
|
737
|
+
catch {
|
|
738
|
+
// Swallow — the placeholder itself must not throw further.
|
|
739
|
+
}
|
|
460
740
|
}
|
|
461
741
|
/** Fills the display buffer with the background style. When inactive, adds dim to every cell.
|
|
462
742
|
* No-op when background is 0 (transparent). */
|
|
@@ -588,14 +868,25 @@ export class Window {
|
|
|
588
868
|
/** Removes a previously added child window. No-op if the child is not found. */
|
|
589
869
|
removeChild(child) {
|
|
590
870
|
const idx = this.children.indexOf(child);
|
|
591
|
-
if (idx !== -1)
|
|
871
|
+
if (idx !== -1) {
|
|
592
872
|
this.children.splice(idx, 1);
|
|
873
|
+
child.parent = null;
|
|
874
|
+
// The vacated rectangle has to be re-emitted from the content
|
|
875
|
+
// underneath — escalate so the root Screen repaints everything.
|
|
876
|
+
this.markFullInvalidation();
|
|
877
|
+
}
|
|
593
878
|
}
|
|
594
879
|
/** Replaces both internal regions with new ones of the given dimensions,
|
|
595
880
|
* then re-resolves sizes and positions of all direct children against the updated inner area. */
|
|
596
881
|
resizeRegions(w, h) {
|
|
597
882
|
this.region = new Region(w, h);
|
|
598
883
|
this.content = new Region(w, h);
|
|
884
|
+
// A new region invalidates any per-cell dirty bookkeeping from the
|
|
885
|
+
// previous size — escalate to a full-window repaint and ask the
|
|
886
|
+
// Screen to emit from scratch because the old footprint on stdout
|
|
887
|
+
// may include cells that are no longer part of this window.
|
|
888
|
+
this.dirtyRect = 'all';
|
|
889
|
+
this.markFullInvalidation();
|
|
599
890
|
this.reflowChildren();
|
|
600
891
|
}
|
|
601
892
|
/** Resizes this window to the given absolute dimensions and reflows every
|
|
@@ -633,7 +924,10 @@ export class Window {
|
|
|
633
924
|
}
|
|
634
925
|
}
|
|
635
926
|
/** Absolute layout: each child independently resolves its Pos/Size against
|
|
636
|
-
* the parent's inner area — the pre-flex behaviour.
|
|
927
|
+
* the parent's inner area — the pre-flex behaviour. A child's `margin`
|
|
928
|
+
* shifts the resolved position by `(marginLeft, marginTop)` without
|
|
929
|
+
* altering its size, so callers can push a window away from its declared
|
|
930
|
+
* anchor without recomputing coordinates by hand. */
|
|
637
931
|
layoutAbsolute(pw, ph, ox, oy) {
|
|
638
932
|
for (const child of this.children) {
|
|
639
933
|
if (!child.sizeSpec.isAbsolute()) {
|
|
@@ -642,8 +936,8 @@ export class Window {
|
|
|
642
936
|
}
|
|
643
937
|
const { width: cw, height: ch } = child.getSize();
|
|
644
938
|
const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
|
|
645
|
-
child.x = x + ox;
|
|
646
|
-
child.y = y + oy;
|
|
939
|
+
child.x = x + ox + child.margin.left;
|
|
940
|
+
child.y = y + oy + child.margin.top;
|
|
647
941
|
}
|
|
648
942
|
}
|
|
649
943
|
/** Row / column flex layout. The main axis (width for 'row', height for
|
|
@@ -656,7 +950,13 @@ export class Window {
|
|
|
656
950
|
* distribution only when no child consumes slack via flex-grow. Invisible
|
|
657
951
|
* children are skipped so `setVisible(false)` effectively removes them
|
|
658
952
|
* from the stack. Children are ordered by `Pos.flex(order)` first, then
|
|
659
|
-
* by addChild insertion (stable).
|
|
953
|
+
* by addChild insertion (stable).
|
|
954
|
+
*
|
|
955
|
+
* Each child's `margin` reserves extra cells around it: the main axis
|
|
956
|
+
* loses `marginLeft+marginRight` (row) or `marginTop+marginBottom` (column)
|
|
957
|
+
* per child before distribution, and the cross axis loses `margin*` per
|
|
958
|
+
* child before alignment. Grow/shrink still apply to the inner size so the
|
|
959
|
+
* declared margin stays constant even when the child is flex-resized. */
|
|
660
960
|
layoutFlex(mode, pw, ph, ox, oy) {
|
|
661
961
|
const ordered = this.orderedVisibleChildren();
|
|
662
962
|
if (ordered.length === 0)
|
|
@@ -665,7 +965,13 @@ export class Window {
|
|
|
665
965
|
const mainParent = isRow ? pw : ph;
|
|
666
966
|
const crossParent = isRow ? ph : pw;
|
|
667
967
|
const gap = this.gap;
|
|
668
|
-
//
|
|
968
|
+
// Per-child main/cross margin totals captured up-front so they stay
|
|
969
|
+
// constant across grow/shrink passes.
|
|
970
|
+
const mainMargin = (c) => isRow ? c.margin.left + c.margin.right : c.margin.top + c.margin.bottom;
|
|
971
|
+
const crossMargin = (c) => isRow ? c.margin.top + c.margin.bottom : c.margin.left + c.margin.right;
|
|
972
|
+
// First pass: basis sizes per child (inner, i.e. the child's own region
|
|
973
|
+
// without margin). Natural size is taken before margin so content-sized
|
|
974
|
+
// children keep their intrinsic footprint.
|
|
669
975
|
const items = ordered.map((c) => {
|
|
670
976
|
const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
|
|
671
977
|
const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
|
|
@@ -677,10 +983,13 @@ export class Window {
|
|
|
677
983
|
crossSize: resolveFlexBasis(crossSpec, crossParent, isRow ? c.getSize().height : c.getSize().width),
|
|
678
984
|
};
|
|
679
985
|
});
|
|
680
|
-
// Distribute leftover main-axis space.
|
|
986
|
+
// Distribute leftover main-axis space. The parent sees each child's
|
|
987
|
+
// slot as `mainSize + mainMargin`, so margins are charged against the
|
|
988
|
+
// remaining space before grow/shrink.
|
|
681
989
|
const totalGap = Math.max(0, items.length - 1) * gap;
|
|
990
|
+
const totalMargin = items.reduce((s, it) => s + mainMargin(it.child), 0);
|
|
682
991
|
const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
|
|
683
|
-
let remainder = mainParent - totalMain - totalGap;
|
|
992
|
+
let remainder = mainParent - totalMain - totalMargin - totalGap;
|
|
684
993
|
const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
|
|
685
994
|
if (remainder > 0 && totalGrow > 0) {
|
|
686
995
|
let distributed = 0;
|
|
@@ -713,17 +1022,21 @@ export class Window {
|
|
|
713
1022
|
it.mainSize = Math.max(0, it.mainSize - take);
|
|
714
1023
|
}
|
|
715
1024
|
const consumed = items.reduce((s, it) => s + it.mainSize, 0);
|
|
716
|
-
remainder = mainParent - consumed - totalGap;
|
|
1025
|
+
remainder = mainParent - consumed - totalMargin - totalGap;
|
|
717
1026
|
}
|
|
718
1027
|
}
|
|
719
|
-
// Apply cross-axis stretch where allowed.
|
|
1028
|
+
// Apply cross-axis stretch where allowed. Cross-axis margin is charged
|
|
1029
|
+
// before stretch/clamp so a stretched child + its margin fits within
|
|
1030
|
+
// crossParent.
|
|
720
1031
|
for (const it of items) {
|
|
1032
|
+
const cm = crossMargin(it.child);
|
|
1033
|
+
const available = Math.max(0, crossParent - cm);
|
|
721
1034
|
const mode = it.crossSpec.mode;
|
|
722
1035
|
if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
|
|
723
|
-
it.crossSize =
|
|
1036
|
+
it.crossSize = available;
|
|
724
1037
|
}
|
|
725
1038
|
else {
|
|
726
|
-
it.crossSize = Math.min(it.crossSize,
|
|
1039
|
+
it.crossSize = Math.min(it.crossSize, available);
|
|
727
1040
|
}
|
|
728
1041
|
}
|
|
729
1042
|
// justifyContent kicks in only when there is positive slack with no grow.
|
|
@@ -750,7 +1063,10 @@ export class Window {
|
|
|
750
1063
|
break;
|
|
751
1064
|
}
|
|
752
1065
|
}
|
|
753
|
-
// Write final sizes and positions.
|
|
1066
|
+
// Write final sizes and positions. The child's main-axis starting
|
|
1067
|
+
// coordinate is `cursor + marginLeading`; cursor then advances by
|
|
1068
|
+
// `mainSize + mainMargin + itemSpacing` so the next slot is offset by
|
|
1069
|
+
// this child's full footprint.
|
|
754
1070
|
let cursor = mainStart;
|
|
755
1071
|
for (const it of items) {
|
|
756
1072
|
const mainSize = Math.max(0, it.mainSize);
|
|
@@ -760,30 +1076,36 @@ export class Window {
|
|
|
760
1076
|
if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
|
|
761
1077
|
it.child.resizeRegions(newW, newH);
|
|
762
1078
|
}
|
|
1079
|
+
const m = it.child.margin;
|
|
1080
|
+
const mainLead = isRow ? m.left : m.top;
|
|
1081
|
+
const mainTrail = isRow ? m.right : m.bottom;
|
|
1082
|
+
const crossLead = isRow ? m.top : m.left;
|
|
1083
|
+
const cm = crossMargin(it.child);
|
|
763
1084
|
let crossPos = 0;
|
|
764
1085
|
if (this.alignItems !== 'stretch') {
|
|
765
|
-
const free = crossParent - crossSize;
|
|
1086
|
+
const free = Math.max(0, crossParent - crossSize - cm);
|
|
766
1087
|
if (this.alignItems === 'center')
|
|
767
1088
|
crossPos = Math.floor(free / 2);
|
|
768
1089
|
else if (this.alignItems === 'end')
|
|
769
1090
|
crossPos = free;
|
|
770
1091
|
}
|
|
771
1092
|
if (isRow) {
|
|
772
|
-
it.child.x = ox + cursor;
|
|
773
|
-
it.child.y = oy + crossPos;
|
|
1093
|
+
it.child.x = ox + cursor + mainLead;
|
|
1094
|
+
it.child.y = oy + crossPos + crossLead;
|
|
774
1095
|
}
|
|
775
1096
|
else {
|
|
776
|
-
it.child.x = ox + crossPos;
|
|
777
|
-
it.child.y = oy + cursor;
|
|
1097
|
+
it.child.x = ox + crossPos + crossLead;
|
|
1098
|
+
it.child.y = oy + cursor + mainLead;
|
|
778
1099
|
}
|
|
779
|
-
cursor += mainSize + itemSpacing;
|
|
1100
|
+
cursor += mainSize + mainLead + mainTrail + itemSpacing;
|
|
780
1101
|
}
|
|
781
1102
|
}
|
|
782
1103
|
/** Grid layout: children are placed row-major into equally sized cells.
|
|
783
1104
|
* Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
|
|
784
1105
|
* heights use the same formula with the derived row count. Children are
|
|
785
|
-
* resized to the cell dimensions
|
|
786
|
-
*
|
|
1106
|
+
* resized to the cell dimensions minus their own margin and positioned
|
|
1107
|
+
* at the cell's top-left offset by `(marginLeft, marginTop)`. Invisible
|
|
1108
|
+
* children are skipped. */
|
|
787
1109
|
layoutGrid(pw, ph, ox, oy) {
|
|
788
1110
|
const ordered = this.orderedVisibleChildren();
|
|
789
1111
|
if (ordered.length === 0)
|
|
@@ -797,11 +1119,14 @@ export class Window {
|
|
|
797
1119
|
const child = ordered[i];
|
|
798
1120
|
const c = i % cols;
|
|
799
1121
|
const r = Math.floor(i / cols);
|
|
800
|
-
|
|
801
|
-
|
|
1122
|
+
const m = child.margin;
|
|
1123
|
+
const innerW = Math.max(0, cellW - m.left - m.right);
|
|
1124
|
+
const innerH = Math.max(0, cellH - m.top - m.bottom);
|
|
1125
|
+
if (innerW !== child.getSize().width || innerH !== child.getSize().height) {
|
|
1126
|
+
child.resizeRegions(innerW, innerH);
|
|
802
1127
|
}
|
|
803
|
-
child.x = ox + c * (cellW + gap);
|
|
804
|
-
child.y = oy + r * (cellH + gap);
|
|
1128
|
+
child.x = ox + c * (cellW + gap) + m.left;
|
|
1129
|
+
child.y = oy + r * (cellH + gap) + m.top;
|
|
805
1130
|
}
|
|
806
1131
|
}
|
|
807
1132
|
/** Returns visible children sorted by Pos.flex(order) ascending; children
|