@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.
@@ -0,0 +1,120 @@
1
+ import { createRenderer } from "@vue/runtime-core";
2
+ import type { RendererOptions } from "@vue/runtime-core";
3
+ import {
4
+ createElement,
5
+ createTextNode,
6
+ createComment,
7
+ insertChild,
8
+ removeChild,
9
+ } from "./nodes";
10
+ import type { TNode, TElement, TText } from "./nodes";
11
+
12
+ // Vue's RendererOptions<HostNode, HostElement>
13
+ type Options = RendererOptions<TNode, TElement>;
14
+
15
+ function buildOptions(onUpdate: () => void): Options {
16
+ return {
17
+ // -----------------------------------------------------------------------
18
+ // Element creation
19
+ // -----------------------------------------------------------------------
20
+ createElement(type: string): TElement {
21
+ return createElement(type);
22
+ },
23
+
24
+ createText(text: string): TText {
25
+ return createTextNode(text);
26
+ },
27
+
28
+ createComment(text: string) {
29
+ return createComment(text);
30
+ },
31
+
32
+ // -----------------------------------------------------------------------
33
+ // Text / props mutation
34
+ // -----------------------------------------------------------------------
35
+ setText(node: TNode, text: string): void {
36
+ if (node.type === "text") {
37
+ (node as TText).text = text;
38
+ onUpdate();
39
+ }
40
+ },
41
+
42
+ setElementText(el: TElement, text: string): void {
43
+ // Replace all children with a single text node
44
+ el.children = [];
45
+ const textNode = createTextNode(text);
46
+ textNode.parent = el;
47
+ el.children.push(textNode);
48
+ onUpdate();
49
+ },
50
+
51
+ patchProp(
52
+ el: TElement,
53
+ key: string,
54
+ _prevValue: unknown,
55
+ nextValue: unknown,
56
+ ): void {
57
+ el.props[key] = nextValue;
58
+ onUpdate();
59
+ },
60
+
61
+ // -----------------------------------------------------------------------
62
+ // Tree mutations
63
+ // -----------------------------------------------------------------------
64
+ insert(child: TNode, parent: TElement, anchor: TNode | null = null): void {
65
+ insertChild(parent, child, anchor);
66
+ onUpdate();
67
+ },
68
+
69
+ remove(child: TNode): void {
70
+ if (child.parent !== null) {
71
+ removeChild(child.parent, child);
72
+ onUpdate();
73
+ }
74
+ },
75
+
76
+ // -----------------------------------------------------------------------
77
+ // Queries (Vue uses these to walk the DOM)
78
+ // -----------------------------------------------------------------------
79
+ parentNode(node: TNode): TElement | null {
80
+ return node.parent;
81
+ },
82
+
83
+ nextSibling(node: TNode): TNode | null {
84
+ return node.nextSibling;
85
+ },
86
+
87
+ querySelector(_selector: string): TElement | null {
88
+ // Not needed for terminal rendering
89
+ return null;
90
+ },
91
+
92
+ setScopeId(el: TElement, id: string): void {
93
+ el.props["data-v-" + id] = true;
94
+ },
95
+
96
+ // -----------------------------------------------------------------------
97
+ // Clone (used for static hoisting)
98
+ // -----------------------------------------------------------------------
99
+ cloneNode(node: TNode): TNode {
100
+ if (node.type === "text") {
101
+ return createTextNode((node as TText).text);
102
+ }
103
+ if (node.type === "comment") {
104
+ return createComment(String(node["text"] ?? ""));
105
+ }
106
+ const el = node as TElement;
107
+ const cloned = createElement(el.tag, { ...el.props });
108
+ for (const child of el.children) {
109
+ const clonedChild = this.cloneNode!(child) as TNode;
110
+ insertChild(cloned, clonedChild, null);
111
+ }
112
+ return cloned;
113
+ },
114
+ };
115
+ }
116
+
117
+ export function createTerminalRenderer(onUpdate: () => void) {
118
+ const { createApp } = createRenderer<TNode, TElement>(buildOptions(onUpdate));
119
+ return { createApp };
120
+ }
package/src/style.ts ADDED
@@ -0,0 +1,107 @@
1
+ /** The full CSS-like style interface for terminal elements. */
2
+ export interface TerminalStyle {
3
+ // Text
4
+ fontWeight?: "bold" | "normal" | undefined;
5
+ fontStyle?: "italic" | "normal" | undefined;
6
+ textDecoration?: "underline" | "line-through" | "none" | undefined;
7
+ opacity?: number | undefined; // 1 = normal, 0.5 = dim
8
+
9
+ // Colors — named colors from theme, or raw ANSI codes
10
+ color?: string | undefined;
11
+ backgroundColor?: string | undefined;
12
+
13
+ // Sizing (chars for width, lines for height)
14
+ width?: number | "100%" | "auto" | undefined;
15
+ minWidth?: number | undefined;
16
+ maxWidth?: number | undefined;
17
+ height?: number | "100%" | "auto" | undefined;
18
+ minHeight?: number | undefined;
19
+ maxHeight?: number | undefined;
20
+
21
+ // Spacing (chars horizontal, lines vertical)
22
+ marginTop?: number | undefined;
23
+ marginBottom?: number | undefined;
24
+ marginLeft?: number | undefined;
25
+ marginRight?: number | undefined;
26
+ paddingTop?: number | undefined;
27
+ paddingBottom?: number | undefined;
28
+ paddingLeft?: number | undefined;
29
+ paddingRight?: number | undefined;
30
+
31
+ // Border
32
+ borderTop?: "solid" | "none" | undefined;
33
+ borderBottom?: "solid" | "none" | undefined;
34
+ borderLeft?: "solid" | "heavy" | "none" | undefined;
35
+ borderRight?: "solid" | "heavy" | "none" | undefined;
36
+ borderColor?: string | undefined;
37
+ borderTopColor?: string | undefined;
38
+ borderBottomColor?: string | undefined;
39
+ borderLeftColor?: string | undefined;
40
+ borderRightColor?: string | undefined;
41
+
42
+ // Layout
43
+ display?: "block" | "inline" | "none" | undefined;
44
+
45
+ // Text wrapping
46
+ whiteSpace?: "normal" | "nowrap" | undefined;
47
+
48
+ // Overflow
49
+ overflow?: "hidden" | "scroll" | "collapse" | undefined;
50
+ }
51
+
52
+ export interface Theme {
53
+ colors: Record<string, string>; // name → ANSI code
54
+ symbols: {
55
+ divider: string; // "─"
56
+ prompt: string; // "❯"
57
+ bullet: string; // "●"
58
+ spinner: string[]; // ["◆", "◇"]
59
+ };
60
+ reset: string; // "\x1b[0m"
61
+ bold: string; // "\x1b[1m"
62
+ dim: string; // "\x1b[2m"
63
+ italic: string; // "\x1b[3m"
64
+ underline: string; // "\x1b[4m"
65
+ strikethrough: string; // "\x1b[9m"
66
+ reverse: string; // "\x1b[7m"
67
+ }
68
+
69
+ /** Resolve a color string (theme name or raw ANSI) to an ANSI escape. */
70
+ export function resolveColor(color: string | undefined, theme: Theme): string {
71
+ if (color === undefined) return "";
72
+ // If it starts with \x1b it's already a raw ANSI escape
73
+ if (color.startsWith("\x1b")) return color;
74
+ // Look up in theme colors
75
+ const resolved = theme.colors[color];
76
+ if (resolved !== undefined) return resolved;
77
+ // Return as-is (e.g. user passed a raw escape without \x1b prefix — unlikely but safe)
78
+ return color;
79
+ }
80
+
81
+ /** Resolve a TerminalStyle into ANSI open/close codes for inline text. */
82
+ export function resolveTextStyle(
83
+ style: TerminalStyle,
84
+ theme: Theme,
85
+ ): { open: string; close: string } {
86
+ let open = "";
87
+ const needsReset =
88
+ style.fontWeight === "bold" ||
89
+ style.fontStyle === "italic" ||
90
+ style.textDecoration === "underline" ||
91
+ style.textDecoration === "line-through" ||
92
+ style.opacity !== undefined ||
93
+ style.color !== undefined ||
94
+ style.backgroundColor !== undefined;
95
+
96
+ if (style.fontWeight === "bold") open += theme.bold;
97
+ if (style.fontStyle === "italic") open += theme.italic;
98
+ if (style.textDecoration === "underline") open += theme.underline;
99
+ if (style.textDecoration === "line-through") open += theme.strikethrough;
100
+ if (style.opacity !== undefined && style.opacity < 1) open += theme.dim;
101
+ if (style.color !== undefined) open += resolveColor(style.color, theme);
102
+ if (style.backgroundColor !== undefined)
103
+ open += resolveColor(style.backgroundColor, theme);
104
+
105
+ const close = needsReset ? theme.reset : "";
106
+ return { open, close };
107
+ }
@@ -0,0 +1,326 @@
1
+ import { h } from "@vue/runtime-core";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Tagged template for TUI components
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const PLACEHOLDER_PREFIX = "\x00__TUI_";
8
+ const PLACEHOLDER_SUFFIX = "__\x00";
9
+ const PLACEHOLDER_RE = /\x00__TUI_(\d+)__\x00/g;
10
+
11
+ // Void elements that don't need closing tags
12
+ const VOID_ELEMENTS = new Set(["spacer"]);
13
+
14
+ // Shorthand attribute → style mapping
15
+ const STYLE_SHORTHANDS: Record<string, (value: string | true) => Record<string, unknown>> = {
16
+ color: (v) => ({ color: v }),
17
+ bg: (v) => ({ backgroundColor: typeof v === "string" ? (v.startsWith("bg-") ? v : `bg-${v}`) : v }),
18
+ bold: () => ({ fontWeight: "bold" }),
19
+ italic: () => ({ fontStyle: "italic" }),
20
+ underline: () => ({ textDecoration: "underline" }),
21
+ strikethrough: () => ({ textDecoration: "line-through" }),
22
+ dim: () => ({ opacity: 0.5 }),
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Parser types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface ParsedNode {
30
+ type: "element" | "text";
31
+ tag?: string;
32
+ attrs?: Record<string, unknown>;
33
+ children?: ParsedNode[];
34
+ text?: string;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // html tagged template
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Tagged template literal for TUI components.
43
+ *
44
+ * Usage:
45
+ * ```ts
46
+ * html`<line><text color="accent" bold>hello</text></line>`
47
+ * ```
48
+ *
49
+ * Supports interpolations for dynamic values:
50
+ * ```ts
51
+ * html`<text cursorAt=${col}>${text}</text>`
52
+ * ```
53
+ */
54
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): unknown {
55
+ // Build the raw string with placeholders for interpolated values
56
+ let raw = "";
57
+ for (let i = 0; i < strings.length; i++) {
58
+ raw += strings[i];
59
+ if (i < values.length) {
60
+ raw += `${PLACEHOLDER_PREFIX}${i}${PLACEHOLDER_SUFFIX}`;
61
+ }
62
+ }
63
+
64
+ const nodes = parse(raw.trim(), values);
65
+
66
+ if (nodes.length === 0) return null;
67
+ if (nodes.length === 1) return toVNode(nodes[0]!, values);
68
+ return nodes.map((n) => toVNode(n, values));
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Parser
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function parse(input: string, values: unknown[]): ParsedNode[] {
76
+ let pos = 0;
77
+ const nodes: ParsedNode[] = [];
78
+
79
+ while (pos < input.length) {
80
+ if (input[pos] === "<") {
81
+ // Could be opening tag, closing tag, or self-closing
82
+ if (input[pos + 1] === "/") {
83
+ // Closing tag — stop parsing children (handled by caller)
84
+ break;
85
+ }
86
+ const result = parseElement(input, pos, values);
87
+ if (result) {
88
+ nodes.push(result.node);
89
+ pos = result.end;
90
+ } else {
91
+ // Malformed — treat as text
92
+ const textEnd = input.indexOf("<", pos + 1);
93
+ const end = textEnd === -1 ? input.length : textEnd;
94
+ nodes.push({ type: "text", text: input.slice(pos, end) });
95
+ pos = end;
96
+ }
97
+ } else {
98
+ // Text content
99
+ const textEnd = input.indexOf("<", pos);
100
+ const end = textEnd === -1 ? input.length : textEnd;
101
+ const text = input.slice(pos, end);
102
+ // Only keep text that has meaningful content or placeholders
103
+ // Strip pure whitespace between tags (indentation/newlines)
104
+ if (text.includes(PLACEHOLDER_PREFIX) || text.replace(/[\s\t\n\r]+/g, " ").trim()) {
105
+ nodes.push({ type: "text", text: text.replace(/[\s\t\n\r]+/g, " ") });
106
+ }
107
+ pos = end;
108
+ }
109
+ }
110
+
111
+ return nodes;
112
+ }
113
+
114
+ function parseElement(input: string, start: number, values: unknown[]): { node: ParsedNode; end: number } | null {
115
+ // Match opening tag: <tagName ...attrs...> or <tagName ...attrs... />
116
+ const tagMatch = input.slice(start).match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
117
+ if (!tagMatch) return null;
118
+
119
+ const tag = tagMatch[1]!;
120
+ let pos = start + tagMatch[0]!.length;
121
+
122
+ // Parse attributes
123
+ const attrs: Record<string, unknown> = {};
124
+ pos = parseAttributes(input, pos, attrs, values);
125
+
126
+ // Check for self-closing or void
127
+ const selfClose = input.slice(pos).startsWith("/>");
128
+ const isVoid = VOID_ELEMENTS.has(tag);
129
+
130
+ if (selfClose) {
131
+ pos += 2; // skip />
132
+ return { node: { type: "element", tag, attrs, children: [] }, end: pos };
133
+ }
134
+
135
+ // Skip >
136
+ if (input[pos] === ">") {
137
+ pos += 1;
138
+ } else {
139
+ return null; // malformed
140
+ }
141
+
142
+ if (isVoid) {
143
+ return { node: { type: "element", tag, attrs, children: [] }, end: pos };
144
+ }
145
+
146
+ // Parse children until closing tag
147
+ const children = parseChildren(input, pos, tag, values);
148
+ pos = children.end;
149
+
150
+ return { node: { type: "element", tag, attrs, children: children.nodes }, end: pos };
151
+ }
152
+
153
+ function parseChildren(input: string, start: number, parentTag: string, values: unknown[]): { nodes: ParsedNode[]; end: number } {
154
+ const closingTag = `</${parentTag}>`;
155
+ const nodes: ParsedNode[] = [];
156
+ let pos = start;
157
+
158
+ while (pos < input.length) {
159
+ // Check for closing tag
160
+ if (input.slice(pos).startsWith(closingTag)) {
161
+ return { nodes, end: pos + closingTag.length };
162
+ }
163
+
164
+ if (input[pos] === "<") {
165
+ if (input[pos + 1] === "/") {
166
+ // Unexpected closing tag — stop
167
+ const endIdx = input.indexOf(">", pos);
168
+ return { nodes, end: endIdx === -1 ? input.length : endIdx + 1 };
169
+ }
170
+ const result = parseElement(input, pos, values);
171
+ if (result) {
172
+ nodes.push(result.node);
173
+ pos = result.end;
174
+ } else {
175
+ pos++;
176
+ }
177
+ } else {
178
+ // Text content
179
+ const nextTag = input.indexOf("<", pos);
180
+ const end = nextTag === -1 ? input.length : nextTag;
181
+ const text = input.slice(pos, end);
182
+ // Strip pure whitespace between tags (indentation/newlines)
183
+ if (text.includes(PLACEHOLDER_PREFIX) || text.replace(/[\s\t\n\r]+/g, " ").trim()) {
184
+ nodes.push({ type: "text", text: text.replace(/[\s\t\n\r]+/g, " ") });
185
+ }
186
+ pos = end;
187
+ }
188
+ }
189
+
190
+ return { nodes, end: pos };
191
+ }
192
+
193
+ function parseAttributes(input: string, start: number, attrs: Record<string, unknown>, values: unknown[]): number {
194
+ let pos = start;
195
+
196
+ while (pos < input.length) {
197
+ // Skip whitespace
198
+ while (pos < input.length && /\s/.test(input[pos]!)) pos++;
199
+
200
+ // End of attributes?
201
+ if (input[pos] === ">" || input[pos] === "/") break;
202
+
203
+ // Attribute name
204
+ const nameMatch = input.slice(pos).match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
205
+ if (!nameMatch) break;
206
+
207
+ const name = nameMatch[1]!;
208
+ pos += name.length;
209
+
210
+ // Check for = value
211
+ if (input[pos] === "=") {
212
+ pos++; // skip =
213
+
214
+ if (input[pos] === '"') {
215
+ // Quoted string value
216
+ pos++; // skip opening "
217
+ const endQuote = input.indexOf('"', pos);
218
+ if (endQuote === -1) break;
219
+ const val = input.slice(pos, endQuote);
220
+ attrs[name] = resolvePlaceholders(val, values);
221
+ pos = endQuote + 1;
222
+ } else if (input[pos] === "'") {
223
+ // Single-quoted string value
224
+ pos++; // skip opening '
225
+ const endQuote = input.indexOf("'", pos);
226
+ if (endQuote === -1) break;
227
+ const val = input.slice(pos, endQuote);
228
+ attrs[name] = resolvePlaceholders(val, values);
229
+ pos = endQuote + 1;
230
+ } else if (input.slice(pos).startsWith(PLACEHOLDER_PREFIX)) {
231
+ // Direct placeholder value (for objects, numbers, etc.)
232
+ const phEnd = input.indexOf(PLACEHOLDER_SUFFIX, pos);
233
+ if (phEnd === -1) break;
234
+ const idxStr = input.slice(pos + PLACEHOLDER_PREFIX.length, phEnd);
235
+ const idx = parseInt(idxStr, 10);
236
+ attrs[name] = values[idx];
237
+ pos = phEnd + PLACEHOLDER_SUFFIX.length;
238
+ } else {
239
+ // Unquoted value (until whitespace, >, or /)
240
+ const valMatch = input.slice(pos).match(/^[^\s>\/]+/);
241
+ if (valMatch) {
242
+ attrs[name] = resolvePlaceholders(valMatch[0]!, values);
243
+ pos += valMatch[0]!.length;
244
+ }
245
+ }
246
+ } else {
247
+ // Boolean attribute (e.g. `bold`, `italic`)
248
+ attrs[name] = true;
249
+ }
250
+ }
251
+
252
+ return pos;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Placeholder resolution
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function resolvePlaceholders(str: string, values: unknown[]): unknown {
260
+ // If the entire string is a single placeholder, return the value directly
261
+ const singleMatch = str.match(/^\x00__TUI_(\d+)__\x00$/);
262
+ if (singleMatch) {
263
+ return values[parseInt(singleMatch[1]!, 10)];
264
+ }
265
+
266
+ // Otherwise, string-replace placeholders
267
+ if (str.includes(PLACEHOLDER_PREFIX)) {
268
+ return str.replace(PLACEHOLDER_RE, (_, idxStr: string) => {
269
+ return String(values[parseInt(idxStr, 10)] ?? "");
270
+ });
271
+ }
272
+
273
+ return str;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // VNode conversion
278
+ // ---------------------------------------------------------------------------
279
+
280
+ function toVNode(node: ParsedNode, values: unknown[]): unknown {
281
+ if (node.type === "text") {
282
+ const text = node.text ?? "";
283
+ // Resolve any placeholders in text
284
+ const resolved = resolvePlaceholders(text, values);
285
+ return resolved;
286
+ }
287
+
288
+ const tag = node.tag!;
289
+ const rawAttrs = node.attrs ?? {};
290
+ const children = (node.children ?? []).map((c) => toVNode(c, values));
291
+
292
+ // Separate shorthand style attrs from regular props
293
+ const props: Record<string, unknown> = {};
294
+ let style: Record<string, unknown> = {};
295
+
296
+ for (const [key, value] of Object.entries(rawAttrs)) {
297
+ if (key === "style") {
298
+ // Merge with existing style
299
+ if (typeof value === "object" && value !== null) {
300
+ style = { ...style, ...(value as Record<string, unknown>) };
301
+ }
302
+ } else if (key in STYLE_SHORTHANDS) {
303
+ const styleFn = STYLE_SHORTHANDS[key]!;
304
+ const result = styleFn(value as string | true);
305
+ style = { ...style, ...result };
306
+ } else {
307
+ props[key] = value;
308
+ }
309
+ }
310
+
311
+ // Only add style if non-empty
312
+ if (Object.keys(style).length > 0) {
313
+ props["style"] = style;
314
+ }
315
+
316
+ // For text elements with only text children, join them
317
+ if (tag === "text" && children.length > 0 && children.every((c) => typeof c === "string")) {
318
+ return h(tag, props, children.join(""));
319
+ }
320
+
321
+ if (children.length === 0) {
322
+ return h(tag, props);
323
+ }
324
+
325
+ return h(tag, props, children as any[]);
326
+ }