@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/package.json +42 -0
- package/src/cel.ts +716 -0
- package/src/cell-buffer.ts +196 -0
- package/src/emitter.ts +192 -0
- package/src/hit-test.ts +146 -0
- package/src/index.ts +49 -0
- package/src/keys.ts +147 -0
- package/src/layout.ts +422 -0
- package/src/paint.ts +646 -0
- package/src/primitives/stacks.ts +42 -0
- package/src/primitives/text-input.ts +42 -0
- package/src/primitives/text.ts +29 -0
- package/src/terminal.ts +194 -0
- package/src/text-edit.ts +164 -0
- package/src/width.ts +174 -0
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
|
+
};
|