@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 +2 -0
- package/dist/index.mjs +93 -44
- package/package.json +2 -2
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
|
-
|
|
113
|
-
const visLen = visibleLength(left) +
|
|
114
|
-
const pad = Math.max(0, width - 2 -
|
|
115
|
-
content
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 =
|
|
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
|
|
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(` ║ [1m${
|
|
459
|
+
lines.push(` ║ [1m${safeTitle}[22m${" ".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
|
|
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
|
|
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,
|
|
433
|
-
lines.push(` ║ ${
|
|
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
|
-
|
|
533
|
-
|
|
575
|
+
if (width <= 0) return "";
|
|
576
|
+
const leftSeg = sections.map((sec) => {
|
|
534
577
|
const style = STYLE_MAP[sec.style ?? "default"];
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"@bdocs/dui": "0.7.0-next.4"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^22.0.0",
|