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/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. All mouse-driven (nvim-style) with key equivalents.
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
- this.wrap = opts.wrap ?? false;
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 + this.items.length) % this.items.length;
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; // code-point index into value
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 Array.from(this.value); } // code points
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 = Array.from(this.value);
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, Array.from(this.value).length));
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 = Array.from(r.text).slice(0, cursor - r.start);
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 = Array.from(r.text);
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 + Array.from(text).length, id });
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 = Array.from(text);
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 = Array.from(text).length - (to - from); mark.start += delta; mark.end += 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 = Array.from(this.value);
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(Array.from(visible).length) : visible;
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(Array.from(r.text).length) : r.text;
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 = Array.from(r.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 Array.from(this.value)) {
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 Array.from(this.value)) {
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 + Array.from(placeholder).length };
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 = Array.from(this.value).slice(this.selStart, this.selEnd).join("");
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) % Math.max(1, this.buttons.length); return true; }
842
- if (ev.name === "left") { this.btnIdx = Math.max(0, this.btnIdx - 1); return true; }
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 = Math.max(0, this.sel - 1); return true;
883
- case "down": this.sel = Math.min(this.items.length - 1, this.sel + 1); return true;
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
  }