dsh-neotui 0.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/bin/dsh-tui.js +130 -0
- package/package.json +35 -0
- package/src/api.js +140 -0
- package/src/index.js +54 -0
- package/src/md.js +374 -0
- package/src/panels.js +1464 -0
- package/src/screen.js +215 -0
- package/src/term.js +270 -0
- package/src/text.js +110 -0
- package/src/theme.js +90 -0
- package/src/views.js +2403 -0
- package/src/widgets.js +567 -0
package/src/widgets.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// widgets.js — Minimal widget layer: hit-testing, lists, scroll views, input,
|
|
2
|
+
// popups, context menus, status bar. All mouse-driven (nvim-style) with key equivalents.
|
|
3
|
+
import { truncate, pad, strWidth } from "./text.js";
|
|
4
|
+
import { T } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
export class Widget {
|
|
7
|
+
constructor({ x = 0, y = 0, w = 0, h = 0 } = {}) {
|
|
8
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
9
|
+
this.visible = true;
|
|
10
|
+
}
|
|
11
|
+
inside(px, py) {
|
|
12
|
+
return px >= this.x && px < this.x + this.w && py >= this.y && py < this.y + this.h;
|
|
13
|
+
}
|
|
14
|
+
hitTest(px, py) { return this.inside(px, py) ? this : null; }
|
|
15
|
+
render() {}
|
|
16
|
+
onMouse() { return false; }
|
|
17
|
+
onKey() { return false; }
|
|
18
|
+
onFocus() {}
|
|
19
|
+
onBlur() {}
|
|
20
|
+
dispose() {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- ScrollView: styled lines with vertical scroll ----
|
|
24
|
+
|
|
25
|
+
export class ScrollView extends Widget {
|
|
26
|
+
constructor(opts = {}) {
|
|
27
|
+
super(opts);
|
|
28
|
+
this.lines = []; // array of arrays of segs: {t, fg, bg, bold, italic, underline, strike, code, link}
|
|
29
|
+
this.scrollY = 0;
|
|
30
|
+
this.autoScroll = opts.autoScroll ?? false;
|
|
31
|
+
this.onClick = opts.onClick ?? null; // (y, ev) => bool
|
|
32
|
+
this.onWheel = null; // optional custom wheel handler
|
|
33
|
+
this.showScrollbar = opts.showScrollbar ?? true;
|
|
34
|
+
this.title = opts.title ?? "";
|
|
35
|
+
}
|
|
36
|
+
setLines(lines, { keep = false } = {}) {
|
|
37
|
+
const atBottom = this.autoScroll && this.scrollY + this.h >= this.lines.length - 1 || (keep && this.scrollY + this.h >= this.lines.length);
|
|
38
|
+
this.lines = lines;
|
|
39
|
+
if (atBottom || this.scrollY > Math.max(0, this.lines.length - this.h)) {
|
|
40
|
+
this.scrollY = Math.max(0, this.lines.length - this.h);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
contentHeight() { return Math.max(this.lines.length, 0); }
|
|
44
|
+
maxScroll() { return Math.max(0, this.lines.length - this.h); }
|
|
45
|
+
scroll(dy) {
|
|
46
|
+
const before = this.scrollY;
|
|
47
|
+
this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + dy));
|
|
48
|
+
return this.scrollY !== before;
|
|
49
|
+
}
|
|
50
|
+
render(screen) {
|
|
51
|
+
if (!this.visible) return;
|
|
52
|
+
const y0 = this.y;
|
|
53
|
+
for (let i = 0; i < this.h; i++) {
|
|
54
|
+
const lineIdx = this.scrollY + i;
|
|
55
|
+
const line = this.lines[lineIdx];
|
|
56
|
+
if (!line) {
|
|
57
|
+
screen.hline(this.x, this.x + this.w - 1, y0 + i, " ", {});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let px = this.x;
|
|
61
|
+
for (const seg of line) {
|
|
62
|
+
const w = strWidth(seg.t);
|
|
63
|
+
if (w === 0) continue;
|
|
64
|
+
if (px >= this.x + this.w) break;
|
|
65
|
+
const style = {
|
|
66
|
+
fg: seg.fg, bg: seg.bg,
|
|
67
|
+
attrs: (seg.bold ? 1 : 0) | (seg.dim ? 2 : 0) | (seg.italic ? 4 : 0) | (seg.underline ? 8 : 0) | (seg.strike ? 32 : 0) | (seg.reverse ? 16 : 0),
|
|
68
|
+
link: seg.link,
|
|
69
|
+
};
|
|
70
|
+
let tx = seg.t;
|
|
71
|
+
if (px + w > this.x + this.w) tx = truncate(tx, this.x + this.w - px);
|
|
72
|
+
screen.text(px, y0 + i, tx, style);
|
|
73
|
+
px += strWidth(tx);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (this.showScrollbar && this.lines.length > this.h) {
|
|
77
|
+
const sbX = this.x + this.w - 1;
|
|
78
|
+
const trackH = Math.max(1, this.h - 2);
|
|
79
|
+
const total = Math.max(1, this.lines.length);
|
|
80
|
+
const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
|
|
81
|
+
const thumbY = Math.floor((this.h - 2) * this.scrollY / Math.max(1, this.maxScroll()));
|
|
82
|
+
for (let i = 0; i < this.h; i++) {
|
|
83
|
+
const inThumb = i >= 1 + thumbY && i < 1 + thumbY + thumbH;
|
|
84
|
+
const inTrack = i >= 1 && i < this.h - 1;
|
|
85
|
+
screen.put(sbX, y0 + i, inThumb ? "█" : inTrack ? "░" : " ", { fg: inThumb ? T.SCROLLTHUMB : T.SCROLLTRACK });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (this.title) screen.text(this.x, y0, this.title, { fg: T.DIM, attrs: 8 });
|
|
89
|
+
}
|
|
90
|
+
onMouse(ev) {
|
|
91
|
+
if (ev.kind === "wheel-up") return this.scroll(-3);
|
|
92
|
+
if (ev.kind === "wheel-down") return this.scroll(3);
|
|
93
|
+
// scrollbar interaction: click to jump, drag to scrub (nvim-style)
|
|
94
|
+
if (this.showScrollbar && this.lines.length > this.h && ev.x === this.x + this.w - 1) {
|
|
95
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
96
|
+
this.scrubbing = true;
|
|
97
|
+
this.#scrubTo(ev.y);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (ev.kind === "drag" && ev.button === 0 && this.scrubbing) {
|
|
101
|
+
this.#scrubTo(ev.y);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (ev.kind === "release" && ev.button === 0 && this.scrubbing) {
|
|
105
|
+
this.scrubbing = false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return this.scrubbing;
|
|
109
|
+
}
|
|
110
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
111
|
+
if (this.onClick && this.onClick(ev.y - this.y + this.scrollY, ev)) return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
#scrubTo(ey) {
|
|
117
|
+
const trackH = Math.max(1, this.h - 2);
|
|
118
|
+
const total = Math.max(1, this.lines.length);
|
|
119
|
+
const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
|
|
120
|
+
const ty = Math.max(0, Math.min(this.h - 2 - thumbH, ey - this.y - 1 - Math.floor(thumbH / 2)));
|
|
121
|
+
const frac = this.h - 2 - thumbH > 0 ? ty / (this.h - 2 - thumbH) : 0;
|
|
122
|
+
this.scrollY = Math.round(frac * this.maxScroll());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- List: selectable items over a ScrollView ----
|
|
127
|
+
|
|
128
|
+
export class List extends ScrollView {
|
|
129
|
+
constructor(opts = {}) {
|
|
130
|
+
super(opts);
|
|
131
|
+
this.items = []; // { text, sub, badge, badgeFg, data, lines? }
|
|
132
|
+
this.selected = 0;
|
|
133
|
+
this.onSelect = opts.onSelect ?? null; // (item, ev) => void
|
|
134
|
+
this.onContext = opts.onContext ?? null; // (item, ev) => void
|
|
135
|
+
this.selFg = opts.selFg ?? T.SELFG;
|
|
136
|
+
this.selBg = opts.selBg ?? T.ACCENT2;
|
|
137
|
+
this.cursorFg = opts.cursorFg ?? T.CURSORFG;
|
|
138
|
+
this.cursorBg = opts.cursorBg ?? T.CURSORBG;
|
|
139
|
+
this.wrap = opts.wrap ?? false;
|
|
140
|
+
}
|
|
141
|
+
setItems(items, { keepSelection = false } = {}) {
|
|
142
|
+
this.items = items;
|
|
143
|
+
if (!keepSelection || this.selected >= items.length) this.selected = Math.min(this.selected, items.length - 1);
|
|
144
|
+
if (this.selected < 0) this.selected = 0;
|
|
145
|
+
this.#rebuildLines();
|
|
146
|
+
this.scrollToSelected();
|
|
147
|
+
}
|
|
148
|
+
#rebuildLines() {
|
|
149
|
+
const w = Math.max(8, this.w - (this.showScrollbar ? 1 : 0));
|
|
150
|
+
this.lines = this.items.map((it) => it.lines ?? this.itemLine(it, w));
|
|
151
|
+
}
|
|
152
|
+
itemLine(it, w) {
|
|
153
|
+
const segs = [];
|
|
154
|
+
if (it.badge) segs.push({ t: it.badge + " ", fg: it.badgeFg ?? T.ACCENT });
|
|
155
|
+
segs.push({ t: truncate(it.text ?? "", Math.max(0, w - strWidth(it.sub ?? "") - (it.badge ? strWidth(it.badge) + 1 : 0))), bold: it.bold });
|
|
156
|
+
if (it.sub) segs.push({ t: " " + truncate(it.sub, Math.min(24, w)), fg: T.DIM });
|
|
157
|
+
return segs;
|
|
158
|
+
}
|
|
159
|
+
scrollToSelected() {
|
|
160
|
+
if (this.selected < this.scrollY) this.scrollY = this.selected;
|
|
161
|
+
else if (this.selected >= this.scrollY + this.h) this.scrollY = this.selected - this.h + 1;
|
|
162
|
+
}
|
|
163
|
+
render(screen) {
|
|
164
|
+
super.render(screen);
|
|
165
|
+
const y = this.y + this.selected - this.scrollY;
|
|
166
|
+
if (y < this.y || y >= this.y + this.h) return;
|
|
167
|
+
const line = this.lines[this.selected] ?? [];
|
|
168
|
+
screen.fillRect(this.x, y, this.x + this.w - 1, y, " ", { bg: this.selBg });
|
|
169
|
+
let px = this.x;
|
|
170
|
+
for (const seg of line) {
|
|
171
|
+
if (px >= this.x + this.w) break;
|
|
172
|
+
const tx = truncate(seg.t, this.x + this.w - px);
|
|
173
|
+
screen.text(px, y, tx, {
|
|
174
|
+
fg: this.selFg,
|
|
175
|
+
bg: this.selBg,
|
|
176
|
+
attrs: seg.bold ? 1 : 0,
|
|
177
|
+
link: seg.link,
|
|
178
|
+
});
|
|
179
|
+
px += strWidth(tx);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
move(delta) {
|
|
183
|
+
if (this.items.length === 0) return false;
|
|
184
|
+
const next = this.selected + delta;
|
|
185
|
+
if (next < 0 || next >= this.items.length) {
|
|
186
|
+
if (this.wrap) this.selected = (next + this.items.length) % this.items.length;
|
|
187
|
+
else return false;
|
|
188
|
+
} else this.selected = next;
|
|
189
|
+
this.scrollToSelected();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
onMouse(ev) {
|
|
193
|
+
if (super.onMouse(ev)) return true;
|
|
194
|
+
if (ev.kind === "press") {
|
|
195
|
+
if (ev.button === 0) {
|
|
196
|
+
const idx = ev.y - this.y + this.scrollY;
|
|
197
|
+
if (idx >= 0 && idx < this.items.length) {
|
|
198
|
+
this.selected = idx;
|
|
199
|
+
this.onSelect?.(this.items[idx], ev);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
} else if (ev.button === 2) {
|
|
203
|
+
const idx = ev.y - this.y + this.scrollY;
|
|
204
|
+
if (idx >= 0 && idx < this.items.length) {
|
|
205
|
+
this.selected = idx;
|
|
206
|
+
this.onContext?.(this.items[idx], ev);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
onKey(ev) {
|
|
214
|
+
if (ev.type !== "key") return false;
|
|
215
|
+
switch (ev.name) {
|
|
216
|
+
case "up": return this.move(-1);
|
|
217
|
+
case "down": return this.move(1);
|
|
218
|
+
case "pgup": return this.scroll(-this.h);
|
|
219
|
+
case "pgdn": return this.scroll(this.h);
|
|
220
|
+
case "home": this.selected = 0; this.scrollToSelected(); return true;
|
|
221
|
+
case "end": this.selected = this.items.length - 1; this.scrollToSelected(); return true;
|
|
222
|
+
case "enter": if (this.items[this.selected]) this.onSelect?.(this.items[this.selected], ev); return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Input line (cursor = code-point index; CJK-safe) ----
|
|
229
|
+
|
|
230
|
+
export class Input extends Widget {
|
|
231
|
+
constructor(opts = {}) {
|
|
232
|
+
super(opts);
|
|
233
|
+
this.value = "";
|
|
234
|
+
this.cursor = 0; // code-point index into value
|
|
235
|
+
this.prompt = opts.prompt ?? "❯ ";
|
|
236
|
+
this.onEnter = opts.onEnter ?? null;
|
|
237
|
+
this.onChange = null;
|
|
238
|
+
this.placeholder = opts.placeholder ?? "";
|
|
239
|
+
this.fg = opts.fg ?? 0xd4d8dd;
|
|
240
|
+
this.bg = opts.bg ?? -1;
|
|
241
|
+
this.border = opts.border ?? T.BORDER2;
|
|
242
|
+
this.multi = opts.multi ?? false;
|
|
243
|
+
this.maxLines = opts.maxLines ?? 6;
|
|
244
|
+
this.onChange = opts.onChange ?? null;
|
|
245
|
+
this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
|
|
246
|
+
this.history = [];
|
|
247
|
+
this.histIdx = -1;
|
|
248
|
+
}
|
|
249
|
+
#cps() { return Array.from(this.value); } // code points
|
|
250
|
+
#lines() { return this.value.split("\n"); }
|
|
251
|
+
/** Rendered height: 1 line, or up to maxLines when multi-line. */
|
|
252
|
+
height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#lines().length)) : 1; }
|
|
253
|
+
setValue(v, opts = {}) {
|
|
254
|
+
this.value = String(v);
|
|
255
|
+
this.cursor = this.#cps().length;
|
|
256
|
+
this.selectAll = Boolean(opts.select); // first insert/text replaces the whole value
|
|
257
|
+
this.onChange?.();
|
|
258
|
+
}
|
|
259
|
+
insert(text) {
|
|
260
|
+
const cps = this.selectAll ? [] : this.#cps();
|
|
261
|
+
const at = this.selectAll ? 0 : this.cursor;
|
|
262
|
+
this.selectAll = false;
|
|
263
|
+
cps.splice(at, 0, ...Array.from(text));
|
|
264
|
+
this.value = cps.join("");
|
|
265
|
+
this.cursor = at + Array.from(text).length;
|
|
266
|
+
this.onChange?.();
|
|
267
|
+
}
|
|
268
|
+
#deleteAt(idx) {
|
|
269
|
+
const cps = this.#cps();
|
|
270
|
+
cps.splice(idx, 1);
|
|
271
|
+
this.value = cps.join("");
|
|
272
|
+
}
|
|
273
|
+
/** [line, display-col] of the cursor. */
|
|
274
|
+
#cursorPos() {
|
|
275
|
+
const before = this.#cps().slice(0, this.cursor);
|
|
276
|
+
let line = 0, col = 0;
|
|
277
|
+
for (const ch of before) {
|
|
278
|
+
if (ch === "\n") { line++; col = 0; }
|
|
279
|
+
else col += strWidth(ch);
|
|
280
|
+
}
|
|
281
|
+
return [line, col];
|
|
282
|
+
}
|
|
283
|
+
/** Code-point index of the nearest position at [line, col]. */
|
|
284
|
+
#indexAtLineCol(line, col) {
|
|
285
|
+
const lines = this.#lines();
|
|
286
|
+
let idx = 0;
|
|
287
|
+
for (let i = 0; i < Math.min(line, lines.length); i++) idx += Array.from(lines[i]).length + 1;
|
|
288
|
+
const target = lines[Math.min(line, lines.length - 1)] ?? "";
|
|
289
|
+
const cps = Array.from(target);
|
|
290
|
+
let w = 0;
|
|
291
|
+
for (let j = 0; j < cps.length; j++) {
|
|
292
|
+
const cw = strWidth(cps[j]);
|
|
293
|
+
if (col < w + cw / 2) break;
|
|
294
|
+
w += cw;
|
|
295
|
+
idx++;
|
|
296
|
+
}
|
|
297
|
+
return idx;
|
|
298
|
+
}
|
|
299
|
+
render(screen) {
|
|
300
|
+
const n = this.height();
|
|
301
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + n - 1, " ", { bg: this.bg });
|
|
302
|
+
screen.hline(this.x, this.x + this.w - 1, this.y, "─", { fg: this.border, bg: this.bg });
|
|
303
|
+
const lines = this.#lines();
|
|
304
|
+
const [curLine, curCol] = this.#cursorPos();
|
|
305
|
+
const promptW = strWidth(this.prompt);
|
|
306
|
+
const inner0 = Math.max(0, this.w - promptW - 2);
|
|
307
|
+
const innerN = Math.max(0, this.w - 2);
|
|
308
|
+
if (this.value === "" && this.placeholder) {
|
|
309
|
+
screen.text(this.x, this.y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
310
|
+
screen.text(this.x + promptW, this.y, truncate(this.placeholder, inner0), { fg: T.FAINT, bg: this.bg });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
for (let li = 0; li < n; li++) {
|
|
314
|
+
const y = this.y + li;
|
|
315
|
+
const line = lines[li] ?? "";
|
|
316
|
+
if (li === 0) {
|
|
317
|
+
screen.text(this.x, y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
318
|
+
let start = 0;
|
|
319
|
+
if (curLine === 0) {
|
|
320
|
+
while (curCol - start >= inner0) start += Math.max(1, Math.floor(inner0 / 2));
|
|
321
|
+
}
|
|
322
|
+
const visible = truncate(Array.from(line).slice(start).join(""), inner0);
|
|
323
|
+
screen.text(this.x + promptW, y, visible, { fg: this.fg, bg: this.bg });
|
|
324
|
+
if (curLine === 0) {
|
|
325
|
+
const at = Math.min(inner0, Math.max(0, curCol - start));
|
|
326
|
+
screen.put(this.x + promptW + at, y, "█", { fg: T.FAINT, bg: this.bg });
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
screen.text(this.x + 1, y, truncate(line, innerN), { fg: this.fg, bg: this.bg });
|
|
330
|
+
if (curLine === li) {
|
|
331
|
+
const at = Math.min(innerN, Math.max(0, curCol));
|
|
332
|
+
screen.put(this.x + 1 + at, y, "█", { fg: T.FAINT, bg: this.bg });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
onMouse(ev) {
|
|
338
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
339
|
+
const line = Math.max(0, Math.min(this.#lines().length - 1, ev.y - this.y));
|
|
340
|
+
const rx = ev.x - this.x - (line === 0 ? strWidth(this.prompt) : 1);
|
|
341
|
+
this.cursor = this.#indexAtLineCol(line, Math.max(0, rx));
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
onKey(ev) {
|
|
347
|
+
if (ev.type === "text") { this.insert(ev.text); return true; }
|
|
348
|
+
if (ev.type !== "key") return false;
|
|
349
|
+
switch (ev.name) {
|
|
350
|
+
case "backspace":
|
|
351
|
+
if (this.cursor > 0) { this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
|
|
352
|
+
return true;
|
|
353
|
+
case "delete":
|
|
354
|
+
if (this.cursor < this.#cps().length) { this.#deleteAt(this.cursor); this.onChange?.(); }
|
|
355
|
+
return true;
|
|
356
|
+
case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
|
|
357
|
+
case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
|
|
358
|
+
case "home": { const [l] = this.#cursorPos(); this.cursor = this.#indexAtLineCol(l, 0); return true; }
|
|
359
|
+
case "end": { const [l] = this.#cursorPos(); const line = this.#lines()[l] ?? ""; this.cursor = this.#indexAtLineCol(l, strWidth(line)); return true; }
|
|
360
|
+
case "up":
|
|
361
|
+
if (this.multi) {
|
|
362
|
+
const [l, c] = this.#cursorPos();
|
|
363
|
+
if (l > 0) this.cursor = this.#indexAtLineCol(l - 1, c);
|
|
364
|
+
} else if (this.history.length) {
|
|
365
|
+
this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
|
|
366
|
+
this.setValue(this.history[this.histIdx] ?? "");
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
case "down":
|
|
370
|
+
if (this.multi) {
|
|
371
|
+
const [l, c] = this.#cursorPos();
|
|
372
|
+
if (l < this.#lines().length - 1) this.cursor = this.#indexAtLineCol(l + 1, c);
|
|
373
|
+
} else if (this.histIdx >= 0) {
|
|
374
|
+
this.histIdx++;
|
|
375
|
+
if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }
|
|
376
|
+
else this.setValue(this.history[this.histIdx]);
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
case "char":
|
|
380
|
+
if (ev.ctrl) {
|
|
381
|
+
switch (ev.key) {
|
|
382
|
+
case "j": if (this.multi) { this.insert("\n"); return true; } return false;
|
|
383
|
+
case "u": this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
|
|
384
|
+
case "k": this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
|
|
385
|
+
case "a": this.cursor = 0; return true;
|
|
386
|
+
case "e": this.cursor = this.#cps().length; return true;
|
|
387
|
+
case "w": {
|
|
388
|
+
const cps = this.#cps();
|
|
389
|
+
let idx = this.cursor;
|
|
390
|
+
while (idx > 0 && /\s/.test(cps[idx - 1])) idx--;
|
|
391
|
+
while (idx > 0 && !/\s/.test(cps[idx - 1])) idx--;
|
|
392
|
+
cps.splice(idx, this.cursor - idx);
|
|
393
|
+
this.value = cps.join("");
|
|
394
|
+
this.cursor = idx;
|
|
395
|
+
this.onChange?.();
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
this.insert(ev.text);
|
|
402
|
+
return true;
|
|
403
|
+
case "enter":
|
|
404
|
+
if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
|
|
405
|
+
const v = this.value;
|
|
406
|
+
this.history.push(v);
|
|
407
|
+
this.histIdx = -1;
|
|
408
|
+
this.value = "";
|
|
409
|
+
this.cursor = 0;
|
|
410
|
+
this.onChange?.();
|
|
411
|
+
this.onEnter?.(v);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---- Popup (modal) ----
|
|
419
|
+
|
|
420
|
+
export class Popup extends Widget {
|
|
421
|
+
constructor(opts = {}) {
|
|
422
|
+
super(opts);
|
|
423
|
+
this.title = opts.title ?? "";
|
|
424
|
+
this.lines = opts.lines ?? []; // styled line arrays
|
|
425
|
+
this.buttons = opts.buttons ?? []; // { label, action, key, style }
|
|
426
|
+
this.btnIdx = 0;
|
|
427
|
+
this.onAction = opts.onAction ?? null;
|
|
428
|
+
this.fg = opts.fg;
|
|
429
|
+
}
|
|
430
|
+
render(screen) {
|
|
431
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
432
|
+
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: this.fg ?? 0x67b7ff, bg: T.BG2 }, this.title);
|
|
433
|
+
let ly = this.y + 1;
|
|
434
|
+
for (const line of this.lines) {
|
|
435
|
+
if (Array.isArray(line)) {
|
|
436
|
+
// styled line: array of segments
|
|
437
|
+
let px = this.x + 2;
|
|
438
|
+
for (const seg of line) {
|
|
439
|
+
if (typeof seg !== "object" || seg === null || typeof seg.t !== "string") continue;
|
|
440
|
+
const tx = truncate(seg.t, this.x + this.w - 2 - px);
|
|
441
|
+
if (tx) screen.text(px, ly, tx, {
|
|
442
|
+
fg: seg.fg, bg: seg.bg ?? T.BG2,
|
|
443
|
+
attrs: (seg.bold ? 1 : 0) | (seg.italic ? 4 : 0) | (seg.underline ? 8 : 0),
|
|
444
|
+
});
|
|
445
|
+
px += strWidth(tx);
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
screen.text(this.x + 2, ly, truncate(String(line), this.w - 4), { fg: T.TXT, bg: T.BG2 });
|
|
449
|
+
}
|
|
450
|
+
ly++;
|
|
451
|
+
}
|
|
452
|
+
// buttons on last row
|
|
453
|
+
if (this.buttons.length) {
|
|
454
|
+
let bx = this.x + 2;
|
|
455
|
+
const by = this.y + this.h - 2;
|
|
456
|
+
this.buttons.forEach((b, i) => {
|
|
457
|
+
const label = ` ${b.label} `;
|
|
458
|
+
const sel = i === this.btnIdx;
|
|
459
|
+
screen.text(bx, by, label, {
|
|
460
|
+
fg: sel ? T.SELFG : T.TXT,
|
|
461
|
+
bg: sel ? T.ACCENT : T.MENUSEL,
|
|
462
|
+
attrs: 1,
|
|
463
|
+
});
|
|
464
|
+
bx += strWidth(label) + 1;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
onMouse(ev) {
|
|
469
|
+
if (ev.kind === "press" && ev.button === 0 && this.buttons.length) {
|
|
470
|
+
let bx = this.x + 2;
|
|
471
|
+
const by = this.y + this.h - 2;
|
|
472
|
+
for (let i = 0; i < this.buttons.length; i++) {
|
|
473
|
+
const label = ` ${this.buttons[i].label} `;
|
|
474
|
+
if (ev.y === by && ev.x >= bx && ev.x < bx + strWidth(label)) {
|
|
475
|
+
this.onAction?.(this.buttons[i], i);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
bx += strWidth(label) + 1;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
onKey(ev) {
|
|
484
|
+
if (ev.type !== "key") return false;
|
|
485
|
+
if (ev.name === "escape") { this.onAction?.({ label: "__cancel__", action: "__cancel__" }, -1); return true; }
|
|
486
|
+
if (ev.name === "tab") { this.btnIdx = (this.btnIdx + 1) % Math.max(1, this.buttons.length); return true; }
|
|
487
|
+
if (ev.name === "left") { this.btnIdx = Math.max(0, this.btnIdx - 1); return true; }
|
|
488
|
+
if (ev.name === "right") { this.btnIdx = Math.min(this.buttons.length - 1, this.btnIdx + 1); return true; }
|
|
489
|
+
if (ev.name === "enter" && this.buttons[this.btnIdx]) { this.onAction?.(this.buttons[this.btnIdx], this.btnIdx); return true; }
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---- Floating context menu ----
|
|
495
|
+
|
|
496
|
+
export class Menu extends Widget {
|
|
497
|
+
constructor(opts = {}) {
|
|
498
|
+
super(opts);
|
|
499
|
+
this.items = opts.items ?? []; // { label, action, hint?, danger? }
|
|
500
|
+
this.sel = 0;
|
|
501
|
+
this.onAction = opts.onAction ?? null;
|
|
502
|
+
}
|
|
503
|
+
render(screen) {
|
|
504
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.MENUBG });
|
|
505
|
+
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: T.ACCENT, bg: T.MENUBG });
|
|
506
|
+
this.items.forEach((it, i) => {
|
|
507
|
+
const sel = i === this.sel;
|
|
508
|
+
screen.fillRect(this.x + 1, this.y + 1 + i, this.x + this.w - 2, this.y + 1 + i, " ", { bg: sel ? T.MENUSEL : T.MENUBG });
|
|
509
|
+
screen.text(this.x + 2, this.y + 1 + i, truncate(it.label, this.w - 4), {
|
|
510
|
+
fg: sel ? 0xffffff : it.danger ? T.ERR : T.TXT,
|
|
511
|
+
bg: sel ? T.MENUSEL : T.MENUBG,
|
|
512
|
+
});
|
|
513
|
+
if (it.hint) screen.text(this.x + this.w - 2 - strWidth(it.hint), this.y + 1 + i, it.hint, { fg: T.DIM, bg: sel ? T.MENUSEL : T.MENUBG });
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
onMouse(ev) {
|
|
517
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
518
|
+
const idx = ev.y - this.y - 1;
|
|
519
|
+
if (idx >= 0 && idx < this.items.length) { this.onAction?.(this.items[idx], idx); return true; }
|
|
520
|
+
return true; // swallow clicks inside menu
|
|
521
|
+
}
|
|
522
|
+
return ev.x >= this.x && ev.x < this.x + this.w && ev.y >= this.y && ev.y < this.y + this.h;
|
|
523
|
+
}
|
|
524
|
+
onKey(ev) {
|
|
525
|
+
if (ev.type !== "key") return false;
|
|
526
|
+
switch (ev.name) {
|
|
527
|
+
case "up": this.sel = Math.max(0, this.sel - 1); return true;
|
|
528
|
+
case "down": this.sel = Math.min(this.items.length - 1, this.sel + 1); return true;
|
|
529
|
+
case "enter": if (this.items[this.sel]) { this.onAction?.(this.items[this.sel], this.sel); return true; } return false;
|
|
530
|
+
case "escape": this.onAction?.(null, -1); return true;
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---- Status bar (multi-row powerline footer) ----
|
|
537
|
+
|
|
538
|
+
export class StatusBar extends Widget {
|
|
539
|
+
constructor(opts = {}) {
|
|
540
|
+
super(opts);
|
|
541
|
+
this.rows = []; // array of { left: [seg], right: [seg] }
|
|
542
|
+
}
|
|
543
|
+
render(screen) {
|
|
544
|
+
for (let r = 0; r < this.rows.length; r++) {
|
|
545
|
+
const row = this.rows[r];
|
|
546
|
+
const y = this.y + r;
|
|
547
|
+
screen.fillRect(this.x, y, this.x + this.w - 1, y, " ", { bg: T.STATUSBG });
|
|
548
|
+
let px = this.x;
|
|
549
|
+
for (const seg of row.left ?? []) {
|
|
550
|
+
const t = truncate(seg.t, this.x + this.w - px - 4);
|
|
551
|
+
if (!t) break;
|
|
552
|
+
screen.text(px, y, t, { fg: seg.fg ?? T.DIM, bg: seg.bg ?? T.STATUSBG, attrs: seg.bold ? 1 : 0 });
|
|
553
|
+
px += strWidth(t);
|
|
554
|
+
}
|
|
555
|
+
let rx = this.x + this.w;
|
|
556
|
+
for (let i = (row.right ?? []).length - 1; i >= 0; i--) {
|
|
557
|
+
const seg = row.right[i];
|
|
558
|
+
const t = truncate(seg.t, Math.max(0, rx - px - 2));
|
|
559
|
+
if (!t) continue;
|
|
560
|
+
rx -= strWidth(t);
|
|
561
|
+
if (rx >= px) {
|
|
562
|
+
screen.text(rx, y, t, { fg: seg.fg ?? T.DIM, bg: seg.bg ?? T.STATUSBG, attrs: seg.bold ? 1 : 0 });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|