@dex-ai/vue-tui 0.1.10
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/package.json +51 -0
- package/src/app.ts +385 -0
- package/src/components/TuiCheckbox.ts +35 -0
- package/src/components/TuiField.ts +123 -0
- package/src/components/TuiSelect.ts +86 -0
- package/src/components/index.ts +7 -0
- package/src/composables/index.ts +19 -0
- package/src/composables/useFocusList.ts +101 -0
- package/src/composables/useForm.ts +335 -0
- package/src/composables/useSelectList.ts +199 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +131 -0
- package/src/input.ts +603 -0
- package/src/nodes.ts +153 -0
- package/src/panel.ts +51 -0
- package/src/plugin.ts +148 -0
- package/src/register.ts +4 -0
- package/src/render.ts +632 -0
- package/src/renderer.ts +120 -0
- package/src/style.ts +107 -0
- package/src/template.ts +326 -0
- package/src/text-buffer.ts +609 -0
- package/src/theme.ts +44 -0
- package/src/types/form.ts +90 -0
- package/src/widget-renderer.ts +237 -0
- package/src/widget.ts +326 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/vue-tui",
|
|
3
|
+
"version": "0.1.10",
|
|
4
|
+
"description": "Vue-powered terminal UI renderer. CSS-like styling, composable components, reactive updates, SFC support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./plugin": {
|
|
12
|
+
"types": "./src/plugin.ts",
|
|
13
|
+
"default": "./src/plugin.ts"
|
|
14
|
+
},
|
|
15
|
+
"./register": {
|
|
16
|
+
"default": "./src/register.ts"
|
|
17
|
+
},
|
|
18
|
+
"./composables": {
|
|
19
|
+
"types": "./src/composables/index.ts",
|
|
20
|
+
"default": "./src/composables/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./components": {
|
|
23
|
+
"types": "./src/components/index.ts",
|
|
24
|
+
"default": "./src/components/index.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"demo": "bun run examples/agent.ts",
|
|
33
|
+
"changeset": "changeset",
|
|
34
|
+
"version": "changeset version",
|
|
35
|
+
"release": "changeset publish"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@vue/runtime-core": "^3.5.0",
|
|
39
|
+
"@vue/reactivity": "^3.5.0",
|
|
40
|
+
"@vue/compiler-sfc": "^3.5.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^5.6.3",
|
|
44
|
+
"@changesets/cli": "^2.29.0"
|
|
45
|
+
},
|
|
46
|
+
"sideEffects": false,
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"registry": "https://registry.npmjs.org/"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import type { Component, App } from "@vue/runtime-core";
|
|
2
|
+
import { createElement } from "./nodes";
|
|
3
|
+
import type { TElement } from "./nodes";
|
|
4
|
+
import type { Theme } from "./style";
|
|
5
|
+
import { defaultTheme } from "./theme";
|
|
6
|
+
import { createTerminalRenderer } from "./renderer";
|
|
7
|
+
import { renderToLines } from "./render";
|
|
8
|
+
import type { KeyEvent, KeyHandler, RawInputHandle } from "./input";
|
|
9
|
+
import { createRawInput } from "./input";
|
|
10
|
+
|
|
11
|
+
export interface TerminalApp {
|
|
12
|
+
mount(): void;
|
|
13
|
+
unmount(): void;
|
|
14
|
+
forceRedraw(): void;
|
|
15
|
+
/** Get the last rendered frame as an array of ANSI-styled lines. */
|
|
16
|
+
getFrame(): string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A key binding matcher — matches against a KeyEvent. */
|
|
20
|
+
export interface KeyBinding {
|
|
21
|
+
key?: string; // key name: "enter", "c", "up", etc.
|
|
22
|
+
shift?: boolean;
|
|
23
|
+
alt?: boolean;
|
|
24
|
+
ctrl?: boolean;
|
|
25
|
+
raw?: string; // exact raw sequence match (terminal-specific)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Maps key bindings to action names. */
|
|
29
|
+
export type KeyMap = Record<string, KeyBinding | KeyBinding[]>;
|
|
30
|
+
|
|
31
|
+
export type ActionHandler = (action: string, key: KeyEvent) => void;
|
|
32
|
+
|
|
33
|
+
export interface TerminalAppOptions {
|
|
34
|
+
theme?: Theme | undefined;
|
|
35
|
+
stdout?: NodeJS.WriteStream | undefined;
|
|
36
|
+
stdin?: NodeJS.ReadStream | undefined;
|
|
37
|
+
/** Key handler — called for each key press (after keyMap resolution). */
|
|
38
|
+
onKey?: KeyHandler | undefined;
|
|
39
|
+
/** Key map — maps action names to key bindings. */
|
|
40
|
+
keyMap?: KeyMap | undefined;
|
|
41
|
+
/** Action handler — called when a keyMap match is found. */
|
|
42
|
+
onAction?: ActionHandler | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Detect if the terminal supports kitty keyboard protocol. */
|
|
46
|
+
function detectKittyKeyboard(): boolean {
|
|
47
|
+
const term = process.env["TERM_PROGRAM"] ?? "";
|
|
48
|
+
const termName = process.env["TERM"] ?? "";
|
|
49
|
+
// Kitty, WezTerm, and foot natively support it
|
|
50
|
+
if (term === "kitty" || term === "WezTerm" || term === "foot") return true;
|
|
51
|
+
// xterm-kitty TERM value
|
|
52
|
+
if (termName === "xterm-kitty") return true;
|
|
53
|
+
// Ghostty supports it
|
|
54
|
+
if (term === "ghostty") return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if a KeyEvent matches a KeyBinding. */
|
|
59
|
+
function matchesBinding(key: KeyEvent, binding: KeyBinding): boolean {
|
|
60
|
+
// Raw sequence match — takes priority, bypasses name/modifier checks
|
|
61
|
+
if (binding.raw) {
|
|
62
|
+
return key.raw === binding.raw;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!binding.key || key.name !== binding.key) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (binding.shift && !key.shift) return false;
|
|
69
|
+
if (binding.alt && !key.alt) return false;
|
|
70
|
+
if (binding.ctrl && !key.ctrl) return false;
|
|
71
|
+
if (!binding.shift && key.shift) return false;
|
|
72
|
+
if (!binding.alt && key.alt) return false;
|
|
73
|
+
if (!binding.ctrl && key.ctrl) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve a KeyEvent against a KeyMap, returning the action name or null. */
|
|
78
|
+
function resolveKeyMap(key: KeyEvent, keyMap: KeyMap): string | null {
|
|
79
|
+
for (const [action, bindings] of Object.entries(keyMap)) {
|
|
80
|
+
const bindingList = Array.isArray(bindings) ? bindings : [bindings];
|
|
81
|
+
for (const binding of bindingList) {
|
|
82
|
+
if (matchesBinding(key, binding)) {
|
|
83
|
+
return action;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// ANSI cursor helpers
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Move cursor up N lines. */
|
|
95
|
+
function cursorUp(n: number): string {
|
|
96
|
+
return n > 0 ? `\x1b[${n}A` : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clear from cursor to end of line. */
|
|
100
|
+
const CLEAR_LINE = "\x1b[2K\r";
|
|
101
|
+
|
|
102
|
+
/** Synchronized output begin/end (prevents flicker). */
|
|
103
|
+
const SYNC_START = "\x1b[?2026h";
|
|
104
|
+
const SYNC_END = "\x1b[?2026l";
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Diff writer
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Move cursor down N lines. */
|
|
111
|
+
function cursorDown(n: number): string {
|
|
112
|
+
return n > 0 ? `\x1b[${n}B` : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface FrameState {
|
|
116
|
+
/** Lines from the previous render. */
|
|
117
|
+
lines: string[];
|
|
118
|
+
/** Where the viewport top is in the buffer (line index). */
|
|
119
|
+
viewportTop: number;
|
|
120
|
+
/** The row the hardware cursor is on (buffer-absolute). */
|
|
121
|
+
cursorRow: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write the new frame to stdout using differential rendering.
|
|
126
|
+
*
|
|
127
|
+
* Strategy inspired by Pi TUI:
|
|
128
|
+
* - Track where the viewport top is (viewportTop).
|
|
129
|
+
* - Find firstChanged and lastChanged lines.
|
|
130
|
+
* - If firstChanged is above viewportTop, do a full clear+redraw.
|
|
131
|
+
* - Otherwise, move cursor to firstChanged, rewrite only changed lines.
|
|
132
|
+
* - Append new lines at the bottom naturally.
|
|
133
|
+
* - Never cursor-up past the viewport top.
|
|
134
|
+
*/
|
|
135
|
+
function writeFrame(
|
|
136
|
+
state: FrameState,
|
|
137
|
+
next: string[],
|
|
138
|
+
out: NodeJS.WriteStream,
|
|
139
|
+
): void {
|
|
140
|
+
const height = out.rows ?? 24;
|
|
141
|
+
const prev = state.lines;
|
|
142
|
+
|
|
143
|
+
out.write(SYNC_START);
|
|
144
|
+
|
|
145
|
+
// Helper: compute screen-row delta from current cursor to target buffer row
|
|
146
|
+
const computeMove = (targetRow: number): number => {
|
|
147
|
+
const currentScreen = state.cursorRow - state.viewportTop;
|
|
148
|
+
const targetScreen = targetRow - state.viewportTop;
|
|
149
|
+
return targetScreen - currentScreen;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// --- First render or force-redraw ---
|
|
153
|
+
if (prev.length === 0) {
|
|
154
|
+
// If cursorRow > 0, this is a force-redraw (not truly the first render).
|
|
155
|
+
// Move cursor back to the top of the viewport before rewriting.
|
|
156
|
+
if (state.cursorRow > 0) {
|
|
157
|
+
const moveUp = state.cursorRow - state.viewportTop;
|
|
158
|
+
if (moveUp > 0) out.write(cursorUp(moveUp));
|
|
159
|
+
}
|
|
160
|
+
for (let i = 0; i < next.length; i++) {
|
|
161
|
+
if (i > 0) out.write("\n");
|
|
162
|
+
out.write(CLEAR_LINE + next[i]!);
|
|
163
|
+
}
|
|
164
|
+
// Clear any leftover lines below (in case frame shrank since last render)
|
|
165
|
+
out.write("\x1b[J");
|
|
166
|
+
state.cursorRow = Math.max(0, next.length - 1);
|
|
167
|
+
state.viewportTop = Math.max(0, next.length - height);
|
|
168
|
+
state.lines = next;
|
|
169
|
+
out.write(SYNC_END);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Find changed region ---
|
|
174
|
+
let firstChanged = -1;
|
|
175
|
+
let lastChanged = -1;
|
|
176
|
+
const maxLines = Math.max(prev.length, next.length);
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < maxLines; i++) {
|
|
179
|
+
const oldLine = i < prev.length ? prev[i] : undefined;
|
|
180
|
+
const newLine = i < next.length ? next[i] : undefined;
|
|
181
|
+
if (oldLine !== newLine) {
|
|
182
|
+
if (firstChanged === -1) firstChanged = i;
|
|
183
|
+
lastChanged = i;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Nothing changed
|
|
188
|
+
if (firstChanged === -1) {
|
|
189
|
+
out.write(SYNC_END);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If change is above the viewport, do a full clear+redraw.
|
|
194
|
+
// This handles the scroll-desync case: user scrolled up, changes are in
|
|
195
|
+
// scrollback that we can't reliably reach.
|
|
196
|
+
if (firstChanged < state.viewportTop) {
|
|
197
|
+
// Clear screen and scrollback, redraw everything
|
|
198
|
+
out.write("\x1b[2J\x1b[H\x1b[3J");
|
|
199
|
+
for (let i = 0; i < next.length; i++) {
|
|
200
|
+
if (i > 0) out.write("\n");
|
|
201
|
+
out.write(next[i]!);
|
|
202
|
+
}
|
|
203
|
+
state.cursorRow = Math.max(0, next.length - 1);
|
|
204
|
+
state.viewportTop = Math.max(0, next.length - height);
|
|
205
|
+
state.lines = next;
|
|
206
|
+
out.write(SYNC_END);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Differential render within viewport ---
|
|
211
|
+
|
|
212
|
+
// Detect append-only: new lines added at the end, nothing else changed
|
|
213
|
+
const appendOnly = firstChanged >= prev.length && next.length > prev.length;
|
|
214
|
+
|
|
215
|
+
if (appendOnly) {
|
|
216
|
+
// Move cursor to end of prev content and append new lines
|
|
217
|
+
const move = computeMove(prev.length - 1);
|
|
218
|
+
if (move > 0) out.write(cursorDown(move));
|
|
219
|
+
else if (move < 0) out.write(cursorUp(-move));
|
|
220
|
+
|
|
221
|
+
for (let i = prev.length; i < next.length; i++) {
|
|
222
|
+
out.write("\n" + CLEAR_LINE + next[i]!);
|
|
223
|
+
}
|
|
224
|
+
state.cursorRow = next.length - 1;
|
|
225
|
+
} else {
|
|
226
|
+
// Move cursor to firstChanged
|
|
227
|
+
const move = computeMove(firstChanged);
|
|
228
|
+
if (move > 0) out.write(cursorDown(move));
|
|
229
|
+
else if (move < 0) out.write(cursorUp(-move));
|
|
230
|
+
|
|
231
|
+
// Render from firstChanged to lastChanged (or end of next, whichever is greater)
|
|
232
|
+
const renderEnd = Math.max(lastChanged, next.length - 1);
|
|
233
|
+
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
234
|
+
if (i > firstChanged) out.write("\n");
|
|
235
|
+
out.write(CLEAR_LINE + (i < next.length ? next[i]! : ""));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
state.cursorRow = renderEnd;
|
|
239
|
+
|
|
240
|
+
// If prev was longer and there are lines beyond renderEnd to clear,
|
|
241
|
+
// move down and erase them. (In practice renderEnd >= prev.length - 1
|
|
242
|
+
// because the diff loop detects removed lines, but guard defensively.)
|
|
243
|
+
if (prev.length - 1 > renderEnd) {
|
|
244
|
+
const extra = prev.length - 1 - renderEnd;
|
|
245
|
+
for (let i = 0; i < extra; i++) {
|
|
246
|
+
out.write("\n" + CLEAR_LINE);
|
|
247
|
+
}
|
|
248
|
+
// Move back up to end of new content
|
|
249
|
+
out.write(cursorUp(extra));
|
|
250
|
+
state.cursorRow = renderEnd;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Update viewport top
|
|
255
|
+
state.viewportTop = Math.max(state.viewportTop, state.cursorRow - height + 1);
|
|
256
|
+
state.lines = next;
|
|
257
|
+
out.write(SYNC_END);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// createTerminalApp
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export function createTerminalApp(
|
|
265
|
+
rootComponent: Component,
|
|
266
|
+
options?: TerminalAppOptions,
|
|
267
|
+
): TerminalApp {
|
|
268
|
+
const theme = options?.theme ?? defaultTheme;
|
|
269
|
+
const stdout: NodeJS.WriteStream = options?.stdout ?? process.stdout;
|
|
270
|
+
const stdin: NodeJS.ReadStream = options?.stdin ?? process.stdin;
|
|
271
|
+
|
|
272
|
+
let vueApp: App<TElement> | null = null;
|
|
273
|
+
let rootEl: TElement | null = null;
|
|
274
|
+
const frameState: FrameState = { lines: [], viewportTop: 0, cursorRow: 0 };
|
|
275
|
+
let scheduled = false;
|
|
276
|
+
let inputHandle: RawInputHandle | null = null;
|
|
277
|
+
const useKitty = detectKittyKeyboard();
|
|
278
|
+
|
|
279
|
+
function scheduleRender(): void {
|
|
280
|
+
if (scheduled) return;
|
|
281
|
+
scheduled = true;
|
|
282
|
+
// Use microtask to batch multiple synchronous mutations
|
|
283
|
+
queueMicrotask(doRender);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Force a full redraw by invalidating the previous frame cache. */
|
|
287
|
+
function forceRedraw(): void {
|
|
288
|
+
frameState.lines = [];
|
|
289
|
+
scheduleRender();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function doRender(): void {
|
|
293
|
+
scheduled = false;
|
|
294
|
+
if (rootEl === null) return;
|
|
295
|
+
|
|
296
|
+
const width = stdout.columns ?? 80;
|
|
297
|
+
const next = renderToLines(rootEl, width, theme);
|
|
298
|
+
|
|
299
|
+
writeFrame(frameState, next, stdout);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
getFrame(): string[] {
|
|
304
|
+
return [...frameState.lines];
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
mount(): void {
|
|
308
|
+
rootEl = createElement("root");
|
|
309
|
+
|
|
310
|
+
const { createApp } = createTerminalRenderer(scheduleRender);
|
|
311
|
+
vueApp = createApp(rootComponent);
|
|
312
|
+
|
|
313
|
+
// Hide hardware cursor — the app renders its own software cursor
|
|
314
|
+
stdout.write("\x1b[?25l");
|
|
315
|
+
|
|
316
|
+
// Enable bracketed paste mode so we can detect pastes
|
|
317
|
+
stdout.write("\x1b[?2004h");
|
|
318
|
+
|
|
319
|
+
// Enable kitty keyboard protocol if terminal supports it
|
|
320
|
+
if (useKitty) {
|
|
321
|
+
stdout.write("\x1b[>1u");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Force full redraw on terminal resize
|
|
325
|
+
stdout.on("resize", forceRedraw);
|
|
326
|
+
|
|
327
|
+
// Force full redraw when returning from background (e.g. Ctrl+Z → fg)
|
|
328
|
+
process.on("SIGCONT", forceRedraw);
|
|
329
|
+
|
|
330
|
+
// Set up raw stdin input if onKey handler provided
|
|
331
|
+
if (options?.onKey) {
|
|
332
|
+
inputHandle = createRawInput((key) => {
|
|
333
|
+
// Resolve key through keyMap if provided
|
|
334
|
+
if (options.keyMap) {
|
|
335
|
+
const action = resolveKeyMap(key, options.keyMap);
|
|
336
|
+
if (action && options.onAction) {
|
|
337
|
+
options.onAction(action, key);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
options.onKey!(key);
|
|
342
|
+
}, stdin);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Mount onto the root element — Vue will populate it
|
|
346
|
+
vueApp.mount(rootEl);
|
|
347
|
+
|
|
348
|
+
// Trigger initial render
|
|
349
|
+
scheduleRender();
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
forceRedraw,
|
|
353
|
+
|
|
354
|
+
unmount(): void {
|
|
355
|
+
// Tear down input
|
|
356
|
+
if (inputHandle !== null) {
|
|
357
|
+
inputHandle.destroy();
|
|
358
|
+
inputHandle = null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Remove event listeners
|
|
362
|
+
stdout.off("resize", forceRedraw);
|
|
363
|
+
process.off("SIGCONT", forceRedraw);
|
|
364
|
+
|
|
365
|
+
if (vueApp !== null) {
|
|
366
|
+
vueApp.unmount();
|
|
367
|
+
vueApp = null;
|
|
368
|
+
}
|
|
369
|
+
rootEl = null;
|
|
370
|
+
frameState.lines = [];
|
|
371
|
+
frameState.viewportTop = 0;
|
|
372
|
+
frameState.cursorRow = 0;
|
|
373
|
+
// Move cursor below rendered content so shell prompt doesn't overwrite
|
|
374
|
+
stdout.write("\r\n");
|
|
375
|
+
// Disable bracketed paste mode
|
|
376
|
+
stdout.write("\x1b[?2004l");
|
|
377
|
+
// Disable kitty keyboard protocol if it was enabled
|
|
378
|
+
if (useKitty) {
|
|
379
|
+
stdout.write("\x1b[<u");
|
|
380
|
+
}
|
|
381
|
+
// Ensure cursor is visible
|
|
382
|
+
stdout.write("\x1b[?25h");
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — TuiCheckbox component
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { defineComponent, h } from "@vue/runtime-core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a checkbox as `[x]` or `[ ]` with a label.
|
|
9
|
+
*
|
|
10
|
+
* Props:
|
|
11
|
+
* - checked: boolean
|
|
12
|
+
* - label: string
|
|
13
|
+
* - focused: boolean (optional) — highlights when focused
|
|
14
|
+
*/
|
|
15
|
+
export const TuiCheckbox = defineComponent({
|
|
16
|
+
name: "TuiCheckbox",
|
|
17
|
+
props: {
|
|
18
|
+
checked: { type: Boolean, required: true },
|
|
19
|
+
label: { type: String, required: true },
|
|
20
|
+
focused: { type: Boolean, default: false },
|
|
21
|
+
},
|
|
22
|
+
setup(props) {
|
|
23
|
+
return () => {
|
|
24
|
+
const box = props.checked ? "[x]" : "[ ]";
|
|
25
|
+
const color = props.focused ? "accent" : undefined;
|
|
26
|
+
|
|
27
|
+
return h("line", {}, [
|
|
28
|
+
h("text", { color, bold: props.focused }, box),
|
|
29
|
+
h("text", { color, style: { paddingLeft: 1 } }, props.label),
|
|
30
|
+
]);
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export default TuiCheckbox;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — TuiField component
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { defineComponent, h, type PropType } from "@vue/runtime-core";
|
|
6
|
+
import type { FormField } from "../types/form";
|
|
7
|
+
import { TuiCheckbox } from "./TuiCheckbox";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders a single form field line with focus indicator, label, separator,
|
|
11
|
+
* and value display. Handles text, password, select, checkbox, and number types.
|
|
12
|
+
*
|
|
13
|
+
* Props:
|
|
14
|
+
* - field: FormField
|
|
15
|
+
* - focused: boolean
|
|
16
|
+
* - editing: boolean
|
|
17
|
+
* - editBuffer: string
|
|
18
|
+
* - value: string | boolean | undefined
|
|
19
|
+
* - error: string | undefined
|
|
20
|
+
*/
|
|
21
|
+
export const TuiField = defineComponent({
|
|
22
|
+
name: "TuiField",
|
|
23
|
+
props: {
|
|
24
|
+
field: { type: Object as PropType<FormField>, required: true },
|
|
25
|
+
focused: { type: Boolean, default: false },
|
|
26
|
+
editing: { type: Boolean, default: false },
|
|
27
|
+
editBuffer: { type: String, default: "" },
|
|
28
|
+
value: { type: [String, Boolean], default: undefined },
|
|
29
|
+
error: { type: String, default: undefined },
|
|
30
|
+
},
|
|
31
|
+
setup(props) {
|
|
32
|
+
return () => {
|
|
33
|
+
const { field, focused, editing, editBuffer, value, error } = props;
|
|
34
|
+
|
|
35
|
+
// Checkbox — delegate to TuiCheckbox
|
|
36
|
+
if (field.type === "checkbox") {
|
|
37
|
+
return h(TuiCheckbox, {
|
|
38
|
+
checked: value === true,
|
|
39
|
+
label: field.label,
|
|
40
|
+
focused,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Standard field layout: [indicator] [label] │ [value]
|
|
45
|
+
const children = [];
|
|
46
|
+
|
|
47
|
+
// Focus indicator
|
|
48
|
+
children.push(
|
|
49
|
+
h(
|
|
50
|
+
"text",
|
|
51
|
+
{ color: focused ? "accent" : "muted" },
|
|
52
|
+
focused ? "❯ " : " ",
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Label
|
|
57
|
+
children.push(
|
|
58
|
+
h(
|
|
59
|
+
"text",
|
|
60
|
+
{
|
|
61
|
+
color: focused ? "text" : "muted",
|
|
62
|
+
bold: focused,
|
|
63
|
+
},
|
|
64
|
+
field.label,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Required indicator
|
|
69
|
+
if (field.required && !field.readonly && !value) {
|
|
70
|
+
children.push(
|
|
71
|
+
h("text", { color: "warn", style: { paddingLeft: 1 } }, "*"),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Separator
|
|
76
|
+
children.push(h("text", { dim: true }, " │ "));
|
|
77
|
+
|
|
78
|
+
// Value display
|
|
79
|
+
if (editing) {
|
|
80
|
+
// Show edit buffer with cursor
|
|
81
|
+
children.push(
|
|
82
|
+
h("text", { color: "accent" }, editBuffer + "▏"),
|
|
83
|
+
);
|
|
84
|
+
} else if (field.readonly && value) {
|
|
85
|
+
children.push(h("text", { dim: true }, String(value)));
|
|
86
|
+
} else if (field.readonly) {
|
|
87
|
+
children.push(h("text", { dim: true }, "—"));
|
|
88
|
+
} else if (field.type === "password" && value) {
|
|
89
|
+
children.push(h("text", {}, maskPassword(String(value))));
|
|
90
|
+
} else if (value !== undefined && value !== "") {
|
|
91
|
+
children.push(h("text", {}, String(value)));
|
|
92
|
+
} else if (field.placeholder) {
|
|
93
|
+
children.push(h("text", { dim: true }, field.placeholder));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Select type indicator
|
|
97
|
+
if (field.type === "select" && !editing) {
|
|
98
|
+
children.push(h("text", { dim: true, style: { paddingLeft: 1 } }, "▾"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nodes = [h("line", {}, children)];
|
|
102
|
+
|
|
103
|
+
// Error display
|
|
104
|
+
if (error) {
|
|
105
|
+
nodes.push(
|
|
106
|
+
h("line", {}, [
|
|
107
|
+
h("text", { color: "error", style: { paddingLeft: 4 } }, `⚠ ${error}`),
|
|
108
|
+
]),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return h("stack", {}, nodes);
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** Mask a password value, showing only the last 4 chars. */
|
|
118
|
+
function maskPassword(val: string): string {
|
|
119
|
+
if (val.length <= 4) return "••••";
|
|
120
|
+
return "••••••••" + val.slice(-4);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default TuiField;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — TuiSelect component
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { defineComponent, h, type PropType } from "@vue/runtime-core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a vertical select list with one highlighted option.
|
|
9
|
+
*
|
|
10
|
+
* Props:
|
|
11
|
+
* - options: string[]
|
|
12
|
+
* - selectedIndex: number
|
|
13
|
+
* - filter: string (optional) — shows filter text above options
|
|
14
|
+
* - maxVisible: number (optional, default 8) — scroll window height
|
|
15
|
+
* - scrollOffset: number (optional, default 0) — current scroll position
|
|
16
|
+
*/
|
|
17
|
+
export const TuiSelect = defineComponent({
|
|
18
|
+
name: "TuiSelect",
|
|
19
|
+
props: {
|
|
20
|
+
options: { type: Array as PropType<string[]>, required: true },
|
|
21
|
+
selectedIndex: { type: Number, required: true },
|
|
22
|
+
filter: { type: String, default: "" },
|
|
23
|
+
maxVisible: { type: Number, default: 8 },
|
|
24
|
+
scrollOffset: { type: Number, default: 0 },
|
|
25
|
+
},
|
|
26
|
+
setup(props) {
|
|
27
|
+
return () => {
|
|
28
|
+
const children = [];
|
|
29
|
+
|
|
30
|
+
// Filter indicator
|
|
31
|
+
if (props.filter) {
|
|
32
|
+
children.push(
|
|
33
|
+
h("line", {}, [
|
|
34
|
+
h("text", { dim: true }, "filter: "),
|
|
35
|
+
h("text", { color: "accent" }, props.filter),
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Compute visible window
|
|
41
|
+
const total = props.options.length;
|
|
42
|
+
const visCount = Math.min(props.maxVisible, total);
|
|
43
|
+
const start = props.scrollOffset;
|
|
44
|
+
const end = Math.min(start + visCount, total);
|
|
45
|
+
|
|
46
|
+
// Scroll indicator (top)
|
|
47
|
+
if (start > 0) {
|
|
48
|
+
children.push(h("text", { dim: true }, " ↑ more"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Visible options
|
|
52
|
+
for (let i = start; i < end; i++) {
|
|
53
|
+
const selected = i === props.selectedIndex;
|
|
54
|
+
const option = props.options[i] ?? "";
|
|
55
|
+
|
|
56
|
+
children.push(
|
|
57
|
+
h("line", {}, [
|
|
58
|
+
h(
|
|
59
|
+
"text",
|
|
60
|
+
selected
|
|
61
|
+
? { color: "accent", bold: true }
|
|
62
|
+
: {},
|
|
63
|
+
selected ? "▸ " : " ",
|
|
64
|
+
),
|
|
65
|
+
h(
|
|
66
|
+
"text",
|
|
67
|
+
selected
|
|
68
|
+
? { color: "accent", bold: true }
|
|
69
|
+
: {},
|
|
70
|
+
option,
|
|
71
|
+
),
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Scroll indicator (bottom)
|
|
77
|
+
if (end < total) {
|
|
78
|
+
children.push(h("text", { dim: true }, " ↓ more"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return h("stack", {}, children);
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export default TuiSelect;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui/components — barrel export
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export { TuiField } from "./TuiField";
|
|
6
|
+
export { TuiSelect } from "./TuiSelect";
|
|
7
|
+
export { TuiCheckbox } from "./TuiCheckbox";
|