@cel-tui/core 0.6.2 → 0.7.1
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 +2 -2
- package/src/cel.ts +191 -67
- package/src/cell-buffer.ts +7 -2
- package/src/emitter.ts +1 -2
- package/src/hit-test.ts +30 -15
- package/src/index.ts +15 -16
- package/src/keys.ts +41 -11
- package/src/layout.ts +88 -40
- package/src/paint.ts +26 -15
- package/src/primitives/text-input.ts +7 -3
- package/src/scroll.ts +10 -6
- package/src/text-edit.ts +99 -41
- package/src/text-layout.ts +65 -8
- package/src/width.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"layout"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@cel-tui/types": "0.
|
|
37
|
+
"@cel-tui/types": "0.7.1",
|
|
38
38
|
"get-east-asian-width": "^1.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/src/cel.ts
CHANGED
|
@@ -5,33 +5,36 @@ import type {
|
|
|
5
5
|
Theme,
|
|
6
6
|
} from "@cel-tui/types";
|
|
7
7
|
import { CellBuffer } from "./cell-buffer.js";
|
|
8
|
-
import { emitBuffer, emitDiff
|
|
8
|
+
import { defaultTheme, emitBuffer, emitDiff } from "./emitter.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
collectFocusable,
|
|
11
|
+
collectKeyPressHandlers,
|
|
11
12
|
findClickHandler,
|
|
12
13
|
findScrollTarget,
|
|
13
|
-
|
|
14
|
-
collectFocusable,
|
|
14
|
+
hitTest,
|
|
15
15
|
} from "./hit-test.js";
|
|
16
16
|
import { decodeKeyEvents, isEditingKey, type KeyInput } from "./keys.js";
|
|
17
|
-
import {
|
|
17
|
+
import { type LayoutNode, layout } from "./layout.js";
|
|
18
18
|
import {
|
|
19
|
-
paint,
|
|
20
19
|
getTextInputCursor,
|
|
21
|
-
|
|
20
|
+
getTextInputCursorScreenPos,
|
|
22
21
|
getTextInputScroll,
|
|
22
|
+
paint,
|
|
23
|
+
setTextInputCursor,
|
|
23
24
|
setTextInputScroll,
|
|
24
|
-
getTextInputCursorScreenPos,
|
|
25
25
|
} from "./paint.js";
|
|
26
|
+
import { getMaxScrollOffset, getScrollStep } from "./scroll.js";
|
|
27
|
+
import type { Terminal } from "./terminal.js";
|
|
26
28
|
import {
|
|
27
|
-
insertChar,
|
|
28
29
|
deleteBackward,
|
|
29
30
|
deleteForward,
|
|
30
|
-
|
|
31
|
+
deleteWordBackward,
|
|
32
|
+
deleteWordForward,
|
|
31
33
|
type EditState,
|
|
34
|
+
insertChar,
|
|
35
|
+
moveCursor,
|
|
36
|
+
moveCursorByWord,
|
|
32
37
|
} from "./text-edit.js";
|
|
33
|
-
import type { Terminal } from "./terminal.js";
|
|
34
|
-
import { getMaxScrollOffset, getScrollStep } from "./scroll.js";
|
|
35
38
|
|
|
36
39
|
type RenderFn = () => Node | Node[];
|
|
37
40
|
|
|
@@ -76,6 +79,18 @@ let stampedScrollNodes: { node: Node; key: string }[] = [];
|
|
|
76
79
|
/** The cursor state currently expected on the terminal. */
|
|
77
80
|
let lastTerminalCursor: TerminalCursorState | null = { visible: false };
|
|
78
81
|
|
|
82
|
+
function requiredAt<T>(
|
|
83
|
+
items: readonly T[],
|
|
84
|
+
index: number,
|
|
85
|
+
description: string,
|
|
86
|
+
): T {
|
|
87
|
+
const item = items[index];
|
|
88
|
+
if (item === undefined) {
|
|
89
|
+
throw new Error(`Missing ${description} at index ${index}`);
|
|
90
|
+
}
|
|
91
|
+
return item;
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
function doRender(): void {
|
|
80
95
|
renderScheduled = false;
|
|
81
96
|
if (!renderFn || !terminal) return;
|
|
@@ -108,6 +123,8 @@ function doRender(): void {
|
|
|
108
123
|
currentLayouts.push(layoutTree);
|
|
109
124
|
}
|
|
110
125
|
|
|
126
|
+
syncLastFocusedIndex();
|
|
127
|
+
|
|
111
128
|
// Stamp uncontrolled focus and scroll before painting
|
|
112
129
|
stampUncontrolledFocus();
|
|
113
130
|
stampUncontrolledScroll();
|
|
@@ -163,7 +180,9 @@ function getDesiredTerminalCursor(): TerminalCursorState {
|
|
|
163
180
|
function findFocusedTextInputLayout(): LayoutNode | null {
|
|
164
181
|
// Check controlled focus: scan all layers for TextInput with focused: true
|
|
165
182
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
166
|
-
const found = findFocusedTIInTree(
|
|
183
|
+
const found = findFocusedTIInTree(
|
|
184
|
+
requiredAt(currentLayouts, i, "layout root"),
|
|
185
|
+
);
|
|
167
186
|
if (found) return found;
|
|
168
187
|
}
|
|
169
188
|
// Check uncontrolled focus
|
|
@@ -172,7 +191,11 @@ function findFocusedTextInputLayout(): LayoutNode | null {
|
|
|
172
191
|
if (topLayer) {
|
|
173
192
|
const focusables = collectFocusable(topLayer);
|
|
174
193
|
if (frameworkFocusIndex < focusables.length) {
|
|
175
|
-
const target =
|
|
194
|
+
const target = requiredAt(
|
|
195
|
+
focusables,
|
|
196
|
+
frameworkFocusIndex,
|
|
197
|
+
"focusable node",
|
|
198
|
+
);
|
|
176
199
|
if (target.node.type === "textinput") return target;
|
|
177
200
|
}
|
|
178
201
|
}
|
|
@@ -189,13 +212,45 @@ function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
|
|
|
189
212
|
return null;
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
function syncLastFocusedIndex(): void {
|
|
216
|
+
const topLayer = currentLayouts[currentLayouts.length - 1];
|
|
217
|
+
if (!topLayer) return;
|
|
218
|
+
|
|
219
|
+
const focusables = collectFocusable(topLayer);
|
|
220
|
+
if (focusables.length === 0) return;
|
|
221
|
+
|
|
222
|
+
const controlled = findFocusedInTree(topLayer);
|
|
223
|
+
if (controlled) {
|
|
224
|
+
let idx = focusables.indexOf(controlled);
|
|
225
|
+
if (idx === -1) {
|
|
226
|
+
idx = focusables.findIndex(
|
|
227
|
+
(focusable) => focusable.node === controlled.node,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (idx !== -1) {
|
|
231
|
+
lastFocusedIndex = idx;
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (frameworkFocusIndex >= 0 && frameworkFocusIndex < focusables.length) {
|
|
237
|
+
lastFocusedIndex = frameworkFocusIndex;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
192
241
|
// --- Input handling ---
|
|
193
242
|
|
|
194
243
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
195
244
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
196
245
|
|
|
197
246
|
// Regex for a single SGR mouse event, anchored to the current parse position.
|
|
198
|
-
|
|
247
|
+
// biome-ignore lint/complexity/useRegexLiterals: using RegExp here avoids control-character regex diagnostics.
|
|
248
|
+
const SGR_MOUSE_RE = new RegExp(String.raw`^\x1b\[<(\d+);(\d+);(\d+)([Mm])`);
|
|
249
|
+
// biome-ignore lint/complexity/useRegexLiterals: using RegExp here avoids control-character regex diagnostics.
|
|
250
|
+
const TERMINAL_TITLE_CONTROL_CHARS_RE = new RegExp(
|
|
251
|
+
String.raw`[\x00-\x1f\x7f-\x9f]`,
|
|
252
|
+
"g",
|
|
253
|
+
);
|
|
199
254
|
|
|
200
255
|
// Trailing keyboard data that ended with an incomplete CSI sequence.
|
|
201
256
|
let pendingKeyData = "";
|
|
@@ -216,6 +271,10 @@ let batchScrollOffsets: Map<object, number> | null = null;
|
|
|
216
271
|
// keys in the same chunk must see the updated value/cursor immediately.
|
|
217
272
|
let batchTextInputEdits: Map<TextInputProps, EditState> | null = null;
|
|
218
273
|
|
|
274
|
+
function sanitizeTerminalTitle(title: string): string {
|
|
275
|
+
return title.replace(TERMINAL_TITLE_CONTROL_CHARS_RE, "");
|
|
276
|
+
}
|
|
277
|
+
|
|
219
278
|
function handleInput(data: string): void {
|
|
220
279
|
batchTextInputEdits = new Map();
|
|
221
280
|
|
|
@@ -368,9 +427,16 @@ function readSgrMouseEvent(
|
|
|
368
427
|
* Returns a MouseEvent for scroll and click events, null for unhandled buttons.
|
|
369
428
|
*/
|
|
370
429
|
function parseSgrMatch(match: RegExpExecArray): MouseEvent | null {
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
const
|
|
430
|
+
const cbMatch = match[1];
|
|
431
|
+
const xMatch = match[2];
|
|
432
|
+
const yMatch = match[3];
|
|
433
|
+
if (cbMatch === undefined || xMatch === undefined || yMatch === undefined) {
|
|
434
|
+
throw new Error("Incomplete SGR mouse match");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const cb = parseInt(cbMatch, 10);
|
|
438
|
+
const x = parseInt(xMatch, 10) - 1; // 1-indexed → 0-indexed
|
|
439
|
+
const y = parseInt(yMatch, 10) - 1;
|
|
374
440
|
const isRelease = match[4] === "m";
|
|
375
441
|
|
|
376
442
|
// Scroll up (cb=64) / scroll down (cb=65)
|
|
@@ -402,7 +468,9 @@ function isControlledFocus(ln: LayoutNode): boolean {
|
|
|
402
468
|
function findFocusedElement(): LayoutNode | null {
|
|
403
469
|
// Controlled: scan tree for focused: true
|
|
404
470
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
405
|
-
const found = findFocusedInTree(
|
|
471
|
+
const found = findFocusedInTree(
|
|
472
|
+
requiredAt(currentLayouts, i, "layout root"),
|
|
473
|
+
);
|
|
406
474
|
if (found) return found;
|
|
407
475
|
}
|
|
408
476
|
// Uncontrolled: check framework-tracked index
|
|
@@ -411,7 +479,7 @@ function findFocusedElement(): LayoutNode | null {
|
|
|
411
479
|
if (topLayer) {
|
|
412
480
|
const focusables = collectFocusable(topLayer);
|
|
413
481
|
if (frameworkFocusIndex < focusables.length) {
|
|
414
|
-
return focusables
|
|
482
|
+
return requiredAt(focusables, frameworkFocusIndex, "focusable node");
|
|
415
483
|
}
|
|
416
484
|
}
|
|
417
485
|
// Index out of bounds (tree changed) — clear
|
|
@@ -432,7 +500,7 @@ function stampUncontrolledFocus(): void {
|
|
|
432
500
|
|
|
433
501
|
// Don't stamp if a controlled element owns focus
|
|
434
502
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
435
|
-
if (findFocusedInTree(currentLayouts
|
|
503
|
+
if (findFocusedInTree(requiredAt(currentLayouts, i, "layout root"))) {
|
|
436
504
|
frameworkFocusIndex = -1;
|
|
437
505
|
return;
|
|
438
506
|
}
|
|
@@ -445,14 +513,14 @@ function stampUncontrolledFocus(): void {
|
|
|
445
513
|
frameworkFocusIndex = -1;
|
|
446
514
|
return;
|
|
447
515
|
}
|
|
448
|
-
const target = focusables
|
|
516
|
+
const target = requiredAt(focusables, frameworkFocusIndex, "focusable node");
|
|
449
517
|
const node = target.node;
|
|
450
518
|
if (
|
|
451
519
|
node.type === "textinput" ||
|
|
452
520
|
node.type === "vstack" ||
|
|
453
521
|
node.type === "hstack"
|
|
454
522
|
) {
|
|
455
|
-
|
|
523
|
+
node.props.focused = true;
|
|
456
524
|
stampedNode = target;
|
|
457
525
|
}
|
|
458
526
|
}
|
|
@@ -470,7 +538,7 @@ function unstampUncontrolledFocus(): void {
|
|
|
470
538
|
node.type === "vstack" ||
|
|
471
539
|
node.type === "hstack"
|
|
472
540
|
) {
|
|
473
|
-
delete
|
|
541
|
+
delete node.props.focused;
|
|
474
542
|
}
|
|
475
543
|
stampedNode = null;
|
|
476
544
|
}
|
|
@@ -482,7 +550,10 @@ function unstampUncontrolledFocus(): void {
|
|
|
482
550
|
function computeTreePath(root: LayoutNode, target: LayoutNode): string | null {
|
|
483
551
|
if (root === target) return "";
|
|
484
552
|
for (let i = 0; i < root.children.length; i++) {
|
|
485
|
-
const sub = computeTreePath(
|
|
553
|
+
const sub = computeTreePath(
|
|
554
|
+
requiredAt(root.children, i, "layout child"),
|
|
555
|
+
target,
|
|
556
|
+
);
|
|
486
557
|
if (sub !== null) return sub === "" ? String(i) : `${i}/${sub}`;
|
|
487
558
|
}
|
|
488
559
|
return null;
|
|
@@ -493,7 +564,10 @@ function computeTreePath(root: LayoutNode, target: LayoutNode): string | null {
|
|
|
493
564
|
*/
|
|
494
565
|
function getScrollPathKey(target: LayoutNode): string | null {
|
|
495
566
|
for (let i = 0; i < currentLayouts.length; i++) {
|
|
496
|
-
const path = computeTreePath(
|
|
567
|
+
const path = computeTreePath(
|
|
568
|
+
requiredAt(currentLayouts, i, "layout root"),
|
|
569
|
+
target,
|
|
570
|
+
);
|
|
497
571
|
if (path !== null) return `L${i}:${path}`;
|
|
498
572
|
}
|
|
499
573
|
return null;
|
|
@@ -507,7 +581,7 @@ function stampUncontrolledScroll(): void {
|
|
|
507
581
|
stampedScrollNodes = [];
|
|
508
582
|
if (uncontrolledScrollOffsets.size === 0) return;
|
|
509
583
|
for (let i = 0; i < currentLayouts.length; i++) {
|
|
510
|
-
walkAndStampScroll(currentLayouts
|
|
584
|
+
walkAndStampScroll(requiredAt(currentLayouts, i, "layout root"), `L${i}:`);
|
|
511
585
|
}
|
|
512
586
|
}
|
|
513
587
|
|
|
@@ -521,7 +595,7 @@ function walkAndStampScroll(ln: LayoutNode, pathKey: string): void {
|
|
|
521
595
|
) {
|
|
522
596
|
const offset = uncontrolledScrollOffsets.get(pathKey);
|
|
523
597
|
if (offset !== undefined && offset !== 0) {
|
|
524
|
-
|
|
598
|
+
node.props.scrollOffset = offset;
|
|
525
599
|
stampedScrollNodes.push({ node, key: pathKey });
|
|
526
600
|
}
|
|
527
601
|
}
|
|
@@ -531,14 +605,14 @@ function walkAndStampScroll(ln: LayoutNode, pathKey: string): void {
|
|
|
531
605
|
const childKey = pathKey.endsWith(":")
|
|
532
606
|
? `${pathKey}${i}`
|
|
533
607
|
: `${pathKey}/${i}`;
|
|
534
|
-
walkAndStampScroll(ln.children
|
|
608
|
+
walkAndStampScroll(requiredAt(ln.children, i, "layout child"), childKey);
|
|
535
609
|
}
|
|
536
610
|
}
|
|
537
611
|
|
|
538
612
|
function unstampUncontrolledScroll(): void {
|
|
539
613
|
for (const { node } of stampedScrollNodes) {
|
|
540
614
|
if (node.type === "vstack" || node.type === "hstack") {
|
|
541
|
-
delete
|
|
615
|
+
delete node.props.scrollOffset;
|
|
542
616
|
}
|
|
543
617
|
}
|
|
544
618
|
stampedScrollNodes = [];
|
|
@@ -608,8 +682,7 @@ function resolveScrollOffset(ln: import("./layout.js").LayoutNode): number {
|
|
|
608
682
|
const node = ln.node;
|
|
609
683
|
if (node.type === "text") return 0;
|
|
610
684
|
const props = node.props;
|
|
611
|
-
if (
|
|
612
|
-
return (props as any).scrollOffset;
|
|
685
|
+
if (props.scrollOffset !== undefined) return props.scrollOffset;
|
|
613
686
|
// Check uncontrolled map
|
|
614
687
|
const pathKey = getScrollPathKey(ln);
|
|
615
688
|
if (pathKey !== null) {
|
|
@@ -621,7 +694,7 @@ function resolveScrollOffset(ln: import("./layout.js").LayoutNode): number {
|
|
|
621
694
|
function handleMouseEvent(event: MouseEvent): void {
|
|
622
695
|
// Hit test on topmost layer first
|
|
623
696
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
624
|
-
const layoutRoot = currentLayouts
|
|
697
|
+
const layoutRoot = requiredAt(currentLayouts, i, "layout root");
|
|
625
698
|
const path = hitTest(layoutRoot, event.x, event.y, resolveScrollOffset);
|
|
626
699
|
if (path.length === 0) continue;
|
|
627
700
|
|
|
@@ -654,9 +727,12 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
654
727
|
const clamped = Math.max(0, Math.min(maxOffset, current + delta));
|
|
655
728
|
setTextInputScroll(tiProps, clamped);
|
|
656
729
|
cel.render();
|
|
657
|
-
} else
|
|
658
|
-
|
|
659
|
-
|
|
730
|
+
} else if (
|
|
731
|
+
target.node.type === "vstack" ||
|
|
732
|
+
target.node.type === "hstack"
|
|
733
|
+
) {
|
|
734
|
+
const props = target.node.props;
|
|
735
|
+
if (props.overflow === "scroll") {
|
|
660
736
|
if (props.onScroll) {
|
|
661
737
|
// Controlled scroll: notify app.
|
|
662
738
|
// Use batch accumulator if available (multiple events in one chunk),
|
|
@@ -665,9 +741,7 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
665
741
|
// applying the delta — otherwise Infinity + (-1) = Infinity
|
|
666
742
|
// and scrolling up never unsticks.
|
|
667
743
|
const rawBase =
|
|
668
|
-
batchScrollOffsets?.get(props) ??
|
|
669
|
-
(props as any).scrollOffset ??
|
|
670
|
-
0;
|
|
744
|
+
batchScrollOffsets?.get(props) ?? props.scrollOffset ?? 0;
|
|
671
745
|
const baseOffset = Math.min(rawBase, maxOffset);
|
|
672
746
|
const newOffset = Math.max(
|
|
673
747
|
0,
|
|
@@ -701,13 +775,14 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
701
775
|
*/
|
|
702
776
|
function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
|
|
703
777
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
704
|
-
const
|
|
705
|
-
|
|
778
|
+
const layoutNode = requiredAt(path, i, "hit path node");
|
|
779
|
+
const node = layoutNode.node;
|
|
780
|
+
if (node.type === "textinput") return layoutNode;
|
|
706
781
|
if (node.type === "vstack" || node.type === "hstack") {
|
|
707
782
|
const isFocusable =
|
|
708
783
|
node.props.focusable === true ||
|
|
709
784
|
(node.props.onClick != null && node.props.focusable !== false);
|
|
710
|
-
if (isFocusable) return
|
|
785
|
+
if (isFocusable) return layoutNode;
|
|
711
786
|
}
|
|
712
787
|
}
|
|
713
788
|
return null;
|
|
@@ -747,6 +822,15 @@ function handleBracketedPaste(text: string): void {
|
|
|
747
822
|
commitTextInputEdit(props, editState, nextState);
|
|
748
823
|
}
|
|
749
824
|
|
|
825
|
+
function blurFocusedElement(): boolean {
|
|
826
|
+
const current = findFocusedElement();
|
|
827
|
+
if (!current) return false;
|
|
828
|
+
|
|
829
|
+
changeFocus(null);
|
|
830
|
+
cel.render();
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
|
|
750
834
|
function handleKeyEvent(event: KeyInput): void {
|
|
751
835
|
const { key, text } = event;
|
|
752
836
|
|
|
@@ -793,22 +877,12 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
793
877
|
: (currentIdx - 1 + focusables.length) % focusables.length;
|
|
794
878
|
}
|
|
795
879
|
|
|
796
|
-
changeFocus(focusables
|
|
880
|
+
changeFocus(requiredAt(focusables, nextIdx, "focusable node"));
|
|
797
881
|
cel.render();
|
|
798
882
|
return;
|
|
799
883
|
} // end: not a focused TextInput
|
|
800
884
|
}
|
|
801
885
|
|
|
802
|
-
// Escape: unfocus current element
|
|
803
|
-
if (key === "escape") {
|
|
804
|
-
const current = findFocusedElement();
|
|
805
|
-
if (current) {
|
|
806
|
-
changeFocus(null);
|
|
807
|
-
cel.render();
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
886
|
// Enter: activate focused clickable container
|
|
813
887
|
if (key === "enter") {
|
|
814
888
|
const current = findFocusedElement();
|
|
@@ -850,12 +924,20 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
850
924
|
case "delete":
|
|
851
925
|
newState = deleteForward(editState);
|
|
852
926
|
break;
|
|
927
|
+
case "ctrl+w":
|
|
928
|
+
newState = deleteWordBackward(editState);
|
|
929
|
+
break;
|
|
930
|
+
case "alt+d":
|
|
931
|
+
newState = deleteWordForward(editState);
|
|
932
|
+
break;
|
|
853
933
|
case "left":
|
|
854
934
|
case "right":
|
|
855
935
|
case "up":
|
|
856
936
|
case "down":
|
|
857
937
|
case "home":
|
|
858
938
|
case "end":
|
|
939
|
+
case "ctrl+a":
|
|
940
|
+
case "ctrl+e":
|
|
859
941
|
{
|
|
860
942
|
const tiPadX =
|
|
861
943
|
(focusedInput.node as TextInputNode).props.padding?.x ?? 0;
|
|
@@ -863,13 +945,23 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
863
945
|
0,
|
|
864
946
|
focusedInput.rect.width - tiPadX * 2,
|
|
865
947
|
);
|
|
948
|
+
const direction =
|
|
949
|
+
key === "ctrl+a" ? "home" : key === "ctrl+e" ? "end" : key;
|
|
866
950
|
newState = moveCursor(
|
|
867
951
|
editState,
|
|
868
|
-
|
|
952
|
+
direction as "left" | "right" | "up" | "down" | "home" | "end",
|
|
869
953
|
contentWidth,
|
|
870
954
|
);
|
|
871
955
|
}
|
|
872
956
|
break;
|
|
957
|
+
case "alt+b":
|
|
958
|
+
case "ctrl+left":
|
|
959
|
+
newState = moveCursorByWord(editState, "backward");
|
|
960
|
+
break;
|
|
961
|
+
case "alt+f":
|
|
962
|
+
case "ctrl+right":
|
|
963
|
+
newState = moveCursorByWord(editState, "forward");
|
|
964
|
+
break;
|
|
873
965
|
case "enter":
|
|
874
966
|
case "shift+enter":
|
|
875
967
|
newState = insertChar(editState, "\n");
|
|
@@ -896,7 +988,10 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
896
988
|
const focused = findFocusedElement();
|
|
897
989
|
if (focused) {
|
|
898
990
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
899
|
-
const path = findPathTo(
|
|
991
|
+
const path = findPathTo(
|
|
992
|
+
requiredAt(currentLayouts, i, "layout root"),
|
|
993
|
+
focused,
|
|
994
|
+
);
|
|
900
995
|
if (path) {
|
|
901
996
|
let handlers = collectKeyPressHandlers(path);
|
|
902
997
|
// If a TextInput's onKeyPress was already called in the pre-editing
|
|
@@ -904,7 +999,7 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
904
999
|
if (
|
|
905
1000
|
focusedInput &&
|
|
906
1001
|
handlers.length > 0 &&
|
|
907
|
-
handlers
|
|
1002
|
+
requiredAt(handlers, 0, "key handler").layoutNode === focusedInput
|
|
908
1003
|
) {
|
|
909
1004
|
handlers = handlers.slice(1);
|
|
910
1005
|
}
|
|
@@ -920,28 +1015,45 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
920
1015
|
}
|
|
921
1016
|
// Always return — the key was offered to every handler in the
|
|
922
1017
|
// focused element's path (including root). Even if all returned
|
|
923
|
-
// false, we don't retry via the
|
|
924
|
-
if (consumed)
|
|
1018
|
+
// false, we don't retry via the top-layer fallback path.
|
|
1019
|
+
if (consumed) {
|
|
1020
|
+
cel.render();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (key === "escape" && blurFocusedElement()) return;
|
|
925
1024
|
return;
|
|
926
1025
|
}
|
|
927
1026
|
}
|
|
928
1027
|
}
|
|
929
1028
|
}
|
|
930
1029
|
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1030
|
+
// Fallback: offer the key to the topmost layer's root when it was not
|
|
1031
|
+
// already handled on the focused path.
|
|
1032
|
+
const topLayer = currentLayouts[currentLayouts.length - 1];
|
|
1033
|
+
if (!topLayer) {
|
|
1034
|
+
if (key === "escape") blurFocusedElement();
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const handlers = collectKeyPressHandlers([topLayer]);
|
|
1039
|
+
if (handlers.length > 0) {
|
|
1040
|
+
let consumed = false;
|
|
1041
|
+
for (const h of handlers) {
|
|
1042
|
+
const result = h.handler(key);
|
|
1043
|
+
if (result !== false) {
|
|
1044
|
+
consumed = true;
|
|
1045
|
+
break;
|
|
940
1046
|
}
|
|
1047
|
+
}
|
|
1048
|
+
if (consumed) {
|
|
941
1049
|
cel.render();
|
|
942
1050
|
return;
|
|
943
1051
|
}
|
|
944
1052
|
}
|
|
1053
|
+
|
|
1054
|
+
if (key === "escape" && blurFocusedElement()) return;
|
|
1055
|
+
if (handlers.length === 0) return;
|
|
1056
|
+
cel.render();
|
|
945
1057
|
}
|
|
946
1058
|
|
|
947
1059
|
/**
|
|
@@ -1043,6 +1155,18 @@ export const cel = {
|
|
|
1043
1155
|
process.nextTick(doRender);
|
|
1044
1156
|
},
|
|
1045
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* Set the terminal window or tab title.
|
|
1160
|
+
*
|
|
1161
|
+
* This is an imperative side effect, not part of the render tree.
|
|
1162
|
+
* Control characters are stripped from the title before writing the
|
|
1163
|
+
* terminal sequence. Best effort only — some hosts may ignore it.
|
|
1164
|
+
*/
|
|
1165
|
+
setTitle(title: string): void {
|
|
1166
|
+
if (!terminal) return;
|
|
1167
|
+
terminal.write(`\x1b]2;${sanitizeTerminalTitle(title)}\x1b\\`);
|
|
1168
|
+
},
|
|
1169
|
+
|
|
1046
1170
|
/**
|
|
1047
1171
|
* Stop the framework and restore terminal state.
|
|
1048
1172
|
*
|
package/src/cell-buffer.ts
CHANGED
|
@@ -94,7 +94,12 @@ export class CellBuffer {
|
|
|
94
94
|
if (x < 0 || x >= this._width || y < 0 || y >= this._height) {
|
|
95
95
|
return EMPTY_CELL;
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
|
|
98
|
+
const cell = this.cells[y * this._width + x];
|
|
99
|
+
if (cell === undefined) {
|
|
100
|
+
throw new Error(`Missing cell at (${x}, ${y})`);
|
|
101
|
+
}
|
|
102
|
+
return cell;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/**
|
|
@@ -159,7 +164,7 @@ export class CellBuffer {
|
|
|
159
164
|
const copyH = Math.min(this._height, height);
|
|
160
165
|
for (let y = 0; y < copyH; y++) {
|
|
161
166
|
for (let x = 0; x < copyW; x++) {
|
|
162
|
-
newCells[y * width + x] = this.
|
|
167
|
+
newCells[y * width + x] = this.get(x, y);
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
this.cells = newCells;
|
package/src/emitter.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Color, Theme, ThemeValue } from "@cel-tui/types";
|
|
2
|
-
import type { Cell } from "./cell-buffer.js";
|
|
3
|
-
import { CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
|
|
2
|
+
import type { Cell, CellBuffer } from "./cell-buffer.js";
|
|
4
3
|
|
|
5
4
|
// --- Default ANSI 16 theme ---
|
|
6
5
|
|
package/src/hit-test.ts
CHANGED
|
@@ -13,6 +13,18 @@ function pointInRect(x: number, y: number, rect: Rect): boolean {
|
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function requiredAt<T>(
|
|
17
|
+
items: readonly T[],
|
|
18
|
+
index: number,
|
|
19
|
+
description: string,
|
|
20
|
+
): T {
|
|
21
|
+
const item = items[index];
|
|
22
|
+
if (item === undefined) {
|
|
23
|
+
throw new Error(`Missing ${description} at index ${index}`);
|
|
24
|
+
}
|
|
25
|
+
return item;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Callback to resolve the current scroll offset for a scrollable container.
|
|
18
30
|
* Used to adjust hit coordinates when testing children of scrolled containers.
|
|
@@ -21,8 +33,7 @@ type ScrollOffsetResolver = (ln: LayoutNode) => number;
|
|
|
21
33
|
|
|
22
34
|
/** Default resolver: reads scrollOffset from props, defaults to 0. */
|
|
23
35
|
function defaultScrollResolver(ln: LayoutNode): number {
|
|
24
|
-
|
|
25
|
-
return (props as any)?.scrollOffset ?? 0;
|
|
36
|
+
return getProps(ln)?.scrollOffset ?? 0;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/**
|
|
@@ -82,7 +93,7 @@ export function hitTest(
|
|
|
82
93
|
const testX = x + adjustX;
|
|
83
94
|
const testY = y + adjustY;
|
|
84
95
|
for (let i = current.children.length - 1; i >= 0; i--) {
|
|
85
|
-
const child = current.children
|
|
96
|
+
const child = requiredAt(current.children, i, "layout child");
|
|
86
97
|
if (pointInRect(testX, testY, child.rect)) {
|
|
87
98
|
path.push(child);
|
|
88
99
|
current = child;
|
|
@@ -114,9 +125,10 @@ export function findClickHandler(
|
|
|
114
125
|
path: LayoutNode[],
|
|
115
126
|
): { layoutNode: LayoutNode; handler: () => void } | null {
|
|
116
127
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
117
|
-
const
|
|
128
|
+
const layoutNode = requiredAt(path, i, "hit path node");
|
|
129
|
+
const props = getProps(layoutNode);
|
|
118
130
|
if (props?.onClick) {
|
|
119
|
-
return { layoutNode
|
|
131
|
+
return { layoutNode, handler: props.onClick };
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
return null;
|
|
@@ -131,10 +143,11 @@ export function findClickHandler(
|
|
|
131
143
|
*/
|
|
132
144
|
export function findScrollTarget(path: LayoutNode[]): LayoutNode | null {
|
|
133
145
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
const layoutNode = requiredAt(path, i, "hit path node");
|
|
147
|
+
const node = layoutNode.node;
|
|
148
|
+
if (node.type === "textinput") return layoutNode;
|
|
149
|
+
const props = getProps(layoutNode);
|
|
150
|
+
if (props?.overflow === "scroll") return layoutNode;
|
|
138
151
|
}
|
|
139
152
|
return null;
|
|
140
153
|
}
|
|
@@ -146,17 +159,19 @@ export function findScrollTarget(path: LayoutNode[]): LayoutNode | null {
|
|
|
146
159
|
* @param path - Path from root to current node.
|
|
147
160
|
* @returns Array of handlers ordered from deepest to root.
|
|
148
161
|
*/
|
|
149
|
-
export function collectKeyPressHandlers(
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
export function collectKeyPressHandlers(path: LayoutNode[]): {
|
|
163
|
+
layoutNode: LayoutNode;
|
|
164
|
+
handler: NonNullable<ContainerProps["onKeyPress"]>;
|
|
165
|
+
}[] {
|
|
152
166
|
const handlers: {
|
|
153
167
|
layoutNode: LayoutNode;
|
|
154
|
-
handler:
|
|
168
|
+
handler: NonNullable<ContainerProps["onKeyPress"]>;
|
|
155
169
|
}[] = [];
|
|
156
170
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
157
|
-
const
|
|
171
|
+
const layoutNode = requiredAt(path, i, "hit path node");
|
|
172
|
+
const props = getProps(layoutNode);
|
|
158
173
|
if (props?.onKeyPress) {
|
|
159
|
-
handlers.push({ layoutNode
|
|
174
|
+
handlers.push({ layoutNode, handler: props.onKeyPress });
|
|
160
175
|
}
|
|
161
176
|
}
|
|
162
177
|
return handlers;
|