@ice1/glyphx 0.1.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 +3 -0
- package/LICENSE +201 -0
- package/README.md +151 -0
- package/build/index.d.ts +306 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1292 -0
- package/build/index.js.map +1 -0
- package/package.json +37 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.ContainerGroup = exports.Container = exports.Textarea = exports.Button = exports.DangerButtonRenderer = exports.PrimaryButtonRenderer = exports.DefaultButtonRenderer = exports.CheckBox = exports.Radio = exports.Select = exports.Input = exports.Command = exports.Label = exports.Widget = exports.ScrollState = exports.FocusManager = exports.Event = exports.Cursor = exports.Screen = exports.TerminalWriter = void 0;
|
|
40
|
+
const readline = __importStar(require("readline"));
|
|
41
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
42
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
// § 2. INFRASTRUCTURE (concrete I/O implementations)
|
|
44
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
45
|
+
/** Default ITerminalWriter backed by process.stdout + ANSI escape codes. */
|
|
46
|
+
class TerminalWriter {
|
|
47
|
+
moveTo(row, col) {
|
|
48
|
+
process.stdout.write(`\x1b[${row};${col}H`);
|
|
49
|
+
}
|
|
50
|
+
write(text) {
|
|
51
|
+
process.stdout.write(text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.TerminalWriter = TerminalWriter;
|
|
55
|
+
/** Module-level singleton; injected into every widget by default. */
|
|
56
|
+
const defaultWriter = new TerminalWriter();
|
|
57
|
+
// ── Screen ───────────────────────────────────────────────────────────────────
|
|
58
|
+
class Screen {
|
|
59
|
+
active = false;
|
|
60
|
+
enter() {
|
|
61
|
+
if (this.active)
|
|
62
|
+
return;
|
|
63
|
+
this.active = true;
|
|
64
|
+
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
65
|
+
}
|
|
66
|
+
exit() {
|
|
67
|
+
if (!this.active)
|
|
68
|
+
return;
|
|
69
|
+
this.active = false;
|
|
70
|
+
process.stdout.write("\x1b[?1049l");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.Screen = Screen;
|
|
74
|
+
// ── Legacy static Cursor (kept for external callers) ─────────────────────────
|
|
75
|
+
class Cursor {
|
|
76
|
+
static moveTo(row, col) {
|
|
77
|
+
defaultWriter.moveTo(row, col);
|
|
78
|
+
}
|
|
79
|
+
static hide() {
|
|
80
|
+
process.stdout.write("\x1b[?25l");
|
|
81
|
+
}
|
|
82
|
+
static show() {
|
|
83
|
+
process.stdout.write("\x1b[?25h");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.Cursor = Cursor;
|
|
87
|
+
class Event {
|
|
88
|
+
proc;
|
|
89
|
+
screen;
|
|
90
|
+
subEvents = [];
|
|
91
|
+
constructor(proc, screen) {
|
|
92
|
+
this.proc = proc;
|
|
93
|
+
this.screen = screen;
|
|
94
|
+
readline.emitKeypressEvents(proc.stdin);
|
|
95
|
+
proc.stdin.resume();
|
|
96
|
+
}
|
|
97
|
+
setup() {
|
|
98
|
+
this.proc.stdin.on("keypress", (_str, key) => {
|
|
99
|
+
if (key?.ctrl && key.name === "c") {
|
|
100
|
+
this.screen.exit();
|
|
101
|
+
this.proc.exit(0);
|
|
102
|
+
}
|
|
103
|
+
[...this.subEvents].forEach((e) => e.next(key));
|
|
104
|
+
});
|
|
105
|
+
["SIGINT", "SIGTERM"].forEach((sig) => {
|
|
106
|
+
this.proc.on(sig, () => {
|
|
107
|
+
this.screen.exit();
|
|
108
|
+
this.proc.exit(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
add(sub) {
|
|
113
|
+
if (!this.subEvents.find((e) => e.name === sub.name))
|
|
114
|
+
this.subEvents.push(sub);
|
|
115
|
+
}
|
|
116
|
+
remove(name) {
|
|
117
|
+
this.subEvents = this.subEvents.filter((e) => e.name !== name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.Event = Event;
|
|
121
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
122
|
+
// § 3. STATE HELPERS (SRP: each class owns exactly one concern)
|
|
123
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
124
|
+
// ── FocusManager (SRP: focus-index bookkeeping extracted from Container) ────
|
|
125
|
+
class FocusManager {
|
|
126
|
+
_focusables;
|
|
127
|
+
_idx = -1;
|
|
128
|
+
constructor(_focusables) {
|
|
129
|
+
this._focusables = _focusables;
|
|
130
|
+
}
|
|
131
|
+
get index() {
|
|
132
|
+
return this._idx;
|
|
133
|
+
}
|
|
134
|
+
get current() {
|
|
135
|
+
return this._focusables[this._idx];
|
|
136
|
+
}
|
|
137
|
+
get hasFocus() {
|
|
138
|
+
return this._idx >= 0;
|
|
139
|
+
}
|
|
140
|
+
focusAt(i) {
|
|
141
|
+
this._blurActive();
|
|
142
|
+
this._idx = Math.max(0, Math.min(i, this._focusables.length - 1));
|
|
143
|
+
this.current?.focus();
|
|
144
|
+
}
|
|
145
|
+
cycle(direction) {
|
|
146
|
+
const len = this._focusables.length;
|
|
147
|
+
if (len === 0)
|
|
148
|
+
return;
|
|
149
|
+
this._blurActive();
|
|
150
|
+
this._idx = (this._idx + direction + len) % len;
|
|
151
|
+
this.current?.focus();
|
|
152
|
+
}
|
|
153
|
+
clear() {
|
|
154
|
+
this._blurActive();
|
|
155
|
+
this._idx = -1;
|
|
156
|
+
}
|
|
157
|
+
initFirst() {
|
|
158
|
+
if (this._focusables.length > 0 && this._idx < 0)
|
|
159
|
+
this.focusAt(0);
|
|
160
|
+
}
|
|
161
|
+
_blurActive() {
|
|
162
|
+
this.current?.blur();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
exports.FocusManager = FocusManager;
|
|
166
|
+
// ── ScrollState (SRP: 2-D scroll math extracted from Container & Textarea) ──
|
|
167
|
+
class ScrollState {
|
|
168
|
+
_x = 0;
|
|
169
|
+
_y = 0;
|
|
170
|
+
get x() {
|
|
171
|
+
return this._x;
|
|
172
|
+
}
|
|
173
|
+
get y() {
|
|
174
|
+
return this._y;
|
|
175
|
+
}
|
|
176
|
+
setX(value, max) {
|
|
177
|
+
this._x = Math.max(0, Math.min(value, max));
|
|
178
|
+
}
|
|
179
|
+
setY(value, max) {
|
|
180
|
+
this._y = Math.max(0, Math.min(value, max));
|
|
181
|
+
}
|
|
182
|
+
/** Ensure a cursor column stays within a viewport of `viewportWidth`. */
|
|
183
|
+
clampHorizontal(cursor, viewportWidth) {
|
|
184
|
+
if (cursor < this._x)
|
|
185
|
+
this._x = cursor;
|
|
186
|
+
if (cursor >= this._x + viewportWidth)
|
|
187
|
+
this._x = cursor - viewportWidth + 1;
|
|
188
|
+
this._x = Math.max(0, this._x);
|
|
189
|
+
}
|
|
190
|
+
/** Ensure a cursor row stays within a viewport of `viewportHeight`. */
|
|
191
|
+
clampVertical(cursor, viewportHeight, maxScroll) {
|
|
192
|
+
if (cursor < this._y)
|
|
193
|
+
this._y = cursor;
|
|
194
|
+
if (cursor >= this._y + viewportHeight)
|
|
195
|
+
this._y = cursor - viewportHeight + 1;
|
|
196
|
+
this._y = Math.max(0, Math.min(this._y, maxScroll));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
exports.ScrollState = ScrollState;
|
|
200
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
201
|
+
// § 4. WIDGET BASE (LSP: all concrete widgets honour the contract)
|
|
202
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
/**
|
|
204
|
+
* Abstract base satisfying IDimensional + IRenderable.
|
|
205
|
+
* Concrete subclasses must implement lineCount() and renderLine().
|
|
206
|
+
* naturalWidth() defaults to 0 (override when meaningful).
|
|
207
|
+
*
|
|
208
|
+
* Every widget receives an ITerminalWriter at construction time (DIP).
|
|
209
|
+
*/
|
|
210
|
+
class Widget {
|
|
211
|
+
writer;
|
|
212
|
+
constructor(writer = defaultWriter) {
|
|
213
|
+
this.writer = writer;
|
|
214
|
+
}
|
|
215
|
+
naturalWidth() {
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
exports.Widget = Widget;
|
|
220
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
221
|
+
// § 5. SIMPLE DISPLAY WIDGETS
|
|
222
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
223
|
+
// ── Label ────────────────────────────────────────────────────────────────────
|
|
224
|
+
class Label extends Widget {
|
|
225
|
+
text;
|
|
226
|
+
color;
|
|
227
|
+
constructor(text, color = "white", writer) {
|
|
228
|
+
super(writer);
|
|
229
|
+
this.text = text;
|
|
230
|
+
this.color = color;
|
|
231
|
+
}
|
|
232
|
+
lineCount() {
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
naturalWidth() {
|
|
236
|
+
return this.text.length;
|
|
237
|
+
}
|
|
238
|
+
renderLine(_li, row, col, maxWidth, scrollX = 0) {
|
|
239
|
+
this.writer.moveTo(row, col);
|
|
240
|
+
if (!this.text.length)
|
|
241
|
+
return;
|
|
242
|
+
const slice = this.text.slice(scrollX);
|
|
243
|
+
if (slice.length > maxWidth) {
|
|
244
|
+
this.writer.write((0, chalk_1.default) `{${this.color} ${slice.slice(0, maxWidth - 2)}}` +
|
|
245
|
+
(0, chalk_1.default) `{gray ..}`);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.writer.write((0, chalk_1.default) `{${this.color} ${slice}}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
exports.Label = Label;
|
|
253
|
+
// ── Command ──────────────────────────────────────────────────────────────────
|
|
254
|
+
const KEY_SYMBOLS = {
|
|
255
|
+
backspace: "⌫",
|
|
256
|
+
ctrl: "⌃",
|
|
257
|
+
shift: "⇧",
|
|
258
|
+
alt: "⌥",
|
|
259
|
+
enter: "↵",
|
|
260
|
+
escape: "⎋",
|
|
261
|
+
tab: "⇥",
|
|
262
|
+
space: "␣",
|
|
263
|
+
up: "↑",
|
|
264
|
+
down: "↓",
|
|
265
|
+
left: "←",
|
|
266
|
+
right: "→",
|
|
267
|
+
};
|
|
268
|
+
class Command extends Widget {
|
|
269
|
+
label;
|
|
270
|
+
shortcut;
|
|
271
|
+
constructor(label, shortcut, writer) {
|
|
272
|
+
super(writer);
|
|
273
|
+
this.label = label;
|
|
274
|
+
this.shortcut = shortcut;
|
|
275
|
+
}
|
|
276
|
+
lineCount() {
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
_segs() {
|
|
280
|
+
const out = [];
|
|
281
|
+
for (let i = 0; i < this.shortcut.length; i++) {
|
|
282
|
+
const lo = (this.shortcut[i] ?? "").toLowerCase();
|
|
283
|
+
const sym = KEY_SYMBOLS[lo] ?? this.shortcut[i] ?? "";
|
|
284
|
+
out.push({
|
|
285
|
+
plain: sym,
|
|
286
|
+
colour: lo in KEY_SYMBOLS ? "cyan" : "gray",
|
|
287
|
+
});
|
|
288
|
+
if (i < this.shortcut.length - 1)
|
|
289
|
+
out.push({ plain: " ", colour: "gray" });
|
|
290
|
+
}
|
|
291
|
+
out.push({ plain: " " + this.label, colour: "white" });
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
naturalWidth() {
|
|
295
|
+
return this._segs().reduce((s, seg) => s + seg.plain.length, 0);
|
|
296
|
+
}
|
|
297
|
+
renderLine(_li, row, col, maxWidth, scrollX = 0) {
|
|
298
|
+
let skip = scrollX;
|
|
299
|
+
let used = 0;
|
|
300
|
+
let out = "";
|
|
301
|
+
for (const seg of this._segs()) {
|
|
302
|
+
if (skip >= seg.plain.length) {
|
|
303
|
+
skip -= seg.plain.length;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const visible = seg.plain.slice(skip);
|
|
307
|
+
skip = 0;
|
|
308
|
+
const fits = maxWidth - used;
|
|
309
|
+
if (visible.length > fits) {
|
|
310
|
+
const cut = visible.slice(0, Math.max(0, fits - 2));
|
|
311
|
+
if (cut)
|
|
312
|
+
out += (0, chalk_1.default) `{${seg.colour} ${cut}}`;
|
|
313
|
+
out += (0, chalk_1.default) `{gray ..}`;
|
|
314
|
+
// eslint-disable-next-line no-useless-assignment
|
|
315
|
+
used = maxWidth;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
out += (0, chalk_1.default) `{${seg.colour} ${visible}}`;
|
|
319
|
+
used += visible.length;
|
|
320
|
+
if (used >= maxWidth)
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (!out)
|
|
324
|
+
return;
|
|
325
|
+
this.writer.moveTo(row, col);
|
|
326
|
+
this.writer.write(out);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
exports.Command = Command;
|
|
330
|
+
class Input extends Widget {
|
|
331
|
+
width;
|
|
332
|
+
placeholder;
|
|
333
|
+
type;
|
|
334
|
+
onChange;
|
|
335
|
+
_value = "";
|
|
336
|
+
_cursor = 0;
|
|
337
|
+
_scroll;
|
|
338
|
+
_focused = false;
|
|
339
|
+
constructor(width, placeholder = "", type = "text", onChange, writer) {
|
|
340
|
+
super(writer);
|
|
341
|
+
this.width = width;
|
|
342
|
+
this.placeholder = placeholder;
|
|
343
|
+
this.type = type;
|
|
344
|
+
this.onChange = onChange;
|
|
345
|
+
this._scroll = new ScrollState();
|
|
346
|
+
}
|
|
347
|
+
lineCount() {
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
naturalWidth() {
|
|
351
|
+
return this.width;
|
|
352
|
+
}
|
|
353
|
+
getValue() {
|
|
354
|
+
return this._value;
|
|
355
|
+
}
|
|
356
|
+
setValue(v) {
|
|
357
|
+
this._value = v;
|
|
358
|
+
this._cursor = v.length;
|
|
359
|
+
this._clampScroll();
|
|
360
|
+
}
|
|
361
|
+
isFocused() {
|
|
362
|
+
return this._focused;
|
|
363
|
+
}
|
|
364
|
+
focus() {
|
|
365
|
+
this._focused = true;
|
|
366
|
+
}
|
|
367
|
+
blur() {
|
|
368
|
+
this._focused = false;
|
|
369
|
+
}
|
|
370
|
+
handleKey(key) {
|
|
371
|
+
const n = key.name;
|
|
372
|
+
if (n === "left") {
|
|
373
|
+
this._cursor = Math.max(0, this._cursor - 1);
|
|
374
|
+
}
|
|
375
|
+
else if (n === "right") {
|
|
376
|
+
this._cursor = Math.min(this._value.length, this._cursor + 1);
|
|
377
|
+
}
|
|
378
|
+
else if (n === "home") {
|
|
379
|
+
this._cursor = 0;
|
|
380
|
+
}
|
|
381
|
+
else if (n === "end") {
|
|
382
|
+
this._cursor = this._value.length;
|
|
383
|
+
}
|
|
384
|
+
else if (n === "backspace") {
|
|
385
|
+
if (this._cursor > 0) {
|
|
386
|
+
this._value =
|
|
387
|
+
this._value.slice(0, this._cursor - 1) +
|
|
388
|
+
this._value.slice(this._cursor);
|
|
389
|
+
this._cursor--;
|
|
390
|
+
this.onChange?.(this._value);
|
|
391
|
+
}
|
|
392
|
+
this._clampScroll();
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
else if (n === "delete") {
|
|
396
|
+
if (this._cursor < this._value.length) {
|
|
397
|
+
this._value =
|
|
398
|
+
this._value.slice(0, this._cursor) +
|
|
399
|
+
this._value.slice(this._cursor + 1);
|
|
400
|
+
this.onChange?.(this._value);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (n !== "return" &&
|
|
404
|
+
n !== "escape" &&
|
|
405
|
+
n !== "tab" &&
|
|
406
|
+
key.sequence &&
|
|
407
|
+
!key.ctrl &&
|
|
408
|
+
!key.meta &&
|
|
409
|
+
key.sequence.length === 1) {
|
|
410
|
+
this._value =
|
|
411
|
+
this._value.slice(0, this._cursor) +
|
|
412
|
+
key.sequence +
|
|
413
|
+
this._value.slice(this._cursor);
|
|
414
|
+
this._cursor++;
|
|
415
|
+
this.onChange?.(this._value);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
this._clampScroll();
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
_clampScroll() {
|
|
424
|
+
this._scroll.clampHorizontal(this._cursor, this.width - 2);
|
|
425
|
+
}
|
|
426
|
+
_display() {
|
|
427
|
+
return this.type === "password"
|
|
428
|
+
? "*".repeat(this._value.length)
|
|
429
|
+
: this._value;
|
|
430
|
+
}
|
|
431
|
+
renderLine(_li, row, col, maxWidth) {
|
|
432
|
+
const w = Math.min(this.width, maxWidth);
|
|
433
|
+
const inner = w - 2;
|
|
434
|
+
const bc = this._focused ? "white" : "gray";
|
|
435
|
+
const display = this._display();
|
|
436
|
+
const visible = display.slice(this._scroll.x, this._scroll.x + inner);
|
|
437
|
+
const relCursor = this._cursor - this._scroll.x;
|
|
438
|
+
let content;
|
|
439
|
+
if (!this._value.length && !this._focused) {
|
|
440
|
+
content = chalk_1.default.gray(this.placeholder.slice(0, inner).padEnd(inner));
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const padded = visible.padEnd(inner);
|
|
444
|
+
if (this._focused) {
|
|
445
|
+
const before = padded.slice(0, relCursor);
|
|
446
|
+
const cur = padded[relCursor] ?? " ";
|
|
447
|
+
const after = padded.slice(relCursor + 1);
|
|
448
|
+
content =
|
|
449
|
+
chalk_1.default.white(before) +
|
|
450
|
+
chalk_1.default.bgWhite.black(cur) +
|
|
451
|
+
chalk_1.default.white(after);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
content = chalk_1.default.white(padded);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
this.writer.moveTo(row, col);
|
|
458
|
+
this.writer.write(chalk_1.default.keyword(bc)("[") + content + chalk_1.default.keyword(bc)("]"));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
exports.Input = Input;
|
|
462
|
+
// ── Select ───────────────────────────────────────────────────────────────────
|
|
463
|
+
class Select extends Widget {
|
|
464
|
+
width;
|
|
465
|
+
options;
|
|
466
|
+
onChange;
|
|
467
|
+
_open = false;
|
|
468
|
+
_focused = false;
|
|
469
|
+
_hovered;
|
|
470
|
+
_selected;
|
|
471
|
+
constructor(width, options, initialIndex = 0, onChange, writer) {
|
|
472
|
+
super(writer);
|
|
473
|
+
this.width = width;
|
|
474
|
+
this.options = options;
|
|
475
|
+
this.onChange = onChange;
|
|
476
|
+
this._selected = Math.max(0, Math.min(initialIndex, options.length - 1));
|
|
477
|
+
this._hovered = this._selected;
|
|
478
|
+
}
|
|
479
|
+
lineCount() {
|
|
480
|
+
return this._open ? 1 + this.options.length : 1;
|
|
481
|
+
}
|
|
482
|
+
naturalWidth() {
|
|
483
|
+
return this.width;
|
|
484
|
+
}
|
|
485
|
+
getIndex() {
|
|
486
|
+
return this._selected;
|
|
487
|
+
}
|
|
488
|
+
getValue() {
|
|
489
|
+
return this.options[this._selected] ?? "";
|
|
490
|
+
}
|
|
491
|
+
isFocused() {
|
|
492
|
+
return this._focused;
|
|
493
|
+
}
|
|
494
|
+
focus() {
|
|
495
|
+
this._focused = true;
|
|
496
|
+
}
|
|
497
|
+
blur() {
|
|
498
|
+
this._focused = false;
|
|
499
|
+
this._open = false;
|
|
500
|
+
}
|
|
501
|
+
handleKey(key) {
|
|
502
|
+
const n = key.name;
|
|
503
|
+
if (!this._open) {
|
|
504
|
+
if (n === "return" || n === "space") {
|
|
505
|
+
this._open = true;
|
|
506
|
+
this._hovered = this._selected;
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
if (n === "up") {
|
|
512
|
+
this._hovered = Math.max(0, this._hovered - 1);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
else if (n === "down") {
|
|
516
|
+
this._hovered = Math.min(this.options.length - 1, this._hovered + 1);
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
else if (n === "return") {
|
|
520
|
+
this._selected = this._hovered;
|
|
521
|
+
this._open = false;
|
|
522
|
+
this.onChange?.(this._selected, this.options[this._selected] ?? "");
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
else if (n === "escape") {
|
|
526
|
+
this._open = false;
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
activeLineHint() {
|
|
532
|
+
return this._open ? 1 + this._hovered : 0;
|
|
533
|
+
}
|
|
534
|
+
renderLine(li, row, col, maxWidth) {
|
|
535
|
+
const w = Math.min(this.width, maxWidth);
|
|
536
|
+
if (li === 0) {
|
|
537
|
+
const inner = w - 4;
|
|
538
|
+
const label = (this.options[this._selected] ?? "(none)")
|
|
539
|
+
.slice(0, inner)
|
|
540
|
+
.padEnd(inner);
|
|
541
|
+
const arrow = this._open ? "▴" : "▾";
|
|
542
|
+
const bc = this._focused
|
|
543
|
+
? this._open
|
|
544
|
+
? "white"
|
|
545
|
+
: "yellow"
|
|
546
|
+
: "gray";
|
|
547
|
+
this.writer.moveTo(row, col);
|
|
548
|
+
this.writer.write(chalk_1.default.keyword(bc)("[") +
|
|
549
|
+
chalk_1.default.white(" " + label) +
|
|
550
|
+
chalk_1.default.keyword(bc)(arrow + "]"));
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const optIdx = li - 1;
|
|
554
|
+
const opt = this.options[optIdx] ?? "";
|
|
555
|
+
const isHovered = optIdx === this._hovered;
|
|
556
|
+
const isSelected = optIdx === this._selected;
|
|
557
|
+
const inner = w - 3;
|
|
558
|
+
const label = opt.slice(0, inner).padEnd(inner);
|
|
559
|
+
const dot = isSelected ? "●" : "○";
|
|
560
|
+
this.writer.moveTo(row, col);
|
|
561
|
+
if (isHovered) {
|
|
562
|
+
this.writer.write(chalk_1.default.bgYellow.black(` ${dot} ${label.slice(0, w - 3)}`));
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
this.writer.write(chalk_1.default.gray(` ${dot} `) + chalk_1.default.white(label.slice(0, w - 3)));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
exports.Select = Select;
|
|
571
|
+
// ── Radio ────────────────────────────────────────────────────────────────────
|
|
572
|
+
class Radio extends Widget {
|
|
573
|
+
options;
|
|
574
|
+
onChange;
|
|
575
|
+
_focused = false;
|
|
576
|
+
_hovered;
|
|
577
|
+
_selected;
|
|
578
|
+
constructor(options, initialIndex = 0, onChange, writer) {
|
|
579
|
+
super(writer);
|
|
580
|
+
this.options = options;
|
|
581
|
+
this.onChange = onChange;
|
|
582
|
+
this._selected = Math.max(0, Math.min(initialIndex, options.length - 1));
|
|
583
|
+
this._hovered = this._selected;
|
|
584
|
+
}
|
|
585
|
+
lineCount() {
|
|
586
|
+
return this.options.length;
|
|
587
|
+
}
|
|
588
|
+
naturalWidth() {
|
|
589
|
+
return 3 + this.options.reduce((m, o) => Math.max(m, o.length), 0);
|
|
590
|
+
}
|
|
591
|
+
getIndex() {
|
|
592
|
+
return this._selected;
|
|
593
|
+
}
|
|
594
|
+
getValue() {
|
|
595
|
+
return this.options[this._selected] ?? "";
|
|
596
|
+
}
|
|
597
|
+
isFocused() {
|
|
598
|
+
return this._focused;
|
|
599
|
+
}
|
|
600
|
+
focus() {
|
|
601
|
+
this._focused = true;
|
|
602
|
+
}
|
|
603
|
+
blur() {
|
|
604
|
+
this._focused = false;
|
|
605
|
+
}
|
|
606
|
+
handleKey(key) {
|
|
607
|
+
const n = key.name;
|
|
608
|
+
if (n === "up") {
|
|
609
|
+
this._hovered = Math.max(0, this._hovered - 1);
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
else if (n === "down") {
|
|
613
|
+
this._hovered = Math.min(this.options.length - 1, this._hovered + 1);
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
else if (n === "return" || n === "space") {
|
|
617
|
+
this._selected = this._hovered;
|
|
618
|
+
this.onChange?.(this._selected, this.options[this._selected] ?? "");
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
activeLineHint() {
|
|
624
|
+
return this._hovered;
|
|
625
|
+
}
|
|
626
|
+
renderLine(li, row, col, maxWidth) {
|
|
627
|
+
const opt = this.options[li] ?? "";
|
|
628
|
+
const isSelected = li === this._selected;
|
|
629
|
+
const isHovered = this._focused && li === this._hovered;
|
|
630
|
+
const dot = isSelected ? "◉" : "○";
|
|
631
|
+
const label = opt.slice(0, maxWidth - 3);
|
|
632
|
+
this.writer.moveTo(row, col);
|
|
633
|
+
if (isHovered && isSelected) {
|
|
634
|
+
this.writer.write(chalk_1.default.yellow(`${dot} `) + chalk_1.default.bgYellow.black(label));
|
|
635
|
+
}
|
|
636
|
+
else if (isHovered) {
|
|
637
|
+
this.writer.write(chalk_1.default.yellow(`${dot} `) + chalk_1.default.yellow(label));
|
|
638
|
+
}
|
|
639
|
+
else if (isSelected) {
|
|
640
|
+
this.writer.write(chalk_1.default.cyan(`${dot} `) + chalk_1.default.white(label));
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
this.writer.write(chalk_1.default.gray(`${dot} `) + chalk_1.default.gray(label));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
exports.Radio = Radio;
|
|
648
|
+
// ── CheckBox ─────────────────────────────────────────────────────────────────
|
|
649
|
+
class CheckBox extends Widget {
|
|
650
|
+
label;
|
|
651
|
+
onChange;
|
|
652
|
+
_focused = false;
|
|
653
|
+
_checked;
|
|
654
|
+
constructor(label, initialChecked = false, onChange, writer) {
|
|
655
|
+
super(writer);
|
|
656
|
+
this.label = label;
|
|
657
|
+
this.onChange = onChange;
|
|
658
|
+
this._checked = initialChecked;
|
|
659
|
+
}
|
|
660
|
+
lineCount() {
|
|
661
|
+
return 1;
|
|
662
|
+
}
|
|
663
|
+
naturalWidth() {
|
|
664
|
+
return 4 + this.label.length;
|
|
665
|
+
}
|
|
666
|
+
isChecked() {
|
|
667
|
+
return this._checked;
|
|
668
|
+
}
|
|
669
|
+
setChecked(v) {
|
|
670
|
+
this._checked = v;
|
|
671
|
+
}
|
|
672
|
+
isFocused() {
|
|
673
|
+
return this._focused;
|
|
674
|
+
}
|
|
675
|
+
focus() {
|
|
676
|
+
this._focused = true;
|
|
677
|
+
}
|
|
678
|
+
blur() {
|
|
679
|
+
this._focused = false;
|
|
680
|
+
}
|
|
681
|
+
handleKey(key) {
|
|
682
|
+
if (key.name === "return" || key.name === "space") {
|
|
683
|
+
this._checked = !this._checked;
|
|
684
|
+
this.onChange?.(this._checked);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
renderLine(_li, row, col, maxWidth) {
|
|
690
|
+
const bc = this._focused ? "yellow" : "gray";
|
|
691
|
+
const mark = this._checked ? chalk_1.default.green("✓") : " ";
|
|
692
|
+
const label = this.label.slice(0, maxWidth - 4);
|
|
693
|
+
const labelColor = this._focused ? "white" : "gray";
|
|
694
|
+
this.writer.moveTo(row, col);
|
|
695
|
+
this.writer.write(chalk_1.default.keyword(bc)("[") +
|
|
696
|
+
mark +
|
|
697
|
+
chalk_1.default.keyword(bc)("]") +
|
|
698
|
+
" " +
|
|
699
|
+
chalk_1.default.keyword(labelColor)(label));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
exports.CheckBox = CheckBox;
|
|
703
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
704
|
+
// § 7. BUTTON — OCP via IButtonRenderer strategy
|
|
705
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
706
|
+
// ── Built-in variant renderers ────────────────────────────────────────────────
|
|
707
|
+
/** Default bordered button: [ Label ] */
|
|
708
|
+
class DefaultButtonRenderer {
|
|
709
|
+
render(writer, row, col, w, label, focused, pressed) {
|
|
710
|
+
const bc = pressed ? "green" : focused ? "white" : "gray";
|
|
711
|
+
const inner = w - 2;
|
|
712
|
+
const prefix = pressed ? chalk_1.default.green("▶") : " ";
|
|
713
|
+
const maxLabel = inner - 2;
|
|
714
|
+
const labelText = label.slice(0, maxLabel).padEnd(maxLabel);
|
|
715
|
+
const labelColour = focused || pressed ? "white" : "gray";
|
|
716
|
+
writer.moveTo(row, col);
|
|
717
|
+
writer.write(chalk_1.default.keyword(bc)("[") +
|
|
718
|
+
prefix +
|
|
719
|
+
chalk_1.default.keyword(labelColour)(labelText) +
|
|
720
|
+
chalk_1.default.keyword(bc)("]"));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
exports.DefaultButtonRenderer = DefaultButtonRenderer;
|
|
724
|
+
/** Filled primary button: ▐ Label ▌ */
|
|
725
|
+
class PrimaryButtonRenderer {
|
|
726
|
+
render(writer, row, col, w, label, _focused, pressed) {
|
|
727
|
+
const inner = w - 4;
|
|
728
|
+
const labelText = label.slice(0, inner).padEnd(inner);
|
|
729
|
+
writer.moveTo(row, col);
|
|
730
|
+
writer.write(pressed
|
|
731
|
+
? chalk_1.default.bgGreen.black(`▐ ${labelText} ▌`)
|
|
732
|
+
: chalk_1.default.bgWhite.black(`▐ ${labelText} ▌`));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
exports.PrimaryButtonRenderer = PrimaryButtonRenderer;
|
|
736
|
+
/** Danger button: [ Label ] in red */
|
|
737
|
+
class DangerButtonRenderer {
|
|
738
|
+
render(writer, row, col, w, label, focused, pressed) {
|
|
739
|
+
const bc = pressed ? "white" : focused ? "red" : "gray";
|
|
740
|
+
const inner = w - 2;
|
|
741
|
+
const prefix = pressed ? chalk_1.default.white("!") : " ";
|
|
742
|
+
const maxLabel = inner - 2;
|
|
743
|
+
const labelText = label.slice(0, maxLabel).padEnd(maxLabel);
|
|
744
|
+
const labelColour = focused ? (pressed ? "white" : "red") : "gray";
|
|
745
|
+
writer.moveTo(row, col);
|
|
746
|
+
writer.write(chalk_1.default.keyword(bc)("[") +
|
|
747
|
+
prefix +
|
|
748
|
+
chalk_1.default.keyword(labelColour)(labelText) +
|
|
749
|
+
chalk_1.default.keyword(bc)("]"));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
exports.DangerButtonRenderer = DangerButtonRenderer;
|
|
753
|
+
const BUTTON_RENDERERS = {
|
|
754
|
+
default: new DefaultButtonRenderer(),
|
|
755
|
+
primary: new PrimaryButtonRenderer(),
|
|
756
|
+
danger: new DangerButtonRenderer(),
|
|
757
|
+
};
|
|
758
|
+
// ── Button widget ─────────────────────────────────────────────────────────────
|
|
759
|
+
class Button extends Widget {
|
|
760
|
+
width;
|
|
761
|
+
label;
|
|
762
|
+
onClick;
|
|
763
|
+
onRender;
|
|
764
|
+
_focused = false;
|
|
765
|
+
_pressed = false;
|
|
766
|
+
_variantRenderer;
|
|
767
|
+
constructor(width, label, variantOrRenderer = "default", onClick, onRender, writer) {
|
|
768
|
+
super(writer);
|
|
769
|
+
this.width = width;
|
|
770
|
+
this.label = label;
|
|
771
|
+
this.onClick = onClick;
|
|
772
|
+
this.onRender = onRender;
|
|
773
|
+
this._variantRenderer =
|
|
774
|
+
typeof variantOrRenderer === "string"
|
|
775
|
+
? BUTTON_RENDERERS[variantOrRenderer]
|
|
776
|
+
: variantOrRenderer;
|
|
777
|
+
}
|
|
778
|
+
lineCount() {
|
|
779
|
+
return 1;
|
|
780
|
+
}
|
|
781
|
+
naturalWidth() {
|
|
782
|
+
return this.width;
|
|
783
|
+
}
|
|
784
|
+
isFocused() {
|
|
785
|
+
return this._focused;
|
|
786
|
+
}
|
|
787
|
+
focus() {
|
|
788
|
+
this._focused = true;
|
|
789
|
+
}
|
|
790
|
+
blur() {
|
|
791
|
+
this._focused = false;
|
|
792
|
+
this._pressed = false;
|
|
793
|
+
}
|
|
794
|
+
handleKey(key) {
|
|
795
|
+
if (key.name === "return" || key.name === "space") {
|
|
796
|
+
this._pressed = true;
|
|
797
|
+
this.onClick?.();
|
|
798
|
+
setTimeout(() => {
|
|
799
|
+
this._pressed = false;
|
|
800
|
+
this.onRender?.();
|
|
801
|
+
}, 120);
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
renderLine(_li, row, col, maxWidth) {
|
|
807
|
+
this._variantRenderer.render(this.writer, row, col, Math.min(this.width, maxWidth), this.label, this._focused, this._pressed);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
exports.Button = Button;
|
|
811
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
812
|
+
// § 8. TEXTAREA
|
|
813
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
814
|
+
class Textarea extends Widget {
|
|
815
|
+
width;
|
|
816
|
+
height;
|
|
817
|
+
placeholder;
|
|
818
|
+
onChange;
|
|
819
|
+
_lines;
|
|
820
|
+
_cursorRow = 0;
|
|
821
|
+
_cursorCol = 0;
|
|
822
|
+
_scroll;
|
|
823
|
+
_focused = false;
|
|
824
|
+
constructor(width, height, initialValue = "", placeholder = "", onChange, writer) {
|
|
825
|
+
super(writer);
|
|
826
|
+
this.width = width;
|
|
827
|
+
this.height = height;
|
|
828
|
+
this.placeholder = placeholder;
|
|
829
|
+
this.onChange = onChange;
|
|
830
|
+
this._lines = initialValue.length > 0 ? initialValue.split("\n") : [""];
|
|
831
|
+
this._scroll = new ScrollState();
|
|
832
|
+
}
|
|
833
|
+
iw() {
|
|
834
|
+
return this.width - 2;
|
|
835
|
+
}
|
|
836
|
+
ih() {
|
|
837
|
+
return this.height - 2;
|
|
838
|
+
}
|
|
839
|
+
lineCount() {
|
|
840
|
+
return this.height;
|
|
841
|
+
}
|
|
842
|
+
naturalWidth() {
|
|
843
|
+
return this.width;
|
|
844
|
+
}
|
|
845
|
+
getValue() {
|
|
846
|
+
return this._lines.join("\n");
|
|
847
|
+
}
|
|
848
|
+
setValue(v) {
|
|
849
|
+
this._lines = v.length > 0 ? v.split("\n") : [""];
|
|
850
|
+
this._cursorRow = 0;
|
|
851
|
+
this._cursorCol = 0;
|
|
852
|
+
this._scroll = new ScrollState();
|
|
853
|
+
}
|
|
854
|
+
isFocused() {
|
|
855
|
+
return this._focused;
|
|
856
|
+
}
|
|
857
|
+
focus() {
|
|
858
|
+
this._focused = true;
|
|
859
|
+
}
|
|
860
|
+
blur() {
|
|
861
|
+
this._focused = false;
|
|
862
|
+
}
|
|
863
|
+
activeLineHint() {
|
|
864
|
+
return 1 + (this._cursorRow - this._scroll.y);
|
|
865
|
+
}
|
|
866
|
+
handleKey(key) {
|
|
867
|
+
const n = key.name;
|
|
868
|
+
if (n === "tab" || n === "escape")
|
|
869
|
+
return false;
|
|
870
|
+
if (n === "left")
|
|
871
|
+
this._moveCursorLeft();
|
|
872
|
+
else if (n === "right")
|
|
873
|
+
this._moveCursorRight();
|
|
874
|
+
else if (n === "up")
|
|
875
|
+
this._moveCursorUp();
|
|
876
|
+
else if (n === "down")
|
|
877
|
+
this._moveCursorDown();
|
|
878
|
+
else if (n === "home") {
|
|
879
|
+
this._cursorCol = key.ctrl ? (this._cursorRow = 0) : 0;
|
|
880
|
+
}
|
|
881
|
+
else if (n === "end") {
|
|
882
|
+
if (key.ctrl) {
|
|
883
|
+
this._cursorRow = this._lines.length - 1;
|
|
884
|
+
}
|
|
885
|
+
this._cursorCol = this._currentLine().length;
|
|
886
|
+
}
|
|
887
|
+
else if (n === "backspace") {
|
|
888
|
+
this._deleteBackward();
|
|
889
|
+
this.onChange?.(this.getValue());
|
|
890
|
+
}
|
|
891
|
+
else if (n === "delete") {
|
|
892
|
+
this._deleteForward();
|
|
893
|
+
this.onChange?.(this.getValue());
|
|
894
|
+
}
|
|
895
|
+
else if (n === "return") {
|
|
896
|
+
this._insertNewline();
|
|
897
|
+
this.onChange?.(this.getValue());
|
|
898
|
+
}
|
|
899
|
+
else if (key.sequence &&
|
|
900
|
+
!key.ctrl &&
|
|
901
|
+
!key.meta &&
|
|
902
|
+
key.sequence.length === 1) {
|
|
903
|
+
this._insertChar(key.sequence);
|
|
904
|
+
this.onChange?.(this.getValue());
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
this._clampScroll();
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
// ── Cursor helpers ────────────────────────────────────────────────────────
|
|
913
|
+
_currentLine() {
|
|
914
|
+
return this._lines[this._cursorRow] ?? "";
|
|
915
|
+
}
|
|
916
|
+
_moveCursorLeft() {
|
|
917
|
+
if (this._cursorCol > 0) {
|
|
918
|
+
this._cursorCol--;
|
|
919
|
+
}
|
|
920
|
+
else if (this._cursorRow > 0) {
|
|
921
|
+
this._cursorRow--;
|
|
922
|
+
this._cursorCol = this._currentLine().length;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
_moveCursorRight() {
|
|
926
|
+
const line = this._currentLine();
|
|
927
|
+
if (this._cursorCol < line.length) {
|
|
928
|
+
this._cursorCol++;
|
|
929
|
+
}
|
|
930
|
+
else if (this._cursorRow < this._lines.length - 1) {
|
|
931
|
+
this._cursorRow++;
|
|
932
|
+
this._cursorCol = 0;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
_moveCursorUp() {
|
|
936
|
+
if (this._cursorRow > 0) {
|
|
937
|
+
this._cursorRow--;
|
|
938
|
+
this._cursorCol = Math.min(this._cursorCol, this._currentLine().length);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
_moveCursorDown() {
|
|
942
|
+
if (this._cursorRow < this._lines.length - 1) {
|
|
943
|
+
this._cursorRow++;
|
|
944
|
+
this._cursorCol = Math.min(this._cursorCol, this._currentLine().length);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// ── Edit helpers ──────────────────────────────────────────────────────────
|
|
948
|
+
_insertChar(ch) {
|
|
949
|
+
const line = this._currentLine();
|
|
950
|
+
this._lines[this._cursorRow] =
|
|
951
|
+
line.slice(0, this._cursorCol) + ch + line.slice(this._cursorCol);
|
|
952
|
+
this._cursorCol++;
|
|
953
|
+
}
|
|
954
|
+
_insertNewline() {
|
|
955
|
+
const line = this._currentLine();
|
|
956
|
+
this._lines[this._cursorRow] = line.slice(0, this._cursorCol);
|
|
957
|
+
this._lines.splice(this._cursorRow + 1, 0, line.slice(this._cursorCol));
|
|
958
|
+
this._cursorRow++;
|
|
959
|
+
this._cursorCol = 0;
|
|
960
|
+
}
|
|
961
|
+
_deleteBackward() {
|
|
962
|
+
if (this._cursorCol > 0) {
|
|
963
|
+
const line = this._currentLine();
|
|
964
|
+
this._lines[this._cursorRow] =
|
|
965
|
+
line.slice(0, this._cursorCol - 1) +
|
|
966
|
+
line.slice(this._cursorCol);
|
|
967
|
+
this._cursorCol--;
|
|
968
|
+
}
|
|
969
|
+
else if (this._cursorRow > 0) {
|
|
970
|
+
const prevLine = this._lines[this._cursorRow - 1] ?? "";
|
|
971
|
+
this._cursorCol = prevLine.length;
|
|
972
|
+
this._lines[this._cursorRow - 1] = prevLine + this._currentLine();
|
|
973
|
+
this._lines.splice(this._cursorRow, 1);
|
|
974
|
+
this._cursorRow--;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
_deleteForward() {
|
|
978
|
+
const line = this._currentLine();
|
|
979
|
+
if (this._cursorCol < line.length) {
|
|
980
|
+
this._lines[this._cursorRow] =
|
|
981
|
+
line.slice(0, this._cursorCol) +
|
|
982
|
+
line.slice(this._cursorCol + 1);
|
|
983
|
+
}
|
|
984
|
+
else if (this._cursorRow < this._lines.length - 1) {
|
|
985
|
+
this._lines[this._cursorRow] =
|
|
986
|
+
line + (this._lines[this._cursorRow + 1] ?? "");
|
|
987
|
+
this._lines.splice(this._cursorRow + 1, 1);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
_clampScroll() {
|
|
991
|
+
const maxScrollY = Math.max(0, this._lines.length - this.ih());
|
|
992
|
+
this._scroll.clampVertical(this._cursorRow, this.ih(), maxScrollY);
|
|
993
|
+
this._scroll.clampHorizontal(this._cursorCol, this.iw());
|
|
994
|
+
}
|
|
995
|
+
// ── Rendering ─────────────────────────────────────────────────────────────
|
|
996
|
+
renderLine(li, row, col, maxWidth) {
|
|
997
|
+
const w = Math.min(this.width, maxWidth);
|
|
998
|
+
const iw = w - 2;
|
|
999
|
+
const bc = this._focused ? "white" : "gray";
|
|
1000
|
+
const b = (s) => chalk_1.default.keyword(bc)(s);
|
|
1001
|
+
const isEmpty = this._lines.length === 1 && this._lines[0] === "";
|
|
1002
|
+
if (li === 0) {
|
|
1003
|
+
this.writer.moveTo(row, col);
|
|
1004
|
+
this.writer.write(b("┌" + "─".repeat(iw) + "┐"));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (li === this.height - 1) {
|
|
1008
|
+
this.writer.moveTo(row, col);
|
|
1009
|
+
this.writer.write(b("└" + "─".repeat(iw) + "┘"));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const visRow = li - 1;
|
|
1013
|
+
const absRow = this._scroll.y + visRow;
|
|
1014
|
+
const lineText = this._lines[absRow];
|
|
1015
|
+
this.writer.moveTo(row, col);
|
|
1016
|
+
if (lineText === undefined) {
|
|
1017
|
+
this.writer.write(b("│") + " ".repeat(iw) + b("│"));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (isEmpty && !this._focused && visRow === 0) {
|
|
1021
|
+
this.writer.write(b("│") +
|
|
1022
|
+
chalk_1.default.gray(this.placeholder.slice(0, iw).padEnd(iw)) +
|
|
1023
|
+
b("│"));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const padded = lineText
|
|
1027
|
+
.slice(this._scroll.x, this._scroll.x + iw)
|
|
1028
|
+
.padEnd(iw);
|
|
1029
|
+
if (this._focused && absRow === this._cursorRow) {
|
|
1030
|
+
const relCol = this._cursorCol - this._scroll.x;
|
|
1031
|
+
this.writer.write(b("│") +
|
|
1032
|
+
chalk_1.default.white(padded.slice(0, relCol)) +
|
|
1033
|
+
chalk_1.default.bgWhite.black(padded[relCol] ?? " ") +
|
|
1034
|
+
chalk_1.default.white(padded.slice(relCol + 1)) +
|
|
1035
|
+
b("│"));
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
this.writer.write(b("│") + chalk_1.default.white(padded) + b("│"));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
exports.Textarea = Textarea;
|
|
1043
|
+
class Container extends Widget {
|
|
1044
|
+
style;
|
|
1045
|
+
children;
|
|
1046
|
+
state = "default";
|
|
1047
|
+
_scroll;
|
|
1048
|
+
_focus;
|
|
1049
|
+
constructor(style, children, writer) {
|
|
1050
|
+
super(writer);
|
|
1051
|
+
this.style = style;
|
|
1052
|
+
this.children = children;
|
|
1053
|
+
this._scroll = new ScrollState();
|
|
1054
|
+
const focusables = children.flatMap((c) => "isFocused" in c ? [c] : []);
|
|
1055
|
+
this._focus = new FocusManager(focusables);
|
|
1056
|
+
}
|
|
1057
|
+
// ── Viewport helpers ──────────────────────────────────────────────────────
|
|
1058
|
+
iw() {
|
|
1059
|
+
return this.style.width - 4;
|
|
1060
|
+
}
|
|
1061
|
+
ih() {
|
|
1062
|
+
return this.style.height - 3;
|
|
1063
|
+
}
|
|
1064
|
+
contentWidth() {
|
|
1065
|
+
return this.iw() - 2;
|
|
1066
|
+
}
|
|
1067
|
+
_totalLines() {
|
|
1068
|
+
return this.children.reduce((s, c) => s + c.lineCount(), 0);
|
|
1069
|
+
}
|
|
1070
|
+
_maxLineWidth() {
|
|
1071
|
+
return this.children.reduce((m, c) => Math.max(m, c.naturalWidth()), 0);
|
|
1072
|
+
}
|
|
1073
|
+
_maxScrollY() {
|
|
1074
|
+
return Math.max(0, this._totalLines() - this.ih());
|
|
1075
|
+
}
|
|
1076
|
+
_maxScrollX() {
|
|
1077
|
+
return Math.max(0, this._maxLineWidth() - this.contentWidth());
|
|
1078
|
+
}
|
|
1079
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
1080
|
+
lineCount() {
|
|
1081
|
+
return this.style.height;
|
|
1082
|
+
}
|
|
1083
|
+
getState() {
|
|
1084
|
+
return this.state;
|
|
1085
|
+
}
|
|
1086
|
+
setState(s) {
|
|
1087
|
+
this.state = s;
|
|
1088
|
+
}
|
|
1089
|
+
// ── Key handling ──────────────────────────────────────────────────────────
|
|
1090
|
+
handleKey(key) {
|
|
1091
|
+
if (this.state === "focus") {
|
|
1092
|
+
this._handleFocusMode(key);
|
|
1093
|
+
}
|
|
1094
|
+
else if (this.state === "select" && key.name === "return") {
|
|
1095
|
+
this.state = "focus";
|
|
1096
|
+
this._focus.initFirst();
|
|
1097
|
+
this._scrollToFocused();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
_handleFocusMode(key) {
|
|
1101
|
+
const n = key.name;
|
|
1102
|
+
const active = this._focus.current;
|
|
1103
|
+
// Delegate to focused child first
|
|
1104
|
+
if (active) {
|
|
1105
|
+
const consumed = active.handleKey(key);
|
|
1106
|
+
if (consumed) {
|
|
1107
|
+
this._scrollToFocused();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// Tab / Shift+Tab: cycle focus
|
|
1112
|
+
if (n === "tab") {
|
|
1113
|
+
this._focus.cycle(key.shift ? -1 : 1);
|
|
1114
|
+
this._scrollToFocused();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
// Escape: blur child → or exit focus mode
|
|
1118
|
+
if (n === "escape") {
|
|
1119
|
+
if (this._focus.hasFocus) {
|
|
1120
|
+
this._focus.clear();
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
this.state = "select";
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// No child focused: scroll container
|
|
1128
|
+
if (!this._focus.hasFocus) {
|
|
1129
|
+
if (n === "up")
|
|
1130
|
+
this._scroll.setY(this._scroll.y - 1, this._maxScrollY());
|
|
1131
|
+
if (n === "down")
|
|
1132
|
+
this._scroll.setY(this._scroll.y + 1, this._maxScrollY());
|
|
1133
|
+
if (n === "left")
|
|
1134
|
+
this._scroll.setX(this._scroll.x - 1, this._maxScrollX());
|
|
1135
|
+
if (n === "right")
|
|
1136
|
+
this._scroll.setX(this._scroll.x + 1, this._maxScrollX());
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* SRP: scroll logic now lives here, using ScrollState's helpers.
|
|
1141
|
+
* The FocusManager provides the target widget; we compute its line offset
|
|
1142
|
+
* within the children list and delegate clamping to ScrollState.
|
|
1143
|
+
*/
|
|
1144
|
+
_scrollToFocused() {
|
|
1145
|
+
const target = this._focus.current;
|
|
1146
|
+
if (!target)
|
|
1147
|
+
return;
|
|
1148
|
+
let lineOffset = 0;
|
|
1149
|
+
for (const child of this.children) {
|
|
1150
|
+
if (child === target) {
|
|
1151
|
+
const hint = target.activeLineHint?.() ?? 0;
|
|
1152
|
+
const targetLine = lineOffset + hint;
|
|
1153
|
+
const topAnchor = lineOffset;
|
|
1154
|
+
const ih = this.ih();
|
|
1155
|
+
if (targetLine < this._scroll.y) {
|
|
1156
|
+
this._scroll.setY(Math.min(targetLine, topAnchor), this._maxScrollY());
|
|
1157
|
+
}
|
|
1158
|
+
else if (targetLine >= this._scroll.y + ih) {
|
|
1159
|
+
const desired = targetLine - ih + 1;
|
|
1160
|
+
this._scroll.setY(Math.min(desired, topAnchor), this._maxScrollY());
|
|
1161
|
+
}
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
lineOffset += child.lineCount();
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// ── Rendering ─────────────────────────────────────────────────────────────
|
|
1168
|
+
renderLine(_li, row, col) {
|
|
1169
|
+
this._renderAt(row, col);
|
|
1170
|
+
}
|
|
1171
|
+
/** Convenience entry point when you know the absolute terminal position. */
|
|
1172
|
+
renderAt(row, col) {
|
|
1173
|
+
this._renderAt(row, col);
|
|
1174
|
+
}
|
|
1175
|
+
_renderAt(row, col) {
|
|
1176
|
+
const w = this.style.width;
|
|
1177
|
+
const ih = this.ih();
|
|
1178
|
+
const iw = this.iw();
|
|
1179
|
+
const tl = this._totalLines();
|
|
1180
|
+
const cw = this.contentWidth();
|
|
1181
|
+
const msY = this._maxScrollY();
|
|
1182
|
+
const msX = this._maxScrollX();
|
|
1183
|
+
const mlw = this._maxLineWidth();
|
|
1184
|
+
const bc = this.state === "focus"
|
|
1185
|
+
? "white"
|
|
1186
|
+
: this.state === "select"
|
|
1187
|
+
? "yellow"
|
|
1188
|
+
: "gray";
|
|
1189
|
+
const b = (s) => chalk_1.default.keyword(bc)(s);
|
|
1190
|
+
// ── Top border ────────────────────────────────────────────────────────
|
|
1191
|
+
const [tagChalk, tagLen] = this.state === "focus"
|
|
1192
|
+
? [chalk_1.default.red("⎋") + chalk_1.default.white("quit"), 5]
|
|
1193
|
+
: [chalk_1.default.green("↵") + chalk_1.default.white("enter"), 6];
|
|
1194
|
+
this.writer.moveTo(row, col);
|
|
1195
|
+
this.writer.write(b("┌──┐") +
|
|
1196
|
+
tagChalk +
|
|
1197
|
+
b("┌" + "─".repeat(w - 4 - tagLen - 2) + "┐"));
|
|
1198
|
+
// ── Content rows + vertical scrollbar ────────────────────────────────
|
|
1199
|
+
const vThumbH = Math.max(1, Math.round((ih / Math.max(tl, 1)) * ih));
|
|
1200
|
+
const vThumbTop = msY > 0 ? Math.round((this._scroll.y / msY) * (ih - vThumbH)) : 0;
|
|
1201
|
+
for (let i = 0; i < ih; i++) {
|
|
1202
|
+
const isThumb = i >= vThumbTop && i < vThumbTop + vThumbH;
|
|
1203
|
+
const sbChar = isThumb ? chalk_1.default.white("┃") : chalk_1.default.gray("║");
|
|
1204
|
+
this.writer.moveTo(row + 1 + i, col);
|
|
1205
|
+
this.writer.write(b("│") + " ".repeat(iw) + sbChar + " " + b("│"));
|
|
1206
|
+
}
|
|
1207
|
+
const contentCol = col + 3;
|
|
1208
|
+
let absLine = 0;
|
|
1209
|
+
for (const child of this.children) {
|
|
1210
|
+
for (let li = 0; li < child.lineCount(); li++) {
|
|
1211
|
+
const vis = absLine - this._scroll.y;
|
|
1212
|
+
if (vis >= 0 && vis < ih) {
|
|
1213
|
+
child.renderLine(li, row + 1 + vis, contentCol, cw, this._scroll.x);
|
|
1214
|
+
}
|
|
1215
|
+
absLine++;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// ── Horizontal scrollbar ──────────────────────────────────────────────
|
|
1219
|
+
const hTrackLen = iw - 1;
|
|
1220
|
+
const hThumbLen = mlw > 0
|
|
1221
|
+
? Math.max(1, Math.round((cw / mlw) * hTrackLen))
|
|
1222
|
+
: hTrackLen;
|
|
1223
|
+
const hThumbPos = msX > 0
|
|
1224
|
+
? Math.round((this._scroll.x / msX) * (hTrackLen - hThumbLen))
|
|
1225
|
+
: 0;
|
|
1226
|
+
let bar = "";
|
|
1227
|
+
for (let i = 0; i < hTrackLen; i++) {
|
|
1228
|
+
bar +=
|
|
1229
|
+
i >= hThumbPos && i < hThumbPos + hThumbLen
|
|
1230
|
+
? chalk_1.default.white("━")
|
|
1231
|
+
: chalk_1.default.gray("═");
|
|
1232
|
+
}
|
|
1233
|
+
bar += chalk_1.default.gray("╝");
|
|
1234
|
+
this.writer.moveTo(row + 1 + ih, col);
|
|
1235
|
+
this.writer.write(b("│") + " " + bar + " " + b("│"));
|
|
1236
|
+
// ── Bottom border ─────────────────────────────────────────────────────
|
|
1237
|
+
this.writer.moveTo(row + 2 + ih, col);
|
|
1238
|
+
this.writer.write(b("└" + "─".repeat(w - 2) + "┘"));
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
exports.Container = Container;
|
|
1242
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1243
|
+
// § 10. CONTAINER GROUP
|
|
1244
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1245
|
+
class ContainerGroup {
|
|
1246
|
+
containers;
|
|
1247
|
+
_sel = -1;
|
|
1248
|
+
constructor(containers) {
|
|
1249
|
+
this.containers = containers;
|
|
1250
|
+
}
|
|
1251
|
+
handleKey(key) {
|
|
1252
|
+
// If any container is in focus mode, delegate to it exclusively
|
|
1253
|
+
const focusedIdx = this.containers.findIndex((c) => c.getState() === "focus");
|
|
1254
|
+
if (focusedIdx !== -1) {
|
|
1255
|
+
this.containers[focusedIdx].handleKey(key);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (this._sel === -1) {
|
|
1259
|
+
if (key.name === "right" || key.name === "tab") {
|
|
1260
|
+
this._moveSel(key.shift ? this.containers.length - 1 : 0);
|
|
1261
|
+
}
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const n = key.name;
|
|
1265
|
+
if (n === "return") {
|
|
1266
|
+
this.containers[this._sel].handleKey(key);
|
|
1267
|
+
}
|
|
1268
|
+
else if (n === "right" || (n === "tab" && !key.shift)) {
|
|
1269
|
+
this._moveSel((this._sel + 1) % this.containers.length);
|
|
1270
|
+
}
|
|
1271
|
+
else if (n === "left" || (n === "tab" && key.shift)) {
|
|
1272
|
+
this._moveSel((this._sel - 1 + this.containers.length) %
|
|
1273
|
+
this.containers.length);
|
|
1274
|
+
}
|
|
1275
|
+
else if (n === "escape") {
|
|
1276
|
+
this._clearSel();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
_moveSel(i) {
|
|
1280
|
+
if (this._sel >= 0)
|
|
1281
|
+
this.containers[this._sel].setState("default");
|
|
1282
|
+
this._sel = i;
|
|
1283
|
+
this.containers[i].setState("select");
|
|
1284
|
+
}
|
|
1285
|
+
_clearSel() {
|
|
1286
|
+
if (this._sel >= 0)
|
|
1287
|
+
this.containers[this._sel].setState("default");
|
|
1288
|
+
this._sel = -1;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
exports.ContainerGroup = ContainerGroup;
|
|
1292
|
+
//# sourceMappingURL=index.js.map
|