take4-console 0.15.0 → 0.25.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 (64) hide show
  1. package/CHANGELOG.md +365 -0
  2. package/README.md +1 -1
  3. package/dist/Screen/InterfaceBuilder.d.mts +15 -4
  4. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  5. package/dist/Screen/InterfaceBuilder.mjs +104 -8
  6. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  7. package/dist/Screen/Pos.d.mts +12 -0
  8. package/dist/Screen/Pos.d.mts.map +1 -1
  9. package/dist/Screen/Pos.mjs +23 -1
  10. package/dist/Screen/Pos.mjs.map +1 -1
  11. package/dist/Screen/Screen.d.mts +77 -3
  12. package/dist/Screen/Screen.d.mts.map +1 -1
  13. package/dist/Screen/Screen.mjs +168 -3
  14. package/dist/Screen/Screen.mjs.map +1 -1
  15. package/dist/Screen/Size.d.mts +49 -6
  16. package/dist/Screen/Size.d.mts.map +1 -1
  17. package/dist/Screen/Size.mjs +81 -7
  18. package/dist/Screen/Size.mjs.map +1 -1
  19. package/dist/Screen/Window.d.mts +131 -20
  20. package/dist/Screen/Window.d.mts.map +1 -1
  21. package/dist/Screen/Window.mjs +474 -57
  22. package/dist/Screen/Window.mjs.map +1 -1
  23. package/dist/Screen/WindowManager.d.mts +85 -5
  24. package/dist/Screen/WindowManager.d.mts.map +1 -1
  25. package/dist/Screen/WindowManager.mjs +279 -26
  26. package/dist/Screen/WindowManager.mjs.map +1 -1
  27. package/dist/Screen/controls/ListBox.d.mts +34 -12
  28. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  29. package/dist/Screen/controls/ListBox.mjs +127 -25
  30. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  31. package/dist/Screen/controls/TextArea.d.mts +15 -1
  32. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  33. package/dist/Screen/controls/TextArea.mjs +74 -1
  34. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  35. package/dist/Screen/controls/TextBox.d.mts +13 -1
  36. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  37. package/dist/Screen/controls/TextBox.mjs +36 -1
  38. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  39. package/dist/Screen/textWidth.d.mts +13 -0
  40. package/dist/Screen/textWidth.d.mts.map +1 -0
  41. package/dist/Screen/textWidth.mjs +188 -0
  42. package/dist/Screen/textWidth.mjs.map +1 -0
  43. package/dist/Screen/types.d.mts +336 -20
  44. package/dist/Screen/types.d.mts.map +1 -1
  45. package/dist/Screen/types.mjs.map +1 -1
  46. package/dist/index.d.mts +3 -2
  47. package/dist/index.d.mts.map +1 -1
  48. package/dist/index.mjs +3 -1
  49. package/dist/index.mjs.map +1 -1
  50. package/package.json +4 -4
  51. package/src/Screen/InterfaceBuilder.mts +116 -20
  52. package/src/Screen/Pos.mts +24 -1
  53. package/src/Screen/Screen.mts +192 -4
  54. package/src/Screen/Size.mts +97 -12
  55. package/src/Screen/Window.mts +463 -63
  56. package/src/Screen/WindowManager.mts +301 -29
  57. package/src/Screen/controls/ListBox.mts +151 -32
  58. package/src/Screen/controls/TextArea.mts +82 -1
  59. package/src/Screen/controls/TextBox.mts +40 -1
  60. package/src/Screen/textWidth.mts +186 -0
  61. package/src/Screen/types.mts +328 -23
  62. package/src/demo.mts +232 -20
  63. package/src/index.mts +23 -3
  64. package/src/layout.yaml +56 -24
@@ -1,22 +1,35 @@
1
- import type { ListBoxProperties, WindowProperties, StyleId } from '../types.mjs';
1
+ import type {
2
+ ListBoxProperties,
3
+ ListBoxRenderContext,
4
+ ListBoxRowSegment,
5
+ ListBoxRowSegments,
6
+ StyleId,
7
+ WindowProperties,
8
+ } from '../types.mjs';
2
9
  import { Window } from '../Window.mjs';
3
10
  import { getRegistry } from '../RegistryHolder.mjs';
4
11
 
5
- /** A scrollable list of single-line items. The selected item is highlighted and may
6
- * be moved with arrow keys, PgUp/PgDn, and Home/End. Emits onChange whenever the
7
- * selection index changes via keyboard. Appearance follows the same built-in style
8
- * naming scheme as the other focusable controls. */
9
- export class ListBox extends Window {
10
- private items: string[];
12
+ /** A scrollable list of items. Generic over the item type T (default: string).
13
+ * The selected item is highlighted and may be moved with arrow keys, PgUp/PgDn,
14
+ * and Home/End. Emits onChange whenever the selection index changes via keyboard.
15
+ * Consumers may customise per-row rendering via `renderItem`, which returns either a
16
+ * plain string (single left-aligned segment) or an array of styled ListBoxRowSegment
17
+ * entries supporting `left`/`right`/`fill` alignment. Appearance follows the same
18
+ * built-in style naming scheme as the other focusable controls. */
19
+ export class ListBox<T = string> extends Window {
20
+ private items: T[];
11
21
  private selectedIndex: number;
12
22
  private scrollTop: number;
13
- private onChange?: (index: number, item: string) => void;
23
+ private onChange?: (index: number, item: T) => void;
24
+ private renderItem?: (item: T, ctx: ListBoxRenderContext) => ListBoxRowSegments;
25
+ private rowHeight: number;
26
+ private keyFn?: (item: T) => string;
14
27
  private selectedStyleId: StyleId;
15
28
  private focusedSelStyle: StyleId;
16
29
 
17
30
  /** Creates a ListBox from window properties and optional control-specific properties.
18
31
  * Uses the global StyleRegistry set by the Screen constructor. */
19
- public constructor(wp: WindowProperties, cp?: ListBoxProperties) {
32
+ public constructor(wp: WindowProperties, cp?: ListBoxProperties<T>) {
20
33
  super({
21
34
  ...wp,
22
35
  defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'single' },
@@ -24,6 +37,9 @@ export class ListBox extends Window {
24
37
 
25
38
  this.items = cp?.items ?? [];
26
39
  this.onChange = cp?.onChange;
40
+ this.renderItem = cp?.renderItem;
41
+ this.rowHeight = Math.max(1, cp?.rowHeight ?? 1);
42
+ this.keyFn = cp?.keyFn;
27
43
  this.scrollTop = 0;
28
44
  this.selectedIndex = cp?.selectedIndex ?? (this.items.length > 0 ? 0 : -1);
29
45
 
@@ -34,14 +50,14 @@ export class ListBox extends Window {
34
50
  }
35
51
 
36
52
  /** Replaces the list items. Resets selection to 0 (or -1 if empty) and scrolls to top. */
37
- public setItems(items: string[]): void {
53
+ public setItems(items: T[]): void {
38
54
  this.items = items;
39
55
  this.selectedIndex = items.length > 0 ? 0 : -1;
40
56
  this.scrollTop = 0;
41
57
  }
42
58
 
43
59
  /** Returns the current list items. */
44
- public getItems(): string[] {
60
+ public getItems(): T[] {
45
61
  return this.items;
46
62
  }
47
63
 
@@ -60,18 +76,33 @@ export class ListBox extends Window {
60
76
  return this.selectedIndex;
61
77
  }
62
78
 
63
- /** Returns the currently selected item string, or undefined when nothing is selected. */
64
- public getSelectedItem(): string | undefined {
79
+ /** Returns the currently selected item, or undefined when nothing is selected. */
80
+ public getSelectedItem(): T | undefined {
65
81
  if (this.selectedIndex < 0 || this.selectedIndex >= this.items.length) return undefined;
66
82
  return this.items[this.selectedIndex];
67
83
  }
68
84
 
85
+ /** Replaces the per-row renderer after construction. Pass undefined to restore default behaviour. */
86
+ public setRenderItem(fn: ((item: T, ctx: ListBoxRenderContext) => ListBoxRowSegments) | undefined): void {
87
+ this.renderItem = fn;
88
+ }
89
+
90
+ /** Returns the configured row height in cells. */
91
+ public getRowHeight(): number {
92
+ return this.rowHeight;
93
+ }
94
+
95
+ /** Returns the stable key for an item, computed via the configured keyFn (falls back to String(item)). */
96
+ public getItemKey(item: T): string {
97
+ return this.keyFn ? this.keyFn(item) : String(item);
98
+ }
99
+
69
100
  /** Processes a key press: arrow keys, Home/End, PgUp/PgDn move the selection. */
70
101
  public handleKey(key: string): void {
71
102
  if (this.disabled || this.items.length === 0) return;
72
103
 
73
104
  const prev = this.selectedIndex;
74
- const pageSz = Math.max(1, this.getInnerSize().height);
105
+ const pageSz = Math.max(1, this.getVisibleRowCount());
75
106
 
76
107
  switch (key) {
77
108
  case '\x1b[A': // Up arrow
@@ -104,9 +135,14 @@ export class ListBox extends Window {
104
135
  }
105
136
  }
106
137
 
138
+ /** Returns the number of item slots that fit in the current inner area (>= 1). */
139
+ private getVisibleRowCount(): number {
140
+ return Math.max(1, Math.floor(this.getInnerSize().height / this.rowHeight));
141
+ }
142
+
107
143
  /** Adjusts scrollTop so that the selected index remains within the visible window. */
108
144
  private ensureVisible(): void {
109
- const visibleRows = Math.max(1, this.getInnerSize().height);
145
+ const visibleRows = this.getVisibleRowCount();
110
146
  if (this.selectedIndex < this.scrollTop) {
111
147
  this.scrollTop = this.selectedIndex;
112
148
  } else if (this.selectedIndex >= this.scrollTop + visibleRows) {
@@ -117,7 +153,7 @@ export class ListBox extends Window {
117
153
  this.scrollTop = Math.max(0, Math.min(maxScroll, this.scrollTop));
118
154
  }
119
155
 
120
- /** Rebuilds the list: renders visible rows with styles. */
156
+ /** Rebuilds the list: renders visible rows with styles and optional custom renderer. */
121
157
  public override render(): void {
122
158
  this.clear();
123
159
 
@@ -129,26 +165,109 @@ export class ListBox extends Window {
129
165
 
130
166
  this.ensureVisible();
131
167
 
132
- const visibleCount = Math.min(height, this.items.length - this.scrollTop);
133
- for (let row = 0; row < visibleCount; row++) {
134
- const itemIndex = this.scrollTop + row;
135
- const rawText = this.items[itemIndex];
136
- // Truncate to fit width; pad with spaces so the highlight covers the whole row.
137
- const text = rawText.length > width ? rawText.slice(0, width) : rawText;
138
- const padded = text + ' '.repeat(width - text.length);
139
-
140
- let style: StyleId;
141
- if (this.disabled) {
142
- style = this.disabledStyleId;
143
- } else if (itemIndex === this.selectedIndex) {
144
- style = this.focused ? this.focusedSelStyle : this.selectedStyleId;
145
- } else {
146
- style = this.normalStyleId;
168
+ const slotCount = this.getVisibleRowCount();
169
+ const visibleCount = Math.min(slotCount, this.items.length - this.scrollTop);
170
+
171
+ for (let slot = 0; slot < visibleCount; slot++) {
172
+ const itemIndex = this.scrollTop + slot;
173
+ const item = this.items[itemIndex];
174
+ const topRow = slot * this.rowHeight;
175
+
176
+ // Pick the base row style from the current focus/selection/disabled state.
177
+ const baseStyle = this.pickRowStyle(itemIndex);
178
+
179
+ // Paint every row of this slot with the base style so selection highlights span the full slot.
180
+ for (let r = 0; r < this.rowHeight && topRow + r < height; r++) {
181
+ this.writeText(' '.repeat(width), { x: 0, y: topRow + r, style: baseStyle });
147
182
  }
148
183
 
149
- this.writeText(padded, { x: 0, y: row, style });
184
+ const segments = this.resolveSegments(item, {
185
+ index: itemIndex,
186
+ focused: this.focused,
187
+ selected: itemIndex === this.selectedIndex,
188
+ width,
189
+ });
190
+
191
+ this.drawRowSegments(topRow, segments, width, baseStyle);
150
192
  }
151
193
 
152
194
  super.render();
153
195
  }
196
+
197
+ /** Returns the row-level base style ID for the given item index. */
198
+ private pickRowStyle(itemIndex: number): StyleId {
199
+ if (this.disabled) return this.disabledStyleId;
200
+ if (itemIndex === this.selectedIndex) {
201
+ return this.focused ? this.focusedSelStyle : this.selectedStyleId;
202
+ }
203
+ return this.normalStyleId;
204
+ }
205
+
206
+ /** Invokes the configured renderItem (or a default stringifier) and normalises the result into segments. */
207
+ private resolveSegments(item: T, ctx: ListBoxRenderContext): ListBoxRowSegment[] {
208
+ const out: ListBoxRowSegments = this.renderItem
209
+ ? this.renderItem(item, ctx)
210
+ : String(item);
211
+ if (typeof out === 'string') {
212
+ return [{ text: out, align: 'left' }];
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /** Draws a single row's segments onto the inner area at the given top row. */
218
+ private drawRowSegments(topRow: number, segments: ListBoxRowSegment[], width: number, baseStyle: StyleId): void {
219
+ const left: ListBoxRowSegment[] = [];
220
+ const right: ListBoxRowSegment[] = [];
221
+ let fill: ListBoxRowSegment | undefined;
222
+
223
+ for (const seg of segments) {
224
+ const align = seg.align ?? 'left';
225
+ if (align === 'left') left.push(seg);
226
+ else if (align === 'right') right.push(seg);
227
+ else if (align === 'fill' && !fill) fill = seg;
228
+ }
229
+
230
+ // Render left-aligned segments left-to-right starting at column 0.
231
+ let leftCursor = 0;
232
+ for (const seg of left) {
233
+ if (leftCursor >= width) break;
234
+ const available = width - leftCursor;
235
+ const text = seg.text.length > available ? seg.text.slice(0, available) : seg.text;
236
+ const style = this.segmentStyle(seg, baseStyle);
237
+ this.writeText(text, { x: leftCursor, y: topRow, style });
238
+ leftCursor += text.length;
239
+ }
240
+
241
+ // Measure right-aligned segments and render them starting at their computed offset.
242
+ let rightWidth = 0;
243
+ for (const seg of right) rightWidth += seg.text.length;
244
+ let rightCursor = Math.max(leftCursor, width - rightWidth);
245
+ for (const seg of right) {
246
+ if (rightCursor >= width) break;
247
+ const available = width - rightCursor;
248
+ const text = seg.text.length > available ? seg.text.slice(0, available) : seg.text;
249
+ const style = this.segmentStyle(seg, baseStyle);
250
+ this.writeText(text, { x: rightCursor, y: topRow, style });
251
+ rightCursor += text.length;
252
+ }
253
+
254
+ // Render the single fill segment (if any) between the left and right groups.
255
+ if (fill) {
256
+ const fillStart = leftCursor;
257
+ const fillEnd = Math.max(fillStart, width - rightWidth);
258
+ const fillWidth = fillEnd - fillStart;
259
+ if (fillWidth > 0) {
260
+ const text = fill.text.length >= fillWidth
261
+ ? fill.text.slice(0, fillWidth)
262
+ : fill.text + ' '.repeat(fillWidth - fill.text.length);
263
+ const style = this.segmentStyle(fill, baseStyle);
264
+ this.writeText(text, { x: fillStart, y: topRow, style });
265
+ }
266
+ }
267
+ }
268
+
269
+ /** Merges a segment's optional style onto the row's base style, preserving the highlight background. */
270
+ private segmentStyle(seg: ListBoxRowSegment, baseStyle: StyleId): StyleId {
271
+ return seg.style !== undefined ? this.registry.merge(baseStyle, seg.style) : baseStyle;
272
+ }
154
273
  }
@@ -14,6 +14,12 @@ export class TextArea extends Window {
14
14
  private placeholderStyleId: StyleId;
15
15
  private cursorStyleId: StyleId;
16
16
 
17
+ private onChange?: (value: string) => void;
18
+ private onSubmit?: (value: string) => void;
19
+ private onKeyDown?: (key: string) => boolean | void;
20
+ private insertTabAsSpaces: number;
21
+ private ctrlDDeletesForward: boolean;
22
+
17
23
  /** Creates a TextArea from window properties and optional control-specific properties.
18
24
  * Uses the global StyleRegistry set by the Screen constructor. */
19
25
  public constructor(wp: WindowProperties, cp?: TextAreaProperties) {
@@ -34,6 +40,12 @@ export class TextArea extends Window {
34
40
  };
35
41
  this.cursor.x = Math.max(0, Math.min(rawCursor.x, this.lines[this.cursor.y].length));
36
42
 
43
+ this.onChange = cp?.onChange;
44
+ this.onSubmit = cp?.onSubmit;
45
+ this.onKeyDown = cp?.onKeyDown;
46
+ this.insertTabAsSpaces = Math.max(0, cp?.insertTabAsSpaces ?? 0);
47
+ this.ctrlDDeletesForward = cp?.ctrlDDeletesForward ?? false;
48
+
37
49
  const reg = getRegistry();
38
50
  this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
39
51
  this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
@@ -41,7 +53,7 @@ export class TextArea extends Window {
41
53
  this.clampScroll();
42
54
  }
43
55
 
44
- /** Replaces the current value; cursor and scroll are clamped to fit. */
56
+ /** Replaces the current value; cursor and scroll are clamped to fit. Does NOT fire onChange. */
45
57
  public setValue(text: string): void {
46
58
  this.lines = text.split('\n');
47
59
  this.cursor.y = Math.min(this.cursor.y, this.lines.length - 1);
@@ -49,6 +61,27 @@ export class TextArea extends Window {
49
61
  this.clampScroll();
50
62
  }
51
63
 
64
+ /** Replaces the onChange callback. */
65
+ public setOnChange(fn?: (value: string) => void): void {
66
+ this.onChange = fn;
67
+ }
68
+
69
+ /** Replaces the onSubmit callback. */
70
+ public setOnSubmit(fn?: (value: string) => void): void {
71
+ this.onSubmit = fn;
72
+ }
73
+
74
+ /** Replaces the onKeyDown pre-dispatch hook. */
75
+ public setOnKeyDown(fn?: (key: string) => boolean | void): void {
76
+ this.onKeyDown = fn;
77
+ }
78
+
79
+ /** Opt-in to Tab interception: when `insertTabAsSpaces > 0`, the
80
+ * WindowManager forwards Tab to handleKey() instead of cycling focus. */
81
+ public capturesTab(): boolean {
82
+ return this.insertTabAsSpaces > 0;
83
+ }
84
+
52
85
  /** Returns the current text value (lines joined with '\n'). */
53
86
  public getValue(): string {
54
87
  return this.lines.join('\n');
@@ -73,6 +106,53 @@ export class TextArea extends Window {
73
106
  * Any single printable character is inserted at the cursor. */
74
107
  public handleKey(key: string): void {
75
108
  if (this.disabled) return;
109
+
110
+ // Give the caller a chance to intercept before built-in logic runs.
111
+ if (this.onKeyDown?.(key) === true) {
112
+ this.clampScroll();
113
+ return;
114
+ }
115
+
116
+ const before = this.getValue();
117
+
118
+ // Tab → soft-tab insert when configured, otherwise pass-through.
119
+ if ((key === '\t' || key === 'tab') && this.insertTabAsSpaces > 0) {
120
+ const spaces = ' '.repeat(this.insertTabAsSpaces);
121
+ const lineTab = this.lines[this.cursor.y];
122
+ this.lines[this.cursor.y] = lineTab.slice(0, this.cursor.x) + spaces + lineTab.slice(this.cursor.x);
123
+ this.cursor.x += spaces.length;
124
+ this.clampScroll();
125
+ if (this.getValue() !== before) this.onChange?.(this.getValue());
126
+ return;
127
+ }
128
+ if (key === '\t' || key === 'tab') {
129
+ // insertTabAsSpaces === 0 → leave Tab for WindowManager focus cycling.
130
+ return;
131
+ }
132
+
133
+ // Ctrl+D forward-delete when enabled — same behaviour as \x1b[3~.
134
+ 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);
141
+ }
142
+ this.clampScroll();
143
+ if (this.getValue() !== before) this.onChange?.(this.getValue());
144
+ return;
145
+ }
146
+
147
+ // Ctrl+Enter submits without inserting a newline. In xterm this arrives
148
+ // as '\x1b\r' (ESC + CR) or '\n' depending on terminal; we treat the
149
+ // explicit alias 'ctrl+enter' plus '\n' (LF on its own) as the submit
150
+ // key, keeping plain '\r' for newline insertion.
151
+ if (key === 'ctrl+enter') {
152
+ this.onSubmit?.(this.getValue());
153
+ return;
154
+ }
155
+
76
156
  const line = this.lines[this.cursor.y];
77
157
  switch (key) {
78
158
  case '\x7f': case '\b': case 'backspace':
@@ -142,6 +222,7 @@ export class TextArea extends Window {
142
222
  }
143
223
  }
144
224
  this.clampScroll();
225
+ if (this.getValue() !== before) this.onChange?.(this.getValue());
145
226
  }
146
227
 
147
228
  /** Rebuilds the TextArea: renders visible lines, draws cursor. */
@@ -14,6 +14,13 @@ export class TextBox extends Window {
14
14
  private placeholderStyleId: StyleId;
15
15
  private cursorStyleId: StyleId;
16
16
 
17
+ /** Invoked after every value change driven by handleKey. Not called by setValue. */
18
+ private onChange?: (value: string) => void;
19
+ /** Invoked when Enter is pressed while focused. */
20
+ private onSubmit?: (value: string) => void;
21
+ /** Pre-dispatch hook — return true to short-circuit built-in handling. */
22
+ private onKeyDown?: (key: string) => boolean | void;
23
+
17
24
  /** Creates a TextBox from window properties and optional control-specific properties.
18
25
  * Uses the global StyleRegistry set by the Screen constructor. */
19
26
  public constructor(wp: WindowProperties, cp?: TextBoxProperties) {
@@ -29,6 +36,10 @@ export class TextBox extends Window {
29
36
  ? Math.max(0, Math.min(cp.cursor, this.value.length))
30
37
  : this.value.length;
31
38
 
39
+ this.onChange = cp?.onChange;
40
+ this.onSubmit = cp?.onSubmit;
41
+ this.onKeyDown = cp?.onKeyDown;
42
+
32
43
  const reg = getRegistry();
33
44
  this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
34
45
  this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
@@ -36,13 +47,28 @@ export class TextBox extends Window {
36
47
  this.clampScroll();
37
48
  }
38
49
 
39
- /** Replaces the current value; clamps cursor and scroll to fit. */
50
+ /** Replaces the current value; clamps cursor and scroll to fit. Does NOT fire onChange. */
40
51
  public setValue(value: string): void {
41
52
  this.value = value;
42
53
  this.cursor = Math.min(this.cursor, value.length);
43
54
  this.clampScroll();
44
55
  }
45
56
 
57
+ /** Replaces the onChange callback (passing undefined clears it). */
58
+ public setOnChange(fn?: (value: string) => void): void {
59
+ this.onChange = fn;
60
+ }
61
+
62
+ /** Replaces the onSubmit callback. */
63
+ public setOnSubmit(fn?: (value: string) => void): void {
64
+ this.onSubmit = fn;
65
+ }
66
+
67
+ /** Replaces the onKeyDown pre-dispatch hook. */
68
+ public setOnKeyDown(fn?: (key: string) => boolean | void): void {
69
+ this.onKeyDown = fn;
70
+ }
71
+
46
72
  /** Returns the current text value. */
47
73
  public getValue(): string {
48
74
  return this.value;
@@ -66,7 +92,19 @@ export class TextBox extends Window {
66
92
  * Any single printable character is inserted at the cursor position. */
67
93
  public handleKey(key: string): void {
68
94
  if (this.disabled) return;
95
+
96
+ // Give the caller a chance to intercept before built-in logic runs.
97
+ if (this.onKeyDown?.(key) === true) {
98
+ this.clampScroll();
99
+ return;
100
+ }
101
+
102
+ const before = this.value;
69
103
  switch (key) {
104
+ case '\r': case '\n': case 'enter':
105
+ this.onSubmit?.(this.value);
106
+ this.clampScroll();
107
+ return; // Enter never mutates value by itself in TextBox.
70
108
  case '\x7f': case '\b': case 'backspace':
71
109
  if (this.cursor > 0) {
72
110
  this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
@@ -97,6 +135,7 @@ export class TextBox extends Window {
97
135
  }
98
136
  }
99
137
  this.clampScroll();
138
+ if (this.value !== before) this.onChange?.(this.value);
100
139
  }
101
140
 
102
141
  /** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
@@ -0,0 +1,186 @@
1
+ // Unicode display width for monospace terminals.
2
+ //
3
+ // Maps a codepoint to its visible cell width (0, 1 or 2). Used by
4
+ // Window.writeText so wide characters (CJK, emoji, NerdFonts double-width)
5
+ // occupy two consecutive cells in the buffer, and by Window.getTextWidth /
6
+ // stringWidth() so consumers can size labels correctly.
7
+ //
8
+ // The internal tables are derived from the Unicode East Asian Width data
9
+ // (W and F categories) plus a curated set of zero-width / control / combining
10
+ // ranges. They are intentionally compact rather than exhaustive — the goal is
11
+ // "good enough for labels and log output", not full Unicode normalization.
12
+
13
+ /** Configurable display width for the Private Use Area (NerdFonts glyphs).
14
+ * Most NerdFonts ship single-cell glyphs but some patched fonts use 2 cells. */
15
+ let puaWidth: 1 | 2 = 1;
16
+
17
+ /** Sets the display width used for codepoints in the Unicode Private Use Areas
18
+ * (U+E000–F8FF, U+F0000–FFFFD, U+100000–10FFFD). NerdFonts glyphs typically
19
+ * render as one cell; some patched fonts render them as two cells. Default: 1. */
20
+ export const setPuaWidth = (width: 1 | 2): void => {
21
+ puaWidth = width;
22
+ };
23
+
24
+ /** Returns the currently configured Private Use Area width. */
25
+ export const getPuaWidth = (): 1 | 2 => puaWidth;
26
+
27
+ /** Zero-width ranges: control characters, combining marks, format characters,
28
+ * variation selectors, joiners. Sorted by start codepoint for binary search. */
29
+ const ZERO_RANGES: ReadonlyArray<readonly [number, number]> = [
30
+ [0x0000, 0x001F],
31
+ [0x007F, 0x009F],
32
+ [0x00AD, 0x00AD],
33
+ [0x0300, 0x036F],
34
+ [0x0483, 0x0489],
35
+ [0x0591, 0x05BD],
36
+ [0x05BF, 0x05BF],
37
+ [0x05C1, 0x05C2],
38
+ [0x05C4, 0x05C5],
39
+ [0x05C7, 0x05C7],
40
+ [0x0610, 0x061A],
41
+ [0x061C, 0x061C],
42
+ [0x064B, 0x065F],
43
+ [0x0670, 0x0670],
44
+ [0x06D6, 0x06DC],
45
+ [0x06DF, 0x06E4],
46
+ [0x06E7, 0x06E8],
47
+ [0x06EA, 0x06ED],
48
+ [0x0711, 0x0711],
49
+ [0x0730, 0x074A],
50
+ [0x07A6, 0x07B0],
51
+ [0x07EB, 0x07F3],
52
+ [0x07FD, 0x07FD],
53
+ [0x0816, 0x0819],
54
+ [0x081B, 0x0823],
55
+ [0x0825, 0x0827],
56
+ [0x0829, 0x082D],
57
+ [0x0859, 0x085B],
58
+ [0x08D3, 0x08E1],
59
+ [0x08E3, 0x0902],
60
+ [0x093A, 0x093A],
61
+ [0x093C, 0x093C],
62
+ [0x0941, 0x0948],
63
+ [0x094D, 0x094D],
64
+ [0x0951, 0x0957],
65
+ [0x0962, 0x0963],
66
+ [0x180B, 0x180E],
67
+ [0x200B, 0x200F],
68
+ [0x202A, 0x202E],
69
+ [0x2060, 0x2064],
70
+ [0x2066, 0x206F],
71
+ [0xFE00, 0xFE0F],
72
+ [0xFEFF, 0xFEFF],
73
+ [0xE0100, 0xE01EF],
74
+ ];
75
+
76
+ /** Wide (W or F) ranges: characters that occupy two terminal cells. */
77
+ const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
78
+ [0x1100, 0x115F],
79
+ [0x231A, 0x231B],
80
+ [0x2329, 0x232A],
81
+ [0x23E9, 0x23EC],
82
+ [0x23F0, 0x23F0],
83
+ [0x23F3, 0x23F3],
84
+ [0x25FD, 0x25FE],
85
+ [0x2614, 0x2615],
86
+ [0x2648, 0x2653],
87
+ [0x267F, 0x267F],
88
+ [0x2693, 0x2693],
89
+ [0x26A1, 0x26A1],
90
+ [0x26AA, 0x26AB],
91
+ [0x26BD, 0x26BE],
92
+ [0x26C4, 0x26C5],
93
+ [0x26CE, 0x26CE],
94
+ [0x26D4, 0x26D4],
95
+ [0x26EA, 0x26EA],
96
+ [0x26F2, 0x26F3],
97
+ [0x26F5, 0x26F5],
98
+ [0x26FA, 0x26FA],
99
+ [0x26FD, 0x26FD],
100
+ [0x2705, 0x2705],
101
+ [0x270A, 0x270B],
102
+ [0x2728, 0x2728],
103
+ [0x274C, 0x274C],
104
+ [0x274E, 0x274E],
105
+ [0x2753, 0x2755],
106
+ [0x2757, 0x2757],
107
+ [0x2795, 0x2797],
108
+ [0x27B0, 0x27B0],
109
+ [0x27BF, 0x27BF],
110
+ [0x2B1B, 0x2B1C],
111
+ [0x2B50, 0x2B50],
112
+ [0x2B55, 0x2B55],
113
+ [0x2E80, 0x303E],
114
+ [0x3041, 0x33FF],
115
+ [0x3400, 0x4DBF],
116
+ [0x4E00, 0x9FFF],
117
+ [0xA000, 0xA4CF],
118
+ [0xA960, 0xA97F],
119
+ [0xAC00, 0xD7A3],
120
+ [0xF900, 0xFAFF],
121
+ [0xFE10, 0xFE19],
122
+ [0xFE30, 0xFE6F],
123
+ [0xFF00, 0xFF60],
124
+ [0xFFE0, 0xFFE6],
125
+ [0x16FE0, 0x16FE4],
126
+ [0x17000, 0x187F7],
127
+ [0x18800, 0x18AFF],
128
+ [0x1B000, 0x1B11F],
129
+ [0x1F004, 0x1F004],
130
+ [0x1F0CF, 0x1F0CF],
131
+ [0x1F18E, 0x1F18E],
132
+ [0x1F191, 0x1F19A],
133
+ [0x1F200, 0x1F320],
134
+ [0x1F330, 0x1F335],
135
+ [0x1F337, 0x1F37C],
136
+ [0x1F380, 0x1F39F],
137
+ [0x1F3A0, 0x1F3FA],
138
+ [0x1F400, 0x1F4FD],
139
+ [0x1F500, 0x1F53D],
140
+ [0x1F549, 0x1F54E],
141
+ [0x1F550, 0x1F567],
142
+ [0x1F57A, 0x1F57A],
143
+ [0x1F595, 0x1F596],
144
+ [0x1F5A4, 0x1F5A4],
145
+ [0x1F5FB, 0x1F64F],
146
+ [0x1F680, 0x1F6FF],
147
+ [0x1F900, 0x1F9FF],
148
+ [0x1FA70, 0x1FAFF],
149
+ [0x20000, 0x2FFFD],
150
+ [0x30000, 0x3FFFD],
151
+ ];
152
+
153
+ /** Binary search: returns true when codepoint lies inside any [start, end] interval. */
154
+ const inRanges = (cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean => {
155
+ let lo = 0;
156
+ let hi = ranges.length - 1;
157
+ while (lo <= hi) {
158
+ const mid = (lo + hi) >>> 1;
159
+ const r = ranges[mid];
160
+ if (cp < r[0]) hi = mid - 1;
161
+ else if (cp > r[1]) lo = mid + 1;
162
+ else return true;
163
+ }
164
+ return false;
165
+ };
166
+
167
+ /** Returns the display width (0, 1 or 2 cells) of a single Unicode codepoint. */
168
+ export const charWidth = (codepoint: number): 0 | 1 | 2 => {
169
+ // Private Use Areas — checked first because they overlap with neither
170
+ // zero nor wide tables and we want the configured width to win.
171
+ if (codepoint >= 0xE000 && codepoint <= 0xF8FF) return puaWidth;
172
+ if (codepoint >= 0xF0000 && codepoint <= 0xFFFFD) return puaWidth;
173
+ if (codepoint >= 0x100000 && codepoint <= 0x10FFFD) return puaWidth;
174
+ if (inRanges(codepoint, ZERO_RANGES)) return 0;
175
+ if (inRanges(codepoint, WIDE_RANGES)) return 2;
176
+ return 1;
177
+ };
178
+
179
+ /** Returns the total display width of a string in monospace terminal cells.
180
+ * Iterates by codepoint, so surrogate pairs are counted once with their
181
+ * combined width (typically 2 for emoji and supplementary CJK). */
182
+ export const stringWidth = (str: string): number => {
183
+ let total = 0;
184
+ for (const ch of str) total += charWidth(ch.codePointAt(0)!);
185
+ return total;
186
+ };