@zakmandhro/bunti 0.1.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/README.md +104 -0
- package/package.json +54 -0
- package/src/colors.ts +255 -0
- package/src/components/Button.ts +104 -0
- package/src/components/Card.ts +53 -0
- package/src/components/Header.ts +65 -0
- package/src/components/Input.ts +124 -0
- package/src/components/index.ts +4 -0
- package/src/data/glyphs.ts +30 -0
- package/src/detect.ts +60 -0
- package/src/dsl.ts +661 -0
- package/src/icons.ts +186 -0
- package/src/index.ts +72 -0
- package/src/layout.ts +639 -0
- package/src/render.ts +302 -0
- package/src/state.ts +148 -0
- package/src/utils.ts +165 -0
package/src/layout.ts
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunti Functional Layout & Drawing
|
|
3
|
+
* Strictly functional primitives for buffer manipulation and layout generation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveColor } from './colors';
|
|
7
|
+
import { replaceEmojis } from './icons';
|
|
8
|
+
import type { Cell, RGB, ScreenState } from './state';
|
|
9
|
+
import { charWidth, truncate, visibleWidth, wrapText } from './utils';
|
|
10
|
+
|
|
11
|
+
// --- Functional Primitives (Buffer Manipulation) ---
|
|
12
|
+
|
|
13
|
+
export function setCell(
|
|
14
|
+
state: ScreenState,
|
|
15
|
+
x: number,
|
|
16
|
+
y: number,
|
|
17
|
+
cell: Partial<Cell>,
|
|
18
|
+
) {
|
|
19
|
+
if (x >= 0 && x < state.width && y >= 0 && y < state.height) {
|
|
20
|
+
const target = state.backBuffer[y * state.width + x];
|
|
21
|
+
if (cell.char !== undefined) target.char = replaceEmojis(cell.char);
|
|
22
|
+
if (cell.fg !== undefined) {
|
|
23
|
+
target.fg = cell.fg;
|
|
24
|
+
target.fgCode = resolveColor(cell.fg);
|
|
25
|
+
}
|
|
26
|
+
if (cell.bg !== undefined) {
|
|
27
|
+
target.bg = cell.bg;
|
|
28
|
+
target.bgCode = resolveColor(cell.bg);
|
|
29
|
+
}
|
|
30
|
+
if (cell.bold !== undefined) target.bold = cell.bold;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import type { Gradient } from './colors';
|
|
35
|
+
|
|
36
|
+
export interface RectOptions {
|
|
37
|
+
char?: string;
|
|
38
|
+
fg?: string | number | RGB;
|
|
39
|
+
bg?: string | number | RGB | Gradient;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rect(
|
|
43
|
+
state: ScreenState,
|
|
44
|
+
x: number,
|
|
45
|
+
y: number,
|
|
46
|
+
w: number,
|
|
47
|
+
h: number,
|
|
48
|
+
style: RectOptions,
|
|
49
|
+
) {
|
|
50
|
+
const isGradient =
|
|
51
|
+
style.bg && typeof style.bg === 'object' && 'colors' in style.bg;
|
|
52
|
+
|
|
53
|
+
for (let dy = 0; dy < h; dy++) {
|
|
54
|
+
const row = y + dy;
|
|
55
|
+
if (row < 0 || row >= state.height) continue;
|
|
56
|
+
|
|
57
|
+
for (let dx = 0; dx < w; dx++) {
|
|
58
|
+
const col = x + dx;
|
|
59
|
+
if (col < 0 || col >= state.width) continue;
|
|
60
|
+
|
|
61
|
+
let resolvedBg: string | number | RGB | undefined;
|
|
62
|
+
|
|
63
|
+
if (isGradient) {
|
|
64
|
+
const grad = style.bg as any;
|
|
65
|
+
if (grad.direction === 'horizontal') {
|
|
66
|
+
resolvedBg = grad.colors[Math.floor((dx / w) * grad.colors.length)];
|
|
67
|
+
} else {
|
|
68
|
+
resolvedBg = grad.colors[Math.floor((dy / h) * grad.colors.length)];
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
resolvedBg =
|
|
72
|
+
style.bg !== undefined
|
|
73
|
+
? resolveColor(style.bg as string | number | RGB)
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cell: Partial<Cell> = { bg: resolvedBg };
|
|
78
|
+
if (style.char) cell.char = style.char;
|
|
79
|
+
if (style.fg) cell.fg = resolveColor(style.fg) as any;
|
|
80
|
+
|
|
81
|
+
const existing = state.backBuffer[row * state.width + col];
|
|
82
|
+
if (existing.char !== ' ' && !style.char) {
|
|
83
|
+
// Keep existing char/fg if drawing a pure background
|
|
84
|
+
cell.char = existing.char;
|
|
85
|
+
cell.fg = existing.fg;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setCell(state, col, row, cell);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function blit(
|
|
94
|
+
state: ScreenState,
|
|
95
|
+
startX: number,
|
|
96
|
+
startY: number,
|
|
97
|
+
content: string,
|
|
98
|
+
style: Partial<Cell> = {},
|
|
99
|
+
) {
|
|
100
|
+
const lines = content.split('\n');
|
|
101
|
+
for (let row = 0; row < lines.length; row++) {
|
|
102
|
+
const line = lines[row];
|
|
103
|
+
let x = startX;
|
|
104
|
+
let currentFg: any, currentBg: any;
|
|
105
|
+
let currentBold = false;
|
|
106
|
+
const regex = /\x1B\[([0-9;]*)m|([^\x1B]+)/g;
|
|
107
|
+
let match: RegExpExecArray | null = null;
|
|
108
|
+
while ((match = regex.exec(line)) !== null) {
|
|
109
|
+
if (match[1] !== undefined) {
|
|
110
|
+
const codes = match[1].split(';');
|
|
111
|
+
for (let i = 0; i < codes.length; i++) {
|
|
112
|
+
const code = parseInt(codes[i] || '0', 10);
|
|
113
|
+
if (code === 0) {
|
|
114
|
+
currentFg = undefined;
|
|
115
|
+
currentBg = undefined;
|
|
116
|
+
currentBold = false;
|
|
117
|
+
} else if (code === 1) currentBold = true;
|
|
118
|
+
else if (code === 22) currentBold = false;
|
|
119
|
+
else if (code >= 30 && code <= 37) currentFg = (code - 30).toString();
|
|
120
|
+
else if (code >= 40 && code <= 47) currentBg = (code - 40).toString();
|
|
121
|
+
else if (code >= 90 && code <= 97)
|
|
122
|
+
currentFg = (code - 90 + 8).toString();
|
|
123
|
+
else if (code >= 100 && code <= 107)
|
|
124
|
+
currentBg = (code - 100 + 8).toString();
|
|
125
|
+
else if (code === 38 && codes[i + 1] === '5') {
|
|
126
|
+
currentFg = parseInt(codes[i + 2], 10);
|
|
127
|
+
i += 2;
|
|
128
|
+
} else if (code === 38 && codes[i + 1] === '2') {
|
|
129
|
+
currentFg = {
|
|
130
|
+
r: parseInt(codes[i + 2], 10),
|
|
131
|
+
g: parseInt(codes[i + 3], 10),
|
|
132
|
+
b: parseInt(codes[i + 4], 10),
|
|
133
|
+
};
|
|
134
|
+
i += 4;
|
|
135
|
+
} else if (code === 48 && codes[i + 1] === '5') {
|
|
136
|
+
currentBg = parseInt(codes[i + 2], 10);
|
|
137
|
+
i += 2;
|
|
138
|
+
} else if (code === 48 && codes[i + 1] === '2') {
|
|
139
|
+
currentBg = {
|
|
140
|
+
r: parseInt(codes[i + 2], 10),
|
|
141
|
+
g: parseInt(codes[i + 3], 10),
|
|
142
|
+
b: parseInt(codes[i + 4], 10),
|
|
143
|
+
};
|
|
144
|
+
i += 4;
|
|
145
|
+
} else if (code === 39) currentFg = undefined;
|
|
146
|
+
else if (code === 49) currentBg = undefined;
|
|
147
|
+
}
|
|
148
|
+
} else if (match[2]) {
|
|
149
|
+
const processedText = replaceEmojis(match[2]);
|
|
150
|
+
const chars = Array.from(processedText);
|
|
151
|
+
for (const char of chars) {
|
|
152
|
+
const w = charWidth(char);
|
|
153
|
+
if (w === 0) continue;
|
|
154
|
+
|
|
155
|
+
const cell: Partial<Cell> = { char, ...style };
|
|
156
|
+
|
|
157
|
+
// Only overwrite buffer colors if explicitly set in the string or style
|
|
158
|
+
if (currentFg !== undefined) cell.fg = currentFg;
|
|
159
|
+
else if (style.fg !== undefined) cell.fg = style.fg;
|
|
160
|
+
|
|
161
|
+
if (currentBg !== undefined) cell.bg = currentBg;
|
|
162
|
+
else if (style.bg !== undefined) cell.bg = style.bg;
|
|
163
|
+
|
|
164
|
+
if (currentBold) cell.bold = true;
|
|
165
|
+
|
|
166
|
+
setCell(state, x, startY + row, cell);
|
|
167
|
+
x += w;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- High-Level Layout ---
|
|
175
|
+
|
|
176
|
+
export type BorderStyle =
|
|
177
|
+
| 'default'
|
|
178
|
+
| 'rounded'
|
|
179
|
+
| 'double'
|
|
180
|
+
| 'dashed'
|
|
181
|
+
| 'dotted'
|
|
182
|
+
| 'frame'
|
|
183
|
+
| 'thick-frame'
|
|
184
|
+
| 'classic'
|
|
185
|
+
| 'none';
|
|
186
|
+
|
|
187
|
+
export interface SideColors {
|
|
188
|
+
top?: any;
|
|
189
|
+
bottom?: any;
|
|
190
|
+
left?: any;
|
|
191
|
+
right?: any;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type SizeUnit = number | string; // e.g. 20, "50%", "1fr"
|
|
195
|
+
|
|
196
|
+
export interface StyleOptions {
|
|
197
|
+
width?: SizeUnit;
|
|
198
|
+
minWidth?: number;
|
|
199
|
+
maxWidth?: number;
|
|
200
|
+
height?: SizeUnit;
|
|
201
|
+
minHeight?: number;
|
|
202
|
+
maxHeight?: number;
|
|
203
|
+
padding?: [number, number];
|
|
204
|
+
border?: BorderStyle;
|
|
205
|
+
borderColor?: string | number | RGB | ((s: string) => string) | SideColors;
|
|
206
|
+
bgColor?: string | number | RGB | Gradient;
|
|
207
|
+
align?: 'left' | 'center' | 'right';
|
|
208
|
+
valign?: 'top' | 'middle' | 'bottom';
|
|
209
|
+
wrap?: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const BORDERS: Record<string, any> = {
|
|
213
|
+
default: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
|
|
214
|
+
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
|
215
|
+
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
|
216
|
+
dashed: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '╌', v: '╎' },
|
|
217
|
+
dotted: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '┈', v: '┊' },
|
|
218
|
+
frame: {
|
|
219
|
+
tl: '█',
|
|
220
|
+
tr: '█',
|
|
221
|
+
bl: '█',
|
|
222
|
+
br: '█',
|
|
223
|
+
top: '▀',
|
|
224
|
+
bottom: '▄',
|
|
225
|
+
left: '█',
|
|
226
|
+
right: '█',
|
|
227
|
+
},
|
|
228
|
+
'thick-frame': { tl: '█', tr: '█', bl: '█', br: '█', h: '█', v: '█' },
|
|
229
|
+
classic: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolves a SizeUnit to an absolute integer based on parent dimensions.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveSize(
|
|
236
|
+
unit: SizeUnit | undefined,
|
|
237
|
+
parentDim: number,
|
|
238
|
+
contentDim: number,
|
|
239
|
+
): number {
|
|
240
|
+
if (unit === undefined || unit === 'auto') return contentDim;
|
|
241
|
+
if (typeof unit === 'number') return unit;
|
|
242
|
+
if (typeof unit === 'string') {
|
|
243
|
+
if (unit.endsWith('%')) {
|
|
244
|
+
const pct = parseFloat(unit) / 100;
|
|
245
|
+
return Math.floor(parentDim * pct);
|
|
246
|
+
}
|
|
247
|
+
if (unit.endsWith('fr')) {
|
|
248
|
+
return parentDim;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return contentDim;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generates a styled box string with perfect alignment and optional wrapping.
|
|
256
|
+
*/
|
|
257
|
+
export function box(
|
|
258
|
+
content: string,
|
|
259
|
+
options: StyleOptions = {},
|
|
260
|
+
parentW?: number,
|
|
261
|
+
parentH?: number,
|
|
262
|
+
): string {
|
|
263
|
+
// Ensure we operate on the swapped glyphs for all layout math
|
|
264
|
+
content = replaceEmojis(content);
|
|
265
|
+
|
|
266
|
+
const px = options.padding?.[1] ?? 0;
|
|
267
|
+
const py = options.padding?.[0] ?? 0;
|
|
268
|
+
const borderStyle = options.border || 'none';
|
|
269
|
+
const borderOffset = borderStyle === 'none' ? 0 : 2;
|
|
270
|
+
|
|
271
|
+
const rawLines = content.split('\n');
|
|
272
|
+
const maxRawW = Math.max(...rawLines.map((l) => visibleWidth(l)), 0);
|
|
273
|
+
const intrinsicW = maxRawW + px * 2 + borderOffset;
|
|
274
|
+
|
|
275
|
+
// 1. Resolve Target Width (Outer)
|
|
276
|
+
const resolvedW = resolveSize(options.width, parentW || 0, intrinsicW);
|
|
277
|
+
|
|
278
|
+
// 2. Calculate Inner Width (between borders, including padding)
|
|
279
|
+
let targetInnerW = Math.max(0, resolvedW - borderOffset);
|
|
280
|
+
|
|
281
|
+
if (options.minWidth)
|
|
282
|
+
targetInnerW = Math.max(targetInnerW, options.minWidth - borderOffset);
|
|
283
|
+
if (options.maxWidth)
|
|
284
|
+
targetInnerW = Math.min(targetInnerW, options.maxWidth - borderOffset);
|
|
285
|
+
|
|
286
|
+
let lines: string[] = [];
|
|
287
|
+
const contentWidth = Math.max(0, targetInnerW - px * 2);
|
|
288
|
+
if (options.wrap && contentWidth > 0) {
|
|
289
|
+
lines = wrapText(content, contentWidth);
|
|
290
|
+
} else {
|
|
291
|
+
lines = content.split('\n').map((l) => truncate(l, contentWidth));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const contentH = lines.length + py * 2;
|
|
295
|
+
const resolvedH = resolveSize(options.height, parentH || 0, contentH);
|
|
296
|
+
let finalInnerH = resolvedH ? resolvedH - borderOffset : contentH;
|
|
297
|
+
if (options.minHeight)
|
|
298
|
+
finalInnerH = Math.max(finalInnerH, options.minHeight - borderOffset);
|
|
299
|
+
if (options.maxHeight)
|
|
300
|
+
finalInnerH = Math.min(finalInnerH, options.maxHeight - borderOffset);
|
|
301
|
+
|
|
302
|
+
const b =
|
|
303
|
+
borderStyle === 'none'
|
|
304
|
+
? null
|
|
305
|
+
: BORDERS[borderStyle as keyof typeof BORDERS] || BORDERS.default;
|
|
306
|
+
|
|
307
|
+
// Color Resolution
|
|
308
|
+
const { fg } = require('./colors');
|
|
309
|
+
const resolveSide = (color: any) =>
|
|
310
|
+
typeof color === 'function' ? color : (s: string) => fg(color, s);
|
|
311
|
+
const wrapBg = (s: string) => {
|
|
312
|
+
if (!options.bgColor) return s;
|
|
313
|
+
const { resolveColor } = require('./colors');
|
|
314
|
+
const code = resolveColor(options.bgColor);
|
|
315
|
+
const prefix =
|
|
316
|
+
typeof options.bgColor === 'object' || String(code).startsWith('2;')
|
|
317
|
+
? '48'
|
|
318
|
+
: '48;5';
|
|
319
|
+
const bgOn = `\x1b[${prefix};${code}m`;
|
|
320
|
+
// Apply bg, and if the string contains resets, re-apply the bg right after the reset.
|
|
321
|
+
// We add a final reset at the very end to clean up.
|
|
322
|
+
return `${bgOn + s.replace(/\x1b\[0m/g, `\x1b[0m${bgOn}`)}\x1b[0m`;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const bc = options.borderColor;
|
|
326
|
+
const colors =
|
|
327
|
+
typeof bc === 'object' && !('r' in bc)
|
|
328
|
+
? {
|
|
329
|
+
top: resolveSide(
|
|
330
|
+
(bc as SideColors).top ||
|
|
331
|
+
(bc as SideColors).left ||
|
|
332
|
+
(bc as SideColors).right,
|
|
333
|
+
),
|
|
334
|
+
bottom: resolveSide(
|
|
335
|
+
(bc as SideColors).bottom ||
|
|
336
|
+
(bc as SideColors).left ||
|
|
337
|
+
(bc as SideColors).right,
|
|
338
|
+
),
|
|
339
|
+
left: resolveSide((bc as SideColors).left || (bc as SideColors).top),
|
|
340
|
+
right: resolveSide(
|
|
341
|
+
(bc as SideColors).right || (bc as SideColors).top,
|
|
342
|
+
),
|
|
343
|
+
}
|
|
344
|
+
: {
|
|
345
|
+
top: resolveSide(bc || ((s: string) => s)),
|
|
346
|
+
bottom: resolveSide(bc || ((s: string) => s)),
|
|
347
|
+
left: resolveSide(bc || ((s: string) => s)),
|
|
348
|
+
right: resolveSide(bc || ((s: string) => s)),
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const hTop = b?.top || b?.h || ' ';
|
|
352
|
+
const hBottom = b?.bottom || b?.h || ' ';
|
|
353
|
+
const vLeft = b?.left || b?.v || ' ';
|
|
354
|
+
const vRight = b?.right || b?.v || ' ';
|
|
355
|
+
|
|
356
|
+
const vSpace = Math.max(0, finalInnerH - lines.length - py * 2);
|
|
357
|
+
let topS = 0,
|
|
358
|
+
bottomS = vSpace;
|
|
359
|
+
if (options.valign === 'middle') {
|
|
360
|
+
topS = Math.floor(vSpace / 2);
|
|
361
|
+
bottomS = Math.ceil(vSpace / 2);
|
|
362
|
+
} else if (options.valign === 'bottom') {
|
|
363
|
+
topS = vSpace;
|
|
364
|
+
bottomS = 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const out = [];
|
|
368
|
+
|
|
369
|
+
// Top Border
|
|
370
|
+
if (b) {
|
|
371
|
+
out.push(
|
|
372
|
+
wrapBg(
|
|
373
|
+
colors.top(b.tl) +
|
|
374
|
+
colors.top(hTop.repeat(targetInnerW)) +
|
|
375
|
+
colors.top(b.tr),
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Top Padding
|
|
381
|
+
for (let i = 0; i < py + topS; i++) {
|
|
382
|
+
let row = b ? colors.left(vLeft) : '';
|
|
383
|
+
row += wrapBg(' '.repeat(targetInnerW));
|
|
384
|
+
if (b) row += colors.right(vRight);
|
|
385
|
+
out.push(row);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Content Lines
|
|
389
|
+
for (let i = 0; i < lines.length; i++) {
|
|
390
|
+
const line = lines[i].trim();
|
|
391
|
+
const lineW = visibleWidth(line);
|
|
392
|
+
const extra = Math.max(0, targetInnerW - lineW - px * 2);
|
|
393
|
+
let left = 0,
|
|
394
|
+
right = 0;
|
|
395
|
+
const align = options.align || 'center';
|
|
396
|
+
|
|
397
|
+
if (align === 'center') {
|
|
398
|
+
left = px + Math.floor(extra / 2);
|
|
399
|
+
right = px + Math.ceil(extra / 2);
|
|
400
|
+
} else if (align === 'right') {
|
|
401
|
+
left = px + extra;
|
|
402
|
+
right = px;
|
|
403
|
+
} else {
|
|
404
|
+
left = px;
|
|
405
|
+
right = px + extra;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let row = b ? colors.left(vLeft) : '';
|
|
409
|
+
row += wrapBg(
|
|
410
|
+
' '.repeat(Math.max(0, left)) + line + ' '.repeat(Math.max(0, right)),
|
|
411
|
+
);
|
|
412
|
+
if (b) row += colors.right(vRight);
|
|
413
|
+
out.push(row);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Bottom Padding
|
|
417
|
+
for (let i = 0; i < py + bottomS; i++) {
|
|
418
|
+
let row = b ? colors.left(vLeft) : '';
|
|
419
|
+
row += wrapBg(' '.repeat(targetInnerW));
|
|
420
|
+
if (b) row += colors.right(vRight);
|
|
421
|
+
out.push(row);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Bottom Border
|
|
425
|
+
if (b) {
|
|
426
|
+
out.push(
|
|
427
|
+
wrapBg(
|
|
428
|
+
colors.bottom(b.bl) +
|
|
429
|
+
colors.bottom(hBottom.repeat(targetInnerW)) +
|
|
430
|
+
colors.bottom(b.br),
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return out.join('\n');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function joinHorizontal(...blocks: string[]): string {
|
|
439
|
+
const parsed = blocks.map((b) => b.split('\n'));
|
|
440
|
+
const maxH = Math.max(...parsed.map((p) => p.length));
|
|
441
|
+
const widths = parsed.map((p) => Math.max(...p.map(visibleWidth)));
|
|
442
|
+
|
|
443
|
+
const out = [];
|
|
444
|
+
for (let i = 0; i < maxH; i++) {
|
|
445
|
+
let row = '';
|
|
446
|
+
for (let j = 0; j < parsed.length; j++) {
|
|
447
|
+
const block = parsed[j]!;
|
|
448
|
+
const targetW = widths[j]!;
|
|
449
|
+
if (block[i] !== undefined) {
|
|
450
|
+
row +=
|
|
451
|
+
block[i] + ' '.repeat(Math.max(0, targetW - visibleWidth(block[i])));
|
|
452
|
+
} else {
|
|
453
|
+
row += ' '.repeat(targetW);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
out.push(row);
|
|
457
|
+
}
|
|
458
|
+
return out.join('\n');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function joinVertical(...blocks: string[]): string {
|
|
462
|
+
return blocks.filter(Boolean).join('\n');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function createStyle(defaults: StyleOptions) {
|
|
466
|
+
return (content: string, overrides: StyleOptions = {}) => {
|
|
467
|
+
return box(content, { ...defaults, ...overrides });
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function badge(
|
|
472
|
+
text: string,
|
|
473
|
+
colorFn: (s: string) => string = (s) => s,
|
|
474
|
+
): string {
|
|
475
|
+
return colorFn(` ${text.toUpperCase()} `);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export interface TableOptions {
|
|
479
|
+
width?: SizeUnit;
|
|
480
|
+
columns?: { width?: SizeUnit; align?: 'left' | 'center' | 'right' }[];
|
|
481
|
+
border?: BorderStyle;
|
|
482
|
+
padding?: [number, number];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Renders a data table with perfectly aligned columns and shared borders.
|
|
487
|
+
*/
|
|
488
|
+
export function table(
|
|
489
|
+
rows: string[][],
|
|
490
|
+
options: TableOptions = {},
|
|
491
|
+
parentW?: number,
|
|
492
|
+
): string {
|
|
493
|
+
const borderStyle = options.border || 'default';
|
|
494
|
+
const _px = options.padding?.[1] ?? 1;
|
|
495
|
+
const _py = options.padding?.[0] ?? 0;
|
|
496
|
+
|
|
497
|
+
const colCount = rows[0]?.length || 0;
|
|
498
|
+
if (colCount === 0) return '';
|
|
499
|
+
|
|
500
|
+
// 1. Resolve Column Widths
|
|
501
|
+
const resolvedWidth = resolveSize(options.width, parentW || 0, 80);
|
|
502
|
+
const gutterW = 1;
|
|
503
|
+
const colWidth = Math.floor(
|
|
504
|
+
(resolvedWidth - (colCount - 1) * gutterW) / colCount,
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// 2. Render each cell as a rigid block
|
|
508
|
+
const renderedRows = rows.map((row) => {
|
|
509
|
+
const cells = row.map((content, i) => {
|
|
510
|
+
// Explicitly pad empty content to ensure it occupies the full column width
|
|
511
|
+
const safeContent = content || ' '.repeat(colWidth);
|
|
512
|
+
const cellAlign = options.columns?.[i]?.align
|
|
513
|
+
? options.columns[i].align
|
|
514
|
+
: 'left';
|
|
515
|
+
|
|
516
|
+
return box(
|
|
517
|
+
safeContent,
|
|
518
|
+
{
|
|
519
|
+
width: colWidth,
|
|
520
|
+
border: 'none',
|
|
521
|
+
// Strict internal zero-padding; let the gutter handle spacing
|
|
522
|
+
padding: [0, 0],
|
|
523
|
+
align: cellAlign as 'left' | 'center' | 'right',
|
|
524
|
+
},
|
|
525
|
+
colWidth,
|
|
526
|
+
0,
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
// Join with gutter
|
|
530
|
+
return joinHorizontal(
|
|
531
|
+
...cells.flatMap((c, i) =>
|
|
532
|
+
i < cells.length - 1 ? [c, ' '.repeat(gutterW)] : [c],
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
return box(joinVertical(...renderedRows), {
|
|
538
|
+
border: borderStyle,
|
|
539
|
+
padding: [0, 0],
|
|
540
|
+
width: resolvedWidth,
|
|
541
|
+
align: 'left',
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function getWindow<T>(
|
|
546
|
+
items: T[],
|
|
547
|
+
selectedIndex: number,
|
|
548
|
+
maxVisible: number,
|
|
549
|
+
) {
|
|
550
|
+
const start = Math.max(
|
|
551
|
+
0,
|
|
552
|
+
Math.min(
|
|
553
|
+
selectedIndex - Math.floor(maxVisible / 2),
|
|
554
|
+
items.length - maxVisible,
|
|
555
|
+
),
|
|
556
|
+
);
|
|
557
|
+
const visible = items.slice(start, start + maxVisible);
|
|
558
|
+
return {
|
|
559
|
+
visible,
|
|
560
|
+
start,
|
|
561
|
+
hasMoreAbove: start > 0,
|
|
562
|
+
hasMoreBelow: start + maxVisible < items.length,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface ListOptions {
|
|
567
|
+
bullet?: string;
|
|
568
|
+
indent?: number;
|
|
569
|
+
focusedIndex?: number;
|
|
570
|
+
focusStyle?: (s: string) => string;
|
|
571
|
+
maxVisible?: number;
|
|
572
|
+
interactive?: boolean;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function list(items: string[], options: ListOptions = {}): string {
|
|
576
|
+
const bullet = options.bullet || '';
|
|
577
|
+
const indent = ' '.repeat(options.indent || 0);
|
|
578
|
+
let targetItems = items,
|
|
579
|
+
offset = 0,
|
|
580
|
+
hasMoreAbove = false,
|
|
581
|
+
hasMoreBelow = false;
|
|
582
|
+
if (options.maxVisible && items.length > options.maxVisible) {
|
|
583
|
+
const win = getWindow(items, options.focusedIndex || 0, options.maxVisible);
|
|
584
|
+
targetItems = win.visible;
|
|
585
|
+
offset = win.start;
|
|
586
|
+
hasMoreAbove = win.hasMoreAbove;
|
|
587
|
+
hasMoreBelow = win.hasMoreBelow;
|
|
588
|
+
}
|
|
589
|
+
const rendered = targetItems
|
|
590
|
+
.map((item, idx) => {
|
|
591
|
+
const actualIdx = offset + idx;
|
|
592
|
+
const line = `${indent}${bullet}${item}`;
|
|
593
|
+
if (options.focusedIndex === actualIdx && options.focusStyle)
|
|
594
|
+
return options.focusStyle(line);
|
|
595
|
+
return line;
|
|
596
|
+
})
|
|
597
|
+
.join('\n');
|
|
598
|
+
let out = rendered;
|
|
599
|
+
if (hasMoreAbove) out = `${indent} ↑ more…\n${out}`;
|
|
600
|
+
if (hasMoreBelow) out = `${out}\n${indent} ↓ more…`;
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function viewport(
|
|
605
|
+
content: string,
|
|
606
|
+
width: number,
|
|
607
|
+
height: number,
|
|
608
|
+
scrollY: number = 0,
|
|
609
|
+
): string {
|
|
610
|
+
const lines = content.split('\n');
|
|
611
|
+
const visibleLines = lines.slice(scrollY, scrollY + height);
|
|
612
|
+
|
|
613
|
+
return visibleLines
|
|
614
|
+
.map((line) => {
|
|
615
|
+
return truncate(line, width, '');
|
|
616
|
+
})
|
|
617
|
+
.join('\n');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function wallpaper(state: any, options: { bg: any }) {
|
|
621
|
+
const { bg } = options;
|
|
622
|
+
rect(state, 0, 0, state.width, state.height, { char: ' ', bg });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function gradient(
|
|
626
|
+
state: any,
|
|
627
|
+
colors: any[],
|
|
628
|
+
options: { direction?: 'vertical' | 'horizontal' } = {},
|
|
629
|
+
) {
|
|
630
|
+
const { direction = 'vertical' } = options;
|
|
631
|
+
rect(state, 0, 0, state.width, state.height, {
|
|
632
|
+
char: ' ',
|
|
633
|
+
bg: {
|
|
634
|
+
colors,
|
|
635
|
+
direction,
|
|
636
|
+
steps: direction === 'vertical' ? state.height : state.width,
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
}
|