@cel-tui/core 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/cel.ts ADDED
@@ -0,0 +1,716 @@
1
+ import type { Node } from "@cel-tui/types";
2
+ import { CellBuffer } from "./cell-buffer.js";
3
+ import { emitBuffer, emitDiff } from "./emitter.js";
4
+ import {
5
+ hitTest,
6
+ findClickHandler,
7
+ findScrollTarget,
8
+ findKeyPressHandler,
9
+ collectFocusable,
10
+ } from "./hit-test.js";
11
+ import { parseKey, isEditingKey, normalizeKey } from "./keys.js";
12
+ import { layout, type LayoutNode } from "./layout.js";
13
+ import {
14
+ paint,
15
+ getTextInputCursor,
16
+ setTextInputCursor,
17
+ getTextInputScroll,
18
+ setTextInputScroll,
19
+ getContainerScroll,
20
+ setContainerScroll,
21
+ } from "./paint.js";
22
+ import {
23
+ insertChar,
24
+ deleteBackward,
25
+ deleteForward,
26
+ moveCursor,
27
+ type EditState,
28
+ } from "./text-edit.js";
29
+ import type { Terminal } from "./terminal.js";
30
+ import { visibleWidth } from "./width.js";
31
+
32
+ type RenderFn = () => Node | Node[];
33
+
34
+ let terminal: Terminal | null = null;
35
+ let renderFn: RenderFn | null = null;
36
+ let renderScheduled = false;
37
+ let prevBuffer: CellBuffer | null = null;
38
+ let currentBuffer: CellBuffer | null = null;
39
+ let currentLayouts: LayoutNode[] = [];
40
+ let lastFocusedIndex = -1;
41
+
42
+ /**
43
+ * Framework-tracked focus index for uncontrolled focus.
44
+ * Points into the `collectFocusable()` list of the topmost layer.
45
+ * -1 means no uncontrolled element is focused.
46
+ */
47
+ let frameworkFocusIndex = -1;
48
+
49
+ /** The node whose props were stamped with `focused: true` during the last paint. */
50
+ let stampedNode: LayoutNode | null = null;
51
+
52
+ function doRender(): void {
53
+ renderScheduled = false;
54
+ if (!renderFn || !terminal) return;
55
+
56
+ const width = terminal.columns;
57
+ const height = terminal.rows;
58
+
59
+ // Create or resize buffer
60
+ const isResize =
61
+ currentBuffer !== null &&
62
+ (currentBuffer.width !== width || currentBuffer.height !== height);
63
+ const isFirstRender = currentBuffer === null;
64
+
65
+ if (isFirstRender || isResize) {
66
+ prevBuffer = null;
67
+ currentBuffer = new CellBuffer(width, height);
68
+ } else {
69
+ prevBuffer = currentBuffer;
70
+ currentBuffer = new CellBuffer(width, height);
71
+ }
72
+
73
+ // Get the tree from the render function
74
+ const tree = renderFn();
75
+ const layers = Array.isArray(tree) ? tree : [tree];
76
+
77
+ // Layout each layer
78
+ currentLayouts = [];
79
+ for (const layer of layers) {
80
+ const layoutTree = layout(layer, width, height);
81
+ currentLayouts.push(layoutTree);
82
+ }
83
+
84
+ // Stamp uncontrolled focus before painting so focusStyle and cursor work
85
+ stampUncontrolledFocus();
86
+
87
+ // Paint each layer into the buffer
88
+ for (const layoutTree of currentLayouts) {
89
+ paint(layoutTree, currentBuffer);
90
+ }
91
+
92
+ // Unstamp after paint so input handlers see clean props
93
+ unstampUncontrolledFocus();
94
+
95
+ // Emit to terminal — differential when possible
96
+ if (prevBuffer) {
97
+ const output = emitDiff(prevBuffer, currentBuffer);
98
+ if (output.length > 0) terminal.write(output);
99
+ } else {
100
+ const output = emitBuffer(currentBuffer);
101
+ terminal.write(output);
102
+ }
103
+ }
104
+
105
+ // --- Input handling ---
106
+
107
+ // Regex for a single SGR mouse event (non-anchored, for scanning batched input)
108
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
109
+
110
+ // Tracks accumulated scroll offsets during a batch of mouse events.
111
+ // Controlled scroll reads scrollOffset from props, which doesn't update until
112
+ // re-render. Within a single data chunk containing multiple scroll events, we
113
+ // need to remember the offset we already dispatched via onScroll.
114
+ let batchScrollOffsets: Map<object, number> | null = null;
115
+
116
+ function handleInput(data: string): void {
117
+ // Terminals may batch multiple mouse events into one data chunk.
118
+ // Scan for all SGR mouse sequences and handle each one.
119
+ SGR_MOUSE_RE.lastIndex = 0;
120
+ let match = SGR_MOUSE_RE.exec(data);
121
+ if (match) {
122
+ batchScrollOffsets = new Map();
123
+ while (match) {
124
+ const mouse = parseSgrMatch(match);
125
+ if (mouse) handleMouseEvent(mouse);
126
+ match = SGR_MOUSE_RE.exec(data);
127
+ }
128
+ batchScrollOffsets = null;
129
+ return;
130
+ }
131
+
132
+ // Keyboard input
133
+ const key = parseKey(data);
134
+ handleKeyEvent(key, data);
135
+ }
136
+
137
+ interface MouseEvent {
138
+ type: "click" | "scroll-up" | "scroll-down";
139
+ x: number;
140
+ y: number;
141
+ }
142
+
143
+ /**
144
+ * Parse a single SGR mouse event from a RegExp match.
145
+ * Returns a MouseEvent for scroll and click events, null for unhandled buttons.
146
+ */
147
+ function parseSgrMatch(match: RegExpExecArray): MouseEvent | null {
148
+ const cb = parseInt(match[1]!, 10);
149
+ const x = parseInt(match[2]!, 10) - 1; // 1-indexed → 0-indexed
150
+ const y = parseInt(match[3]!, 10) - 1;
151
+ const isRelease = match[4] === "m";
152
+
153
+ // Scroll up (cb=64) / scroll down (cb=65)
154
+ if (cb === 64) return { type: "scroll-up", x, y };
155
+ if (cb === 65) return { type: "scroll-down", x, y };
156
+
157
+ // Button click (cb=0 for left button, release event)
158
+ if (cb === 0 && isRelease) return { type: "click", x, y };
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Check if a focusable element uses controlled focus (explicit `focused` prop).
165
+ */
166
+ function isControlledFocus(ln: LayoutNode): boolean {
167
+ const node = ln.node;
168
+ if (node.type === "textinput") return node.props.focused !== undefined;
169
+ if (node.type === "vstack" || node.type === "hstack")
170
+ return node.props.focused !== undefined;
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * Find the currently focused element across all layers.
176
+ * Checks controlled focus (`focused: true` in props) first,
177
+ * then falls back to framework-tracked uncontrolled focus.
178
+ */
179
+ function findFocusedElement(): LayoutNode | null {
180
+ // Controlled: scan tree for focused: true
181
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
182
+ const found = findFocusedInTree(currentLayouts[i]!);
183
+ if (found) return found;
184
+ }
185
+ // Uncontrolled: check framework-tracked index
186
+ if (frameworkFocusIndex >= 0) {
187
+ const topLayer = currentLayouts[currentLayouts.length - 1];
188
+ if (topLayer) {
189
+ const focusables = collectFocusable(topLayer);
190
+ if (frameworkFocusIndex < focusables.length) {
191
+ return focusables[frameworkFocusIndex]!;
192
+ }
193
+ }
194
+ // Index out of bounds (tree changed) — clear
195
+ frameworkFocusIndex = -1;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * Stamp `focused: true` on the framework-tracked focused node's props
202
+ * before painting, so that paint/focusStyle/cursor rendering picks it up.
203
+ * Only stamps if no controlled element is already focused.
204
+ * Must be followed by {@link unstampUncontrolledFocus} after paint.
205
+ */
206
+ function stampUncontrolledFocus(): void {
207
+ stampedNode = null;
208
+ if (frameworkFocusIndex < 0) return;
209
+
210
+ // Don't stamp if a controlled element owns focus
211
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
212
+ if (findFocusedInTree(currentLayouts[i]!)) {
213
+ frameworkFocusIndex = -1;
214
+ return;
215
+ }
216
+ }
217
+
218
+ const topLayer = currentLayouts[currentLayouts.length - 1];
219
+ if (!topLayer) return;
220
+ const focusables = collectFocusable(topLayer);
221
+ if (frameworkFocusIndex >= focusables.length) {
222
+ frameworkFocusIndex = -1;
223
+ return;
224
+ }
225
+ const target = focusables[frameworkFocusIndex]!;
226
+ const node = target.node;
227
+ if (
228
+ node.type === "textinput" ||
229
+ node.type === "vstack" ||
230
+ node.type === "hstack"
231
+ ) {
232
+ (node.props as any).focused = true;
233
+ stampedNode = target;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Remove the `focused: true` stamp set by {@link stampUncontrolledFocus}.
239
+ * Called after paint so input handlers see the original props and can
240
+ * correctly distinguish controlled from uncontrolled elements.
241
+ */
242
+ function unstampUncontrolledFocus(): void {
243
+ if (!stampedNode) return;
244
+ const node = stampedNode.node;
245
+ if (
246
+ node.type === "textinput" ||
247
+ node.type === "vstack" ||
248
+ node.type === "hstack"
249
+ ) {
250
+ delete (node.props as any).focused;
251
+ }
252
+ stampedNode = null;
253
+ }
254
+
255
+ /**
256
+ * Blur the currently focused element and focus a new one.
257
+ * Manages both controlled (via onFocus/onBlur callbacks) and
258
+ * uncontrolled (via frameworkFocusIndex) focus.
259
+ */
260
+ function changeFocus(target: LayoutNode | null): void {
261
+ const current = findFocusedElement();
262
+
263
+ // Blur current — save position for Tab/Shift+Tab continuity
264
+ if (current && current !== target) {
265
+ const topLayer = currentLayouts[currentLayouts.length - 1];
266
+ if (topLayer) {
267
+ const focusables = collectFocusable(topLayer);
268
+ let idx = focusables.indexOf(current);
269
+ if (idx === -1)
270
+ idx = focusables.findIndex((f) => f.node === current.node);
271
+ lastFocusedIndex = idx;
272
+ }
273
+ // Always clear framework tracking on blur
274
+ frameworkFocusIndex = -1;
275
+ const props =
276
+ current.node.type === "textinput"
277
+ ? current.node.props
278
+ : current.node.type !== "text"
279
+ ? current.node.props
280
+ : null;
281
+ if (props && "onBlur" in props && props.onBlur) {
282
+ props.onBlur();
283
+ }
284
+ }
285
+
286
+ // Focus new target — clear saved position
287
+ if (target && target !== current) {
288
+ lastFocusedIndex = -1;
289
+ // Set or clear framework tracking based on controlled/uncontrolled
290
+ if (!isControlledFocus(target)) {
291
+ const topLayer = currentLayouts[currentLayouts.length - 1];
292
+ if (topLayer) {
293
+ const focusables = collectFocusable(topLayer);
294
+ frameworkFocusIndex = focusables.indexOf(target);
295
+ }
296
+ } else {
297
+ frameworkFocusIndex = -1;
298
+ }
299
+ const props =
300
+ target.node.type === "textinput"
301
+ ? target.node.props
302
+ : target.node.type !== "text"
303
+ ? target.node.props
304
+ : null;
305
+ if (props && "onFocus" in props && props.onFocus) {
306
+ props.onFocus();
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Compute the maximum scroll offset for a scrollable container or TextInput.
313
+ * Returns 0 if content fits within the viewport.
314
+ */
315
+ function getMaxScrollOffset(target: LayoutNode): number {
316
+ const { rect, children } = target;
317
+
318
+ // TextInput: compute content height from wrapped text value
319
+ if (target.node.type === "textinput") {
320
+ const w = rect.width;
321
+ const value = target.node.props.value;
322
+ let lineCount = 0;
323
+ for (const rawLine of value.split("\n")) {
324
+ const lw = visibleWidth(rawLine);
325
+ lineCount += w > 0 && lw > w ? Math.ceil(lw / w) : 1;
326
+ }
327
+ return Math.max(0, lineCount - rect.height);
328
+ }
329
+
330
+ const isVertical = target.node.type === "vstack";
331
+
332
+ if (isVertical) {
333
+ let contentHeight = 0;
334
+ for (const child of children) {
335
+ const childBottom = child.rect.y + child.rect.height - rect.y;
336
+ if (childBottom > contentHeight) contentHeight = childBottom;
337
+ }
338
+ return Math.max(0, contentHeight - rect.height);
339
+ } else {
340
+ let contentWidth = 0;
341
+ for (const child of children) {
342
+ const childRight = child.rect.x + child.rect.width - rect.x;
343
+ if (childRight > contentWidth) contentWidth = childRight;
344
+ }
345
+ return Math.max(0, contentWidth - rect.width);
346
+ }
347
+ }
348
+
349
+ function handleMouseEvent(event: MouseEvent): void {
350
+ // Hit test on topmost layer first
351
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
352
+ const layoutRoot = currentLayouts[i]!;
353
+ const path = hitTest(layoutRoot, event.x, event.y);
354
+ if (path.length === 0) continue;
355
+
356
+ if (event.type === "click") {
357
+ // Find and focus the focusable element at click position
358
+ const focusable = findClickFocusTarget(path);
359
+ if (focusable) {
360
+ changeFocus(focusable);
361
+ }
362
+
363
+ const click = findClickHandler(path);
364
+ if (click) {
365
+ click.handler();
366
+ }
367
+ cel.render();
368
+ return;
369
+ }
370
+
371
+ if (event.type === "scroll-up" || event.type === "scroll-down") {
372
+ const target = findScrollTarget(path);
373
+ if (target) {
374
+ const delta = event.type === "scroll-up" ? -1 : 1;
375
+ const maxOffset = getMaxScrollOffset(target);
376
+
377
+ if (target.node.type === "textinput") {
378
+ // TextInput scroll is always framework-managed
379
+ const tiProps = target.node.props;
380
+ const current = getTextInputScroll(tiProps);
381
+ const clamped = Math.max(0, Math.min(maxOffset, current + delta));
382
+ setTextInputScroll(tiProps, clamped);
383
+ cel.render();
384
+ } else {
385
+ const props = target.node.type !== "text" ? target.node.props : null;
386
+ if (props && "onScroll" in props) {
387
+ if (props.onScroll) {
388
+ // Controlled scroll: notify app.
389
+ // Use batch accumulator if available (multiple events in one chunk),
390
+ // otherwise read from props.
391
+ const baseOffset =
392
+ batchScrollOffsets?.get(props) ??
393
+ (props as any).scrollOffset ??
394
+ 0;
395
+ const newOffset = Math.max(
396
+ 0,
397
+ Math.min(maxOffset, baseOffset + delta),
398
+ );
399
+ batchScrollOffsets?.set(props, newOffset);
400
+ props.onScroll(newOffset);
401
+ } else {
402
+ // Uncontrolled scroll: framework manages state
403
+ const current = getContainerScroll(props);
404
+ const clamped = Math.max(0, Math.min(maxOffset, current + delta));
405
+ setContainerScroll(props, clamped);
406
+ }
407
+ cel.render();
408
+ }
409
+ }
410
+ }
411
+ return;
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Find the nearest focusable element in a hit path (for mouse click focusing).
418
+ */
419
+ function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
420
+ for (let i = path.length - 1; i >= 0; i--) {
421
+ const node = path[i]!.node;
422
+ if (node.type === "textinput") return path[i]!;
423
+ if (
424
+ (node.type === "vstack" || node.type === "hstack") &&
425
+ node.props.onClick &&
426
+ node.props.focusable !== false
427
+ ) {
428
+ return path[i]!;
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+
434
+ function handleKeyEvent(key: string, rawData?: string): void {
435
+ // --- Focus traversal keys ---
436
+
437
+ // Tab / Shift+Tab: cycle through focusable elements
438
+ // Skip focus traversal when a TextInput is focused — Tab is an editing key
439
+ // that inserts \t. The user must Escape first, then Tab to traverse.
440
+ if (key === "tab" || key === "shift+tab") {
441
+ const focusedTI = findFocusedTextInput();
442
+ if (focusedTI) {
443
+ // Fall through to TextInput key routing below
444
+ } else {
445
+ const topLayer = currentLayouts[currentLayouts.length - 1];
446
+ if (!topLayer) return;
447
+ const focusables = collectFocusable(topLayer);
448
+ if (focusables.length === 0) return;
449
+
450
+ const current = findFocusedElement();
451
+ let currentIdx = current ? focusables.indexOf(current) : -1;
452
+
453
+ // If current not found in focusables list, search by identity
454
+ if (currentIdx === -1 && current) {
455
+ currentIdx = focusables.findIndex((f) => f.node === current.node);
456
+ }
457
+
458
+ // If still not found, use the last focused element's position
459
+ // so Tab/Shift+Tab continues from where focus was lost (e.g. after Escape)
460
+ if (
461
+ currentIdx === -1 &&
462
+ lastFocusedIndex >= 0 &&
463
+ lastFocusedIndex < focusables.length
464
+ ) {
465
+ currentIdx = lastFocusedIndex;
466
+ }
467
+
468
+ let nextIdx: number;
469
+ if (key === "tab") {
470
+ nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % focusables.length;
471
+ } else {
472
+ nextIdx =
473
+ currentIdx === -1
474
+ ? focusables.length - 1
475
+ : (currentIdx - 1 + focusables.length) % focusables.length;
476
+ }
477
+
478
+ changeFocus(focusables[nextIdx]!);
479
+ cel.render();
480
+ return;
481
+ } // end: not a focused TextInput
482
+ }
483
+
484
+ // Escape: unfocus current element
485
+ if (key === "escape") {
486
+ const current = findFocusedElement();
487
+ if (current) {
488
+ changeFocus(null);
489
+ cel.render();
490
+ return;
491
+ }
492
+ }
493
+
494
+ // Enter: activate focused clickable container
495
+ if (key === "enter") {
496
+ const current = findFocusedElement();
497
+ if (current && current.node.type !== "textinput") {
498
+ const props = current.node.type !== "text" ? current.node.props : null;
499
+ if (props?.onClick) {
500
+ props.onClick();
501
+ cel.render();
502
+ return;
503
+ }
504
+ }
505
+ }
506
+
507
+ // --- TextInput key routing ---
508
+ // Find the focused TextInput (if any) to route editing keys
509
+ const focusedInput = findFocusedTextInput();
510
+
511
+ if (focusedInput) {
512
+ const props = focusedInput.node
513
+ .props as import("@cel-tui/types").TextInputProps;
514
+
515
+ // Check submitKey
516
+ const submitKey = normalizeKey(props.submitKey ?? "enter");
517
+ if (key === submitKey && props.onSubmit) {
518
+ props.onSubmit();
519
+ cel.render();
520
+ return;
521
+ }
522
+
523
+ // Editing keys are consumed by TextInput
524
+ if (isEditingKey(key)) {
525
+ const cursor = getTextInputCursor(props);
526
+ const editState: EditState = { value: props.value, cursor };
527
+ let newState: EditState | null = null;
528
+
529
+ switch (key) {
530
+ case "backspace":
531
+ newState = deleteBackward(editState);
532
+ break;
533
+ case "delete":
534
+ newState = deleteForward(editState);
535
+ break;
536
+ case "left":
537
+ case "right":
538
+ case "up":
539
+ case "down":
540
+ case "home":
541
+ case "end":
542
+ newState = moveCursor(
543
+ editState,
544
+ key as "left" | "right" | "up" | "down" | "home" | "end",
545
+ focusedInput.rect.width,
546
+ );
547
+ break;
548
+ case "enter":
549
+ newState = insertChar(editState, "\n");
550
+ break;
551
+ case "tab":
552
+ newState = insertChar(editState, "\t");
553
+ break;
554
+ default:
555
+ // Single printable character — use raw data to preserve case
556
+ if (key.length === 1 && rawData && rawData.length === 1) {
557
+ newState = insertChar(editState, rawData);
558
+ } else if (key.length === 1) {
559
+ newState = insertChar(editState, key);
560
+ }
561
+ break;
562
+ }
563
+
564
+ if (newState && newState !== editState) {
565
+ setTextInputCursor(props, newState.cursor);
566
+ if (newState.value !== editState.value) {
567
+ props.onChange(newState.value);
568
+ }
569
+ cel.render();
570
+ return;
571
+ }
572
+ }
573
+ }
574
+
575
+ // Key not consumed by TextInput — bubble up from focused element
576
+ const focused = findFocusedElement();
577
+ if (focused) {
578
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
579
+ const path = findPathTo(currentLayouts[i]!, focused);
580
+ if (path) {
581
+ const handler = findKeyPressHandler(path);
582
+ if (handler) {
583
+ handler.handler(key);
584
+ cel.render();
585
+ return;
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ // No focused element — try root onKeyPress on topmost layer
592
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
593
+ const layoutRoot = currentLayouts[i]!;
594
+ const path = [layoutRoot];
595
+ const handler = findKeyPressHandler(path);
596
+ if (handler) {
597
+ handler.handler(key);
598
+ cel.render();
599
+ return;
600
+ }
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Build the path from root to a target node (depth-first search).
606
+ * Returns the path array [root, ..., target] or null if not found.
607
+ */
608
+ function findPathTo(root: LayoutNode, target: LayoutNode): LayoutNode[] | null {
609
+ if (root === target || root.node === target.node) return [root];
610
+ for (const child of root.children) {
611
+ const childPath = findPathTo(child, target);
612
+ if (childPath) return [root, ...childPath];
613
+ }
614
+ return null;
615
+ }
616
+
617
+ function findFocusedTextInput(): LayoutNode | null {
618
+ const focused = findFocusedElement();
619
+ return focused && focused.node.type === "textinput" ? focused : null;
620
+ }
621
+
622
+ function findFocusedInTree(ln: LayoutNode): LayoutNode | null {
623
+ if (ln.node.type === "textinput" && ln.node.props.focused) {
624
+ return ln;
625
+ }
626
+ if (
627
+ (ln.node.type === "vstack" || ln.node.type === "hstack") &&
628
+ ln.node.props.focused
629
+ ) {
630
+ return ln;
631
+ }
632
+ for (const child of ln.children) {
633
+ const found = findFocusedInTree(child);
634
+ if (found) return found;
635
+ }
636
+ return null;
637
+ }
638
+
639
+ // --- Public API ---
640
+
641
+ /**
642
+ * cel-tui framework entrypoint.
643
+ *
644
+ * The framework is stateless — it renders whatever tree the render function
645
+ * returns. State management is fully external. Use any approach you like
646
+ * (plain variables, classes, libraries) and call {@link cel.render} when
647
+ * state changes.
648
+ *
649
+ * @example
650
+ * ```ts
651
+ * import { cel, VStack, Text, ProcessTerminal } from "@cel-tui/core";
652
+ *
653
+ * cel.init(new ProcessTerminal());
654
+ * cel.viewport(() =>
655
+ * VStack({ height: "100%" }, [
656
+ * Text("Hello, world!", { bold: true }),
657
+ * ])
658
+ * );
659
+ * ```
660
+ */
661
+ export const cel = {
662
+ /**
663
+ * Initialize the framework with a terminal implementation.
664
+ * Must be called before {@link cel.viewport}.
665
+ *
666
+ * @param term - Terminal to render to (ProcessTerminal or MockTerminal).
667
+ */
668
+ init(term: Terminal): void {
669
+ terminal = term;
670
+ terminal.start(handleInput, () => cel.render());
671
+ },
672
+
673
+ /**
674
+ * Set the render function that returns the UI tree.
675
+ * Triggers the first render automatically.
676
+ *
677
+ * @param fn - A function that returns the current UI tree.
678
+ */
679
+ viewport(fn: RenderFn): void {
680
+ renderFn = fn;
681
+ cel.render();
682
+ },
683
+
684
+ /**
685
+ * Request a re-render. Call this after state changes.
686
+ *
687
+ * Batched via `process.nextTick()` — multiple calls within the same
688
+ * tick produce a single render.
689
+ */
690
+ render(): void {
691
+ if (renderScheduled) return;
692
+ renderScheduled = true;
693
+ process.nextTick(doRender);
694
+ },
695
+
696
+ /**
697
+ * Stop the framework and restore terminal state.
698
+ */
699
+ stop(): void {
700
+ terminal?.stop();
701
+ terminal = null;
702
+ renderFn = null;
703
+ prevBuffer = null;
704
+ currentBuffer = null;
705
+ currentLayouts = [];
706
+ renderScheduled = false;
707
+ lastFocusedIndex = -1;
708
+ frameworkFocusIndex = -1;
709
+ stampedNode = null;
710
+ },
711
+
712
+ /** @internal */
713
+ _getBuffer(): CellBuffer | null {
714
+ return currentBuffer;
715
+ },
716
+ };