@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/dsl.ts
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunti High-Level Contextual DSL
|
|
3
|
+
* Scoped closure API with contextual capabilities via trait-based composition.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
import {
|
|
8
|
+
bg,
|
|
9
|
+
createGradient,
|
|
10
|
+
darken,
|
|
11
|
+
fg,
|
|
12
|
+
type Gradient,
|
|
13
|
+
lighten,
|
|
14
|
+
rgb,
|
|
15
|
+
} from './colors';
|
|
16
|
+
import { icon, init, replaceEmojis } from './icons';
|
|
17
|
+
import {
|
|
18
|
+
joinHorizontal,
|
|
19
|
+
joinVertical,
|
|
20
|
+
type ListOptions,
|
|
21
|
+
blit as layoutBlit,
|
|
22
|
+
box as layoutBox,
|
|
23
|
+
gradient as layoutGradient,
|
|
24
|
+
list as layoutList,
|
|
25
|
+
table as layoutTable,
|
|
26
|
+
viewport as layoutViewport,
|
|
27
|
+
wallpaper as layoutWallpaper,
|
|
28
|
+
rect,
|
|
29
|
+
resolveSize,
|
|
30
|
+
type SideColors,
|
|
31
|
+
type StyleOptions,
|
|
32
|
+
type TableOptions,
|
|
33
|
+
} from './layout';
|
|
34
|
+
import { flush, loop } from './render';
|
|
35
|
+
import {
|
|
36
|
+
type Cell,
|
|
37
|
+
clearBackBuffer,
|
|
38
|
+
createScreenState,
|
|
39
|
+
type RGB,
|
|
40
|
+
type ScreenOptions,
|
|
41
|
+
type ScreenState,
|
|
42
|
+
} from './state';
|
|
43
|
+
import { visibleWidth } from './utils';
|
|
44
|
+
|
|
45
|
+
// --- Traits (Contextual Capabilities) ---
|
|
46
|
+
|
|
47
|
+
export const KEYS = {
|
|
48
|
+
UP: '\x1b[A',
|
|
49
|
+
DOWN: '\x1b[B',
|
|
50
|
+
RIGHT: '\x1b[C',
|
|
51
|
+
LEFT: '\x1b[D',
|
|
52
|
+
ENTER: '\r',
|
|
53
|
+
ESCAPE: '\x1b',
|
|
54
|
+
TAB: '\t',
|
|
55
|
+
BACKSPACE: '\x7f',
|
|
56
|
+
SPACE: ' ',
|
|
57
|
+
};
|
|
58
|
+
export interface DSLBoxOptions extends StyleOptions {
|
|
59
|
+
bgColor?: string | number | RGB | Gradient;
|
|
60
|
+
color?: string | number | RGB | 'blank';
|
|
61
|
+
x?: number;
|
|
62
|
+
y?: number;
|
|
63
|
+
anchor?: 'top' | 'bottom';
|
|
64
|
+
size?: 'auto' | number;
|
|
65
|
+
title?: string;
|
|
66
|
+
titleStyle?: (s: string) => string;
|
|
67
|
+
id?: string;
|
|
68
|
+
borderColor?: string | number | RGB | ((s: string) => string) | SideColors;
|
|
69
|
+
detach?: boolean; // If true, the string is returned but NOT appended to the flow
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The interface for the contextual builder provided to closures.
|
|
74
|
+
*/
|
|
75
|
+
export interface BuntiContext {
|
|
76
|
+
color: typeof pc & {
|
|
77
|
+
darken: typeof darken;
|
|
78
|
+
lighten: typeof lighten;
|
|
79
|
+
rgb: typeof rgb;
|
|
80
|
+
fg: typeof fg;
|
|
81
|
+
bg: typeof bg;
|
|
82
|
+
};
|
|
83
|
+
state: ScreenState;
|
|
84
|
+
width: number;
|
|
85
|
+
height: number;
|
|
86
|
+
offsetX: number;
|
|
87
|
+
offsetY: number;
|
|
88
|
+
readonly cursorX: number;
|
|
89
|
+
readonly cursorY: number;
|
|
90
|
+
mouseX: number;
|
|
91
|
+
mouseY: number;
|
|
92
|
+
mouseButton: number;
|
|
93
|
+
isMouseDown: boolean;
|
|
94
|
+
lastKey?: string;
|
|
95
|
+
focusedId?: string;
|
|
96
|
+
elapsedTime: number;
|
|
97
|
+
|
|
98
|
+
text(str: string | number): BuntiContext;
|
|
99
|
+
icon(name: string): string; // Pure string return for template literal safety
|
|
100
|
+
blit(
|
|
101
|
+
x: number,
|
|
102
|
+
y: number,
|
|
103
|
+
content: string,
|
|
104
|
+
style?: Partial<Cell>,
|
|
105
|
+
): BuntiContext;
|
|
106
|
+
rect(
|
|
107
|
+
x: number,
|
|
108
|
+
y: number,
|
|
109
|
+
w: number,
|
|
110
|
+
h: number,
|
|
111
|
+
style: Partial<Cell>,
|
|
112
|
+
): BuntiContext;
|
|
113
|
+
viewport(
|
|
114
|
+
content: string,
|
|
115
|
+
width: number,
|
|
116
|
+
height: number,
|
|
117
|
+
scrollY?: number,
|
|
118
|
+
): string;
|
|
119
|
+
span(
|
|
120
|
+
options: { color?: string | number | RGB | ((s: string) => string) },
|
|
121
|
+
callback: (sub: BuntiContext) => void,
|
|
122
|
+
): string;
|
|
123
|
+
box(options: DSLBoxOptions, callback: (sub: BuntiContext) => void): string;
|
|
124
|
+
joinHorizontal(...blocks: string[]): string;
|
|
125
|
+
joinVertical(...blocks: string[]): string;
|
|
126
|
+
wallpaper(
|
|
127
|
+
input: string | number | RGB | RGB[] | Gradient | { color: any },
|
|
128
|
+
): void;
|
|
129
|
+
gradient(options: {
|
|
130
|
+
colors: (string | number | RGB)[];
|
|
131
|
+
direction?: 'vertical' | 'horizontal';
|
|
132
|
+
steps?: number;
|
|
133
|
+
}): Gradient;
|
|
134
|
+
rgb(r: number, g: number, b: number): RGB;
|
|
135
|
+
|
|
136
|
+
// State & Focus
|
|
137
|
+
useState<T>(key: string, initial: T): [T, (val: T) => void];
|
|
138
|
+
focusable(id: string): boolean;
|
|
139
|
+
isFocused(id: string): boolean;
|
|
140
|
+
focus(id: string): void;
|
|
141
|
+
focusNext(): void;
|
|
142
|
+
|
|
143
|
+
list(id: string, items: string[], options?: ListOptions): BuntiContext;
|
|
144
|
+
table(rows: string[][], options?: TableOptions): BuntiContext;
|
|
145
|
+
|
|
146
|
+
// Animation
|
|
147
|
+
animate(
|
|
148
|
+
duration: number,
|
|
149
|
+
options?: { loop?: boolean; delay?: number; id?: string },
|
|
150
|
+
): number;
|
|
151
|
+
flicker(intensity?: number): boolean;
|
|
152
|
+
|
|
153
|
+
// Async data
|
|
154
|
+
useAsync<T>(
|
|
155
|
+
key: string,
|
|
156
|
+
fetcher: () => Promise<T>,
|
|
157
|
+
options?: { interval?: number },
|
|
158
|
+
): {
|
|
159
|
+
data: T | undefined;
|
|
160
|
+
loading: boolean;
|
|
161
|
+
error: Error | undefined;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
requestStop(): void;
|
|
165
|
+
flushFlow(): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The DSL state container allowing stable references with dynamic capture targets.
|
|
170
|
+
*/
|
|
171
|
+
interface DSLState {
|
|
172
|
+
activeContents: string[];
|
|
173
|
+
stack: string[][];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Common Context Factory: Provided to every closure.
|
|
178
|
+
*/
|
|
179
|
+
function createDSLContext(
|
|
180
|
+
state: ScreenState,
|
|
181
|
+
dslState: DSLState,
|
|
182
|
+
availableW: number,
|
|
183
|
+
availableH: number,
|
|
184
|
+
offsetX: number = 0,
|
|
185
|
+
offsetY: number = 0,
|
|
186
|
+
): BuntiContext {
|
|
187
|
+
const ctx: BuntiContext = {
|
|
188
|
+
color: { ...pc, darken, lighten, rgb, fg, bg } as any,
|
|
189
|
+
state,
|
|
190
|
+
width: availableW,
|
|
191
|
+
height: availableH,
|
|
192
|
+
offsetX,
|
|
193
|
+
offsetY,
|
|
194
|
+
get cursorX() {
|
|
195
|
+
const currentFlow = dslState.activeContents.join('');
|
|
196
|
+
const lines = currentFlow.split('\n');
|
|
197
|
+
return visibleWidth(lines[lines.length - 1]);
|
|
198
|
+
},
|
|
199
|
+
get cursorY() {
|
|
200
|
+
const currentFlow = dslState.activeContents.join('');
|
|
201
|
+
return Math.max(0, currentFlow.split('\n').length - 1);
|
|
202
|
+
},
|
|
203
|
+
mouseX: state.mouseX,
|
|
204
|
+
mouseY: state.mouseY,
|
|
205
|
+
mouseButton: state.mouseButton,
|
|
206
|
+
isMouseDown: state.isMouseDown,
|
|
207
|
+
lastKey: state.lastKey,
|
|
208
|
+
focusedId: state.focusedId,
|
|
209
|
+
elapsedTime: Date.now() - state.startTime,
|
|
210
|
+
|
|
211
|
+
text(str: string | number) {
|
|
212
|
+
dslState.activeContents.push(replaceEmojis(String(str)));
|
|
213
|
+
return ctx;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
animate(
|
|
217
|
+
duration: number,
|
|
218
|
+
options: { loop?: boolean; delay?: number; id?: string } = {},
|
|
219
|
+
) {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const start = options.id
|
|
222
|
+
? this.useState(`${options.id}_start`, now)[0]
|
|
223
|
+
: state.startTime;
|
|
224
|
+
const elapsed = now - start - (options.delay || 0);
|
|
225
|
+
if (elapsed < 0) return 0;
|
|
226
|
+
if (options.loop) return (elapsed % duration) / duration;
|
|
227
|
+
return Math.min(1, elapsed / duration);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
flicker(intensity: number = 0.5) {
|
|
231
|
+
return Math.random() > 1 - intensity;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
useAsync<T>(
|
|
235
|
+
key: string,
|
|
236
|
+
fetcher: () => Promise<T>,
|
|
237
|
+
options: { interval?: number } = {},
|
|
238
|
+
) {
|
|
239
|
+
const interval = options.interval ?? 0;
|
|
240
|
+
const dataKey = `${key}_data`;
|
|
241
|
+
const loadingKey = `${key}_loading`;
|
|
242
|
+
const errorKey = `${key}_error`;
|
|
243
|
+
const lastFetchKey = `${key}_lastFetch`;
|
|
244
|
+
const fetchingKey = `${key}_fetching`;
|
|
245
|
+
|
|
246
|
+
if (!state.componentState.has(loadingKey)) {
|
|
247
|
+
state.componentState.set(loadingKey, true);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lastFetch = state.componentState.get(lastFetchKey) as
|
|
251
|
+
| number
|
|
252
|
+
| undefined;
|
|
253
|
+
const isFetching = state.componentState.get(fetchingKey) as boolean;
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const shouldFetch =
|
|
256
|
+
!isFetching &&
|
|
257
|
+
(lastFetch === undefined ||
|
|
258
|
+
(interval > 0 && now - lastFetch >= interval));
|
|
259
|
+
|
|
260
|
+
if (shouldFetch) {
|
|
261
|
+
state.componentState.set(fetchingKey, true);
|
|
262
|
+
state.componentState.set(lastFetchKey, now);
|
|
263
|
+
fetcher()
|
|
264
|
+
.then((result) => {
|
|
265
|
+
state.componentState.set(dataKey, result);
|
|
266
|
+
state.componentState.set(loadingKey, false);
|
|
267
|
+
state.componentState.set(errorKey, undefined);
|
|
268
|
+
})
|
|
269
|
+
.catch((err: Error) => {
|
|
270
|
+
state.componentState.set(errorKey, err);
|
|
271
|
+
state.componentState.set(loadingKey, false);
|
|
272
|
+
})
|
|
273
|
+
.finally(() => {
|
|
274
|
+
state.componentState.set(fetchingKey, false);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
data: state.componentState.get(dataKey) as T | undefined,
|
|
280
|
+
loading: state.componentState.get(loadingKey) as boolean,
|
|
281
|
+
error: state.componentState.get(errorKey) as Error | undefined,
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
useState<T>(key: string, initial: T): [T, (val: T) => void] {
|
|
286
|
+
if (!state.componentState.has(key)) {
|
|
287
|
+
state.componentState.set(key, initial);
|
|
288
|
+
}
|
|
289
|
+
return [
|
|
290
|
+
state.componentState.get(key),
|
|
291
|
+
(val: T) => state.componentState.set(key, val),
|
|
292
|
+
];
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
focusable(id: string) {
|
|
296
|
+
if (!state.focusableIds.includes(id)) {
|
|
297
|
+
state.focusableIds.push(id);
|
|
298
|
+
}
|
|
299
|
+
if (!state.focusedId) state.focusedId = id;
|
|
300
|
+
return state.focusedId === id;
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
isFocused(id: string) {
|
|
304
|
+
return state.focusedId === id;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
focus(id: string) {
|
|
308
|
+
state.focusedId = id;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
focusNext() {
|
|
312
|
+
if (state.focusableIds.length === 0) return;
|
|
313
|
+
const idx = state.focusableIds.indexOf(state.focusedId || '');
|
|
314
|
+
const nextIdx = (idx + 1) % state.focusableIds.length;
|
|
315
|
+
state.focusedId = state.focusableIds[nextIdx];
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
list(id: string, items: string[], options: ListOptions = {}) {
|
|
319
|
+
const [selectedIndex, setSelectedIndex] = this.useState(`${id}_index`, 0);
|
|
320
|
+
const isFocused = this.focusable(id);
|
|
321
|
+
|
|
322
|
+
if (isFocused && options.interactive !== false) {
|
|
323
|
+
if (state.lastKey === KEYS.UP)
|
|
324
|
+
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
|
325
|
+
if (state.lastKey === KEYS.DOWN)
|
|
326
|
+
setSelectedIndex(Math.min(items.length - 1, selectedIndex + 1));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const content = layoutList(items, {
|
|
330
|
+
...options,
|
|
331
|
+
focusedIndex: selectedIndex,
|
|
332
|
+
focusStyle: isFocused ? options.focusStyle : (s) => pc.dim(s),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
dslState.activeContents.push(content);
|
|
336
|
+
return ctx;
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
table(rows: string[][], options: TableOptions = {}) {
|
|
340
|
+
const content = layoutTable(rows, options, availableW);
|
|
341
|
+
dslState.activeContents.push(content);
|
|
342
|
+
return ctx;
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
icon(name: string) {
|
|
346
|
+
return icon(name);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
blit(x: number, y: number, content: string, style: Partial<Cell> = {}) {
|
|
350
|
+
layoutBlit(state, x, y, content, style);
|
|
351
|
+
return ctx;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
rect(x: number, y: number, w: number, h: number, style: Partial<Cell>) {
|
|
355
|
+
rect(state, x, y, w, h, style);
|
|
356
|
+
return ctx;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
viewport(
|
|
360
|
+
content: string,
|
|
361
|
+
width: number,
|
|
362
|
+
height: number,
|
|
363
|
+
scrollY: number = 0,
|
|
364
|
+
) {
|
|
365
|
+
return layoutViewport(content, width, height, scrollY);
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
span(options: { color?: any }, callback: (sub: BuntiContext) => void) {
|
|
369
|
+
const subContents: string[] = [];
|
|
370
|
+
dslState.stack.push(dslState.activeContents);
|
|
371
|
+
dslState.activeContents = subContents;
|
|
372
|
+
|
|
373
|
+
callback(ctx);
|
|
374
|
+
|
|
375
|
+
dslState.activeContents = dslState.stack.pop()!;
|
|
376
|
+
const combined = subContents.join('');
|
|
377
|
+
|
|
378
|
+
let styled = combined;
|
|
379
|
+
if (typeof options.color === 'function') {
|
|
380
|
+
styled = options.color(combined);
|
|
381
|
+
} else if (options.color !== undefined) {
|
|
382
|
+
styled = fg(options.color, combined);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
dslState.activeContents.push(styled);
|
|
386
|
+
return styled;
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
box(options: DSLBoxOptions, callback: (sub: BuntiContext) => void) {
|
|
390
|
+
const borderOffset = options.border === 'none' || !options.border ? 0 : 2;
|
|
391
|
+
const px = options.padding?.[1] ?? 0;
|
|
392
|
+
const py = options.padding?.[0] ?? 0;
|
|
393
|
+
|
|
394
|
+
// Measure parent-relative dimensions
|
|
395
|
+
const resolvedW = resolveSize(options.width, availableW, 0);
|
|
396
|
+
const innerW = resolvedW
|
|
397
|
+
? Math.max(0, resolvedW - borderOffset - px * 2)
|
|
398
|
+
: availableW;
|
|
399
|
+
|
|
400
|
+
const resolvedH = resolveSize(options.height, availableH, 0);
|
|
401
|
+
const innerH = resolvedH
|
|
402
|
+
? Math.max(0, resolvedH - borderOffset - py * 2)
|
|
403
|
+
: availableH;
|
|
404
|
+
|
|
405
|
+
const subContents: string[] = [];
|
|
406
|
+
dslState.stack.push(dslState.activeContents);
|
|
407
|
+
dslState.activeContents = subContents;
|
|
408
|
+
|
|
409
|
+
const boxW = resolvedW || availableW;
|
|
410
|
+
const boxH = resolvedH || availableH;
|
|
411
|
+
|
|
412
|
+
let absX = offsetX;
|
|
413
|
+
let absY = offsetY;
|
|
414
|
+
|
|
415
|
+
if (options.x !== undefined) {
|
|
416
|
+
absX += options.x;
|
|
417
|
+
} else {
|
|
418
|
+
absX += Math.max(0, Math.floor((availableW - boxW) / 2));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (options.y !== undefined) {
|
|
422
|
+
absY += options.y;
|
|
423
|
+
} else if (options.anchor === 'top') {
|
|
424
|
+
absY = offsetY;
|
|
425
|
+
} else if (options.anchor === 'bottom') {
|
|
426
|
+
absY = offsetY + availableH - boxH;
|
|
427
|
+
} else {
|
|
428
|
+
absY += Math.max(0, Math.floor((availableH - boxH) / 2));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const subCtx = createDSLContext(
|
|
432
|
+
state,
|
|
433
|
+
dslState,
|
|
434
|
+
innerW,
|
|
435
|
+
innerH,
|
|
436
|
+
absX + borderOffset / 2 + px,
|
|
437
|
+
absY + borderOffset / 2 + py,
|
|
438
|
+
);
|
|
439
|
+
callback(subCtx);
|
|
440
|
+
|
|
441
|
+
dslState.activeContents = dslState.stack.pop()!;
|
|
442
|
+
|
|
443
|
+
const innerContent = subContents.join('');
|
|
444
|
+
const styledBox = layoutBox(
|
|
445
|
+
innerContent,
|
|
446
|
+
options,
|
|
447
|
+
availableW,
|
|
448
|
+
availableH,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
if (!options.detach) {
|
|
452
|
+
dslState.activeContents.push(styledBox);
|
|
453
|
+
}
|
|
454
|
+
return styledBox;
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
joinHorizontal(...blocks: string[]) {
|
|
458
|
+
return joinHorizontal(...blocks);
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
joinVertical(...blocks: string[]) {
|
|
462
|
+
return joinVertical(...blocks);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
wallpaper(input: any) {
|
|
466
|
+
if (typeof input === 'object' && 'colors' in input) {
|
|
467
|
+
layoutGradient(state, input.colors, { direction: input.direction });
|
|
468
|
+
} else if (Array.isArray(input)) {
|
|
469
|
+
layoutGradient(state, input);
|
|
470
|
+
} else if (typeof input === 'object' && 'color' in input) {
|
|
471
|
+
this.wallpaper(input.color);
|
|
472
|
+
} else {
|
|
473
|
+
layoutWallpaper(state, { bg: input });
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
gradient: (opts: {
|
|
478
|
+
colors: (string | number | RGB)[];
|
|
479
|
+
direction?: 'vertical' | 'horizontal';
|
|
480
|
+
steps?: number;
|
|
481
|
+
}) => ({
|
|
482
|
+
colors: createGradient(opts.colors, opts.steps || 10),
|
|
483
|
+
direction: opts.direction || 'vertical',
|
|
484
|
+
steps: opts.steps || 10,
|
|
485
|
+
}),
|
|
486
|
+
|
|
487
|
+
rgb,
|
|
488
|
+
|
|
489
|
+
requestStop() {
|
|
490
|
+
state.requestStop?.();
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
flushFlow() {},
|
|
494
|
+
};
|
|
495
|
+
return ctx;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Top-level Screen Context
|
|
500
|
+
*/
|
|
501
|
+
export function createScreenContext(state: ScreenState): BuntiContext {
|
|
502
|
+
state.focusableIds = []; // Clear for this frame
|
|
503
|
+
|
|
504
|
+
const dslState: DSLState = {
|
|
505
|
+
activeContents: [],
|
|
506
|
+
stack: [],
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const base = createDSLContext(state, dslState, state.width, state.height);
|
|
510
|
+
|
|
511
|
+
const flushFlow = () => {
|
|
512
|
+
const flow = dslState.activeContents.join('');
|
|
513
|
+
if (flow) layoutBlit(state, 0, 0, flow);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
if (state.lastKey === KEYS.TAB) {
|
|
517
|
+
base.focusNext();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Override box for top-level to handle auto-centering and direct-to-buffer rendering
|
|
521
|
+
const boxOverride = (
|
|
522
|
+
options: DSLBoxOptions,
|
|
523
|
+
callback: (ctx: BuntiContext) => void,
|
|
524
|
+
) => {
|
|
525
|
+
// 1. Resolve Anchor dimensions
|
|
526
|
+
if (options.anchor === 'top') {
|
|
527
|
+
options.x = 0;
|
|
528
|
+
options.y = 0;
|
|
529
|
+
options.width = state.width;
|
|
530
|
+
} else if (options.anchor === 'bottom') {
|
|
531
|
+
options.x = 0;
|
|
532
|
+
options.width = state.width;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const borderOffset = options.border === 'none' || !options.border ? 0 : 2;
|
|
536
|
+
const px = options.padding?.[1] ?? 0;
|
|
537
|
+
const py = options.padding?.[0] ?? 0;
|
|
538
|
+
|
|
539
|
+
// 2. Resolve dimensions (top-level uses screen width)
|
|
540
|
+
const resolvedW = resolveSize(options.width, state.width, 0);
|
|
541
|
+
const innerW = resolvedW
|
|
542
|
+
? Math.max(0, resolvedW - borderOffset - px * 2)
|
|
543
|
+
: state.width;
|
|
544
|
+
const resolvedH = resolveSize(options.height, state.height, 0);
|
|
545
|
+
const innerH = resolvedH
|
|
546
|
+
? Math.max(0, resolvedH - borderOffset - py * 2)
|
|
547
|
+
: state.height;
|
|
548
|
+
|
|
549
|
+
const subContents: string[] = [];
|
|
550
|
+
dslState.stack.push(dslState.activeContents);
|
|
551
|
+
dslState.activeContents = subContents;
|
|
552
|
+
|
|
553
|
+
let x = options.x !== undefined ? options.x : 0; // Temp assignment for offset
|
|
554
|
+
let y = options.y !== undefined ? options.y : 0;
|
|
555
|
+
if (options.anchor === 'top') {
|
|
556
|
+
y = 0;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const subCtx = createDSLContext(
|
|
560
|
+
state,
|
|
561
|
+
dslState,
|
|
562
|
+
innerW,
|
|
563
|
+
innerH,
|
|
564
|
+
x + borderOffset / 2 + px,
|
|
565
|
+
y + borderOffset / 2 + py,
|
|
566
|
+
);
|
|
567
|
+
callback(subCtx);
|
|
568
|
+
|
|
569
|
+
dslState.activeContents = dslState.stack.pop()!;
|
|
570
|
+
|
|
571
|
+
const contentStr = subContents.join('');
|
|
572
|
+
const styledBox = layoutBox(contentStr, options, state.width, state.height);
|
|
573
|
+
|
|
574
|
+
const lines = styledBox.split('\n');
|
|
575
|
+
const lineWidths = lines.map(visibleWidth);
|
|
576
|
+
const boxW = resolveSize(
|
|
577
|
+
options.width,
|
|
578
|
+
state.width,
|
|
579
|
+
lineWidths.length > 0 ? Math.max(...lineWidths) : 0,
|
|
580
|
+
);
|
|
581
|
+
const boxH = resolveSize(options.height, state.height, lines.length);
|
|
582
|
+
|
|
583
|
+
x =
|
|
584
|
+
options.x !== undefined
|
|
585
|
+
? options.x
|
|
586
|
+
: Math.max(0, Math.floor((state.width - boxW) / 2));
|
|
587
|
+
y =
|
|
588
|
+
options.y !== undefined
|
|
589
|
+
? options.y
|
|
590
|
+
: Math.max(0, Math.floor((state.height - boxH) / 2));
|
|
591
|
+
|
|
592
|
+
if (options.anchor === 'top') {
|
|
593
|
+
y = 0;
|
|
594
|
+
} else if (options.anchor === 'bottom') {
|
|
595
|
+
y = state.height - boxH;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (options.bgColor || options.color) {
|
|
599
|
+
rect(state, x, y, boxW, boxH, {
|
|
600
|
+
char: ' ',
|
|
601
|
+
bg: options.bgColor,
|
|
602
|
+
fg: options.color === 'blank' ? undefined : options.color,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
layoutBlit(state, x, y, styledBox);
|
|
607
|
+
return base;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
...base,
|
|
612
|
+
box: boxOverride as any,
|
|
613
|
+
flushFlow,
|
|
614
|
+
requestStop: () => {
|
|
615
|
+
state.requestStop?.();
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Primary Entry Point
|
|
622
|
+
*/
|
|
623
|
+
export async function render(
|
|
624
|
+
callback: ((b: BuntiContext) => void) | string,
|
|
625
|
+
options: ScreenOptions & { once?: boolean } = {},
|
|
626
|
+
) {
|
|
627
|
+
// Sync apply forced options first
|
|
628
|
+
if (options.nerdFont !== undefined) {
|
|
629
|
+
await init({ nerdFont: options.nerdFont });
|
|
630
|
+
} else {
|
|
631
|
+
// Start detection in background, don't await!
|
|
632
|
+
init();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const state = createScreenState(options);
|
|
636
|
+
|
|
637
|
+
const tick = () => {
|
|
638
|
+
clearBackBuffer(state);
|
|
639
|
+
const b = createScreenContext(state);
|
|
640
|
+
if (typeof callback === 'string') {
|
|
641
|
+
b.blit(0, 0, callback);
|
|
642
|
+
} else {
|
|
643
|
+
callback(b);
|
|
644
|
+
}
|
|
645
|
+
b.flushFlow();
|
|
646
|
+
flush(state);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
if (options.once) {
|
|
650
|
+
tick();
|
|
651
|
+
await new Promise<void>((resolve) => {
|
|
652
|
+
setTimeout(() => {
|
|
653
|
+
resolve();
|
|
654
|
+
process.exit(0);
|
|
655
|
+
}, 50);
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
await loop(state, (_s) => tick());
|
|
661
|
+
}
|