@dui-toolkit/plugin-tui 0.1.1-next.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesus Alcala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,227 @@
1
+ import { DuiPlugin } from "@bdocs/dui";
2
+
3
+ //#region src/widget.d.ts
4
+ /**
5
+ * Base widget interface — every widget implements this.
6
+ *
7
+ * Widgets are pure renderers: they receive state and return an ANSI string.
8
+ * No side effects, no mutation. The engine handles focus, input routing,
9
+ * and re-rendering.
10
+ */
11
+ type WidgetType = "text-input" | "select-list" | "modal" | "status-bar" | "custom";
12
+ interface WidgetState {
13
+ /** Widget unique id. */
14
+ id: string;
15
+ /** Widget type. */
16
+ type: WidgetType;
17
+ /** Whether this widget can receive focus. */
18
+ focusable: boolean;
19
+ /** Whether this widget is currently focused. */
20
+ focused: boolean;
21
+ /** Whether this widget is visible. */
22
+ visible: boolean;
23
+ /** Widget-specific state. */
24
+ data: Record<string, unknown>;
25
+ }
26
+ interface WidgetRenderOptions {
27
+ /** Available width in columns. */
28
+ width: number;
29
+ /** Available height in rows. */
30
+ height: number;
31
+ /** Whether the widget is focused. */
32
+ focused: boolean;
33
+ /** Theme overrides. */
34
+ theme?: Record<string, string>;
35
+ }
36
+ interface WidgetInputEvent {
37
+ /** Key name (e.g. "a", "Enter", "ArrowUp", "Tab"). */
38
+ key: string;
39
+ /** Raw character (for printable keys). */
40
+ char?: string;
41
+ /** Modifier flags. */
42
+ ctrl?: boolean;
43
+ shift?: boolean;
44
+ alt?: boolean;
45
+ }
46
+ interface Widget<TData = Record<string, unknown>> {
47
+ /** Widget unique id. */
48
+ id: string;
49
+ /** Widget type. */
50
+ type: WidgetType;
51
+ /** Whether this widget can receive focus. */
52
+ focusable: boolean;
53
+ /** Whether this widget is visible. */
54
+ visible: boolean;
55
+ /** Get current state. */
56
+ getState(): WidgetState;
57
+ /** Render to ANSI string. */
58
+ render(opts: WidgetRenderOptions): string;
59
+ /** Handle input event. Returns true if the event was consumed. */
60
+ handleInput(event: WidgetInputEvent): boolean;
61
+ /** Update widget data. */
62
+ setData(data: Partial<TData>): void;
63
+ /** Get widget data. */
64
+ getData(): TData;
65
+ /** Set focused state. */
66
+ setFocused(focused: boolean): void;
67
+ /** Set visible state. */
68
+ setVisible(visible: boolean): void;
69
+ }
70
+ declare abstract class BaseWidget<TData = Record<string, unknown>> implements Widget<TData> {
71
+ id: string;
72
+ type: WidgetType;
73
+ focusable: boolean;
74
+ visible: boolean;
75
+ protected focused: boolean;
76
+ protected data: TData;
77
+ constructor(id: string, type: WidgetType, data: TData, focusable?: boolean);
78
+ getState(): WidgetState;
79
+ abstract render(opts: WidgetRenderOptions): string;
80
+ abstract handleInput(event: WidgetInputEvent): boolean;
81
+ setData(data: Partial<TData>): void;
82
+ getData(): TData;
83
+ setFocused(focused: boolean): void;
84
+ setVisible(visible: boolean): void;
85
+ }
86
+ //#endregion
87
+ //#region src/widgets/text-input.d.ts
88
+ interface TextInputData {
89
+ /** Current text value. */
90
+ value: string;
91
+ /** Placeholder when empty. */
92
+ placeholder: string;
93
+ /** Maximum length (0 = unlimited). */
94
+ maxLength: number;
95
+ /** Whether the input is read-only. */
96
+ readOnly: boolean;
97
+ /** Mask character for password fields (null = plain text). */
98
+ mask: string | null;
99
+ /** Callback when value changes. */
100
+ onChange?: (value: string) => void;
101
+ /** Callback when Enter is pressed. */
102
+ onSubmit?: (value: string) => void;
103
+ }
104
+ interface TextInputOptions {
105
+ placeholder?: string;
106
+ maxLength?: number;
107
+ readOnly?: boolean;
108
+ mask?: string | null;
109
+ onChange?: (value: string) => void;
110
+ onSubmit?: (value: string) => void;
111
+ }
112
+ declare class TextInput extends BaseWidget<TextInputData> {
113
+ private cursorPos;
114
+ constructor(id: string, opts?: TextInputOptions);
115
+ /** Set the text value programmatically. */
116
+ setValue(value: string): void;
117
+ /** Get the current text value. */
118
+ getValue(): string;
119
+ render(opts: WidgetRenderOptions): string;
120
+ handleInput(event: WidgetInputEvent): boolean;
121
+ }
122
+ //#endregion
123
+ //#region src/widgets/select-list.d.ts
124
+ interface SelectItem {
125
+ label: string;
126
+ value: string;
127
+ disabled?: boolean;
128
+ /** Optional description shown dimmed. */
129
+ description?: string;
130
+ }
131
+ interface SelectListData {
132
+ items: SelectItem[];
133
+ selectedIndex: number;
134
+ scrollOffset: number;
135
+ /** Filter query (empty = no filter). */
136
+ filter: string;
137
+ /** Whether filter mode is active. */
138
+ filterActive: boolean;
139
+ /** Callback when selection changes. */
140
+ onSelect?: (item: SelectItem, index: number) => void;
141
+ /** Callback when Enter is pressed. */
142
+ onSubmit?: (item: SelectItem, index: number) => void;
143
+ }
144
+ interface SelectListOptions {
145
+ items: SelectItem[];
146
+ onSelect?: (item: SelectItem, index: number) => void;
147
+ onSubmit?: (item: SelectItem, index: number) => void;
148
+ }
149
+ declare class SelectList extends BaseWidget<SelectListData> {
150
+ constructor(id: string, opts: SelectListOptions);
151
+ /** Get the currently selected item. */
152
+ getSelected(): SelectItem | undefined;
153
+ /** Get all items matching the current filter. */
154
+ private getFilteredItems;
155
+ /** Set items programmatically. */
156
+ setItems(items: SelectItem[]): void;
157
+ render(opts: WidgetRenderOptions): string;
158
+ handleInput(event: WidgetInputEvent): boolean;
159
+ }
160
+ //#endregion
161
+ //#region src/widgets/modal.d.ts
162
+ interface ModalAction {
163
+ label: string;
164
+ value: string;
165
+ /** Whether this is the primary (highlighted) action. */
166
+ primary?: boolean;
167
+ }
168
+ interface ModalData {
169
+ title: string;
170
+ content: string;
171
+ actions: ModalAction[];
172
+ selectedAction: number;
173
+ /** Callback when an action is selected. */
174
+ onAction?: (action: ModalAction) => void;
175
+ /** Callback when Escape is pressed. */
176
+ onCancel?: () => void;
177
+ }
178
+ interface ModalOptions {
179
+ title: string;
180
+ content: string;
181
+ actions?: Array<string | ModalAction>;
182
+ onAction?: (action: ModalAction) => void;
183
+ onCancel?: () => void;
184
+ }
185
+ declare class Modal extends BaseWidget<ModalData> {
186
+ constructor(id: string, opts: ModalOptions);
187
+ render(opts: WidgetRenderOptions): string;
188
+ handleInput(event: WidgetInputEvent): boolean;
189
+ }
190
+ //#endregion
191
+ //#region src/widgets/status-bar.d.ts
192
+ type StatusBarStyle = "default" | "info" | "success" | "warning" | "error";
193
+ interface StatusBarSection {
194
+ text: string;
195
+ style?: StatusBarStyle;
196
+ /** Keyboard hint to show (e.g. "Ctrl+S"). */
197
+ shortcut?: string;
198
+ }
199
+ interface StatusBarData {
200
+ left: string;
201
+ center: string;
202
+ right: string;
203
+ sections: StatusBarSection[];
204
+ }
205
+ interface StatusBarOptions {
206
+ left?: string;
207
+ center?: string;
208
+ right?: string;
209
+ sections?: StatusBarSection[];
210
+ }
211
+ declare class StatusBar extends BaseWidget<StatusBarData> {
212
+ constructor(id: string, opts?: StatusBarOptions);
213
+ /** Update status bar text. */
214
+ update(opts: {
215
+ left?: string;
216
+ center?: string;
217
+ right?: string;
218
+ sections?: StatusBarSection[];
219
+ }): void;
220
+ render(opts: WidgetRenderOptions): string;
221
+ handleInput(): boolean;
222
+ }
223
+ //#endregion
224
+ //#region src/plugin.d.ts
225
+ declare const tuiPlugin: DuiPlugin;
226
+ //#endregion
227
+ export { BaseWidget, Modal, type ModalAction, type ModalData, type ModalOptions, type SelectItem, SelectList, type SelectListData, type SelectListOptions, StatusBar, type StatusBarData, type StatusBarOptions, type StatusBarSection, type StatusBarStyle, TextInput, type TextInputData, type TextInputOptions, type Widget, type WidgetInputEvent, type WidgetRenderOptions, type WidgetState, type WidgetType, tuiPlugin };
package/dist/index.mjs ADDED
@@ -0,0 +1,589 @@
1
+ import { stripAnsi, visibleLength } from "@bdocs/dui";
2
+ import { readFileSync } from "node:fs";
3
+ //#region src/widget.ts
4
+ var BaseWidget = class {
5
+ id;
6
+ type;
7
+ focusable;
8
+ visible;
9
+ focused = false;
10
+ data;
11
+ constructor(id, type, data, focusable = true) {
12
+ this.id = id;
13
+ this.type = type;
14
+ this.focusable = focusable;
15
+ this.visible = true;
16
+ this.data = data;
17
+ }
18
+ getState() {
19
+ return {
20
+ id: this.id,
21
+ type: this.type,
22
+ focusable: this.focusable,
23
+ focused: this.focused,
24
+ visible: this.visible,
25
+ data: this.data
26
+ };
27
+ }
28
+ setData(data) {
29
+ Object.assign(this.data, data);
30
+ }
31
+ getData() {
32
+ return this.data;
33
+ }
34
+ setFocused(focused) {
35
+ this.focused = focused;
36
+ }
37
+ setVisible(visible) {
38
+ this.visible = visible;
39
+ }
40
+ };
41
+ //#endregion
42
+ //#region src/widgets/text-input.ts
43
+ /**
44
+ * Text Input widget — editable single-line text field.
45
+ *
46
+ * Features:
47
+ * - Cursor navigation (left/right, home/end)
48
+ * - Insert/delete (backspace, delete)
49
+ * - Placeholder text when empty
50
+ * - Focus ring
51
+ * - Value change callback
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const input = new TextInput("name", { placeholder: "Enter name..." });
56
+ * input.render({ width: 40, height: 3, focused: true });
57
+ * ```
58
+ */
59
+ var TextInput = class extends BaseWidget {
60
+ cursorPos = 0;
61
+ constructor(id, opts = {}) {
62
+ super(id, "text-input", {
63
+ value: "",
64
+ placeholder: opts.placeholder ?? "",
65
+ maxLength: opts.maxLength ?? 0,
66
+ readOnly: opts.readOnly ?? false,
67
+ mask: opts.mask ?? null,
68
+ onChange: opts.onChange,
69
+ onSubmit: opts.onSubmit
70
+ });
71
+ }
72
+ /** Set the text value programmatically. */
73
+ setValue(value) {
74
+ const max = this.data.maxLength;
75
+ this.data.value = max > 0 ? value.slice(0, max) : value;
76
+ this.cursorPos = this.data.value.length;
77
+ this.data.onChange?.(this.data.value);
78
+ }
79
+ /** Get the current text value. */
80
+ getValue() {
81
+ return this.data.value;
82
+ }
83
+ render(opts) {
84
+ if (!this.visible) return "";
85
+ const { width } = opts;
86
+ const value = this.data.mask ? this.data.mask.repeat(this.data.value.length) : this.data.value;
87
+ const display = value || this.data.placeholder;
88
+ const isEmpty = !value;
89
+ const cursorInDisplay = this.cursorPos;
90
+ let before = display.slice(0, cursorInDisplay);
91
+ let cursor = display[cursorInDisplay] || " ";
92
+ let after = display.slice(cursorInDisplay + 1);
93
+ const maxVisible = width - 4;
94
+ if (visibleLength(display) > maxVisible) {
95
+ const start = Math.max(0, cursorInDisplay - Math.floor(maxVisible / 2));
96
+ before = display.slice(start, cursorInDisplay);
97
+ cursor = display[cursorInDisplay] || " ";
98
+ after = display.slice(cursorInDisplay + 1, start + maxVisible);
99
+ }
100
+ const lines = [];
101
+ lines.push(`┌${"─".repeat(width - 2)}┐`);
102
+ const isFocused = this.focused;
103
+ const placeholder = this.data.placeholder;
104
+ let content;
105
+ if (isEmpty && !isFocused) {
106
+ content = ` ${placeholder}`.padEnd(width - 2).slice(0, width - 2);
107
+ content = `\x1b[2m${content}\x1b[22m`;
108
+ } else if (isEmpty && isFocused) content = ` \x1b[7m \x1b[27m`.padEnd(width - 2).slice(0, width - 2);
109
+ else {
110
+ const left = before;
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);
117
+ }
118
+ const border = isFocused ? "\x1B[36m" : "\x1B[2m";
119
+ const reset = "\x1B[0m";
120
+ lines.push(`${border}│${reset}${content}${border}│${reset}`);
121
+ lines.push(`${border}└${"─".repeat(width - 2)}┘${reset}`);
122
+ return lines.join("\n");
123
+ }
124
+ handleInput(event) {
125
+ if (!this.focused || this.data.readOnly) return false;
126
+ const { key, char, ctrl } = event;
127
+ switch (key) {
128
+ case "ArrowLeft":
129
+ if (this.cursorPos > 0) this.cursorPos--;
130
+ return true;
131
+ case "ArrowRight":
132
+ if (this.cursorPos < this.data.value.length) this.cursorPos++;
133
+ return true;
134
+ case "Home":
135
+ this.cursorPos = 0;
136
+ return true;
137
+ case "End":
138
+ this.cursorPos = this.data.value.length;
139
+ return true;
140
+ case "Backspace":
141
+ if (this.cursorPos > 0) {
142
+ this.data.value = this.data.value.slice(0, this.cursorPos - 1) + this.data.value.slice(this.cursorPos);
143
+ this.cursorPos--;
144
+ this.data.onChange?.(this.data.value);
145
+ }
146
+ return true;
147
+ case "Delete":
148
+ if (this.cursorPos < this.data.value.length) {
149
+ this.data.value = this.data.value.slice(0, this.cursorPos) + this.data.value.slice(this.cursorPos + 1);
150
+ this.data.onChange?.(this.data.value);
151
+ }
152
+ return true;
153
+ case "Enter":
154
+ this.data.onSubmit?.(this.data.value);
155
+ return true;
156
+ case "a":
157
+ if (ctrl) {
158
+ this.cursorPos = 0;
159
+ return true;
160
+ }
161
+ break;
162
+ case "e":
163
+ if (ctrl) {
164
+ this.cursorPos = this.data.value.length;
165
+ return true;
166
+ }
167
+ break;
168
+ case "u":
169
+ if (ctrl) {
170
+ this.data.value = "";
171
+ this.cursorPos = 0;
172
+ this.data.onChange?.(this.data.value);
173
+ return true;
174
+ }
175
+ break;
176
+ }
177
+ if (char && char.length === 1 && char >= " ") {
178
+ const max = this.data.maxLength;
179
+ if (max > 0 && this.data.value.length >= max) return true;
180
+ this.data.value = this.data.value.slice(0, this.cursorPos) + char + this.data.value.slice(this.cursorPos);
181
+ this.cursorPos++;
182
+ this.data.onChange?.(this.data.value);
183
+ return true;
184
+ }
185
+ return false;
186
+ }
187
+ };
188
+ //#endregion
189
+ //#region src/widgets/select-list.ts
190
+ /**
191
+ * Select List widget — scrollable list with single selection.
192
+ *
193
+ * Features:
194
+ * - Arrow key navigation
195
+ * - Scroll when list exceeds viewport
196
+ * - Selection highlighting
197
+ * - Search/filter mode
198
+ * - Disabled items
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * const list = new SelectList("files", {
203
+ * items: [
204
+ * { label: "index.ts", value: "src/index.ts" },
205
+ * { label: "app.ts", value: "src/app.ts" },
206
+ * ],
207
+ * });
208
+ * ```
209
+ */
210
+ var SelectList = class extends BaseWidget {
211
+ constructor(id, opts) {
212
+ super(id, "select-list", {
213
+ items: opts.items,
214
+ selectedIndex: 0,
215
+ scrollOffset: 0,
216
+ filter: "",
217
+ filterActive: false,
218
+ onSelect: opts.onSelect,
219
+ onSubmit: opts.onSubmit
220
+ });
221
+ }
222
+ /** Get the currently selected item. */
223
+ getSelected() {
224
+ return this.getFilteredItems()[this.data.selectedIndex];
225
+ }
226
+ /** Get all items matching the current filter. */
227
+ getFilteredItems() {
228
+ if (!this.data.filter) return this.data.items;
229
+ const q = this.data.filter.toLowerCase();
230
+ return this.data.items.filter((item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
231
+ }
232
+ /** Set items programmatically. */
233
+ setItems(items) {
234
+ this.data.items = items;
235
+ this.data.selectedIndex = 0;
236
+ this.data.scrollOffset = 0;
237
+ this.data.filter = "";
238
+ this.data.filterActive = false;
239
+ }
240
+ render(opts) {
241
+ if (!this.visible) return "";
242
+ const { width, height } = opts;
243
+ const items = this.getFilteredItems();
244
+ const isFocused = this.focused;
245
+ const maxVisible = height - (this.data.filterActive ? 2 : 0) - 2;
246
+ const lines = [];
247
+ const header = this.data.filterActive ? ` 🔍 ${this.data.filter}█` : ` 📋 ${items.length} item(s)`;
248
+ lines.push(header);
249
+ const start = this.data.scrollOffset;
250
+ for (let i = 0; i < maxVisible; i++) {
251
+ const idx = start + i;
252
+ if (idx >= items.length) {
253
+ lines.push(` ${" ".repeat(width - 4)}`);
254
+ continue;
255
+ }
256
+ const item = items[idx];
257
+ const isSelected = idx === this.data.selectedIndex;
258
+ const isDisabled = item.disabled;
259
+ let prefix;
260
+ 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) + "…";
276
+ lines.push(`${prefix}${label}`);
277
+ }
278
+ if (items.length > maxVisible) {
279
+ const scrollPercent = Math.round((start + maxVisible) / items.length * 100);
280
+ lines.push(` ── ${scrollPercent}% ──`);
281
+ }
282
+ return lines.join("\n");
283
+ }
284
+ handleInput(event) {
285
+ if (!this.focused) return false;
286
+ const { key, char, ctrl } = event;
287
+ const items = this.getFilteredItems();
288
+ const maxVisible = 10;
289
+ if (this.data.filterActive) {
290
+ if (key === "Escape") {
291
+ this.data.filterActive = false;
292
+ this.data.filter = "";
293
+ return true;
294
+ }
295
+ if (key === "Enter") {
296
+ this.data.filterActive = false;
297
+ return true;
298
+ }
299
+ if (key === "Backspace") {
300
+ this.data.filter = this.data.filter.slice(0, -1);
301
+ this.data.selectedIndex = 0;
302
+ this.data.scrollOffset = 0;
303
+ return true;
304
+ }
305
+ if (char && char.length === 1) {
306
+ this.data.filter += char;
307
+ this.data.selectedIndex = 0;
308
+ this.data.scrollOffset = 0;
309
+ return true;
310
+ }
311
+ return false;
312
+ }
313
+ switch (key) {
314
+ case "ArrowUp":
315
+ if (this.data.selectedIndex > 0) {
316
+ this.data.selectedIndex--;
317
+ if (this.data.selectedIndex < this.data.scrollOffset) this.data.scrollOffset = this.data.selectedIndex;
318
+ }
319
+ this.data.onSelect?.(items[this.data.selectedIndex], this.data.selectedIndex);
320
+ return true;
321
+ case "ArrowDown":
322
+ if (this.data.selectedIndex < items.length - 1) {
323
+ this.data.selectedIndex++;
324
+ if (this.data.selectedIndex >= this.data.scrollOffset + maxVisible) this.data.scrollOffset = this.data.selectedIndex - maxVisible + 1;
325
+ }
326
+ this.data.onSelect?.(items[this.data.selectedIndex], this.data.selectedIndex);
327
+ return true;
328
+ case "Home":
329
+ this.data.selectedIndex = 0;
330
+ this.data.scrollOffset = 0;
331
+ this.data.onSelect?.(items[0], 0);
332
+ return true;
333
+ case "End":
334
+ this.data.selectedIndex = items.length - 1;
335
+ if (items.length > maxVisible) this.data.scrollOffset = items.length - maxVisible;
336
+ this.data.onSelect?.(items[this.data.selectedIndex], this.data.selectedIndex);
337
+ return true;
338
+ case "Enter": {
339
+ const sel = items[this.data.selectedIndex];
340
+ if (sel && !sel.disabled) this.data.onSubmit?.(sel, this.data.selectedIndex);
341
+ return true;
342
+ }
343
+ case "/":
344
+ this.data.filterActive = true;
345
+ this.data.filter = "";
346
+ return true;
347
+ case "j":
348
+ if (ctrl) {
349
+ this.data.selectedIndex = Math.min(items.length - 1, this.data.selectedIndex + maxVisible);
350
+ if (this.data.selectedIndex >= this.data.scrollOffset + maxVisible) this.data.scrollOffset = this.data.selectedIndex - maxVisible + 1;
351
+ return true;
352
+ }
353
+ break;
354
+ case "k":
355
+ if (ctrl) {
356
+ this.data.selectedIndex = Math.max(0, this.data.selectedIndex - maxVisible);
357
+ if (this.data.selectedIndex < this.data.scrollOffset) this.data.scrollOffset = this.data.selectedIndex;
358
+ return true;
359
+ }
360
+ break;
361
+ }
362
+ return false;
363
+ }
364
+ };
365
+ //#endregion
366
+ //#region src/widgets/modal.ts
367
+ /**
368
+ * Modal widget — overlay dialog with backdrop and action buttons.
369
+ *
370
+ * Features:
371
+ * - Title bar
372
+ * - Content area (text or child widget)
373
+ * - Action buttons (OK, Cancel, custom)
374
+ * - Backdrop dimming
375
+ * - Focus trapping
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * const modal = new Modal("confirm", {
380
+ * title: "Delete file?",
381
+ * content: "This action cannot be undone.",
382
+ * actions: ["Delete", "Cancel"],
383
+ * });
384
+ * ```
385
+ */
386
+ var Modal = class extends BaseWidget {
387
+ constructor(id, opts) {
388
+ const actions = (opts.actions ?? ["OK", "Cancel"]).map((a) => typeof a === "string" ? {
389
+ label: a,
390
+ value: a.toLowerCase(),
391
+ primary: a === "OK"
392
+ } : a);
393
+ super(id, "modal", {
394
+ title: opts.title,
395
+ content: opts.content,
396
+ actions,
397
+ selectedAction: 0,
398
+ onAction: opts.onAction,
399
+ onCancel: opts.onCancel
400
+ });
401
+ const primaryIdx = actions.findIndex((a) => a.primary);
402
+ if (primaryIdx >= 0) this.data.selectedAction = primaryIdx;
403
+ }
404
+ render(opts) {
405
+ if (!this.visible) return "";
406
+ const { width, height } = opts;
407
+ const { title, content, actions, selectedAction } = this.data;
408
+ const isFocused = this.focused;
409
+ const modalWidth = Math.min(width - 4, 60);
410
+ const modalHeight = Math.min(height - 4, 20);
411
+ const lines = [];
412
+ const backdropLines = Math.max(0, Math.floor((height - modalHeight) / 2));
413
+ for (let i = 0; i < backdropLines; i++) lines.push("");
414
+ const titlePad = modalWidth - 4 - visibleLength(title);
415
+ lines.push(` ╔${"═".repeat(modalWidth - 4)}╗`);
416
+ lines.push(` ║ ${title}${" ".repeat(Math.max(0, titlePad))} ║`);
417
+ lines.push(` ╠${"═".repeat(modalWidth - 4)}╣`);
418
+ const contentLines = content.split("\n");
419
+ const maxContentLines = modalHeight - 6;
420
+ 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);
424
+ lines.push(` ║ ${line}${" ".repeat(pad)} ║`);
425
+ }
426
+ lines.push(` ╠${"═".repeat(modalWidth - 4)}╣`);
427
+ const actionStr = actions.map((a, i) => {
428
+ if (i === selectedAction && isFocused) return `\x1b[7m ${a.label} \x1b[27m`;
429
+ if (a.primary) return `\x1b[1m${a.label}\x1b[22m`;
430
+ return ` ${a.label} `;
431
+ }).join(" ");
432
+ const actionPad = Math.max(0, modalWidth - 4 - visibleLength(stripAnsi(actionStr)));
433
+ lines.push(` ║ ${actionStr}${" ".repeat(actionPad)} ║`);
434
+ lines.push(` ╚${"═".repeat(modalWidth - 4)}╝`);
435
+ for (let i = 0; i < backdropLines; i++) lines.push("");
436
+ return lines.join("\n");
437
+ }
438
+ handleInput(event) {
439
+ if (!this.focused) return false;
440
+ const { key } = event;
441
+ const { actions } = this.data;
442
+ switch (key) {
443
+ case "ArrowLeft":
444
+ this.data.selectedAction = (this.data.selectedAction - 1 + actions.length) % actions.length;
445
+ return true;
446
+ case "ArrowRight":
447
+ this.data.selectedAction = (this.data.selectedAction + 1) % actions.length;
448
+ return true;
449
+ case "Enter": {
450
+ const action = actions[this.data.selectedAction];
451
+ if (action) this.data.onAction?.(action);
452
+ return true;
453
+ }
454
+ case "Escape":
455
+ this.data.onCancel?.();
456
+ return true;
457
+ case "Tab":
458
+ this.data.selectedAction = (this.data.selectedAction + 1) % actions.length;
459
+ return true;
460
+ }
461
+ return false;
462
+ }
463
+ };
464
+ //#endregion
465
+ //#region src/widgets/status-bar.ts
466
+ /**
467
+ * Status Bar widget — fixed bottom bar with sections.
468
+ *
469
+ * Features:
470
+ * - Left / center / right sections
471
+ * - Styled sections (success, warning, error, info)
472
+ * - Keyboard shortcut hints
473
+ * - Auto-ellipsis for overflow
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * const bar = new StatusBar("main-bar", {
478
+ * left: "Ready",
479
+ * center: "index.ts",
480
+ * right: "Ln 42, Col 5",
481
+ * sections: [
482
+ * { text: "git:main", style: "info" },
483
+ * { text: "3 errors", style: "error" },
484
+ * ],
485
+ * });
486
+ * ```
487
+ */
488
+ const STYLE_MAP = {
489
+ default: {
490
+ fg: "\x1B[0m",
491
+ bg: "\x1B[48;2;50;50;60m"
492
+ },
493
+ info: {
494
+ fg: "\x1B[38;2;88;166;255m",
495
+ bg: "\x1B[48;2;30;40;60m"
496
+ },
497
+ success: {
498
+ fg: "\x1B[38;2;34;197;94m",
499
+ bg: "\x1B[48;2;20;50;30m"
500
+ },
501
+ warning: {
502
+ fg: "\x1B[38;2;234;179;8m",
503
+ bg: "\x1B[48;2;60;50;20m"
504
+ },
505
+ error: {
506
+ fg: "\x1B[38;2;220;38;38m",
507
+ bg: "\x1B[48;2;60;20;20m"
508
+ }
509
+ };
510
+ var StatusBar = class extends BaseWidget {
511
+ constructor(id, opts = {}) {
512
+ super(id, "status-bar", {
513
+ left: opts.left ?? "",
514
+ center: opts.center ?? "",
515
+ right: opts.right ?? "",
516
+ sections: opts.sections ?? []
517
+ }, false);
518
+ }
519
+ /** Update status bar text. */
520
+ update(opts) {
521
+ if (opts.left !== void 0) this.data.left = opts.left;
522
+ if (opts.center !== void 0) this.data.center = opts.center;
523
+ if (opts.right !== void 0) this.data.right = opts.right;
524
+ if (opts.sections !== void 0) this.data.sections = opts.sections;
525
+ }
526
+ render(opts) {
527
+ if (!this.visible) return "";
528
+ const { width } = opts;
529
+ const { left, center, right, sections } = this.data;
530
+ const bg = "\x1B[48;2;40;40;50m";
531
+ const reset = "\x1B[0m";
532
+ const parts = [];
533
+ for (const sec of sections) {
534
+ 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);
545
+ return `${bg}${" ".repeat(width)}${reset}\r${bar}`;
546
+ }
547
+ handleInput() {
548
+ return false;
549
+ }
550
+ };
551
+ //#endregion
552
+ //#region src/plugin.ts
553
+ /**
554
+ * @dui-toolkit/plugin-tui — DuiPlugin definition.
555
+ */
556
+ const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
557
+ const DEFAULTS = {
558
+ "tui.focusRing": "#58a6ff",
559
+ "tui.border": "#646478",
560
+ "tui.borderFocused": "#58a6ff",
561
+ "tui.placeholder": "#8b949e",
562
+ "tui.selected": "#58a6ff",
563
+ "tui.selectedBg": "#1a1a2e",
564
+ "tui.disabled": "#484848",
565
+ "tui.modalBackdrop": "#000000",
566
+ "tui.statusBarBg": "#282830",
567
+ "tui.statusBarFg": "#e0e0e0"
568
+ };
569
+ const tuiPlugin = {
570
+ name: "@dui-toolkit/plugin-tui",
571
+ version: pkgVersion,
572
+ description: "TUI widget toolkit — text inputs, select lists, modals, status bars for @bdocs/dui.",
573
+ tags: [
574
+ "tui",
575
+ "widget",
576
+ "input",
577
+ "modal"
578
+ ],
579
+ homepage: "https://github.com/bdocs/dui/tree/main/packages/dui-tui",
580
+ author: "DUI Toolkit",
581
+ peerDependencies: { dui: "^0.6.0" },
582
+ setup(api) {
583
+ for (const [slot, defaultColor] of Object.entries(DEFAULTS)) api.registerThemeSlot(slot, defaultColor);
584
+ api.shared.set("renderer", "tui");
585
+ return () => {};
586
+ }
587
+ };
588
+ //#endregion
589
+ export { BaseWidget, Modal, SelectList, StatusBar, TextInput, tuiPlugin };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@dui-toolkit/plugin-tui",
3
+ "version": "0.1.1-next.0",
4
+ "description": "TUI widget toolkit plugin for @bdocs/dui — text inputs, select lists, modals, status bars, and more.",
5
+ "main": "dist/index.mjs",
6
+ "types": "dist/index.d.mts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.mts",
16
+ "import": "./dist/index.mjs"
17
+ }
18
+ },
19
+ "keywords": [
20
+ "terminal",
21
+ "tui",
22
+ "widget",
23
+ "input",
24
+ "select",
25
+ "modal",
26
+ "statusbar"
27
+ ],
28
+ "license": "MIT",
29
+ "type": "module",
30
+ "dependencies": {
31
+ "@bdocs/dui": "0.7.0-next.2"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "tsdown": "^0.21.7",
36
+ "typescript": "^5.9.3",
37
+ "vitest": "^3.0.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown --config-loader unrun",
41
+ "dev": "tsdown --watch --config-loader unrun",
42
+ "test": "vitest run"
43
+ }
44
+ }