take4-console 0.25.0 → 0.30.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 +193 -0
- 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 +50 -1
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +152 -0
- 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 +67 -6
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +195 -27
- 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 +186 -3
- package/dist/Screen/WindowManager.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 +280 -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 +179 -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 +143 -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 +166 -1
- package/src/Screen/StyleRegistry.mts +4 -0
- package/src/Screen/VirtualCursor.mts +175 -0
- package/src/Screen/Window.mts +197 -28
- package/src/Screen/WindowManager.mts +192 -3
- package/src/Screen/controls/TextArea.mts +280 -41
- package/src/Screen/controls/TextBox.mts +181 -10
- package/src/Screen/controls/Toast.mts +138 -0
- package/src/Screen/types.mts +140 -0
- package/src/demo.mts +80 -0
- package/src/index.mts +12 -0
- package/src/layout.yaml +16 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { TextBoxProperties, WindowProperties, StyleId } from '../types.mjs';
|
|
2
|
-
import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR } from '../types.mjs';
|
|
2
|
+
import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR, BUILTIN_TEXT_SELECTION } from '../types.mjs';
|
|
3
3
|
import { Window } from '../Window.mjs';
|
|
4
4
|
import { getRegistry } from '../RegistryHolder.mjs';
|
|
5
|
+
import { VirtualCursor } from '../VirtualCursor.mjs';
|
|
5
6
|
|
|
6
7
|
/** A single-line text-input widget with scrolling, cursor display, and placeholder support.
|
|
7
8
|
* Wraps content area inside a single-line border (total height 3 by default).
|
|
@@ -9,10 +10,17 @@ import { getRegistry } from '../RegistryHolder.mjs';
|
|
|
9
10
|
export class TextBox extends Window {
|
|
10
11
|
private value: string;
|
|
11
12
|
private cursor: number;
|
|
13
|
+
/** Anchor of the active selection, or null when no selection is active.
|
|
14
|
+
* Selection spans the half-open range [min(anchor, cursor), max(anchor, cursor)). */
|
|
15
|
+
private selectionAnchor: number | null;
|
|
12
16
|
private scrollOffset: number;
|
|
13
17
|
private placeholder: string;
|
|
14
18
|
private placeholderStyleId: StyleId;
|
|
15
19
|
private cursorStyleId: StyleId;
|
|
20
|
+
private selectionStyleId: StyleId;
|
|
21
|
+
/** Software-cursor model: blink state + glyph. Always present so the
|
|
22
|
+
* caller can reconfigure at runtime via `getVirtualCursor()`. */
|
|
23
|
+
private virtualCursor: VirtualCursor;
|
|
16
24
|
|
|
17
25
|
/** Invoked after every value change driven by handleKey. Not called by setValue. */
|
|
18
26
|
private onChange?: (value: string) => void;
|
|
@@ -35,6 +43,7 @@ export class TextBox extends Window {
|
|
|
35
43
|
this.cursor = cp?.cursor !== undefined
|
|
36
44
|
? Math.max(0, Math.min(cp.cursor, this.value.length))
|
|
37
45
|
: this.value.length;
|
|
46
|
+
this.selectionAnchor = null;
|
|
38
47
|
|
|
39
48
|
this.onChange = cp?.onChange;
|
|
40
49
|
this.onSubmit = cp?.onSubmit;
|
|
@@ -43,17 +52,75 @@ export class TextBox extends Window {
|
|
|
43
52
|
const reg = getRegistry();
|
|
44
53
|
this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
|
|
45
54
|
this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
|
|
55
|
+
this.selectionStyleId = reg.getNamed(BUILTIN_TEXT_SELECTION)!;
|
|
56
|
+
|
|
57
|
+
this.virtualCursor = new VirtualCursor({
|
|
58
|
+
symbol: cp?.cursorSymbol,
|
|
59
|
+
blink: cp?.cursorBlink,
|
|
60
|
+
});
|
|
46
61
|
|
|
47
62
|
this.clampScroll();
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
/**
|
|
65
|
+
/** Returns the VirtualCursor model so callers can tweak symbol/blink at runtime. */
|
|
66
|
+
public getVirtualCursor(): VirtualCursor {
|
|
67
|
+
return this.virtualCursor;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Replaces the current value; clamps cursor and scroll to fit. Does NOT fire onChange.
|
|
71
|
+
* Any active selection is cleared because the old anchor no longer maps
|
|
72
|
+
* onto the new string. */
|
|
51
73
|
public setValue(value: string): void {
|
|
52
74
|
this.value = value;
|
|
53
75
|
this.cursor = Math.min(this.cursor, value.length);
|
|
76
|
+
this.selectionAnchor = null;
|
|
77
|
+
this.clampScroll();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns the normalized selection range `{ start, end }` (half-open)
|
|
81
|
+
* when a selection is active and non-empty, otherwise `null`. */
|
|
82
|
+
public getSelection(): { start: number; end: number } | null {
|
|
83
|
+
if (this.selectionAnchor === null) return null;
|
|
84
|
+
const a = this.selectionAnchor;
|
|
85
|
+
const b = this.cursor;
|
|
86
|
+
if (a === b) return null;
|
|
87
|
+
return a < b ? { start: a, end: b } : { start: b, end: a };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Returns the currently selected substring, or '' when no selection. */
|
|
91
|
+
public getSelectedText(): string {
|
|
92
|
+
const sel = this.getSelection();
|
|
93
|
+
return sel === null ? '' : this.value.slice(sel.start, sel.end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Replaces the current selection: sets anchor and cursor, clamping both
|
|
97
|
+
* to the current value length. Passing identical indices clears the
|
|
98
|
+
* selection; callers that want an empty anchor should use `clearSelection()`. */
|
|
99
|
+
public setSelection(anchor: number, cursor: number): void {
|
|
100
|
+
const len = this.value.length;
|
|
101
|
+
const a = Math.max(0, Math.min(anchor, len));
|
|
102
|
+
const c = Math.max(0, Math.min(cursor, len));
|
|
103
|
+
this.selectionAnchor = a === c ? null : a;
|
|
104
|
+
this.cursor = c;
|
|
54
105
|
this.clampScroll();
|
|
55
106
|
}
|
|
56
107
|
|
|
108
|
+
/** Selects every character in the current value. No-op when the value is empty. */
|
|
109
|
+
public selectAll(): void {
|
|
110
|
+
if (this.value.length === 0) {
|
|
111
|
+
this.selectionAnchor = null;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.selectionAnchor = 0;
|
|
115
|
+
this.cursor = this.value.length;
|
|
116
|
+
this.clampScroll();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Drops any active selection without moving the cursor. */
|
|
120
|
+
public clearSelection(): void {
|
|
121
|
+
this.selectionAnchor = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
/** Replaces the onChange callback (passing undefined clears it). */
|
|
58
125
|
public setOnChange(fn?: (value: string) => void): void {
|
|
59
126
|
this.onChange = fn;
|
|
@@ -74,9 +141,12 @@ export class TextBox extends Window {
|
|
|
74
141
|
return this.value;
|
|
75
142
|
}
|
|
76
143
|
|
|
77
|
-
/** Sets the cursor to the given character index (clamped to valid range).
|
|
144
|
+
/** Sets the cursor to the given character index (clamped to valid range).
|
|
145
|
+
* Any active selection is dropped — callers that want to adjust the
|
|
146
|
+
* cursor while keeping the selection must use `setSelection()`. */
|
|
78
147
|
public setCursor(pos: number): void {
|
|
79
148
|
this.cursor = Math.max(0, Math.min(pos, this.value.length));
|
|
149
|
+
this.selectionAnchor = null;
|
|
80
150
|
this.clampScroll();
|
|
81
151
|
}
|
|
82
152
|
|
|
@@ -87,9 +157,13 @@ export class TextBox extends Window {
|
|
|
87
157
|
|
|
88
158
|
/** Processes a key string from the terminal input loop and updates value/cursor.
|
|
89
159
|
* Supported special keys: backspace (\x7f), delete (\x1b[3~), left (\x1b[D),
|
|
90
|
-
* right (\x1b[C), home (\x1b[H), end (\x1b[F)
|
|
91
|
-
*
|
|
92
|
-
*
|
|
160
|
+
* right (\x1b[C), home (\x1b[H), end (\x1b[F), plus their shift-modified
|
|
161
|
+
* CSI variants (`\x1b[1;2D` etc.) which extend the selection, and
|
|
162
|
+
* Ctrl+A (`\x01`) which selects the entire value.
|
|
163
|
+
* Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home',
|
|
164
|
+
* 'end', 'shift+left', 'shift+right', 'shift+home', 'shift+end', 'ctrl+a'.
|
|
165
|
+
* Any single printable character is inserted at the cursor position and
|
|
166
|
+
* replaces the active selection (if any). */
|
|
93
167
|
public handleKey(key: string): void {
|
|
94
168
|
if (this.disabled) return;
|
|
95
169
|
|
|
@@ -100,44 +174,119 @@ export class TextBox extends Window {
|
|
|
100
174
|
}
|
|
101
175
|
|
|
102
176
|
const before = this.value;
|
|
177
|
+
|
|
178
|
+
// ── Selection-extending motion (shift + arrow / home / end) ─────────
|
|
179
|
+
if (key === '\x1b[1;2D' || key === 'shift+left') {
|
|
180
|
+
this.ensureAnchor();
|
|
181
|
+
if (this.cursor > 0) this.cursor--;
|
|
182
|
+
this.finishKey(before);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (key === '\x1b[1;2C' || key === 'shift+right') {
|
|
186
|
+
this.ensureAnchor();
|
|
187
|
+
if (this.cursor < this.value.length) this.cursor++;
|
|
188
|
+
this.finishKey(before);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (key === '\x1b[1;2H' || key === 'shift+home') {
|
|
192
|
+
this.ensureAnchor();
|
|
193
|
+
this.cursor = 0;
|
|
194
|
+
this.finishKey(before);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (key === '\x1b[1;2F' || key === 'shift+end') {
|
|
198
|
+
this.ensureAnchor();
|
|
199
|
+
this.cursor = this.value.length;
|
|
200
|
+
this.finishKey(before);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key === '\x01' || key === 'ctrl+a') {
|
|
204
|
+
this.selectAll();
|
|
205
|
+
this.finishKey(before);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
103
209
|
switch (key) {
|
|
104
210
|
case '\r': case '\n': case 'enter':
|
|
105
211
|
this.onSubmit?.(this.value);
|
|
106
212
|
this.clampScroll();
|
|
107
213
|
return; // Enter never mutates value by itself in TextBox.
|
|
108
214
|
case '\x7f': case '\b': case 'backspace':
|
|
215
|
+
if (this.deleteSelection()) break;
|
|
109
216
|
if (this.cursor > 0) {
|
|
110
217
|
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
|
|
111
218
|
this.cursor--;
|
|
112
219
|
}
|
|
113
220
|
break;
|
|
114
221
|
case '\x1b[3~': case 'delete':
|
|
222
|
+
if (this.deleteSelection()) break;
|
|
115
223
|
if (this.cursor < this.value.length) {
|
|
116
224
|
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
117
225
|
}
|
|
118
226
|
break;
|
|
119
227
|
case '\x1b[D': case 'left':
|
|
228
|
+
// Non-shift left collapses an active selection to its start.
|
|
229
|
+
if (this.collapseSelection('start')) break;
|
|
120
230
|
if (this.cursor > 0) this.cursor--;
|
|
121
231
|
break;
|
|
122
232
|
case '\x1b[C': case 'right':
|
|
233
|
+
if (this.collapseSelection('end')) break;
|
|
123
234
|
if (this.cursor < this.value.length) this.cursor++;
|
|
124
235
|
break;
|
|
125
236
|
case '\x1b[H': case 'home':
|
|
237
|
+
this.selectionAnchor = null;
|
|
126
238
|
this.cursor = 0;
|
|
127
239
|
break;
|
|
128
240
|
case '\x1b[F': case 'end':
|
|
241
|
+
this.selectionAnchor = null;
|
|
129
242
|
this.cursor = this.value.length;
|
|
130
243
|
break;
|
|
131
244
|
default:
|
|
132
245
|
if (key.length === 1 && key >= ' ') {
|
|
246
|
+
this.deleteSelection();
|
|
133
247
|
this.value = this.value.slice(0, this.cursor) + key + this.value.slice(this.cursor);
|
|
134
248
|
this.cursor++;
|
|
135
249
|
}
|
|
136
250
|
}
|
|
251
|
+
this.finishKey(before);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Shared post-key housekeeping: clamp scroll, reset blink phase, fire
|
|
255
|
+
* onChange when the value actually changed. */
|
|
256
|
+
private finishKey(before: string): void {
|
|
137
257
|
this.clampScroll();
|
|
258
|
+
this.virtualCursor.resetPhase();
|
|
138
259
|
if (this.value !== before) this.onChange?.(this.value);
|
|
139
260
|
}
|
|
140
261
|
|
|
262
|
+
/** Starts a selection at the current cursor if none is active, so that a
|
|
263
|
+
* subsequent cursor move extends the selection. */
|
|
264
|
+
private ensureAnchor(): void {
|
|
265
|
+
if (this.selectionAnchor === null) this.selectionAnchor = this.cursor;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Removes the selected text when a selection is active; cursor is placed
|
|
269
|
+
* at the start of the deleted range. Returns true when a deletion
|
|
270
|
+
* happened, false otherwise. */
|
|
271
|
+
private deleteSelection(): boolean {
|
|
272
|
+
const sel = this.getSelection();
|
|
273
|
+
if (sel === null) return false;
|
|
274
|
+
this.value = this.value.slice(0, sel.start) + this.value.slice(sel.end);
|
|
275
|
+
this.cursor = sel.start;
|
|
276
|
+
this.selectionAnchor = null;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Drops an active selection, moving the cursor to the selection's
|
|
281
|
+
* `start` or `end` edge. Returns true when it did so. */
|
|
282
|
+
private collapseSelection(edge: 'start' | 'end'): boolean {
|
|
283
|
+
const sel = this.getSelection();
|
|
284
|
+
if (sel === null) return false;
|
|
285
|
+
this.cursor = edge === 'start' ? sel.start : sel.end;
|
|
286
|
+
this.selectionAnchor = null;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
141
290
|
/** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
|
|
142
291
|
public override render(): void {
|
|
143
292
|
this.clear();
|
|
@@ -153,12 +302,34 @@ export class TextBox extends Window {
|
|
|
153
302
|
this.writeText(visible, { style: this.disabled ? undefined : this.normalStyleId });
|
|
154
303
|
}
|
|
155
304
|
|
|
156
|
-
//
|
|
157
|
-
|
|
305
|
+
// Paint the selection highlight over any cells that fall inside the
|
|
306
|
+
// active selection range — run before the cursor so the caret still
|
|
307
|
+
// shows on top of the highlight.
|
|
308
|
+
const sel = this.focused && !this.disabled ? this.getSelection() : null;
|
|
309
|
+
if (sel !== null) {
|
|
310
|
+
const base = this.normalStyleId;
|
|
311
|
+
for (let i = sel.start; i < sel.end; i++) {
|
|
312
|
+
const col = i - this.scrollOffset;
|
|
313
|
+
if (col < 0 || col >= width) continue;
|
|
314
|
+
const merged = this.registry.merge(base, this.selectionStyleId);
|
|
315
|
+
const ch = this.value[i] ?? ' ';
|
|
316
|
+
this.writeText(ch, { x: col, y: 0, style: merged });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Draw cursor when focused and the virtual-cursor blink says "on".
|
|
321
|
+
if (this.focused && !this.disabled && this.virtualCursor.isVisible()) {
|
|
158
322
|
const cursorX = this.cursor - this.scrollOffset;
|
|
159
323
|
if (cursorX >= 0 && cursorX < width) {
|
|
160
|
-
const
|
|
161
|
-
|
|
324
|
+
const useSymbol = this.virtualCursor.hasCustomSymbol();
|
|
325
|
+
// With a custom glyph we draw the glyph itself (styled with the
|
|
326
|
+
// normal text style so the symbol's colours stay predictable);
|
|
327
|
+
// without one we fall back to the legacy inverse-block highlight
|
|
328
|
+
// painted over the character under the caret.
|
|
329
|
+
const cursorChar = useSymbol ? this.virtualCursor.getSymbol() : (this.value[this.cursor] ?? ' ');
|
|
330
|
+
const cursorStyle = useSymbol
|
|
331
|
+
? this.normalStyleId
|
|
332
|
+
: this.registry.merge(this.normalStyleId, this.cursorStyleId);
|
|
162
333
|
this.writeText(cursorChar, { x: cursorX, y: 0, style: cursorStyle });
|
|
163
334
|
}
|
|
164
335
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { StyleId, ToastPosition, WindowBorder } from '../types.mjs';
|
|
2
|
+
import { BUILTIN_TOAST } from '../types.mjs';
|
|
3
|
+
import { Window } from '../Window.mjs';
|
|
4
|
+
import { Pos } from '../Pos.mjs';
|
|
5
|
+
import { Size } from '../Size.mjs';
|
|
6
|
+
import { getRegistry } from '../RegistryHolder.mjs';
|
|
7
|
+
import { stringWidth } from '../textWidth.mjs';
|
|
8
|
+
|
|
9
|
+
/** Resolved option bag consumed by the `Toast` constructor. `Screen.toast()`
|
|
10
|
+
* fills every field so the control itself never has to re-apply defaults. */
|
|
11
|
+
export interface ToastConstructorOptions {
|
|
12
|
+
/** Anchor corner (or top/bottom centre). Stored on the instance so the
|
|
13
|
+
* enclosing Screen can group toasts by anchor for stacking / re-layout. */
|
|
14
|
+
position: ToastPosition;
|
|
15
|
+
/** Pre-registered style used both as the window background and as the
|
|
16
|
+
* base style for the toast text. */
|
|
17
|
+
style: StyleId;
|
|
18
|
+
/** Border configuration. `false` yields a borderless toast. */
|
|
19
|
+
border: WindowBorder | boolean;
|
|
20
|
+
/** Stacking order relative to regular Screen children. */
|
|
21
|
+
zIndex: number;
|
|
22
|
+
/** Optional width override for the text row (inner area, without border).
|
|
23
|
+
* When undefined the toast auto-sizes to the measured string width. */
|
|
24
|
+
width: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Converts a border option (`true` / `false` / `WindowBorder`) into the
|
|
28
|
+
* normalised `{ top, right, bottom, left }` record we need to compute the
|
|
29
|
+
* toast's final width and height at construction time. */
|
|
30
|
+
const resolveBorderSides = (border: WindowBorder | boolean): { top: boolean; right: boolean; bottom: boolean; left: boolean } => {
|
|
31
|
+
if (border === false) return { top: false, right: false, bottom: false, left: false };
|
|
32
|
+
if (border === true) return { top: true, right: true, bottom: true, left: true };
|
|
33
|
+
const style = border.style;
|
|
34
|
+
if (style === 'none') return { top: false, right: false, bottom: false, left: false };
|
|
35
|
+
return {
|
|
36
|
+
top: border.top ?? false,
|
|
37
|
+
right: border.right ?? false,
|
|
38
|
+
bottom: border.bottom ?? false,
|
|
39
|
+
left: border.left ?? false,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A transient overlay window produced by `Screen.toast()`. Holds the
|
|
44
|
+
* message text and the anchor position so the Screen can stack, re-flow,
|
|
45
|
+
* and dismiss toasts without leaking that bookkeeping into every caller.
|
|
46
|
+
*
|
|
47
|
+
* The toast is auto-sized: the inner text row is `max(stringWidth(text), 1)`
|
|
48
|
+
* cells wide (or `options.width` when explicitly provided), plus one cell
|
|
49
|
+
* of horizontal padding on each side and the border insets. Height is
|
|
50
|
+
* always one text row plus the vertical border insets — toasts do not
|
|
51
|
+
* wrap or stack text vertically. */
|
|
52
|
+
export class Toast extends Window {
|
|
53
|
+
/** Message rendered inside the toast body. Stored so `setMessage` can
|
|
54
|
+
* redraw without re-asking the caller for the original text. */
|
|
55
|
+
private message: string;
|
|
56
|
+
/** Anchor corner (or top/bottom centre). Read by `Screen` when re-flowing
|
|
57
|
+
* the active-toast stack after a dismissal or terminal resize. */
|
|
58
|
+
private toastPosition: ToastPosition;
|
|
59
|
+
/** Style shared between the background fill and the text row. Kept so
|
|
60
|
+
* `setMessage` uses the same colour combination the toast launched with. */
|
|
61
|
+
private toastStyle: StyleId;
|
|
62
|
+
/** Screen-supplied dismissal hook. Registered after construction via
|
|
63
|
+
* `attachDismiss()` so `Toast` does not need a circular import on
|
|
64
|
+
* `Screen` to invoke `screen.dismissToast(this)`. */
|
|
65
|
+
private dismissHandler: (() => void) | undefined;
|
|
66
|
+
|
|
67
|
+
/** Builds a Toast with a final size derived from the supplied text and
|
|
68
|
+
* option bag. The caller (`Screen.toast()`) is responsible for placing
|
|
69
|
+
* the toast inside the Screen via `addChild` and setting the final x/y. */
|
|
70
|
+
public constructor(text: string, options: ToastConstructorOptions) {
|
|
71
|
+
const sides = resolveBorderSides(options.border);
|
|
72
|
+
const borderW = (sides.left ? 1 : 0) + (sides.right ? 1 : 0);
|
|
73
|
+
const borderH = (sides.top ? 1 : 0) + (sides.bottom ? 1 : 0);
|
|
74
|
+
const innerWidth = Math.max(1, options.width ?? stringWidth(text));
|
|
75
|
+
const width = innerWidth + 2 + borderW; // 1-cell horizontal padding each side.
|
|
76
|
+
const height = 1 + borderH;
|
|
77
|
+
|
|
78
|
+
super({
|
|
79
|
+
pos: Pos.topLeft(),
|
|
80
|
+
size: new Size(width, height),
|
|
81
|
+
background: options.style,
|
|
82
|
+
border: options.border,
|
|
83
|
+
zIndex: options.zIndex,
|
|
84
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.message = text;
|
|
88
|
+
this.toastPosition = options.position;
|
|
89
|
+
this.toastStyle = options.style;
|
|
90
|
+
|
|
91
|
+
this.writeText(text, { x: 0, y: 0, style: options.style });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns the currently displayed message. */
|
|
95
|
+
public getMessage(): string {
|
|
96
|
+
return this.message;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns the resolved style id used for both the background fill and
|
|
100
|
+
* the text row. Exposed so tests can verify the toast's paint without
|
|
101
|
+
* re-resolving BUILTIN_TOAST by hand. */
|
|
102
|
+
public getToastStyle(): StyleId {
|
|
103
|
+
return this.toastStyle;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns the anchor corner the toast was created with. `Screen` uses
|
|
107
|
+
* this to group sibling toasts when re-flowing the active stack. */
|
|
108
|
+
public getToastPosition(): ToastPosition {
|
|
109
|
+
return this.toastPosition;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Registers the dismiss callback wired up by `Screen.toast()`. Called
|
|
113
|
+
* once immediately after construction so `toast.dismiss()` can trigger
|
|
114
|
+
* the full Screen-side teardown (timer cancel, re-flow, onDismiss
|
|
115
|
+
* callback) without the Toast needing a direct Screen reference. */
|
|
116
|
+
public attachDismiss(handler: () => void): void {
|
|
117
|
+
this.dismissHandler = handler;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Removes the toast from the Screen it was shown on. Equivalent to
|
|
121
|
+
* calling `screen.dismissToast(this)` — the overload exists so callers
|
|
122
|
+
* that only kept the Toast reference do not need to remember the owning
|
|
123
|
+
* Screen. Safe to call before `attachDismiss()` (no-op) or twice
|
|
124
|
+
* (subsequent calls are no-ops because the Screen guards re-dismissal). */
|
|
125
|
+
public dismiss(): void {
|
|
126
|
+
this.dismissHandler?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Builds a fallback toast style when the registry did not already have
|
|
130
|
+
* `BUILTIN_TOAST` mapped. Extracted as a static helper so `Screen.toast`
|
|
131
|
+
* can resolve the style before it knows whether construction will fail. */
|
|
132
|
+
public static resolveDefaultStyle(): StyleId {
|
|
133
|
+
const reg = getRegistry();
|
|
134
|
+
const existing = reg.getNamed(BUILTIN_TOAST);
|
|
135
|
+
if (existing !== undefined) return existing;
|
|
136
|
+
return reg.registerNamed(BUILTIN_TOAST, { background: 24, foreground: 231, bold: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/Screen/types.mts
CHANGED
|
@@ -28,6 +28,41 @@ export const BUILTIN_TEXT_PLACEHOLDER = 'builtin:text-placeholder';
|
|
|
28
28
|
export const BUILTIN_TEXT_CHECKED = 'builtin:text-checked';
|
|
29
29
|
/** Name of the cursor highlight style (inverse) used by text input controls. */
|
|
30
30
|
export const BUILTIN_CURSOR = 'builtin:cursor';
|
|
31
|
+
/** Name of the selection highlight style merged over selected cells in
|
|
32
|
+
* `TextBox` / `TextArea` (backlog P1-20). */
|
|
33
|
+
export const BUILTIN_TEXT_SELECTION = 'builtin:text-selection';
|
|
34
|
+
/** Name of the default background + text style used by `Screen.toast()`
|
|
35
|
+
* overlay windows (backlog P1-27). Consumers can override it via
|
|
36
|
+
* `Screen.setBuiltinStyle(BUILTIN_TOAST, …)` to recolour every toast at
|
|
37
|
+
* once without threading a per-call `style` option. */
|
|
38
|
+
export const BUILTIN_TOAST = 'builtin:toast';
|
|
39
|
+
|
|
40
|
+
// ── Virtual cursor ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Blink configuration for a `VirtualCursor`.
|
|
43
|
+
* - `off` — cursor is never drawn (hide software cursor entirely).
|
|
44
|
+
* - `steady` — cursor is always drawn (no blink).
|
|
45
|
+
* - `slow` — 600 ms on / 600 ms off.
|
|
46
|
+
* - `fast` — 250 ms on / 250 ms off.
|
|
47
|
+
* - `irregular` — each on/off phase picks a randomised duration so the
|
|
48
|
+
* cursor pulses in a non-metronomic "alive" rhythm.
|
|
49
|
+
* - `custom` — caller-supplied on / off durations in milliseconds. */
|
|
50
|
+
export type CursorBlink =
|
|
51
|
+
| { mode: 'off' }
|
|
52
|
+
| { mode: 'steady' }
|
|
53
|
+
| { mode: 'slow' }
|
|
54
|
+
| { mode: 'fast' }
|
|
55
|
+
| { mode: 'irregular' }
|
|
56
|
+
| { mode: 'custom'; onMs: number; offMs: number };
|
|
57
|
+
|
|
58
|
+
/** Construction options for `VirtualCursor`. Both fields are optional;
|
|
59
|
+
* defaults: `symbol = '▎'`, `blink = { mode: 'steady' }`. */
|
|
60
|
+
export interface VirtualCursorOptions {
|
|
61
|
+
/** Glyph rendered for the cursor when visible. Default: `'▎'`. */
|
|
62
|
+
symbol?: string;
|
|
63
|
+
/** Blink schedule. Default: `{ mode: 'steady' }` (no blink). */
|
|
64
|
+
blink?: CursorBlink;
|
|
65
|
+
}
|
|
31
66
|
|
|
32
67
|
/** Integer handle returned by StyleRegistry.register(). ID 0 always means no style (empty {}). */
|
|
33
68
|
export type StyleId = number;
|
|
@@ -167,6 +202,16 @@ export interface WindowProperties {
|
|
|
167
202
|
* padded inner area, and `getInnerSize()` / `getInnerOffset()` reflect the
|
|
168
203
|
* padding as well as the border. Default: 0 on every side. */
|
|
169
204
|
padding?: PaddingSpec;
|
|
205
|
+
/** Outer spacing reserved around the window inside its parent's layout.
|
|
206
|
+
* - In `row` / `column` flex layouts margin stacks with `gap`: each child
|
|
207
|
+
* claims `intrinsic + marginMain` cells on the main axis, and its cross
|
|
208
|
+
* extent is reduced by the cross margin.
|
|
209
|
+
* - In `grid` layout the child fits inside `cell − margin` and is offset by
|
|
210
|
+
* `marginTop` / `marginLeft` within the cell.
|
|
211
|
+
* - In `absolute` layout margin shifts the child's resolved position by
|
|
212
|
+
* `(marginLeft, marginTop)` without changing its size.
|
|
213
|
+
* Default: 0 on every side. */
|
|
214
|
+
margin?: MarginSpec;
|
|
170
215
|
/** Number of columns for `layout: 'grid'`. Children are placed row-major,
|
|
171
216
|
* so `gridColumns` implicitly determines the number of rows from the child
|
|
172
217
|
* count. Default: 1. */
|
|
@@ -177,6 +222,21 @@ export interface WindowProperties {
|
|
|
177
222
|
/** Main-axis distribution of leftover space for `layout: 'row'` / `'column'`
|
|
178
223
|
* when no child consumes it via flex-grow. Default: 'start'. */
|
|
179
224
|
justifyContent?: JustifyContent;
|
|
225
|
+
/** Optional string identifier used by `WindowManager.focusById` and for
|
|
226
|
+
* diagnostics. InterfaceBuilder copies the YAML `id:` into this field so
|
|
227
|
+
* the runtime can cross-reference programmatic focus with the builder map. */
|
|
228
|
+
id?: string;
|
|
229
|
+
/** Stacking order among siblings — windows with a higher `zIndex` render
|
|
230
|
+
* on top of windows with a lower value. Ties are broken by `addChild`
|
|
231
|
+
* insertion order, so the pre-0.26 behaviour (everything at `zIndex: 0`)
|
|
232
|
+
* is preserved for callers that don't opt in. Default: 0. */
|
|
233
|
+
zIndex?: number;
|
|
234
|
+
/** Fires once when this window transitions from unfocused to focused, via
|
|
235
|
+
* any code path (WindowManager, explicit `setFocused(true)`, focus
|
|
236
|
+
* inheritance on dialog open). Not fired when the state does not change. */
|
|
237
|
+
onFocus?: () => void;
|
|
238
|
+
/** Fires once when this window transitions from focused to unfocused. */
|
|
239
|
+
onBlur?: () => void;
|
|
180
240
|
}
|
|
181
241
|
|
|
182
242
|
/** Internal per-axis position spec used by Pos.
|
|
@@ -240,6 +300,20 @@ export interface Padding {
|
|
|
240
300
|
* a partial per-side record (missing sides default to 0). */
|
|
241
301
|
export type PaddingSpec = number | [number, number] | Partial<Padding>;
|
|
242
302
|
|
|
303
|
+
/** Resolved per-side margin values (in cells). Margin is the outer counterpart
|
|
304
|
+
* of padding — it pushes the window away from its parent's inner edges /
|
|
305
|
+
* siblings without reserving space inside the window itself. */
|
|
306
|
+
export interface Margin {
|
|
307
|
+
top: number;
|
|
308
|
+
right: number;
|
|
309
|
+
bottom: number;
|
|
310
|
+
left: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** User-supplied margin: a uniform number, a [vertical, horizontal] tuple, or
|
|
314
|
+
* a partial per-side record (missing sides default to 0). Mirrors `PaddingSpec`. */
|
|
315
|
+
export type MarginSpec = number | [number, number] | Partial<Margin>;
|
|
316
|
+
|
|
243
317
|
/** Options for Window.writeText() – position defaults to (0, 0). */
|
|
244
318
|
export interface WriteTextOptions {
|
|
245
319
|
/** Column to start writing at. Default: 0. */
|
|
@@ -298,6 +372,15 @@ export interface TextBoxProperties {
|
|
|
298
372
|
* `true` to mark the key as handled — the default behaviour (inserting,
|
|
299
373
|
* moving cursor, deleting, …) is then skipped. */
|
|
300
374
|
onKeyDown?: (key: string) => boolean | void;
|
|
375
|
+
/** Virtual-cursor glyph. Overrides the default inverse-block look. When
|
|
376
|
+
* set, the character under the cursor is replaced by this symbol (unless
|
|
377
|
+
* the symbol is a zero-width string, in which case the underlying
|
|
378
|
+
* character stays). Default: undefined (inverse block). */
|
|
379
|
+
cursorSymbol?: string;
|
|
380
|
+
/** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`
|
|
381
|
+
* (no blink). Requires `WindowManager.enableCursorBlink()` for timed
|
|
382
|
+
* modes to actually animate. */
|
|
383
|
+
cursorBlink?: CursorBlink;
|
|
301
384
|
}
|
|
302
385
|
|
|
303
386
|
/** Control-specific properties for the TextArea control. */
|
|
@@ -324,6 +407,10 @@ export interface TextAreaProperties {
|
|
|
324
407
|
/** When true, Ctrl+D deletes the character to the right of the cursor
|
|
325
408
|
* (or joins with the next line at end of line). Default: false. */
|
|
326
409
|
ctrlDDeletesForward?: boolean;
|
|
410
|
+
/** Virtual-cursor glyph. Overrides the default inverse-block look. */
|
|
411
|
+
cursorSymbol?: string;
|
|
412
|
+
/** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`. */
|
|
413
|
+
cursorBlink?: CursorBlink;
|
|
327
414
|
}
|
|
328
415
|
|
|
329
416
|
/** Control-specific properties for the Checkbox control. */
|
|
@@ -465,6 +552,43 @@ export interface SpinnerProperties {
|
|
|
465
552
|
color?: number;
|
|
466
553
|
}
|
|
467
554
|
|
|
555
|
+
/** Corner (or top/bottom centre) where `Screen.toast()` anchors its overlay
|
|
556
|
+
* windows. Toasts launched at the same position stack vertically in the
|
|
557
|
+
* order they were created; a dismissal shifts the remaining stack towards
|
|
558
|
+
* the anchor edge so the UI stays compact. */
|
|
559
|
+
export type ToastPosition =
|
|
560
|
+
| 'top-left' | 'top-center' | 'top-right'
|
|
561
|
+
| 'bottom-left' | 'bottom-center' | 'bottom-right';
|
|
562
|
+
|
|
563
|
+
/** Options accepted by `Screen.toast()`. All fields are optional; sensible
|
|
564
|
+
* defaults make `screen.toast('Saved!')` a single-call notification. */
|
|
565
|
+
export interface ToastOptions {
|
|
566
|
+
/** Auto-dismiss delay in milliseconds. Pass `0` to keep the toast sticky
|
|
567
|
+
* until `toast.dismiss()` is called manually. Default: `2000`. */
|
|
568
|
+
duration?: number;
|
|
569
|
+
/** Pre-registered style ID used as both the window background and the
|
|
570
|
+
* text style. Default: `BUILTIN_TOAST` (falls back to a high-contrast
|
|
571
|
+
* style registered on first use). */
|
|
572
|
+
style?: StyleId;
|
|
573
|
+
/** Corner (or top/bottom centre) the toast anchors to. Default:
|
|
574
|
+
* `'top-right'`. */
|
|
575
|
+
position?: ToastPosition;
|
|
576
|
+
/** Border configuration. Pass `false` for a borderless toast. Default:
|
|
577
|
+
* a rounded single-line border on all four sides. */
|
|
578
|
+
border?: WindowBorder | boolean;
|
|
579
|
+
/** Stacking order among Screen children. Default: `10_000` so toasts
|
|
580
|
+
* draw on top of every regular window without callers having to raise
|
|
581
|
+
* other `zIndex` values by hand. */
|
|
582
|
+
zIndex?: number;
|
|
583
|
+
/** Override the automatic text-based width (in cells, not counting the
|
|
584
|
+
* border). When the text is wider than this value it overflows
|
|
585
|
+
* normally — toasts never wrap. Default: `undefined` (auto-size). */
|
|
586
|
+
width?: number;
|
|
587
|
+
/** Fires once after the toast has been removed from the Screen
|
|
588
|
+
* (auto-dismiss or manual). */
|
|
589
|
+
onDismiss?: () => void;
|
|
590
|
+
}
|
|
591
|
+
|
|
468
592
|
/** Control-specific properties for the BarChart control. */
|
|
469
593
|
export interface BarChartProperties {
|
|
470
594
|
/** Data values for each bar. Default: []. */
|
|
@@ -575,6 +699,10 @@ export interface YamlWindowDef {
|
|
|
575
699
|
insertTabAsSpaces?: number;
|
|
576
700
|
/** TextArea: when true, Ctrl+D deletes the character to the right of the cursor. */
|
|
577
701
|
ctrlDDeletesForward?: boolean;
|
|
702
|
+
/** TextBox / TextArea: virtual-cursor glyph (overrides inverse-block look). */
|
|
703
|
+
cursorSymbol?: string;
|
|
704
|
+
/** TextBox / TextArea: virtual-cursor blink schedule. */
|
|
705
|
+
cursorBlink?: CursorBlink;
|
|
578
706
|
/** LED state ('ok' | 'warn' | 'error' | 'off') — used by statusled. */
|
|
579
707
|
state?: 'ok' | 'warn' | 'error' | 'off';
|
|
580
708
|
/** Whether to show a percentage label over a progress bar. Default: true. */
|
|
@@ -620,6 +748,9 @@ export interface YamlWindowDef {
|
|
|
620
748
|
/** Padding inside the border: uniform number, [vertical, horizontal] tuple,
|
|
621
749
|
* or a partial per-side record. */
|
|
622
750
|
padding?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
|
|
751
|
+
/** Outer spacing reserved around this window inside its parent's layout.
|
|
752
|
+
* Same shape as `padding`. */
|
|
753
|
+
margin?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
|
|
623
754
|
/** Number of columns for `layout: grid`. */
|
|
624
755
|
gridColumns?: number;
|
|
625
756
|
/** Cross-axis alignment for `layout: row|column`. */
|
|
@@ -629,6 +760,9 @@ export interface YamlWindowDef {
|
|
|
629
760
|
/** Free-form property bag for user-registered custom types. Built-in types
|
|
630
761
|
* ignore this field; custom factories read it via `node.props`. */
|
|
631
762
|
props?: Record<string, unknown>;
|
|
763
|
+
/** Stacking order among siblings. Higher values render on top; ties keep
|
|
764
|
+
* YAML declaration order. Default: 0. */
|
|
765
|
+
zIndex?: number;
|
|
632
766
|
}
|
|
633
767
|
|
|
634
768
|
/** Context passed to factories registered via `InterfaceBuilder.registerType`.
|
|
@@ -718,4 +852,10 @@ export interface WindowManagerOptions {
|
|
|
718
852
|
onMouse?: (event: TerminalMouseEvent) => void;
|
|
719
853
|
/** Enable mouse click tracking (SGR protocol). Default: false. */
|
|
720
854
|
mouse?: boolean;
|
|
855
|
+
/** Fires when a descendant Window throws during its `render()` call (or its
|
|
856
|
+
* blit onto the parent). The offending subtree is replaced in-place with a
|
|
857
|
+
* single-line error placeholder so the rest of the frame still paints;
|
|
858
|
+
* the handler is invoked once per error with the thrown value and the
|
|
859
|
+
* Window that produced it. Default: no-op. */
|
|
860
|
+
onError?: (err: unknown, control: Window) => void;
|
|
721
861
|
}
|