@dui-toolkit/plugin-tui 0.1.1-next.0 → 0.1.1-next.2

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/dist/index.d.mts CHANGED
@@ -147,6 +147,8 @@ interface SelectListOptions {
147
147
  onSubmit?: (item: SelectItem, index: number) => void;
148
148
  }
149
149
  declare class SelectList extends BaseWidget<SelectListData> {
150
+ /** Height of the last render — lets handleInput scroll in the same viewport the user actually sees. */
151
+ private viewportHeight;
150
152
  constructor(id: string, opts: SelectListOptions);
151
153
  /** Get the currently selected item. */
152
154
  getSelected(): SelectItem | undefined;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { stripAnsi, visibleLength } from "@bdocs/dui";
1
+ import { stripAnsi, truncateByCells, visibleLength } from "@bdocs/dui";
2
2
  import { readFileSync } from "node:fs";
3
3
  //#region src/widget.ts
4
4
  var BaseWidget = class {
@@ -109,11 +109,10 @@ var TextInput = class extends BaseWidget {
109
109
  else {
110
110
  const left = before;
111
111
  const right = after;
112
- content = ` ${left}${isFocused ? `\x1b[7m${cursor}\x1b[27m` : cursor}${right}`;
113
- const visLen = visibleLength(left) + 1 + visibleLength(right);
114
- const pad = Math.max(0, width - 2 - visLen - 1);
115
- content += " ".repeat(pad);
116
- content = content.slice(0, width - 2);
112
+ const cursorChar = isFocused ? `\x1b[7m${cursor}\x1b[27m` : cursor;
113
+ const visLen = visibleLength(left) + visibleLength(cursorChar) + visibleLength(right);
114
+ const pad = Math.max(0, width - 2 - 1 - visLen);
115
+ content = ` ${left}${cursorChar}${right}${" ".repeat(pad)}`;
117
116
  }
118
117
  const border = isFocused ? "\x1B[36m" : "\x1B[2m";
119
118
  const reset = "\x1B[0m";
@@ -208,6 +207,8 @@ var TextInput = class extends BaseWidget {
208
207
  * ```
209
208
  */
210
209
  var SelectList = class extends BaseWidget {
210
+ /** Height of the last render — lets handleInput scroll in the same viewport the user actually sees. */
211
+ viewportHeight = 10;
211
212
  constructor(id, opts) {
212
213
  super(id, "select-list", {
213
214
  items: opts.items,
@@ -240,12 +241,13 @@ var SelectList = class extends BaseWidget {
240
241
  render(opts) {
241
242
  if (!this.visible) return "";
242
243
  const { width, height } = opts;
244
+ this.viewportHeight = height;
243
245
  const items = this.getFilteredItems();
244
246
  const isFocused = this.focused;
245
247
  const maxVisible = height - (this.data.filterActive ? 2 : 0) - 2;
246
248
  const lines = [];
247
249
  const header = this.data.filterActive ? ` 🔍 ${this.data.filter}█` : ` 📋 ${items.length} item(s)`;
248
- lines.push(header);
250
+ lines.push(truncateByCells(header, width));
249
251
  const start = this.data.scrollOffset;
250
252
  for (let i = 0; i < maxVisible; i++) {
251
253
  const idx = start + i;
@@ -257,22 +259,15 @@ var SelectList = class extends BaseWidget {
257
259
  const isSelected = idx === this.data.selectedIndex;
258
260
  const isDisabled = item.disabled;
259
261
  let prefix;
262
+ if (isSelected) prefix = " ▸ ";
263
+ else prefix = " ";
264
+ const maxLabelWidth = Math.max(0, width - 4 - visibleLength(prefix));
265
+ const labelText = truncateByCells(item.label, maxLabelWidth);
260
266
  let label;
261
- if (isSelected && isFocused) {
262
- prefix = " ";
263
- label = `\x1b[7m ${item.label} \x1b[27m`;
264
- } else if (isSelected) {
265
- prefix = " ▸ ";
266
- label = ` ${item.label} `;
267
- } else if (isDisabled) {
268
- prefix = " ";
269
- label = `\x1b[2m${item.label}\x1b[22m`;
270
- } else {
271
- prefix = " ";
272
- label = ` ${item.label}`;
273
- }
274
- const maxLabelWidth = width - 4 - visibleLength(prefix);
275
- if (visibleLength(label) > maxLabelWidth) label = label.slice(0, maxLabelWidth - 1) + "…";
267
+ if (isSelected && isFocused) label = `\x1b[7m ${labelText} \x1b[27m`;
268
+ else if (isSelected) label = ` ${labelText} `;
269
+ else if (isDisabled) label = `\x1b[2m${labelText}\x1b[22m`;
270
+ else label = ` ${labelText}`;
276
271
  lines.push(`${prefix}${label}`);
277
272
  }
278
273
  if (items.length > maxVisible) {
@@ -285,7 +280,7 @@ var SelectList = class extends BaseWidget {
285
280
  if (!this.focused) return false;
286
281
  const { key, char, ctrl } = event;
287
282
  const items = this.getFilteredItems();
288
- const maxVisible = 10;
283
+ const maxVisible = Math.max(1, this.viewportHeight - 2);
289
284
  if (this.data.filterActive) {
290
285
  if (key === "Escape") {
291
286
  this.data.filterActive = false;
@@ -363,6 +358,52 @@ var SelectList = class extends BaseWidget {
363
358
  }
364
359
  };
365
360
  //#endregion
361
+ //#region src/utils.ts
362
+ /**
363
+ * Layout helpers shared by the TUI widgets.
364
+ *
365
+ * Widgets render ANSI strings whose visible width must fit inside a box /
366
+ * bar region. Naïve `String#slice` indexes by UTF‑16 code units and breaks
367
+ * when the string carries ANSI escape sequences (a trimmed escape code
368
+ * corrupts terminal state) or wide glyphs (CJK / emoji). These helpers stay
369
+ * cell-aware and never split an escape sequence.
370
+ */
371
+ const CSI_RE = /\x1b\[[0-9;:<=>?]*[ -/]*[@-~]/;
372
+ /**
373
+ * Truncate an ANSI-wrapped string to `maxCells` terminal cells.
374
+ *
375
+ * Copies whole escape sequences through untouched so a truncation can never
376
+ * split a `\x1b[...m` code mid-way (which would leak styling into the rest
377
+ * of the screen). Reserves 1 cell for a `…` ellipsis when truncation occurs,
378
+ * mirroring `truncateByCells` from `@bdocs/dui`. If the input fits within
379
+ * `maxCells` it is returned verbatim.
380
+ */
381
+ function truncateAnsi(text, maxCells) {
382
+ if (visibleLength(stripAnsi(text)) <= maxCells) return text;
383
+ if (maxCells <= 0) return "";
384
+ const target = Math.max(0, maxCells - 1);
385
+ let out = "";
386
+ let used = 0;
387
+ let rest = text;
388
+ while (rest.length > 0 && used < target) {
389
+ if (rest.startsWith("\x1B")) {
390
+ const m = CSI_RE.exec(rest);
391
+ if (m && m[0].length > 0) {
392
+ out += m[0];
393
+ rest = rest.slice(m[0].length);
394
+ continue;
395
+ }
396
+ }
397
+ const ch = rest.charAt(0);
398
+ const w = visibleLength(ch);
399
+ if (used + w > target) break;
400
+ out += ch;
401
+ used += w;
402
+ rest = rest.slice(1);
403
+ }
404
+ return out + "…";
405
+ }
406
+ //#endregion
366
407
  //#region src/widgets/modal.ts
367
408
  /**
368
409
  * Modal widget — overlay dialog with backdrop and action buttons.
@@ -411,26 +452,27 @@ var Modal = class extends BaseWidget {
411
452
  const lines = [];
412
453
  const backdropLines = Math.max(0, Math.floor((height - modalHeight) / 2));
413
454
  for (let i = 0; i < backdropLines; i++) lines.push("");
414
- const titlePad = modalWidth - 4 - visibleLength(title);
455
+ const innerWidth = Math.max(0, modalWidth - 6);
456
+ const safeTitle = truncateAnsi(title, innerWidth);
457
+ const titlePad = Math.max(0, innerWidth - visibleLength(stripAnsi(safeTitle)));
415
458
  lines.push(` ╔${"═".repeat(modalWidth - 4)}╗`);
416
- lines.push(` ║ ${title}${" ".repeat(Math.max(0, titlePad))} ║`);
459
+ lines.push(` ║ ${safeTitle}${" ".repeat(titlePad)} ║`);
417
460
  lines.push(` ╠${"═".repeat(modalWidth - 4)}╣`);
418
461
  const contentLines = content.split("\n");
419
462
  const maxContentLines = modalHeight - 6;
420
463
  for (let i = 0; i < maxContentLines; i++) {
421
- const line = contentLines[i] ?? "";
422
- const visLen = visibleLength(line);
423
- const pad = Math.max(0, modalWidth - 4 - visLen);
464
+ const line = truncateAnsi(contentLines[i] ?? "", innerWidth);
465
+ const pad = Math.max(0, innerWidth - visibleLength(stripAnsi(line)));
424
466
  lines.push(` ║ ${line}${" ".repeat(pad)} ║`);
425
467
  }
426
468
  lines.push(` ╠${"═".repeat(modalWidth - 4)}╣`);
427
- const actionStr = actions.map((a, i) => {
469
+ const safeActions = truncateAnsi(actions.map((a, i) => {
428
470
  if (i === selectedAction && isFocused) return `\x1b[7m ${a.label} \x1b[27m`;
429
471
  if (a.primary) return `\x1b[1m${a.label}\x1b[22m`;
430
472
  return ` ${a.label} `;
431
- }).join(" ");
432
- const actionPad = Math.max(0, modalWidth - 4 - visibleLength(stripAnsi(actionStr)));
433
- lines.push(` ║ ${actionStr}${" ".repeat(actionPad)} ║`);
473
+ }).join(" "), innerWidth);
474
+ const actionPad = Math.max(0, innerWidth - visibleLength(stripAnsi(safeActions)));
475
+ lines.push(` ║ ${safeActions}${" ".repeat(actionPad)} ║`);
434
476
  lines.push(` ╚${"═".repeat(modalWidth - 4)}╝`);
435
477
  for (let i = 0; i < backdropLines; i++) lines.push("");
436
478
  return lines.join("\n");
@@ -439,6 +481,7 @@ var Modal = class extends BaseWidget {
439
481
  if (!this.focused) return false;
440
482
  const { key } = event;
441
483
  const { actions } = this.data;
484
+ if (actions.length === 0) return false;
442
485
  switch (key) {
443
486
  case "ArrowLeft":
444
487
  this.data.selectedAction = (this.data.selectedAction - 1 + actions.length) % actions.length;
@@ -529,19 +572,25 @@ var StatusBar = class extends BaseWidget {
529
572
  const { left, center, right, sections } = this.data;
530
573
  const bg = "\x1B[48;2;40;40;50m";
531
574
  const reset = "\x1B[0m";
532
- const parts = [];
533
- for (const sec of sections) {
575
+ if (width <= 0) return "";
576
+ const leftSeg = sections.map((sec) => {
534
577
  const style = STYLE_MAP[sec.style ?? "default"];
535
- parts.push(`${style.bg}${style.fg} ${sec.text} ${reset}`);
536
- }
537
- if (left) parts.push(`${bg}\x1b[1m ${left} ${reset}`);
538
- if (center) parts.push(`${bg}\x1b[2m ${center} ${reset}`);
539
- if (right) parts.push(`${bg} ${right} ${reset}`);
540
- let bar = parts.join("");
541
- const visLen = visibleLength(stripAnsi(bar));
542
- const pad = Math.max(0, width - visLen);
543
- bar += " ".repeat(pad);
544
- if (stripAnsi(bar).length > width) bar = bar.slice(0, width);
578
+ return `${style.bg}${style.fg} ${sec.text} ${reset}`;
579
+ }).join("") + (left ? `${bg}\x1b[1m ${left} ${reset}` : "");
580
+ const centerSeg = center ? `${bg}\x1b[2m ${center} ${reset}` : "";
581
+ const rightSeg = right ? `${bg} ${right} ${reset}` : "";
582
+ const lw = visibleLength(stripAnsi(leftSeg));
583
+ const cw = visibleLength(stripAnsi(centerSeg));
584
+ const rw = visibleLength(stripAnsi(rightSeg));
585
+ let bar;
586
+ if (lw + cw + rw <= width) {
587
+ const rightStart = width - rw;
588
+ const centerStart = Math.min(Math.max(lw, Math.floor((width - cw) / 2)), rightStart - cw);
589
+ bar = `${leftSeg}${" ".repeat(Math.max(0, centerStart - lw))}${centerSeg}${" ".repeat(Math.max(0, rightStart - (centerStart + cw)))}${rightSeg}`;
590
+ } else bar = `${leftSeg}${centerSeg}${rightSeg}`;
591
+ bar = truncateAnsi(bar, width);
592
+ const used = visibleLength(stripAnsi(bar));
593
+ if (used < width) bar += " ".repeat(width - used);
545
594
  return `${bg}${" ".repeat(width)}${reset}\r${bar}`;
546
595
  }
547
596
  handleInput() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dui-toolkit/plugin-tui",
3
- "version": "0.1.1-next.0",
3
+ "version": "0.1.1-next.2",
4
4
  "description": "TUI widget toolkit plugin for @bdocs/dui — text inputs, select lists, modals, status bars, and more.",
5
5
  "main": "dist/index.mjs",
6
6
  "types": "dist/index.d.mts",
@@ -28,7 +28,7 @@
28
28
  "license": "MIT",
29
29
  "type": "module",
30
30
  "dependencies": {
31
- "@bdocs/dui": "0.7.0-next.2"
31
+ "@bdocs/dui": "0.7.0-next.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^22.0.0",