@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/src/render.ts
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import type { TElement, TNode, TText } from "./nodes";
|
|
2
|
+
import { getStyle } from "./nodes";
|
|
3
|
+
import type { Theme, TerminalStyle } from "./style";
|
|
4
|
+
import { resolveColor, resolveTextStyle } from "./style";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Strip ANSI escape codes to measure visible character width. */
|
|
11
|
+
function visibleLength(s: string): number {
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
return s.replace(/\x1b\[[0-9;]*m|\x1b\][^\x1b]*\x1b\\/g, "").length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pad a string to `width` visible characters (right-pad with spaces). */
|
|
17
|
+
function padRight(s: string, width: number): string {
|
|
18
|
+
const vis = visibleLength(s);
|
|
19
|
+
if (vis >= width) return s;
|
|
20
|
+
return s + " ".repeat(width - vis);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Repeat a character `n` times, guarded for n ≤ 0. */
|
|
24
|
+
function repeat(ch: string, n: number): string {
|
|
25
|
+
return n > 0 ? ch.repeat(n) : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prefix every line in `lines` with `prefix`. */
|
|
29
|
+
function prefixLines(lines: string[], prefix: string): string[] {
|
|
30
|
+
if (prefix === "") return lines;
|
|
31
|
+
return lines.map((l) => prefix + l);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Word-wrap a single text segment to fit within `maxWidth` visible chars.
|
|
35
|
+
* Preserves all whitespace — only inserts breaks between words. */
|
|
36
|
+
function wordWrap(text: string, maxWidth: number): string[] {
|
|
37
|
+
if (maxWidth <= 0) return [text];
|
|
38
|
+
if (visibleLength(text) <= maxWidth) return [text];
|
|
39
|
+
|
|
40
|
+
const result: string[] = [];
|
|
41
|
+
let lineStart = 0;
|
|
42
|
+
let lastBreakable = -1; // index after which we can break (after a space)
|
|
43
|
+
let visWidth = 0;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < text.length; i++) {
|
|
46
|
+
const ch = text[i]!;
|
|
47
|
+
|
|
48
|
+
// Skip ANSI escape sequences — don't count toward visible width
|
|
49
|
+
if (ch === "\x1b") {
|
|
50
|
+
if (text[i + 1] === "[") {
|
|
51
|
+
// CSI sequence: \x1b[...m
|
|
52
|
+
const end = text.indexOf("m", i);
|
|
53
|
+
if (end !== -1) {
|
|
54
|
+
i = end; // loop will i++ past the 'm'
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
} else if (text[i + 1] === "]") {
|
|
58
|
+
// OSC sequence: \x1b]...\x1b\\
|
|
59
|
+
const end = text.indexOf("\x1b\\", i);
|
|
60
|
+
if (end !== -1) {
|
|
61
|
+
i = end + 1; // loop will i++ past the '\\'
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
visWidth++;
|
|
68
|
+
|
|
69
|
+
if (visWidth > maxWidth) {
|
|
70
|
+
if (lastBreakable > lineStart) {
|
|
71
|
+
// Break at last space
|
|
72
|
+
result.push(text.slice(lineStart, lastBreakable + 1));
|
|
73
|
+
lineStart = lastBreakable + 1;
|
|
74
|
+
} else {
|
|
75
|
+
// No breakable point — hard break at current position
|
|
76
|
+
result.push(text.slice(lineStart, i));
|
|
77
|
+
lineStart = i;
|
|
78
|
+
}
|
|
79
|
+
visWidth = visibleLength(text.slice(lineStart, i + 1));
|
|
80
|
+
lastBreakable = -1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (ch === " ") {
|
|
84
|
+
lastBreakable = i;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Remainder
|
|
89
|
+
if (lineStart < text.length) {
|
|
90
|
+
result.push(text.slice(lineStart));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result.length > 0 ? result : [""];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Expand a text string (may contain \n) into wrapped lines. */
|
|
97
|
+
function expandText(text: string, maxWidth: number): string[] {
|
|
98
|
+
const segments = text.split("\n");
|
|
99
|
+
const result: string[] = [];
|
|
100
|
+
for (const seg of segments) {
|
|
101
|
+
const wrapped = wordWrap(seg, maxWidth);
|
|
102
|
+
for (const line of wrapped) {
|
|
103
|
+
result.push(line);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result.length > 0 ? result : [""];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build a divider line of `─` characters at the given width. */
|
|
110
|
+
function dividerLine(width: number, color: string, reset: string): string {
|
|
111
|
+
const bar = repeat("─", width);
|
|
112
|
+
return color !== "" ? color + bar + reset : bar;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Render context
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
interface RenderContext {
|
|
120
|
+
width: number;
|
|
121
|
+
theme: Theme;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Core render dispatch
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render a TNode into an array of string lines.
|
|
130
|
+
* `ctx` carries the available width and theme.
|
|
131
|
+
*/
|
|
132
|
+
function renderNode(node: TNode, ctx: RenderContext): string[] {
|
|
133
|
+
if (node.type === "text") {
|
|
134
|
+
const text = (node as TText).text;
|
|
135
|
+
// Skip empty text nodes (whitespace artifacts from template compilation)
|
|
136
|
+
if (text === "") return [];
|
|
137
|
+
return expandText(text, ctx.width);
|
|
138
|
+
}
|
|
139
|
+
if (node.type === "comment") return [];
|
|
140
|
+
return renderElement(node as TElement, ctx);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderElement(el: TElement, ctx: RenderContext): string[] {
|
|
144
|
+
const style = getStyle(el);
|
|
145
|
+
|
|
146
|
+
// display: none → skip
|
|
147
|
+
if (style.display === "none") return [];
|
|
148
|
+
|
|
149
|
+
// Render content based on element type
|
|
150
|
+
let lines: string[];
|
|
151
|
+
switch (el.tag) {
|
|
152
|
+
case "root":
|
|
153
|
+
case "stack":
|
|
154
|
+
lines = renderStack(el, style, ctx);
|
|
155
|
+
break;
|
|
156
|
+
case "line":
|
|
157
|
+
lines = renderLine(el, style, ctx);
|
|
158
|
+
break;
|
|
159
|
+
case "row":
|
|
160
|
+
lines = renderRow(el, style, ctx);
|
|
161
|
+
break;
|
|
162
|
+
case "text":
|
|
163
|
+
lines = renderTextEl(el, style, ctx);
|
|
164
|
+
break;
|
|
165
|
+
case "spacer":
|
|
166
|
+
lines = [""];
|
|
167
|
+
break;
|
|
168
|
+
case "indent":
|
|
169
|
+
lines = renderIndent(el, style, ctx);
|
|
170
|
+
break;
|
|
171
|
+
case "list":
|
|
172
|
+
lines = renderList(el, style, ctx);
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
// Unknown tag — treat like a stack
|
|
176
|
+
lines = renderStack(el, style, ctx);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Apply common box model to ALL elements
|
|
181
|
+
lines = applyBoxModel(lines, style, ctx);
|
|
182
|
+
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Common box model pass — applies padding, borders, margins, height, width
|
|
188
|
+
* to any element's rendered content lines.
|
|
189
|
+
*/
|
|
190
|
+
function applyBoxModel(lines: string[], style: TerminalStyle, ctx: RenderContext): string[] {
|
|
191
|
+
lines = applyPaddingLeftRight(lines, style, ctx.width);
|
|
192
|
+
lines = applyBorders(lines, style, ctx.width, ctx.theme);
|
|
193
|
+
lines = applyMargins(lines, style, ctx.width);
|
|
194
|
+
lines = applyHeight(lines, style);
|
|
195
|
+
lines = applyMaxHeight(lines, style);
|
|
196
|
+
lines = applyWidth(lines, style, ctx.width, ctx.theme);
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Tag renderers
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/** Vertical stack: each child's lines are appended sequentially. */
|
|
205
|
+
function renderStack(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
206
|
+
const innerWidth = computeInnerWidth(style, ctx.width);
|
|
207
|
+
const innerCtx: RenderContext = { ...ctx, width: innerWidth };
|
|
208
|
+
|
|
209
|
+
let lines: string[] = [];
|
|
210
|
+
|
|
211
|
+
// paddingTop
|
|
212
|
+
const pt = style.paddingTop ?? 0;
|
|
213
|
+
for (let i = 0; i < pt; i++) lines.push("");
|
|
214
|
+
|
|
215
|
+
for (const child of el.children) {
|
|
216
|
+
const childLines = renderNode(child, innerCtx);
|
|
217
|
+
for (const l of childLines) lines.push(l);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// paddingBottom
|
|
221
|
+
const pb = style.paddingBottom ?? 0;
|
|
222
|
+
for (let i = 0; i < pb; i++) lines.push("");
|
|
223
|
+
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A single terminal row: children rendered inline (concatenated). */
|
|
228
|
+
function renderLine(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
229
|
+
const innerWidth = computeInnerWidth(style, ctx.width);
|
|
230
|
+
const innerCtx: RenderContext = { ...ctx, width: innerWidth };
|
|
231
|
+
|
|
232
|
+
// Collect inline text from all children
|
|
233
|
+
const parts: string[] = [];
|
|
234
|
+
for (const child of el.children) {
|
|
235
|
+
const childLines = renderNode(child, innerCtx);
|
|
236
|
+
// For inline rendering, join child lines without injecting spaces
|
|
237
|
+
parts.push(childLines.join(""));
|
|
238
|
+
}
|
|
239
|
+
const joined = parts.join("");
|
|
240
|
+
|
|
241
|
+
// If whiteSpace is nowrap, don't word-wrap — return as a single line
|
|
242
|
+
if (style.whiteSpace === "nowrap") {
|
|
243
|
+
return [joined];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Expand the joined string through newline + wrap
|
|
247
|
+
const lines = expandText(joined, innerWidth);
|
|
248
|
+
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Row: children rendered side-by-side on the same line(s). */
|
|
253
|
+
function renderRow(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
254
|
+
const innerWidth = computeInnerWidth(style, ctx.width);
|
|
255
|
+
|
|
256
|
+
// Determine per-child widths
|
|
257
|
+
const children = el.children.filter((c) => {
|
|
258
|
+
if (c.type === "comment") return false;
|
|
259
|
+
if (c.type === "element") {
|
|
260
|
+
const cs = getStyle(c as TElement);
|
|
261
|
+
if (cs.display === "none") return false;
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (children.length === 0) return [];
|
|
267
|
+
|
|
268
|
+
// Distribute width: fixed-width children take their share, rest share equally
|
|
269
|
+
const childWidths = distributeWidths(children, innerWidth, ctx);
|
|
270
|
+
|
|
271
|
+
// Render each child at its allocated width
|
|
272
|
+
const columns: string[][] = children.map((child, i) => {
|
|
273
|
+
const w = childWidths[i] ?? 1;
|
|
274
|
+
return renderNode(child, { ...ctx, width: w });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Merge columns into rows (zip with padding to max height)
|
|
278
|
+
const maxLines = columns.reduce((m, col) => Math.max(m, col.length), 0);
|
|
279
|
+
const result: string[] = [];
|
|
280
|
+
for (let r = 0; r < maxLines; r++) {
|
|
281
|
+
let rowStr = "";
|
|
282
|
+
for (let c = 0; c < columns.length; c++) {
|
|
283
|
+
const col = columns[c];
|
|
284
|
+
const w = childWidths[c] ?? 1;
|
|
285
|
+
const cell = col !== undefined && r < col.length ? (col[r] ?? "") : "";
|
|
286
|
+
rowStr += padRight(cell, w);
|
|
287
|
+
}
|
|
288
|
+
result.push(rowStr);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Distribute column widths for row children. */
|
|
295
|
+
function distributeWidths(
|
|
296
|
+
children: TNode[],
|
|
297
|
+
totalWidth: number,
|
|
298
|
+
ctx: RenderContext,
|
|
299
|
+
): number[] {
|
|
300
|
+
let remaining = totalWidth;
|
|
301
|
+
const widths: (number | null)[] = children.map((child) => {
|
|
302
|
+
if (child.type !== "element") return null;
|
|
303
|
+
const s = getStyle(child as TElement);
|
|
304
|
+
if (typeof s.width === "number") {
|
|
305
|
+
remaining -= s.width;
|
|
306
|
+
return s.width;
|
|
307
|
+
}
|
|
308
|
+
if (s.width === "100%") return null; // flex
|
|
309
|
+
return null;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const flexCount = widths.filter((w) => w === null).length;
|
|
313
|
+
const flexWidth = flexCount > 0 ? Math.floor(remaining / flexCount) : 0;
|
|
314
|
+
|
|
315
|
+
return widths.map((w) => (w === null ? Math.max(0, flexWidth) : w));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Apply a reverse-video block cursor at position `cursorAt` within expanded lines.
|
|
320
|
+
* Accounts for line wrapping — `cursorAt` is an offset into the original flat text.
|
|
321
|
+
*/
|
|
322
|
+
function applyCursor(
|
|
323
|
+
lines: string[],
|
|
324
|
+
cursorAt: number,
|
|
325
|
+
open: string,
|
|
326
|
+
close: string,
|
|
327
|
+
theme: Theme,
|
|
328
|
+
): string[] {
|
|
329
|
+
// Find which line and column the cursor falls on
|
|
330
|
+
let remaining = cursorAt;
|
|
331
|
+
let cursorLine = -1;
|
|
332
|
+
let cursorCol = 0;
|
|
333
|
+
|
|
334
|
+
for (let i = 0; i < lines.length; i++) {
|
|
335
|
+
const lineLen = visibleLength(lines[i]!);
|
|
336
|
+
if (remaining <= lineLen) {
|
|
337
|
+
cursorLine = i;
|
|
338
|
+
cursorCol = remaining;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
remaining -= lineLen;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// If cursorAt is past all text, put cursor at end of last line
|
|
345
|
+
if (cursorLine === -1) {
|
|
346
|
+
cursorLine = lines.length - 1;
|
|
347
|
+
cursorCol = visibleLength(lines[cursorLine]!);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const result: string[] = [];
|
|
351
|
+
for (let i = 0; i < lines.length; i++) {
|
|
352
|
+
const line = lines[i]!;
|
|
353
|
+
if (i === cursorLine) {
|
|
354
|
+
const before = line.slice(0, cursorCol);
|
|
355
|
+
const charAtCursor = line[cursorCol] ?? "";
|
|
356
|
+
const cursorChar = charAtCursor || " "; // space if at end of line
|
|
357
|
+
const after = charAtCursor ? line.slice(cursorCol + 1) : "";
|
|
358
|
+
|
|
359
|
+
let rendered = "";
|
|
360
|
+
if (open) rendered += open;
|
|
361
|
+
rendered += before;
|
|
362
|
+
if (open) rendered += close;
|
|
363
|
+
rendered += theme.reverse + cursorChar + theme.reset;
|
|
364
|
+
if (after) {
|
|
365
|
+
if (open) rendered += open;
|
|
366
|
+
rendered += after;
|
|
367
|
+
if (open) rendered += close;
|
|
368
|
+
}
|
|
369
|
+
result.push(rendered);
|
|
370
|
+
} else {
|
|
371
|
+
result.push(open !== "" ? open + line + close : line);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Inline styled text element. */
|
|
378
|
+
function renderTextEl(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
379
|
+
const innerWidth = computeInnerWidth(style, ctx.width);
|
|
380
|
+
const { open, close } = resolveTextStyle(style, ctx.theme);
|
|
381
|
+
|
|
382
|
+
// OSC 8 hyperlink wrapping
|
|
383
|
+
const href = typeof el.props["href"] === "string" ? (el.props["href"] as string) : "";
|
|
384
|
+
const linkOpen = href ? `\x1b]8;;${href}\x1b\\` : "";
|
|
385
|
+
const linkClose = href ? `\x1b]8;;\x1b\\` : "";
|
|
386
|
+
|
|
387
|
+
// Gather raw text from children
|
|
388
|
+
const rawParts: string[] = [];
|
|
389
|
+
for (const child of el.children) {
|
|
390
|
+
if (child.type === "text") {
|
|
391
|
+
rawParts.push((child as TText).text);
|
|
392
|
+
} else if (child.type === "element") {
|
|
393
|
+
// Nested text elements — render and join
|
|
394
|
+
const nested = renderNode(child, { ...ctx, width: innerWidth });
|
|
395
|
+
rawParts.push(nested.join("\n"));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const raw = rawParts.join("");
|
|
399
|
+
|
|
400
|
+
// Handle cursorAt prop — render a reverse-video block cursor at the given position
|
|
401
|
+
const cursorAt = typeof el.props["cursorAt"] === "number" ? (el.props["cursorAt"] as number) : -1;
|
|
402
|
+
|
|
403
|
+
const expanded = expandText(raw, innerWidth);
|
|
404
|
+
|
|
405
|
+
if (cursorAt >= 0) {
|
|
406
|
+
return applyCursor(expanded, cursorAt, open, close, ctx.theme);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Combine style + link wrapping
|
|
410
|
+
const fullOpen = linkOpen + open;
|
|
411
|
+
const fullClose = close + linkClose;
|
|
412
|
+
|
|
413
|
+
const styled = expanded.map((line) =>
|
|
414
|
+
fullOpen !== "" || fullClose !== "" ? fullOpen + line + fullClose : line,
|
|
415
|
+
);
|
|
416
|
+
return styled;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Indent: adds `level * 2` spaces prefix to all child lines. */
|
|
420
|
+
function renderIndent(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
421
|
+
const level = typeof el.props["level"] === "number" ? el.props["level"] : 1;
|
|
422
|
+
const spaces = repeat(" ", level * 2);
|
|
423
|
+
const innerWidth = Math.max(0, ctx.width - level * 2);
|
|
424
|
+
const innerCtx: RenderContext = { ...ctx, width: innerWidth };
|
|
425
|
+
|
|
426
|
+
let lines: string[] = [];
|
|
427
|
+
for (const child of el.children) {
|
|
428
|
+
const childLines = renderNode(child, innerCtx);
|
|
429
|
+
for (const l of childLines) lines.push(l);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
lines = prefixLines(lines, spaces);
|
|
433
|
+
|
|
434
|
+
return lines;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* List: renders children as list items with bullet or number prefixes.
|
|
439
|
+
*
|
|
440
|
+
* Props:
|
|
441
|
+
* - `ordered` (boolean): if true, uses "1. ", "2. " etc. Default: false (bullet "• ")
|
|
442
|
+
* - `start` (number): starting number for ordered lists. Default: 1
|
|
443
|
+
*/
|
|
444
|
+
function renderList(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
|
|
445
|
+
const ordered = el.props["ordered"] === true;
|
|
446
|
+
const start = typeof el.props["start"] === "number" ? (el.props["start"] as number) : 1;
|
|
447
|
+
|
|
448
|
+
// Determine prefix width — for ordered, depends on max number length
|
|
449
|
+
const itemCount = el.children.filter(
|
|
450
|
+
(c) => c.type === "element" || c.type === "text",
|
|
451
|
+
).length;
|
|
452
|
+
const maxNum = start + itemCount - 1;
|
|
453
|
+
const numWidth = ordered ? String(maxNum).length : 0;
|
|
454
|
+
const prefixWidth = ordered ? numWidth + 2 : 2; // "N. " or "• "
|
|
455
|
+
|
|
456
|
+
const innerWidth = Math.max(0, computeInnerWidth(style, ctx.width) - prefixWidth);
|
|
457
|
+
const innerCtx: RenderContext = { ...ctx, width: innerWidth };
|
|
458
|
+
const continuation = repeat(" ", prefixWidth);
|
|
459
|
+
|
|
460
|
+
let lines: string[] = [];
|
|
461
|
+
let index = 0;
|
|
462
|
+
|
|
463
|
+
for (const child of el.children) {
|
|
464
|
+
if (child.type === "comment") continue;
|
|
465
|
+
|
|
466
|
+
const childLines = renderNode(child, innerCtx);
|
|
467
|
+
const bullet = ordered
|
|
468
|
+
? String(start + index).padStart(numWidth) + ". "
|
|
469
|
+
: "• ";
|
|
470
|
+
|
|
471
|
+
for (let i = 0; i < childLines.length; i++) {
|
|
472
|
+
const prefix = i === 0 ? bullet : continuation;
|
|
473
|
+
lines.push(prefix + (childLines[i] ?? ""));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
index++;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return lines;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Box-model helpers
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function computeInnerWidth(style: TerminalStyle, availableWidth: number): number {
|
|
487
|
+
let w = availableWidth;
|
|
488
|
+
|
|
489
|
+
if (typeof style.width === "number") w = style.width;
|
|
490
|
+
else if (style.width === "100%") w = availableWidth;
|
|
491
|
+
|
|
492
|
+
if (style.maxWidth !== undefined) w = Math.min(w, style.maxWidth);
|
|
493
|
+
if (style.minWidth !== undefined) w = Math.max(w, style.minWidth);
|
|
494
|
+
|
|
495
|
+
const pl = style.paddingLeft ?? 0;
|
|
496
|
+
const pr = style.paddingRight ?? 0;
|
|
497
|
+
const ml = style.marginLeft ?? 0;
|
|
498
|
+
const mr = style.marginRight ?? 0;
|
|
499
|
+
// Left border takes 2 chars (char + space), right border takes 2 chars (space + char)
|
|
500
|
+
const bl = (style.borderLeft === "solid" || style.borderLeft === "heavy") ? 2 : 0;
|
|
501
|
+
const br = (style.borderRight === "solid" || style.borderRight === "heavy") ? 2 : 0;
|
|
502
|
+
|
|
503
|
+
w = Math.max(0, w - pl - pr - ml - mr - bl - br);
|
|
504
|
+
return w;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function applyPaddingLeftRight(
|
|
508
|
+
lines: string[],
|
|
509
|
+
style: TerminalStyle,
|
|
510
|
+
_innerWidth: number,
|
|
511
|
+
): string[] {
|
|
512
|
+
const pl = style.paddingLeft ?? 0;
|
|
513
|
+
const pr = style.paddingRight ?? 0;
|
|
514
|
+
if (pl === 0 && pr === 0) return lines;
|
|
515
|
+
const leftPad = repeat(" ", pl);
|
|
516
|
+
const rightPad = repeat(" ", pr);
|
|
517
|
+
return lines.map((l) => leftPad + l + rightPad);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function applyBorders(
|
|
521
|
+
lines: string[],
|
|
522
|
+
style: TerminalStyle,
|
|
523
|
+
width: number,
|
|
524
|
+
theme: Theme,
|
|
525
|
+
): string[] {
|
|
526
|
+
// Left/right border characters
|
|
527
|
+
const hasLeft = style.borderLeft === "solid" || style.borderLeft === "heavy";
|
|
528
|
+
const hasRight = style.borderRight === "solid" || style.borderRight === "heavy";
|
|
529
|
+
|
|
530
|
+
if (hasLeft || hasRight) {
|
|
531
|
+
const leftChar = style.borderLeft === "heavy" ? "▎" : "│";
|
|
532
|
+
const rightChar = style.borderRight === "heavy" ? "▕" : "│";
|
|
533
|
+
const leftColor = resolveColor(style.borderLeftColor ?? style.borderColor, theme);
|
|
534
|
+
const rightColor = resolveColor(style.borderRightColor ?? style.borderColor, theme);
|
|
535
|
+
const leftPrefix = leftColor ? leftColor + leftChar + theme.reset + " " : leftChar + " ";
|
|
536
|
+
const rightSuffix = rightColor ? " " + rightColor + rightChar + theme.reset : " " + rightChar;
|
|
537
|
+
|
|
538
|
+
lines = lines.map((l) =>
|
|
539
|
+
(hasLeft ? leftPrefix : "") + l + (hasRight ? rightSuffix : ""),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Top/bottom borders
|
|
544
|
+
const result: string[] = [];
|
|
545
|
+
|
|
546
|
+
if (style.borderTop === "solid") {
|
|
547
|
+
const color = resolveColor(style.borderTopColor ?? style.borderColor, theme);
|
|
548
|
+
result.push(dividerLine(width, color, theme.reset));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
for (const l of lines) result.push(l);
|
|
552
|
+
|
|
553
|
+
if (style.borderBottom === "solid") {
|
|
554
|
+
const color = resolveColor(style.borderBottomColor ?? style.borderColor, theme);
|
|
555
|
+
result.push(dividerLine(width, color, theme.reset));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function applyMargins(lines: string[], style: TerminalStyle, _width: number): string[] {
|
|
562
|
+
const mt = style.marginTop ?? 0;
|
|
563
|
+
const mb = style.marginBottom ?? 0;
|
|
564
|
+
const ml = style.marginLeft ?? 0;
|
|
565
|
+
|
|
566
|
+
let result = lines;
|
|
567
|
+
|
|
568
|
+
if (ml > 0) {
|
|
569
|
+
const leftPad = repeat(" ", ml);
|
|
570
|
+
result = result.map((l) => leftPad + l);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const top: string[] = [];
|
|
574
|
+
for (let i = 0; i < mt; i++) top.push("");
|
|
575
|
+
const bottom: string[] = [];
|
|
576
|
+
for (let i = 0; i < mb; i++) bottom.push("");
|
|
577
|
+
|
|
578
|
+
return [...top, ...result, ...bottom];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function applyHeight(lines: string[], style: TerminalStyle): string[] {
|
|
582
|
+
if (typeof style.height !== "number") return lines;
|
|
583
|
+
const h = style.height;
|
|
584
|
+
if (lines.length >= h) return lines.slice(0, h);
|
|
585
|
+
// Pad with empty lines to reach minHeight
|
|
586
|
+
const result = [...lines];
|
|
587
|
+
while (result.length < h) result.push("");
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function applyMaxHeight(lines: string[], style: TerminalStyle): string[] {
|
|
592
|
+
if (style.maxHeight === undefined) return lines;
|
|
593
|
+
const max =
|
|
594
|
+
typeof style.maxHeight === "number" ? style.maxHeight : lines.length;
|
|
595
|
+
if (lines.length <= max) return lines;
|
|
596
|
+
|
|
597
|
+
if (style.overflow === "collapse") {
|
|
598
|
+
const hidden = lines.length - max + 1; // +1 for the indicator line
|
|
599
|
+
const truncated = lines.slice(0, max - 1);
|
|
600
|
+
truncated.push(`… +${hidden} lines`);
|
|
601
|
+
return truncated;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// hidden / scroll / default: just truncate
|
|
605
|
+
return lines.slice(0, max);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function applyWidth(
|
|
609
|
+
lines: string[],
|
|
610
|
+
style: TerminalStyle,
|
|
611
|
+
availableWidth: number,
|
|
612
|
+
_theme: Theme,
|
|
613
|
+
): string[] {
|
|
614
|
+
if (style.width !== "100%") return lines;
|
|
615
|
+
return lines.map((l) => padRight(l, availableWidth));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Public entry point
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Walk the TElement tree and produce an array of terminal lines.
|
|
624
|
+
*
|
|
625
|
+
* @param root The root TElement (tag = "root")
|
|
626
|
+
* @param width Available terminal width in columns
|
|
627
|
+
* @param theme The active theme
|
|
628
|
+
*/
|
|
629
|
+
export function renderToLines(root: TElement, width: number, theme: Theme): string[] {
|
|
630
|
+
const ctx: RenderContext = { width, theme };
|
|
631
|
+
return renderElement(root, ctx);
|
|
632
|
+
}
|