@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 +21 -0
- package/dist/index.d.mts +227 -0
- package/dist/index.mjs +589 -0
- package/package.json +44 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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(` ║ [1m${title}[22m${" ".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
|
+
}
|