dsh-neotui 0.2.2 → 0.3.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 +25 -0
- package/README.md +32 -14
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -11
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1772 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +1045 -281
- package/src/widgets.js +37 -27
package/src/widgets.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// widgets.js — Minimal widget layer: hit-testing, lists, scroll views, input,
|
|
2
|
-
// popups, context menus, status bar.
|
|
3
|
-
import { truncate, pad, strWidth } from "./text.js";
|
|
2
|
+
// popups, context menus, status bar. Keyboard-first, with mouse hit-testing as an auxiliary path.
|
|
3
|
+
import { truncate, pad, strWidth, graphemes } from "./text.js";
|
|
4
4
|
import { T } from "./theme.js";
|
|
5
5
|
|
|
6
|
+
/** Circular cursor movement shared by every finite choice list. Text cursors
|
|
7
|
+
* and scroll offsets deliberately do not use this helper. */
|
|
8
|
+
export function wrapIndex(index, length) {
|
|
9
|
+
if (!Number.isFinite(length) || length <= 0) return 0;
|
|
10
|
+
return ((index % length) + length) % length;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export class Widget {
|
|
7
14
|
constructor({ x = 0, y = 0, w = 0, h = 0 } = {}) {
|
|
8
15
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
@@ -148,7 +155,8 @@ export class List extends ScrollView {
|
|
|
148
155
|
this.selBg = opts.selBg ?? T.ACCENT2;
|
|
149
156
|
this.cursorFg = opts.cursorFg ?? T.CURSORFG;
|
|
150
157
|
this.cursorBg = opts.cursorBg ?? T.CURSORBG;
|
|
151
|
-
|
|
158
|
+
// Finite option lists wrap by default (↑ on the first reaches the last).
|
|
159
|
+
this.wrap = opts.wrap ?? true;
|
|
152
160
|
}
|
|
153
161
|
setItems(items, { keepSelection = false } = {}) {
|
|
154
162
|
this.items = items;
|
|
@@ -169,8 +177,11 @@ export class List extends ScrollView {
|
|
|
169
177
|
return segs;
|
|
170
178
|
}
|
|
171
179
|
scrollToSelected() {
|
|
180
|
+
if (this.items.length === 0) { this.selected = 0; this.scrollY = 0; return; }
|
|
181
|
+
this.selected = Math.max(0, Math.min(this.items.length - 1, this.selected));
|
|
172
182
|
if (this.selected < this.scrollY) this.scrollY = this.selected;
|
|
173
183
|
else if (this.selected >= this.scrollY + this.h) this.scrollY = this.selected - this.h + 1;
|
|
184
|
+
this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY));
|
|
174
185
|
}
|
|
175
186
|
render(screen) {
|
|
176
187
|
super.render(screen);
|
|
@@ -195,7 +206,7 @@ export class List extends ScrollView {
|
|
|
195
206
|
if (this.items.length === 0) return false;
|
|
196
207
|
const next = this.selected + delta;
|
|
197
208
|
if (next < 0 || next >= this.items.length) {
|
|
198
|
-
if (this.wrap) this.selected = (next
|
|
209
|
+
if (this.wrap) this.selected = wrapIndex(next, this.items.length);
|
|
199
210
|
else return false;
|
|
200
211
|
} else this.selected = next;
|
|
201
212
|
this.scrollToSelected();
|
|
@@ -230,7 +241,7 @@ export class List extends ScrollView {
|
|
|
230
241
|
case "pgup": return this.scroll(-this.h);
|
|
231
242
|
case "pgdn": return this.scroll(this.h);
|
|
232
243
|
case "home": this.selected = 0; this.scrollToSelected(); return true;
|
|
233
|
-
case "end": this.selected = this.items.length - 1; this.scrollToSelected(); return true;
|
|
244
|
+
case "end": this.selected = Math.max(0, this.items.length - 1); this.scrollToSelected(); return true;
|
|
234
245
|
case "enter": if (this.items[this.selected]) this.onSelect?.(this.items[this.selected], ev); return true;
|
|
235
246
|
}
|
|
236
247
|
return false;
|
|
@@ -243,7 +254,7 @@ export class Input extends Widget {
|
|
|
243
254
|
constructor(opts = {}) {
|
|
244
255
|
super(opts);
|
|
245
256
|
this.value = "";
|
|
246
|
-
this.cursor = 0; //
|
|
257
|
+
this.cursor = 0; // grapheme index into value
|
|
247
258
|
this.prompt = opts.prompt ?? "❯ ";
|
|
248
259
|
this.onEnter = opts.onEnter ?? null;
|
|
249
260
|
this.onChange = null;
|
|
@@ -271,13 +282,13 @@ export class Input extends Widget {
|
|
|
271
282
|
this.histIdx = -1;
|
|
272
283
|
this.masked = opts.masked ?? false; // secret fields render •••• instead of the value
|
|
273
284
|
}
|
|
274
|
-
#cps() { return
|
|
285
|
+
#cps() { return graphemes(this.value); } // user-perceived characters
|
|
275
286
|
/** Visual rows for multi-line input: logical lines wrapped at the width. */
|
|
276
287
|
#visualRows() {
|
|
277
288
|
const inner0 = Math.max(1, this.w - strWidth(this.prompt) - 2);
|
|
278
289
|
const innerN = Math.max(1, this.w - 2);
|
|
279
290
|
const rows = [];
|
|
280
|
-
const cps =
|
|
291
|
+
const cps = this.#cps();
|
|
281
292
|
let text = "", width = 0, limit = inner0, start = 0;
|
|
282
293
|
for (let i = 0; i < cps.length; i++) {
|
|
283
294
|
const ch = cps[i];
|
|
@@ -299,11 +310,11 @@ export class Input extends Widget {
|
|
|
299
310
|
/** [visualRow, display-col] of the cursor in wrapped coordinates. */
|
|
300
311
|
#cursorVisual() {
|
|
301
312
|
const rows = this.#visualRows();
|
|
302
|
-
const cursor = Math.max(0, Math.min(this.cursor,
|
|
313
|
+
const cursor = Math.max(0, Math.min(this.cursor, this.#cps().length));
|
|
303
314
|
for (let ri = 0; ri < rows.length; ri++) {
|
|
304
315
|
const r = rows[ri];
|
|
305
316
|
if (cursor >= r.start && cursor <= r.end) {
|
|
306
|
-
const before =
|
|
317
|
+
const before = graphemes(r.text).slice(0, cursor - r.start);
|
|
307
318
|
return { row: ri, col: before.reduce((w, ch) => w + strWidth(ch), 0) };
|
|
308
319
|
}
|
|
309
320
|
}
|
|
@@ -314,7 +325,7 @@ export class Input extends Widget {
|
|
|
314
325
|
#indexAtVisual(row, col) {
|
|
315
326
|
const rows = this.#visualRows();
|
|
316
327
|
const r = rows[Math.max(0, Math.min(row, rows.length - 1))];
|
|
317
|
-
const cps =
|
|
328
|
+
const cps = graphemes(r.text);
|
|
318
329
|
let w = 0, j = 0;
|
|
319
330
|
for (; j < cps.length; j++) {
|
|
320
331
|
const cw = strWidth(cps[j]);
|
|
@@ -336,7 +347,7 @@ export class Input extends Widget {
|
|
|
336
347
|
insertAtomic(text, id = null) {
|
|
337
348
|
const at = this.selectAll ? 0 : this.cursor;
|
|
338
349
|
this.insert(text);
|
|
339
|
-
this.atomicMarks.push({ start: at, end: at +
|
|
350
|
+
this.atomicMarks.push({ start: at, end: at + graphemes(text).length, id });
|
|
340
351
|
this.#snapCursor(1);
|
|
341
352
|
}
|
|
342
353
|
removeAtomic(id) {
|
|
@@ -375,10 +386,10 @@ export class Input extends Widget {
|
|
|
375
386
|
#edit(from, to, text = "") {
|
|
376
387
|
this.selStart = this.selEnd = null; // edits consume the selection
|
|
377
388
|
const cps = this.#cps();
|
|
378
|
-
const t =
|
|
389
|
+
const t = graphemes(text);
|
|
379
390
|
const atomic = this.atomicMarks.find((x) => to > x.start && from < x.end);
|
|
380
391
|
if (atomic) { from = Math.min(from, atomic.start); to = Math.max(to, atomic.end); this.atomicMarks = this.atomicMarks.filter((x) => x !== atomic); }
|
|
381
|
-
for (const mark of this.atomicMarks) { if (to <= mark.start) { const delta =
|
|
392
|
+
for (const mark of this.atomicMarks) { if (to <= mark.start) { const delta = graphemes(text).length - (to - from); mark.start += delta; mark.end += delta; } }
|
|
382
393
|
const m = this.pasteMark;
|
|
383
394
|
if (m && to > m.start && from < m.end) {
|
|
384
395
|
// the edit touches the token: apply it over the whole token span
|
|
@@ -430,7 +441,7 @@ export class Input extends Widget {
|
|
|
430
441
|
this.cursorCell = { x: this.x + promptW, y: this.y };
|
|
431
442
|
return;
|
|
432
443
|
}
|
|
433
|
-
const cps =
|
|
444
|
+
const cps = this.#cps();
|
|
434
445
|
const before = cps.slice(0, this.cursor).join("");
|
|
435
446
|
const cx = strWidth(before);
|
|
436
447
|
const desiredCol = Math.max(0, cx - Math.max(1, inner - 1));
|
|
@@ -440,7 +451,7 @@ export class Input extends Widget {
|
|
|
440
451
|
startIdx++;
|
|
441
452
|
}
|
|
442
453
|
const visible = truncate(cps.slice(startIdx).join(""), inner);
|
|
443
|
-
const drawn = this.masked ? "•".repeat(
|
|
454
|
+
const drawn = this.masked ? "•".repeat(graphemes(visible).length) : visible;
|
|
444
455
|
screen.text(this.x + promptW, this.y, drawn, { fg: this.fg, bg: this.bg });
|
|
445
456
|
this.cursorCell = { x: this.x + promptW + Math.min(inner, Math.max(0, cx - startCol)), y: this.y };
|
|
446
457
|
return;
|
|
@@ -460,7 +471,7 @@ export class Input extends Widget {
|
|
|
460
471
|
for (let ri = start; ri < Math.min(rows.length, start + h); ri++) {
|
|
461
472
|
const r = rows[ri];
|
|
462
473
|
const y = this.y + (ri - start);
|
|
463
|
-
const drawn = this.masked ? "•".repeat(
|
|
474
|
+
const drawn = this.masked ? "•".repeat(graphemes(r.text).length) : r.text;
|
|
464
475
|
if (ri === 0) {
|
|
465
476
|
screen.text(this.x, y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
466
477
|
screen.text(this.x + strWidth(this.prompt), y, drawn, { fg: this.fg, bg: this.bg });
|
|
@@ -476,7 +487,7 @@ export class Input extends Widget {
|
|
|
476
487
|
const hi = Math.min(r.end, this.selEnd);
|
|
477
488
|
if (lo >= hi) continue;
|
|
478
489
|
const rowY = this.y + (ri - start);
|
|
479
|
-
const text =
|
|
490
|
+
const text = graphemes(r.text);
|
|
480
491
|
const preW = strWidth(text.slice(0, lo - r.start).join(""));
|
|
481
492
|
const selW = strWidth(text.slice(lo - r.start, hi - r.start).join(""));
|
|
482
493
|
const x0 = this.x + (ri === 0 ? strWidth(this.prompt) : 1) + preW;
|
|
@@ -501,7 +512,7 @@ export class Input extends Widget {
|
|
|
501
512
|
} else {
|
|
502
513
|
const rx = ev.x - this.x - strWidth(this.prompt);
|
|
503
514
|
let w = 0, idx = 0;
|
|
504
|
-
for (const ch of
|
|
515
|
+
for (const ch of this.#cps()) {
|
|
505
516
|
const cw = strWidth(ch);
|
|
506
517
|
if (rx < w + cw / 2) break;
|
|
507
518
|
w += cw;
|
|
@@ -526,7 +537,7 @@ export class Input extends Widget {
|
|
|
526
537
|
} else {
|
|
527
538
|
const rx = ev.x - this.x - strWidth(this.prompt);
|
|
528
539
|
let w = 0, idx = 0;
|
|
529
|
-
for (const ch of
|
|
540
|
+
for (const ch of this.#cps()) {
|
|
530
541
|
const cw = strWidth(ch);
|
|
531
542
|
if (rx < w + cw / 2) break;
|
|
532
543
|
w += cw;
|
|
@@ -578,7 +589,7 @@ export class Input extends Widget {
|
|
|
578
589
|
const placeholder = `[已复制 ${lines} 行内容]`;
|
|
579
590
|
const at = this.selectAll ? 0 : this.cursor;
|
|
580
591
|
this.insert(placeholder);
|
|
581
|
-
this.pasteMark = { start: at, end: at +
|
|
592
|
+
this.pasteMark = { start: at, end: at + graphemes(placeholder).length };
|
|
582
593
|
this.pendingPaste = { text };
|
|
583
594
|
this.app?.toast?.("再次 Ctrl+Shift+V 粘贴完整内容(Ctrl+L 展开输入栏)");
|
|
584
595
|
return true;
|
|
@@ -669,7 +680,7 @@ export class Input extends Widget {
|
|
|
669
680
|
if (ev.shift) {
|
|
670
681
|
// Ctrl+Shift+C: copy the drag-selection (if any)
|
|
671
682
|
if (this.selStart !== null && this.selEnd !== null && this.selEnd > this.selStart) {
|
|
672
|
-
const text =
|
|
683
|
+
const text = this.#cps().slice(this.selStart, this.selEnd).join("");
|
|
673
684
|
this.selStart = this.selEnd = null;
|
|
674
685
|
this.app?.copyText?.(text);
|
|
675
686
|
} else {
|
|
@@ -838,9 +849,8 @@ export class Popup extends Widget {
|
|
|
838
849
|
if (ev.name === "pgdn") { this.scrollY = Math.min(this.maxScroll(), this.scrollY + this.contentRows()); return true; }
|
|
839
850
|
}
|
|
840
851
|
if (ev.name === "escape") { this.onAction?.({ label: "__cancel__", action: "__cancel__" }, -1); return true; }
|
|
841
|
-
if (ev.name === "tab") { this.btnIdx = (this.btnIdx + 1
|
|
842
|
-
if (ev.name === "left") { this.btnIdx =
|
|
843
|
-
if (ev.name === "right") { this.btnIdx = Math.min(this.buttons.length - 1, this.btnIdx + 1); return true; }
|
|
852
|
+
if (ev.name === "tab" || ev.name === "right") { this.btnIdx = wrapIndex(this.btnIdx + 1, this.buttons.length); return true; }
|
|
853
|
+
if (ev.name === "backtab" || ev.name === "left") { this.btnIdx = wrapIndex(this.btnIdx - 1, this.buttons.length); return true; }
|
|
844
854
|
if (ev.name === "enter" && this.buttons[this.btnIdx]) { this.onAction?.(this.buttons[this.btnIdx], this.btnIdx); return true; }
|
|
845
855
|
return false;
|
|
846
856
|
}
|
|
@@ -879,8 +889,8 @@ export class Menu extends Widget {
|
|
|
879
889
|
onKey(ev) {
|
|
880
890
|
if (ev.type !== "key") return false;
|
|
881
891
|
switch (ev.name) {
|
|
882
|
-
case "up": this.sel =
|
|
883
|
-
case "down": this.sel =
|
|
892
|
+
case "up": this.sel = wrapIndex(this.sel - 1, this.items.length); return true;
|
|
893
|
+
case "down": this.sel = wrapIndex(this.sel + 1, this.items.length); return true;
|
|
884
894
|
case "enter": if (this.items[this.sel]) { this.onAction?.(this.items[this.sel], this.sel); return true; } return false;
|
|
885
895
|
case "escape": this.onAction?.(null, -1); return true;
|
|
886
896
|
}
|