@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.6.2",
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.6.2",
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, defaultTheme } from "./emitter.js";
8
+ import { defaultTheme, emitBuffer, emitDiff } from "./emitter.js";
9
9
  import {
10
- hitTest,
10
+ collectFocusable,
11
+ collectKeyPressHandlers,
11
12
  findClickHandler,
12
13
  findScrollTarget,
13
- collectKeyPressHandlers,
14
- collectFocusable,
14
+ hitTest,
15
15
  } from "./hit-test.js";
16
16
  import { decodeKeyEvents, isEditingKey, type KeyInput } from "./keys.js";
17
- import { layout, type LayoutNode } from "./layout.js";
17
+ import { type LayoutNode, layout } from "./layout.js";
18
18
  import {
19
- paint,
20
19
  getTextInputCursor,
21
- setTextInputCursor,
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
- moveCursor,
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(currentLayouts[i]!);
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 = focusables[frameworkFocusIndex]!;
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
- const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
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 cb = parseInt(match[1]!, 10);
372
- const x = parseInt(match[2]!, 10) - 1; // 1-indexed → 0-indexed
373
- const y = parseInt(match[3]!, 10) - 1;
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(currentLayouts[i]!);
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[frameworkFocusIndex]!;
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[i]!)) {
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[frameworkFocusIndex]!;
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
- (node.props as any).focused = true;
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 (node.props as any).focused;
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(root.children[i]!, target);
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(currentLayouts[i]!, target);
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[i]!, `L${i}:`);
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
- (node.props as any).scrollOffset = offset;
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[i]!, childKey);
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 (node.props as any).scrollOffset;
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 ((props as any).scrollOffset !== undefined)
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[i]!;
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
- const props = target.node.type !== "text" ? target.node.props : null;
659
- if (props && (props as any).overflow === "scroll") {
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 node = path[i]!.node;
705
- if (node.type === "textinput") return path[i]!;
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 path[i]!;
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[nextIdx]!);
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
- key as "left" | "right" | "up" | "down" | "home" | "end",
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(currentLayouts[i]!, focused);
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[0]!.layoutNode === focusedInput
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 unfocused fallback path.
924
- if (consumed) cel.render();
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
- // No focused element try root onKeyPress on topmost layer
932
- for (let i = currentLayouts.length - 1; i >= 0; i--) {
933
- const layoutRoot = currentLayouts[i]!;
934
- const path = [layoutRoot];
935
- const handlers = collectKeyPressHandlers(path);
936
- if (handlers.length > 0) {
937
- for (const h of handlers) {
938
- const result = h.handler(key);
939
- if (result !== false) break;
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
  *
@@ -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
- return this.cells[y * this._width + x]!;
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.cells[y * this._width + x]!;
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
- const props = getProps(ln);
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[i]!;
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 props = getProps(path[i]!);
128
+ const layoutNode = requiredAt(path, i, "hit path node");
129
+ const props = getProps(layoutNode);
118
130
  if (props?.onClick) {
119
- return { layoutNode: path[i]!, handler: props.onClick };
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 node = path[i]!.node;
135
- if (node.type === "textinput") return path[i]!;
136
- const props = getProps(path[i]!);
137
- if (props?.overflow === "scroll") return path[i]!;
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
- path: LayoutNode[],
151
- ): { layoutNode: LayoutNode; handler: (key: string) => boolean | void }[] {
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: (key: string) => boolean | void;
168
+ handler: NonNullable<ContainerProps["onKeyPress"]>;
155
169
  }[] = [];
156
170
  for (let i = path.length - 1; i >= 0; i--) {
157
- const props = getProps(path[i]!);
171
+ const layoutNode = requiredAt(path, i, "hit path node");
172
+ const props = getProps(layoutNode);
158
173
  if (props?.onKeyPress) {
159
- handlers.push({ layoutNode: path[i]!, handler: props.onKeyPress });
174
+ handlers.push({ layoutNode, handler: props.onKeyPress });
160
175
  }
161
176
  }
162
177
  return handlers;