@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/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
+ }