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,18 +1,26 @@
|
|
|
1
1
|
import type { TextAreaProperties, 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 multi-line text-input widget with 2-D cursor, scrolling, and placeholder support.
|
|
7
8
|
* Call handleKey() to feed raw terminal key strings from your input loop. */
|
|
8
9
|
export class TextArea extends Window {
|
|
9
10
|
private lines: string[];
|
|
10
11
|
private cursor: { x: number; y: number };
|
|
12
|
+
/** Anchor of the active selection in 2-D coordinates, or null when no
|
|
13
|
+
* selection is active. The selection spans from `selectionAnchor` to
|
|
14
|
+
* `cursor` regardless of ordering. */
|
|
15
|
+
private selectionAnchor: { x: number; y: number } | null;
|
|
11
16
|
private scrollX: number;
|
|
12
17
|
private scrollY: number;
|
|
13
18
|
private placeholder: string;
|
|
14
19
|
private placeholderStyleId: StyleId;
|
|
15
20
|
private cursorStyleId: StyleId;
|
|
21
|
+
private selectionStyleId: StyleId;
|
|
22
|
+
/** Software-cursor model: blink state + glyph. */
|
|
23
|
+
private virtualCursor: VirtualCursor;
|
|
16
24
|
|
|
17
25
|
private onChange?: (value: string) => void;
|
|
18
26
|
private onSubmit?: (value: string) => void;
|
|
@@ -39,6 +47,7 @@ export class TextArea extends Window {
|
|
|
39
47
|
x: 0,
|
|
40
48
|
};
|
|
41
49
|
this.cursor.x = Math.max(0, Math.min(rawCursor.x, this.lines[this.cursor.y].length));
|
|
50
|
+
this.selectionAnchor = null;
|
|
42
51
|
|
|
43
52
|
this.onChange = cp?.onChange;
|
|
44
53
|
this.onSubmit = cp?.onSubmit;
|
|
@@ -49,16 +58,99 @@ export class TextArea extends Window {
|
|
|
49
58
|
const reg = getRegistry();
|
|
50
59
|
this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
|
|
51
60
|
this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
|
|
61
|
+
this.selectionStyleId = reg.getNamed(BUILTIN_TEXT_SELECTION)!;
|
|
62
|
+
|
|
63
|
+
this.virtualCursor = new VirtualCursor({
|
|
64
|
+
symbol: cp?.cursorSymbol,
|
|
65
|
+
blink: cp?.cursorBlink,
|
|
66
|
+
});
|
|
52
67
|
|
|
53
68
|
this.clampScroll();
|
|
54
69
|
}
|
|
55
70
|
|
|
56
|
-
/**
|
|
71
|
+
/** Returns the VirtualCursor model so callers can tweak symbol/blink at runtime. */
|
|
72
|
+
public getVirtualCursor(): VirtualCursor {
|
|
73
|
+
return this.virtualCursor;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Replaces the current value; cursor and scroll are clamped to fit. Does NOT fire onChange.
|
|
77
|
+
* Any active selection is cleared because the old anchor no longer maps
|
|
78
|
+
* onto the new buffer. */
|
|
57
79
|
public setValue(text: string): void {
|
|
58
80
|
this.lines = text.split('\n');
|
|
59
81
|
this.cursor.y = Math.min(this.cursor.y, this.lines.length - 1);
|
|
60
82
|
this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
|
|
83
|
+
this.selectionAnchor = null;
|
|
84
|
+
this.clampScroll();
|
|
85
|
+
this.markDirty();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Returns the normalized selection range `{ start, end }` in 2-D
|
|
89
|
+
* coordinates (start always ≤ end in document order) or `null` when no
|
|
90
|
+
* selection is active. */
|
|
91
|
+
public getSelection(): { start: { x: number; y: number }; end: { x: number; y: number } } | null {
|
|
92
|
+
if (this.selectionAnchor === null) return null;
|
|
93
|
+
const a = this.selectionAnchor;
|
|
94
|
+
const b = this.cursor;
|
|
95
|
+
if (a.x === b.x && a.y === b.y) return null;
|
|
96
|
+
const aBeforeB = a.y < b.y || (a.y === b.y && a.x < b.x);
|
|
97
|
+
return aBeforeB
|
|
98
|
+
? { start: { ...a }, end: { ...b } }
|
|
99
|
+
: { start: { ...b }, end: { ...a } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Returns the currently selected substring (with embedded newlines), or
|
|
103
|
+
* `''` when no selection is active. */
|
|
104
|
+
public getSelectedText(): string {
|
|
105
|
+
const sel = this.getSelection();
|
|
106
|
+
if (sel === null) return '';
|
|
107
|
+
const { start, end } = sel;
|
|
108
|
+
if (start.y === end.y) return this.lines[start.y].slice(start.x, end.x);
|
|
109
|
+
const parts: string[] = [this.lines[start.y].slice(start.x)];
|
|
110
|
+
for (let y = start.y + 1; y < end.y; y++) parts.push(this.lines[y]);
|
|
111
|
+
parts.push(this.lines[end.y].slice(0, end.x));
|
|
112
|
+
return parts.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Replaces the current selection: sets anchor and cursor, clamping both
|
|
116
|
+
* positions into valid lines/columns. Identical positions clear the
|
|
117
|
+
* selection. */
|
|
118
|
+
public setSelection(anchor: { x: number; y: number }, cursor: { x: number; y: number }): void {
|
|
119
|
+
const a = this.clampPosition(anchor);
|
|
120
|
+
const c = this.clampPosition(cursor);
|
|
121
|
+
this.selectionAnchor = (a.x === c.x && a.y === c.y) ? null : a;
|
|
122
|
+
this.cursor = c;
|
|
61
123
|
this.clampScroll();
|
|
124
|
+
this.markDirty();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Selects every character in the buffer. No-op when the buffer holds a
|
|
128
|
+
* single empty line. */
|
|
129
|
+
public selectAll(): void {
|
|
130
|
+
const lastY = this.lines.length - 1;
|
|
131
|
+
const lastX = this.lines[lastY].length;
|
|
132
|
+
if (lastY === 0 && lastX === 0) {
|
|
133
|
+
this.selectionAnchor = null;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.selectionAnchor = { x: 0, y: 0 };
|
|
137
|
+
this.cursor = { x: lastX, y: lastY };
|
|
138
|
+
this.clampScroll();
|
|
139
|
+
this.markDirty();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drops any active selection without moving the cursor. */
|
|
143
|
+
public clearSelection(): void {
|
|
144
|
+
if (this.selectionAnchor === null) return;
|
|
145
|
+
this.selectionAnchor = null;
|
|
146
|
+
this.markDirty();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Clamps a 2-D position so both components land inside the buffer. */
|
|
150
|
+
private clampPosition(p: { x: number; y: number }): { x: number; y: number } {
|
|
151
|
+
const y = Math.max(0, Math.min(p.y, this.lines.length - 1));
|
|
152
|
+
const x = Math.max(0, Math.min(p.x, this.lines[y].length));
|
|
153
|
+
return { x, y };
|
|
62
154
|
}
|
|
63
155
|
|
|
64
156
|
/** Replaces the onChange callback. */
|
|
@@ -87,11 +179,15 @@ export class TextArea extends Window {
|
|
|
87
179
|
return this.lines.join('\n');
|
|
88
180
|
}
|
|
89
181
|
|
|
90
|
-
/** Sets the cursor to the given position (clamped to valid range).
|
|
182
|
+
/** Sets the cursor to the given position (clamped to valid range).
|
|
183
|
+
* Any active selection is dropped — use `setSelection()` to move the
|
|
184
|
+
* cursor while keeping an anchor. */
|
|
91
185
|
public setCursor(pos: { x: number; y: number }): void {
|
|
92
186
|
this.cursor.y = Math.max(0, Math.min(pos.y, this.lines.length - 1));
|
|
93
187
|
this.cursor.x = Math.max(0, Math.min(pos.x, this.lines[this.cursor.y].length));
|
|
188
|
+
this.selectionAnchor = null;
|
|
94
189
|
this.clampScroll();
|
|
190
|
+
this.markDirty();
|
|
95
191
|
}
|
|
96
192
|
|
|
97
193
|
/** Returns a copy of the current cursor position. */
|
|
@@ -115,14 +211,57 @@ export class TextArea extends Window {
|
|
|
115
211
|
|
|
116
212
|
const before = this.getValue();
|
|
117
213
|
|
|
214
|
+
// ── Selection-extending motion (shift + arrow / home / end) ─────────
|
|
215
|
+
if (key === '\x1b[1;2D' || key === 'shift+left') {
|
|
216
|
+
this.ensureAnchor();
|
|
217
|
+
this.moveCursorLeft();
|
|
218
|
+
this.finishKey(before);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (key === '\x1b[1;2C' || key === 'shift+right') {
|
|
222
|
+
this.ensureAnchor();
|
|
223
|
+
this.moveCursorRight();
|
|
224
|
+
this.finishKey(before);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (key === '\x1b[1;2A' || key === 'shift+up') {
|
|
228
|
+
this.ensureAnchor();
|
|
229
|
+
this.moveCursorUp();
|
|
230
|
+
this.finishKey(before);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (key === '\x1b[1;2B' || key === 'shift+down') {
|
|
234
|
+
this.ensureAnchor();
|
|
235
|
+
this.moveCursorDown();
|
|
236
|
+
this.finishKey(before);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (key === '\x1b[1;2H' || key === 'shift+home') {
|
|
240
|
+
this.ensureAnchor();
|
|
241
|
+
this.cursor.x = 0;
|
|
242
|
+
this.finishKey(before);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (key === '\x1b[1;2F' || key === 'shift+end') {
|
|
246
|
+
this.ensureAnchor();
|
|
247
|
+
this.cursor.x = this.lines[this.cursor.y].length;
|
|
248
|
+
this.finishKey(before);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (key === '\x01' || key === 'ctrl+a') {
|
|
252
|
+
this.selectAll();
|
|
253
|
+
this.finishKey(before);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
118
257
|
// Tab → soft-tab insert when configured, otherwise pass-through.
|
|
119
258
|
if ((key === '\t' || key === 'tab') && this.insertTabAsSpaces > 0) {
|
|
259
|
+
this.deleteSelection();
|
|
120
260
|
const spaces = ' '.repeat(this.insertTabAsSpaces);
|
|
121
261
|
const lineTab = this.lines[this.cursor.y];
|
|
122
262
|
this.lines[this.cursor.y] = lineTab.slice(0, this.cursor.x) + spaces + lineTab.slice(this.cursor.x);
|
|
123
263
|
this.cursor.x += spaces.length;
|
|
124
|
-
this.
|
|
125
|
-
if (this.getValue() !== before) this.onChange?.(this.getValue());
|
|
264
|
+
this.finishKey(before);
|
|
126
265
|
return;
|
|
127
266
|
}
|
|
128
267
|
if (key === '\t' || key === 'tab') {
|
|
@@ -132,15 +271,16 @@ export class TextArea extends Window {
|
|
|
132
271
|
|
|
133
272
|
// Ctrl+D forward-delete when enabled — same behaviour as \x1b[3~.
|
|
134
273
|
if (key === '\x04' && this.ctrlDDeletesForward) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.
|
|
140
|
-
|
|
274
|
+
if (!this.deleteSelection()) {
|
|
275
|
+
const lineD = this.lines[this.cursor.y];
|
|
276
|
+
if (this.cursor.x < lineD.length) {
|
|
277
|
+
this.lines[this.cursor.y] = lineD.slice(0, this.cursor.x) + lineD.slice(this.cursor.x + 1);
|
|
278
|
+
} else if (this.cursor.y < this.lines.length - 1) {
|
|
279
|
+
this.lines[this.cursor.y] = lineD + this.lines[this.cursor.y + 1];
|
|
280
|
+
this.lines.splice(this.cursor.y + 1, 1);
|
|
281
|
+
}
|
|
141
282
|
}
|
|
142
|
-
this.
|
|
143
|
-
if (this.getValue() !== before) this.onChange?.(this.getValue());
|
|
283
|
+
this.finishKey(before);
|
|
144
284
|
return;
|
|
145
285
|
}
|
|
146
286
|
|
|
@@ -153,9 +293,10 @@ export class TextArea extends Window {
|
|
|
153
293
|
return;
|
|
154
294
|
}
|
|
155
295
|
|
|
156
|
-
const line = this.lines[this.cursor.y];
|
|
157
296
|
switch (key) {
|
|
158
|
-
case '\x7f': case '\b': case 'backspace':
|
|
297
|
+
case '\x7f': case '\b': case 'backspace': {
|
|
298
|
+
if (this.deleteSelection()) break;
|
|
299
|
+
const line = this.lines[this.cursor.y];
|
|
159
300
|
if (this.cursor.x > 0) {
|
|
160
301
|
this.lines[this.cursor.y] = line.slice(0, this.cursor.x - 1) + line.slice(this.cursor.x);
|
|
161
302
|
this.cursor.x--;
|
|
@@ -167,7 +308,10 @@ export class TextArea extends Window {
|
|
|
167
308
|
this.cursor.y--;
|
|
168
309
|
}
|
|
169
310
|
break;
|
|
170
|
-
|
|
311
|
+
}
|
|
312
|
+
case '\x1b[3~': case 'delete': {
|
|
313
|
+
if (this.deleteSelection()) break;
|
|
314
|
+
const line = this.lines[this.cursor.y];
|
|
171
315
|
if (this.cursor.x < line.length) {
|
|
172
316
|
this.lines[this.cursor.y] = line.slice(0, this.cursor.x) + line.slice(this.cursor.x + 1);
|
|
173
317
|
} else if (this.cursor.y < this.lines.length - 1) {
|
|
@@ -175,56 +319,134 @@ export class TextArea extends Window {
|
|
|
175
319
|
this.lines.splice(this.cursor.y + 1, 1);
|
|
176
320
|
}
|
|
177
321
|
break;
|
|
178
|
-
|
|
322
|
+
}
|
|
323
|
+
case '\r': case '\n': case 'enter': {
|
|
324
|
+
this.deleteSelection();
|
|
325
|
+
const line = this.lines[this.cursor.y];
|
|
179
326
|
this.lines.splice(this.cursor.y + 1, 0, line.slice(this.cursor.x));
|
|
180
327
|
this.lines[this.cursor.y] = line.slice(0, this.cursor.x);
|
|
181
328
|
this.cursor.y++;
|
|
182
329
|
this.cursor.x = 0;
|
|
183
330
|
break;
|
|
331
|
+
}
|
|
184
332
|
case '\x1b[D': case 'left':
|
|
185
|
-
if (this.
|
|
186
|
-
|
|
187
|
-
} else if (this.cursor.y > 0) {
|
|
188
|
-
this.cursor.y--;
|
|
189
|
-
this.cursor.x = this.lines[this.cursor.y].length;
|
|
190
|
-
}
|
|
333
|
+
if (this.collapseSelection('start')) break;
|
|
334
|
+
this.moveCursorLeft();
|
|
191
335
|
break;
|
|
192
336
|
case '\x1b[C': case 'right':
|
|
193
|
-
if (this.
|
|
194
|
-
|
|
195
|
-
} else if (this.cursor.y < this.lines.length - 1) {
|
|
196
|
-
this.cursor.y++;
|
|
197
|
-
this.cursor.x = 0;
|
|
198
|
-
}
|
|
337
|
+
if (this.collapseSelection('end')) break;
|
|
338
|
+
this.moveCursorRight();
|
|
199
339
|
break;
|
|
200
340
|
case '\x1b[A': case 'up':
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
|
|
204
|
-
}
|
|
341
|
+
this.selectionAnchor = null;
|
|
342
|
+
this.moveCursorUp();
|
|
205
343
|
break;
|
|
206
344
|
case '\x1b[B': case 'down':
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
|
|
210
|
-
}
|
|
345
|
+
this.selectionAnchor = null;
|
|
346
|
+
this.moveCursorDown();
|
|
211
347
|
break;
|
|
212
348
|
case '\x1b[H': case 'home':
|
|
349
|
+
this.selectionAnchor = null;
|
|
213
350
|
this.cursor.x = 0;
|
|
214
351
|
break;
|
|
215
352
|
case '\x1b[F': case 'end':
|
|
353
|
+
this.selectionAnchor = null;
|
|
216
354
|
this.cursor.x = this.lines[this.cursor.y].length;
|
|
217
355
|
break;
|
|
218
356
|
default:
|
|
219
357
|
if (key.length === 1 && key >= ' ') {
|
|
358
|
+
this.deleteSelection();
|
|
359
|
+
const line = this.lines[this.cursor.y];
|
|
220
360
|
this.lines[this.cursor.y] = line.slice(0, this.cursor.x) + key + line.slice(this.cursor.x);
|
|
221
361
|
this.cursor.x++;
|
|
222
362
|
}
|
|
223
363
|
}
|
|
364
|
+
this.finishKey(before);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Shared post-key housekeeping: clamp scroll, reset blink phase, fire
|
|
368
|
+
* onChange when the value actually changed. */
|
|
369
|
+
private finishKey(before: string): void {
|
|
224
370
|
this.clampScroll();
|
|
371
|
+
this.virtualCursor.resetPhase();
|
|
372
|
+
// Every key dispatch may have moved the cursor, changed the
|
|
373
|
+
// selection, or edited the buffer — flag the window dirty so the
|
|
374
|
+
// next Screen.render() re-emits the composed state.
|
|
375
|
+
this.markDirty();
|
|
225
376
|
if (this.getValue() !== before) this.onChange?.(this.getValue());
|
|
226
377
|
}
|
|
227
378
|
|
|
379
|
+
/** Starts a selection at the current cursor if none is active. */
|
|
380
|
+
private ensureAnchor(): void {
|
|
381
|
+
if (this.selectionAnchor === null) this.selectionAnchor = { ...this.cursor };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Removes the selected text and places the cursor at the start of the
|
|
385
|
+
* deleted range. Returns true when a deletion happened. */
|
|
386
|
+
private deleteSelection(): boolean {
|
|
387
|
+
const sel = this.getSelection();
|
|
388
|
+
if (sel === null) return false;
|
|
389
|
+
const { start, end } = sel;
|
|
390
|
+
if (start.y === end.y) {
|
|
391
|
+
const line = this.lines[start.y];
|
|
392
|
+
this.lines[start.y] = line.slice(0, start.x) + line.slice(end.x);
|
|
393
|
+
} else {
|
|
394
|
+
const head = this.lines[start.y].slice(0, start.x);
|
|
395
|
+
const tail = this.lines[end.y].slice(end.x);
|
|
396
|
+
this.lines.splice(start.y, end.y - start.y + 1, head + tail);
|
|
397
|
+
}
|
|
398
|
+
this.cursor = { ...start };
|
|
399
|
+
this.selectionAnchor = null;
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Drops an active selection, moving the cursor to the selection's
|
|
404
|
+
* `start` or `end` edge. Returns true when it did so. */
|
|
405
|
+
private collapseSelection(edge: 'start' | 'end'): boolean {
|
|
406
|
+
const sel = this.getSelection();
|
|
407
|
+
if (sel === null) return false;
|
|
408
|
+
this.cursor = { ...(edge === 'start' ? sel.start : sel.end) };
|
|
409
|
+
this.selectionAnchor = null;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Moves the cursor one cell left, wrapping to the previous line end. */
|
|
414
|
+
private moveCursorLeft(): void {
|
|
415
|
+
if (this.cursor.x > 0) {
|
|
416
|
+
this.cursor.x--;
|
|
417
|
+
} else if (this.cursor.y > 0) {
|
|
418
|
+
this.cursor.y--;
|
|
419
|
+
this.cursor.x = this.lines[this.cursor.y].length;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Moves the cursor one cell right, wrapping to the next line start. */
|
|
424
|
+
private moveCursorRight(): void {
|
|
425
|
+
const line = this.lines[this.cursor.y];
|
|
426
|
+
if (this.cursor.x < line.length) {
|
|
427
|
+
this.cursor.x++;
|
|
428
|
+
} else if (this.cursor.y < this.lines.length - 1) {
|
|
429
|
+
this.cursor.y++;
|
|
430
|
+
this.cursor.x = 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Moves the cursor up one line, clamping column to the new line length. */
|
|
435
|
+
private moveCursorUp(): void {
|
|
436
|
+
if (this.cursor.y > 0) {
|
|
437
|
+
this.cursor.y--;
|
|
438
|
+
this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Moves the cursor down one line, clamping column to the new line length. */
|
|
443
|
+
private moveCursorDown(): void {
|
|
444
|
+
if (this.cursor.y < this.lines.length - 1) {
|
|
445
|
+
this.cursor.y++;
|
|
446
|
+
this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
228
450
|
/** Rebuilds the TextArea: renders visible lines, draws cursor. */
|
|
229
451
|
public override render(): void {
|
|
230
452
|
this.clear();
|
|
@@ -244,13 +466,40 @@ export class TextArea extends Window {
|
|
|
244
466
|
}
|
|
245
467
|
}
|
|
246
468
|
|
|
247
|
-
//
|
|
248
|
-
|
|
469
|
+
// Paint the selection highlight over any cells that fall inside the
|
|
470
|
+
// active selection range. Runs before the cursor so the caret still
|
|
471
|
+
// shows on top.
|
|
472
|
+
const sel = this.focused && !this.disabled ? this.getSelection() : null;
|
|
473
|
+
if (sel !== null) {
|
|
474
|
+
const base = this.normalStyleId;
|
|
475
|
+
const merged = this.registry.merge(base, this.selectionStyleId);
|
|
476
|
+
for (let y = sel.start.y; y <= sel.end.y; y++) {
|
|
477
|
+
const row = y - this.scrollY;
|
|
478
|
+
if (row < 0 || row >= height) continue;
|
|
479
|
+
const lineText = this.lines[y];
|
|
480
|
+
const fromX = y === sel.start.y ? sel.start.x : 0;
|
|
481
|
+
const toX = y === sel.end.y ? sel.end.x : lineText.length + 1; // +1 to paint a trailing newline cell
|
|
482
|
+
for (let x = fromX; x < toX; x++) {
|
|
483
|
+
const col = x - this.scrollX;
|
|
484
|
+
if (col < 0 || col >= width) continue;
|
|
485
|
+
const ch = lineText[x] ?? ' ';
|
|
486
|
+
this.writeText(ch, { x: col, y: row, style: merged });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Draw cursor when focused and the virtual-cursor blink says "on".
|
|
492
|
+
if (this.focused && !this.disabled && this.virtualCursor.isVisible()) {
|
|
249
493
|
const screenX = this.cursor.x - this.scrollX;
|
|
250
494
|
const screenY = this.cursor.y - this.scrollY;
|
|
251
495
|
if (screenX >= 0 && screenX < width && screenY >= 0 && screenY < height) {
|
|
252
|
-
const
|
|
253
|
-
const
|
|
496
|
+
const useSymbol = this.virtualCursor.hasCustomSymbol();
|
|
497
|
+
const cursorChar = useSymbol
|
|
498
|
+
? this.virtualCursor.getSymbol()
|
|
499
|
+
: (this.lines[this.cursor.y][this.cursor.x] ?? ' ');
|
|
500
|
+
const cursorStyle = useSymbol
|
|
501
|
+
? this.normalStyleId
|
|
502
|
+
: this.registry.merge(this.normalStyleId, this.cursorStyleId);
|
|
254
503
|
this.writeText(cursorChar, { x: screenX, y: screenY, style: cursorStyle });
|
|
255
504
|
}
|
|
256
505
|
}
|