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
|
@@ -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,15 +52,78 @@ 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
|
+
this.markDirty();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the normalized selection range `{ start, end }` (half-open)
|
|
82
|
+
* when a selection is active and non-empty, otherwise `null`. */
|
|
83
|
+
public getSelection(): { start: number; end: number } | null {
|
|
84
|
+
if (this.selectionAnchor === null) return null;
|
|
85
|
+
const a = this.selectionAnchor;
|
|
86
|
+
const b = this.cursor;
|
|
87
|
+
if (a === b) return null;
|
|
88
|
+
return a < b ? { start: a, end: b } : { start: b, end: a };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Returns the currently selected substring, or '' when no selection. */
|
|
92
|
+
public getSelectedText(): string {
|
|
93
|
+
const sel = this.getSelection();
|
|
94
|
+
return sel === null ? '' : this.value.slice(sel.start, sel.end);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Replaces the current selection: sets anchor and cursor, clamping both
|
|
98
|
+
* to the current value length. Passing identical indices clears the
|
|
99
|
+
* selection; callers that want an empty anchor should use `clearSelection()`. */
|
|
100
|
+
public setSelection(anchor: number, cursor: number): void {
|
|
101
|
+
const len = this.value.length;
|
|
102
|
+
const a = Math.max(0, Math.min(anchor, len));
|
|
103
|
+
const c = Math.max(0, Math.min(cursor, len));
|
|
104
|
+
this.selectionAnchor = a === c ? null : a;
|
|
105
|
+
this.cursor = c;
|
|
54
106
|
this.clampScroll();
|
|
107
|
+
this.markDirty();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Selects every character in the current value. No-op when the value is empty. */
|
|
111
|
+
public selectAll(): void {
|
|
112
|
+
if (this.value.length === 0) {
|
|
113
|
+
this.selectionAnchor = null;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.selectionAnchor = 0;
|
|
117
|
+
this.cursor = this.value.length;
|
|
118
|
+
this.clampScroll();
|
|
119
|
+
this.markDirty();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drops any active selection without moving the cursor. */
|
|
123
|
+
public clearSelection(): void {
|
|
124
|
+
if (this.selectionAnchor === null) return;
|
|
125
|
+
this.selectionAnchor = null;
|
|
126
|
+
this.markDirty();
|
|
55
127
|
}
|
|
56
128
|
|
|
57
129
|
/** Replaces the onChange callback (passing undefined clears it). */
|
|
@@ -74,10 +146,14 @@ export class TextBox extends Window {
|
|
|
74
146
|
return this.value;
|
|
75
147
|
}
|
|
76
148
|
|
|
77
|
-
/** Sets the cursor to the given character index (clamped to valid range).
|
|
149
|
+
/** Sets the cursor to the given character index (clamped to valid range).
|
|
150
|
+
* Any active selection is dropped — callers that want to adjust the
|
|
151
|
+
* cursor while keeping the selection must use `setSelection()`. */
|
|
78
152
|
public setCursor(pos: number): void {
|
|
79
153
|
this.cursor = Math.max(0, Math.min(pos, this.value.length));
|
|
154
|
+
this.selectionAnchor = null;
|
|
80
155
|
this.clampScroll();
|
|
156
|
+
this.markDirty();
|
|
81
157
|
}
|
|
82
158
|
|
|
83
159
|
/** Returns the current cursor character index. */
|
|
@@ -87,9 +163,13 @@ export class TextBox extends Window {
|
|
|
87
163
|
|
|
88
164
|
/** Processes a key string from the terminal input loop and updates value/cursor.
|
|
89
165
|
* Supported special keys: backspace (\x7f), delete (\x1b[3~), left (\x1b[D),
|
|
90
|
-
* right (\x1b[C), home (\x1b[H), end (\x1b[F)
|
|
91
|
-
*
|
|
92
|
-
*
|
|
166
|
+
* right (\x1b[C), home (\x1b[H), end (\x1b[F), plus their shift-modified
|
|
167
|
+
* CSI variants (`\x1b[1;2D` etc.) which extend the selection, and
|
|
168
|
+
* Ctrl+A (`\x01`) which selects the entire value.
|
|
169
|
+
* Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home',
|
|
170
|
+
* 'end', 'shift+left', 'shift+right', 'shift+home', 'shift+end', 'ctrl+a'.
|
|
171
|
+
* Any single printable character is inserted at the cursor position and
|
|
172
|
+
* replaces the active selection (if any). */
|
|
93
173
|
public handleKey(key: string): void {
|
|
94
174
|
if (this.disabled) return;
|
|
95
175
|
|
|
@@ -100,44 +180,125 @@ export class TextBox extends Window {
|
|
|
100
180
|
}
|
|
101
181
|
|
|
102
182
|
const before = this.value;
|
|
183
|
+
|
|
184
|
+
// ── Selection-extending motion (shift + arrow / home / end) ─────────
|
|
185
|
+
if (key === '\x1b[1;2D' || key === 'shift+left') {
|
|
186
|
+
this.ensureAnchor();
|
|
187
|
+
if (this.cursor > 0) this.cursor--;
|
|
188
|
+
this.finishKey(before);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (key === '\x1b[1;2C' || key === 'shift+right') {
|
|
192
|
+
this.ensureAnchor();
|
|
193
|
+
if (this.cursor < this.value.length) this.cursor++;
|
|
194
|
+
this.finishKey(before);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (key === '\x1b[1;2H' || key === 'shift+home') {
|
|
198
|
+
this.ensureAnchor();
|
|
199
|
+
this.cursor = 0;
|
|
200
|
+
this.finishKey(before);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key === '\x1b[1;2F' || key === 'shift+end') {
|
|
204
|
+
this.ensureAnchor();
|
|
205
|
+
this.cursor = this.value.length;
|
|
206
|
+
this.finishKey(before);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key === '\x01' || key === 'ctrl+a') {
|
|
210
|
+
this.selectAll();
|
|
211
|
+
this.finishKey(before);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
103
215
|
switch (key) {
|
|
104
216
|
case '\r': case '\n': case 'enter':
|
|
105
217
|
this.onSubmit?.(this.value);
|
|
106
218
|
this.clampScroll();
|
|
107
219
|
return; // Enter never mutates value by itself in TextBox.
|
|
108
220
|
case '\x7f': case '\b': case 'backspace':
|
|
221
|
+
if (this.deleteSelection()) break;
|
|
109
222
|
if (this.cursor > 0) {
|
|
110
223
|
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
|
|
111
224
|
this.cursor--;
|
|
112
225
|
}
|
|
113
226
|
break;
|
|
114
227
|
case '\x1b[3~': case 'delete':
|
|
228
|
+
if (this.deleteSelection()) break;
|
|
115
229
|
if (this.cursor < this.value.length) {
|
|
116
230
|
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
117
231
|
}
|
|
118
232
|
break;
|
|
119
233
|
case '\x1b[D': case 'left':
|
|
234
|
+
// Non-shift left collapses an active selection to its start.
|
|
235
|
+
if (this.collapseSelection('start')) break;
|
|
120
236
|
if (this.cursor > 0) this.cursor--;
|
|
121
237
|
break;
|
|
122
238
|
case '\x1b[C': case 'right':
|
|
239
|
+
if (this.collapseSelection('end')) break;
|
|
123
240
|
if (this.cursor < this.value.length) this.cursor++;
|
|
124
241
|
break;
|
|
125
242
|
case '\x1b[H': case 'home':
|
|
243
|
+
this.selectionAnchor = null;
|
|
126
244
|
this.cursor = 0;
|
|
127
245
|
break;
|
|
128
246
|
case '\x1b[F': case 'end':
|
|
247
|
+
this.selectionAnchor = null;
|
|
129
248
|
this.cursor = this.value.length;
|
|
130
249
|
break;
|
|
131
250
|
default:
|
|
132
251
|
if (key.length === 1 && key >= ' ') {
|
|
252
|
+
this.deleteSelection();
|
|
133
253
|
this.value = this.value.slice(0, this.cursor) + key + this.value.slice(this.cursor);
|
|
134
254
|
this.cursor++;
|
|
135
255
|
}
|
|
136
256
|
}
|
|
257
|
+
this.finishKey(before);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Shared post-key housekeeping: clamp scroll, reset blink phase, fire
|
|
261
|
+
* onChange when the value actually changed. */
|
|
262
|
+
private finishKey(before: string): void {
|
|
137
263
|
this.clampScroll();
|
|
264
|
+
this.virtualCursor.resetPhase();
|
|
265
|
+
// Every key dispatch is a potential content / selection / cursor
|
|
266
|
+
// change — flag the window dirty unconditionally so the next
|
|
267
|
+
// Screen.render() re-emits it. The per-key equality checks in
|
|
268
|
+
// individual branches are not worth the code; the bounding box is
|
|
269
|
+
// cheap and we're going to emit at most the whole window.
|
|
270
|
+
this.markDirty();
|
|
138
271
|
if (this.value !== before) this.onChange?.(this.value);
|
|
139
272
|
}
|
|
140
273
|
|
|
274
|
+
/** Starts a selection at the current cursor if none is active, so that a
|
|
275
|
+
* subsequent cursor move extends the selection. */
|
|
276
|
+
private ensureAnchor(): void {
|
|
277
|
+
if (this.selectionAnchor === null) this.selectionAnchor = this.cursor;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Removes the selected text when a selection is active; cursor is placed
|
|
281
|
+
* at the start of the deleted range. Returns true when a deletion
|
|
282
|
+
* happened, false otherwise. */
|
|
283
|
+
private deleteSelection(): boolean {
|
|
284
|
+
const sel = this.getSelection();
|
|
285
|
+
if (sel === null) return false;
|
|
286
|
+
this.value = this.value.slice(0, sel.start) + this.value.slice(sel.end);
|
|
287
|
+
this.cursor = sel.start;
|
|
288
|
+
this.selectionAnchor = null;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Drops an active selection, moving the cursor to the selection's
|
|
293
|
+
* `start` or `end` edge. Returns true when it did so. */
|
|
294
|
+
private collapseSelection(edge: 'start' | 'end'): boolean {
|
|
295
|
+
const sel = this.getSelection();
|
|
296
|
+
if (sel === null) return false;
|
|
297
|
+
this.cursor = edge === 'start' ? sel.start : sel.end;
|
|
298
|
+
this.selectionAnchor = null;
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
141
302
|
/** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
|
|
142
303
|
public override render(): void {
|
|
143
304
|
this.clear();
|
|
@@ -153,12 +314,34 @@ export class TextBox extends Window {
|
|
|
153
314
|
this.writeText(visible, { style: this.disabled ? undefined : this.normalStyleId });
|
|
154
315
|
}
|
|
155
316
|
|
|
156
|
-
//
|
|
157
|
-
|
|
317
|
+
// Paint the selection highlight over any cells that fall inside the
|
|
318
|
+
// active selection range — run before the cursor so the caret still
|
|
319
|
+
// shows on top of the highlight.
|
|
320
|
+
const sel = this.focused && !this.disabled ? this.getSelection() : null;
|
|
321
|
+
if (sel !== null) {
|
|
322
|
+
const base = this.normalStyleId;
|
|
323
|
+
for (let i = sel.start; i < sel.end; i++) {
|
|
324
|
+
const col = i - this.scrollOffset;
|
|
325
|
+
if (col < 0 || col >= width) continue;
|
|
326
|
+
const merged = this.registry.merge(base, this.selectionStyleId);
|
|
327
|
+
const ch = this.value[i] ?? ' ';
|
|
328
|
+
this.writeText(ch, { x: col, y: 0, style: merged });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Draw cursor when focused and the virtual-cursor blink says "on".
|
|
333
|
+
if (this.focused && !this.disabled && this.virtualCursor.isVisible()) {
|
|
158
334
|
const cursorX = this.cursor - this.scrollOffset;
|
|
159
335
|
if (cursorX >= 0 && cursorX < width) {
|
|
160
|
-
const
|
|
161
|
-
|
|
336
|
+
const useSymbol = this.virtualCursor.hasCustomSymbol();
|
|
337
|
+
// With a custom glyph we draw the glyph itself (styled with the
|
|
338
|
+
// normal text style so the symbol's colours stay predictable);
|
|
339
|
+
// without one we fall back to the legacy inverse-block highlight
|
|
340
|
+
// painted over the character under the caret.
|
|
341
|
+
const cursorChar = useSymbol ? this.virtualCursor.getSymbol() : (this.value[this.cursor] ?? ' ');
|
|
342
|
+
const cursorStyle = useSymbol
|
|
343
|
+
? this.normalStyleId
|
|
344
|
+
: this.registry.merge(this.normalStyleId, this.cursorStyleId);
|
|
162
345
|
this.writeText(cursorChar, { x: cursorX, y: 0, style: cursorStyle });
|
|
163
346
|
}
|
|
164
347
|
}
|
|
@@ -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
|
+
}
|