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.
Files changed (107) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +3 -2
  3. package/dist/Screen/ErrorHolder.d.mts +10 -0
  4. package/dist/Screen/ErrorHolder.d.mts.map +1 -0
  5. package/dist/Screen/ErrorHolder.mjs +14 -0
  6. package/dist/Screen/ErrorHolder.mjs.map +1 -0
  7. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  8. package/dist/Screen/InterfaceBuilder.mjs +7 -0
  9. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  10. package/dist/Screen/Screen.d.mts +90 -1
  11. package/dist/Screen/Screen.d.mts.map +1 -1
  12. package/dist/Screen/Screen.mjs +300 -1
  13. package/dist/Screen/Screen.mjs.map +1 -1
  14. package/dist/Screen/StyleRegistry.d.mts.map +1 -1
  15. package/dist/Screen/StyleRegistry.mjs +3 -1
  16. package/dist/Screen/StyleRegistry.mjs.map +1 -1
  17. package/dist/Screen/VirtualCursor.d.mts +57 -0
  18. package/dist/Screen/VirtualCursor.d.mts.map +1 -0
  19. package/dist/Screen/VirtualCursor.mjs +148 -0
  20. package/dist/Screen/VirtualCursor.mjs.map +1 -0
  21. package/dist/Screen/Window.d.mts +116 -6
  22. package/dist/Screen/Window.d.mts.map +1 -1
  23. package/dist/Screen/Window.mjs +359 -34
  24. package/dist/Screen/Window.mjs.map +1 -1
  25. package/dist/Screen/WindowManager.d.mts +78 -2
  26. package/dist/Screen/WindowManager.d.mts.map +1 -1
  27. package/dist/Screen/WindowManager.mjs +197 -3
  28. package/dist/Screen/WindowManager.mjs.map +1 -1
  29. package/dist/Screen/controls/BarChart.d.mts.map +1 -1
  30. package/dist/Screen/controls/BarChart.mjs +3 -0
  31. package/dist/Screen/controls/BarChart.mjs.map +1 -1
  32. package/dist/Screen/controls/Checkbox.d.mts.map +1 -1
  33. package/dist/Screen/controls/Checkbox.mjs +4 -0
  34. package/dist/Screen/controls/Checkbox.mjs.map +1 -1
  35. package/dist/Screen/controls/LineChart.d.mts.map +1 -1
  36. package/dist/Screen/controls/LineChart.mjs +3 -0
  37. package/dist/Screen/controls/LineChart.mjs.map +1 -1
  38. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  39. package/dist/Screen/controls/ListBox.mjs +9 -0
  40. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  41. package/dist/Screen/controls/ProgressBar.d.mts.map +1 -1
  42. package/dist/Screen/controls/ProgressBar.mjs +2 -0
  43. package/dist/Screen/controls/ProgressBar.mjs.map +1 -1
  44. package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -1
  45. package/dist/Screen/controls/ProgressBarV.mjs +2 -0
  46. package/dist/Screen/controls/ProgressBarV.mjs.map +1 -1
  47. package/dist/Screen/controls/Radio.d.mts.map +1 -1
  48. package/dist/Screen/controls/Radio.mjs +7 -1
  49. package/dist/Screen/controls/Radio.mjs.map +1 -1
  50. package/dist/Screen/controls/Sparkline.d.mts.map +1 -1
  51. package/dist/Screen/controls/Sparkline.mjs +3 -0
  52. package/dist/Screen/controls/Sparkline.mjs.map +1 -1
  53. package/dist/Screen/controls/Spinner.d.mts.map +1 -1
  54. package/dist/Screen/controls/Spinner.mjs +8 -0
  55. package/dist/Screen/controls/Spinner.mjs.map +1 -1
  56. package/dist/Screen/controls/StatusLED.d.mts.map +1 -1
  57. package/dist/Screen/controls/StatusLED.mjs +3 -0
  58. package/dist/Screen/controls/StatusLED.mjs.map +1 -1
  59. package/dist/Screen/controls/Tabs.d.mts.map +1 -1
  60. package/dist/Screen/controls/Tabs.mjs +2 -0
  61. package/dist/Screen/controls/Tabs.mjs.map +1 -1
  62. package/dist/Screen/controls/TextArea.d.mts +68 -2
  63. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  64. package/dist/Screen/controls/TextArea.mjs +291 -46
  65. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  66. package/dist/Screen/controls/TextBox.d.mts +52 -5
  67. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  68. package/dist/Screen/controls/TextBox.mjs +192 -10
  69. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  70. package/dist/Screen/controls/Toast.d.mts +72 -0
  71. package/dist/Screen/controls/Toast.d.mts.map +1 -0
  72. package/dist/Screen/controls/Toast.mjs +112 -0
  73. package/dist/Screen/controls/Toast.mjs.map +1 -0
  74. package/dist/Screen/types.d.mts +169 -0
  75. package/dist/Screen/types.d.mts.map +1 -1
  76. package/dist/Screen/types.mjs +8 -0
  77. package/dist/Screen/types.mjs.map +1 -1
  78. package/dist/index.d.mts +4 -2
  79. package/dist/index.d.mts.map +1 -1
  80. package/dist/index.mjs +3 -1
  81. package/dist/index.mjs.map +1 -1
  82. package/package.json +1 -1
  83. package/src/Screen/ErrorHolder.mts +22 -0
  84. package/src/Screen/InterfaceBuilder.mts +12 -5
  85. package/src/Screen/Screen.mts +313 -2
  86. package/src/Screen/StyleRegistry.mts +4 -0
  87. package/src/Screen/VirtualCursor.mts +175 -0
  88. package/src/Screen/Window.mts +352 -34
  89. package/src/Screen/WindowManager.mts +203 -3
  90. package/src/Screen/controls/BarChart.mts +3 -0
  91. package/src/Screen/controls/Checkbox.mts +3 -0
  92. package/src/Screen/controls/LineChart.mts +3 -0
  93. package/src/Screen/controls/ListBox.mts +8 -0
  94. package/src/Screen/controls/ProgressBar.mts +2 -0
  95. package/src/Screen/controls/ProgressBarV.mts +2 -0
  96. package/src/Screen/controls/Radio.mts +6 -1
  97. package/src/Screen/controls/Sparkline.mts +3 -0
  98. package/src/Screen/controls/Spinner.mts +6 -0
  99. package/src/Screen/controls/StatusLED.mts +2 -0
  100. package/src/Screen/controls/Tabs.mts +2 -0
  101. package/src/Screen/controls/TextArea.mts +290 -41
  102. package/src/Screen/controls/TextBox.mts +193 -10
  103. package/src/Screen/controls/Toast.mts +138 -0
  104. package/src/Screen/types.mts +167 -0
  105. package/src/demo.mts +131 -0
  106. package/src/index.mts +13 -0
  107. 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
- /** Replaces the current value; cursor and scroll are clamped to fit. Does NOT fire onChange. */
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.clampScroll();
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
- const lineD = this.lines[this.cursor.y];
136
- if (this.cursor.x < lineD.length) {
137
- this.lines[this.cursor.y] = lineD.slice(0, this.cursor.x) + lineD.slice(this.cursor.x + 1);
138
- } else if (this.cursor.y < this.lines.length - 1) {
139
- this.lines[this.cursor.y] = lineD + this.lines[this.cursor.y + 1];
140
- this.lines.splice(this.cursor.y + 1, 1);
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.clampScroll();
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
- case '\x1b[3~': case 'delete':
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
- case '\r': case '\n': case 'enter':
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.cursor.x > 0) {
186
- this.cursor.x--;
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.cursor.x < line.length) {
194
- this.cursor.x++;
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
- if (this.cursor.y > 0) {
202
- this.cursor.y--;
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
- if (this.cursor.y < this.lines.length - 1) {
208
- this.cursor.y++;
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
- // Draw cursor when focused.
248
- if (this.focused && !this.disabled) {
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 cursorChar = this.lines[this.cursor.y][this.cursor.x] ?? ' ';
253
- const cursorStyle = this.registry.merge(this.normalStyleId, this.cursorStyleId);
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
  }