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.
- package/CHANGELOG.md +365 -0
- package/README.md +1 -1
- package/dist/Screen/InterfaceBuilder.d.mts +15 -4
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +104 -8
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Pos.d.mts +12 -0
- package/dist/Screen/Pos.d.mts.map +1 -1
- package/dist/Screen/Pos.mjs +23 -1
- package/dist/Screen/Pos.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +77 -3
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +168 -3
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/Size.d.mts +49 -6
- package/dist/Screen/Size.d.mts.map +1 -1
- package/dist/Screen/Size.mjs +81 -7
- package/dist/Screen/Size.mjs.map +1 -1
- package/dist/Screen/Window.d.mts +131 -20
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +474 -57
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +85 -5
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +279 -26
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/ListBox.d.mts +34 -12
- package/dist/Screen/controls/ListBox.d.mts.map +1 -1
- package/dist/Screen/controls/ListBox.mjs +127 -25
- package/dist/Screen/controls/ListBox.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +15 -1
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +74 -1
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +13 -1
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +36 -1
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/textWidth.d.mts +13 -0
- package/dist/Screen/textWidth.d.mts.map +1 -0
- package/dist/Screen/textWidth.mjs +188 -0
- package/dist/Screen/textWidth.mjs.map +1 -0
- package/dist/Screen/types.d.mts +336 -20
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +3 -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 +4 -4
- package/src/Screen/InterfaceBuilder.mts +116 -20
- package/src/Screen/Pos.mts +24 -1
- package/src/Screen/Screen.mts +192 -4
- package/src/Screen/Size.mts +97 -12
- package/src/Screen/Window.mts +463 -63
- package/src/Screen/WindowManager.mts +301 -29
- package/src/Screen/controls/ListBox.mts +151 -32
- package/src/Screen/controls/TextArea.mts +82 -1
- package/src/Screen/controls/TextBox.mts +40 -1
- package/src/Screen/textWidth.mts +186 -0
- package/src/Screen/types.mts +328 -23
- package/src/demo.mts +232 -20
- package/src/index.mts +23 -3
- package/src/layout.yaml +56 -24
|
@@ -1,22 +1,35 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
6
|
-
* be moved with arrow keys, PgUp/PgDn,
|
|
7
|
-
* selection index changes via keyboard.
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
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:
|
|
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():
|
|
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
|
|
64
|
-
public getSelectedItem():
|
|
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.
|
|
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 =
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
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
|
+
};
|