@cel-tui/core 0.6.2 → 0.7.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/index.ts CHANGED
@@ -29,25 +29,24 @@
29
29
 
30
30
  export type {
31
31
  Color,
32
- ThemeValue,
33
- Theme,
34
- StyleProps,
35
- SizeValue,
32
+ ContainerNode,
36
33
  ContainerProps,
37
- TextProps,
34
+ Node,
35
+ SizeValue,
36
+ StyleProps,
37
+ TextInputNode,
38
38
  TextInputProps,
39
39
  TextNode,
40
- TextInputNode,
41
- ContainerNode,
42
- Node,
40
+ TextProps,
41
+ Theme,
42
+ ThemeValue,
43
43
  } from "@cel-tui/types";
44
-
45
- export { VStack, HStack } from "./primitives/stacks.js";
46
- export { Text } from "./primitives/text.js";
47
- export { TextInput } from "./primitives/text-input.js";
48
44
  export { cel } from "./cel.js";
45
+ export { type Cell, CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
46
+ export { defaultTheme, emitBuffer } from "./emitter.js";
49
47
  export { measureContentHeight } from "./layout.js";
50
- export { CellBuffer, EMPTY_CELL, type Cell } from "./cell-buffer.js";
51
- export { emitBuffer, defaultTheme } from "./emitter.js";
52
- export { visibleWidth, extractAnsiCode } from "./width.js";
53
- export { type Terminal, ProcessTerminal, MockTerminal } from "./terminal.js";
48
+ export { HStack, VStack } from "./primitives/stacks.js";
49
+ export { Text } from "./primitives/text.js";
50
+ export { TextInput } from "./primitives/text-input.js";
51
+ export { MockTerminal, ProcessTerminal, type Terminal } from "./terminal.js";
52
+ export { extractAnsiCode, visibleWidth } from "./width.js";
package/src/keys.ts CHANGED
@@ -106,7 +106,7 @@ function decodeModifiers(param: number): string {
106
106
  if (bits & 4) parts.push("ctrl");
107
107
  if (bits & 2) parts.push("alt");
108
108
  if (bits & 1) parts.push("shift");
109
- return parts.length > 0 ? parts.join("+") + "+" : "";
109
+ return parts.length > 0 ? `${parts.join("+")}+` : "";
110
110
  }
111
111
 
112
112
  /** Build a key string from a modifier prefix and base key name. */
@@ -125,6 +125,13 @@ const CSI_LETTER_RE = /^(?:1;(\d+))?([A-H])$/;
125
125
  /** Match CSI tilde format: ESC [ <number> ; <modifiers> ~ */
126
126
  const CSI_TILDE_RE = /^(\d+)(?:;(\d+))?~$/;
127
127
 
128
+ function parseDecimal(value: string | undefined, description: string): number {
129
+ if (value === undefined) {
130
+ throw new Error(`Missing ${description}`);
131
+ }
132
+ return parseInt(value, 10);
133
+ }
134
+
128
135
  function parseCsiSequence(seq: string): KeyInput {
129
136
  // Legacy Shift+Tab (CSI Z) — sent by tmux and some terminals
130
137
  if (seq === "Z") return { key: "shift+tab" };
@@ -132,8 +139,8 @@ function parseCsiSequence(seq: string): KeyInput {
132
139
  // CSI u format: codepoint [; modifiers] u
133
140
  let match = CSI_U_RE.exec(seq);
134
141
  if (match) {
135
- const codepoint = parseInt(match[1]!, 10);
136
- const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
142
+ const codepoint = parseDecimal(match[1], "CSI-u codepoint");
143
+ const modParam = match[2] ? parseDecimal(match[2], "CSI-u modifier") : 0;
137
144
 
138
145
  const name = CODEPOINT_NAMES[codepoint];
139
146
  if (name) {
@@ -151,8 +158,13 @@ function parseCsiSequence(seq: string): KeyInput {
151
158
  // CSI letter format: [1 ; modifiers] <letter>
152
159
  match = CSI_LETTER_RE.exec(seq);
153
160
  if (match) {
154
- const modParam = match[1] ? parseInt(match[1]!, 10) : 0;
155
- const letter = match[2]!;
161
+ const modParam = match[1]
162
+ ? parseDecimal(match[1], "CSI-letter modifier")
163
+ : 0;
164
+ const letter = match[2];
165
+ if (letter === undefined) {
166
+ throw new Error("Missing CSI-letter key");
167
+ }
156
168
  const name = LETTER_NAMES[letter];
157
169
  if (name) return { key: withModifiers(modParam, name) };
158
170
  }
@@ -160,8 +172,10 @@ function parseCsiSequence(seq: string): KeyInput {
160
172
  // CSI tilde format: number [; modifiers] ~
161
173
  match = CSI_TILDE_RE.exec(seq);
162
174
  if (match) {
163
- const num = parseInt(match[1]!, 10);
164
- const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
175
+ const num = parseDecimal(match[1], "CSI-tilde code");
176
+ const modParam = match[2]
177
+ ? parseDecimal(match[2], "CSI-tilde modifier")
178
+ : 0;
165
179
  const name = TILDE_NAMES[num];
166
180
  if (name) return { key: withModifiers(modParam, name) };
167
181
  }
@@ -240,7 +254,10 @@ export function decodeKeyEvents(data: string): KeyInput[] {
240
254
  continue;
241
255
  }
242
256
 
243
- const char = data[index]!;
257
+ const char = data[index];
258
+ if (char === undefined) {
259
+ break;
260
+ }
244
261
  if (char === "\x1b") {
245
262
  const next = readCodePoint(data, index + 1);
246
263
  if (next && next.value !== "[") {
@@ -291,7 +308,10 @@ export function normalizeKey(key: string): string {
291
308
  const parts = key.toLowerCase().split("+");
292
309
  if (parts.length <= 1) return key.toLowerCase();
293
310
 
294
- const base = parts[parts.length - 1]!;
311
+ const base = parts.at(-1);
312
+ if (!base) {
313
+ return key.toLowerCase();
314
+ }
295
315
  const mods = parts.slice(0, -1);
296
316
 
297
317
  const ordered: string[] = [];
@@ -306,8 +326,10 @@ export function normalizeKey(key: string): string {
306
326
  * Check whether a semantic key represents TextInput editing/navigation.
307
327
  *
308
328
  * Single-character semantic keys represent insertable text, while named keys
309
- * like `"enter"` and `"left"` represent editing/navigation actions. Modifier
310
- * combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble.
329
+ * like `"enter"` and `"left"` represent editing/navigation actions. Most
330
+ * modifier combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble,
331
+ * but TextInput consumes a small set of readline-style shortcuts for cursor
332
+ * movement and word deletion.
311
333
  */
312
334
  export function isEditingKey(key: string): boolean {
313
335
  if (key.length === 1) return true;
@@ -326,6 +348,14 @@ export function isEditingKey(key: string): boolean {
326
348
  "space",
327
349
  "plus",
328
350
  "shift+enter",
351
+ "ctrl+a",
352
+ "ctrl+e",
353
+ "alt+b",
354
+ "alt+f",
355
+ "ctrl+left",
356
+ "ctrl+right",
357
+ "ctrl+w",
358
+ "alt+d",
329
359
  ]);
330
360
 
331
361
  return editingKeys.has(key);
package/src/layout.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Node, ContainerProps, SizeValue } from "@cel-tui/types";
1
+ import type { ContainerProps, Node, SizeValue } from "@cel-tui/types";
2
2
  import { layoutText } from "./text-layout.js";
3
3
  import { visibleWidth } from "./width.js";
4
4
 
@@ -33,7 +33,13 @@ function resolveSizeValue(
33
33
  if (value === undefined) return undefined;
34
34
  if (typeof value === "number") return value;
35
35
  const match = value.match(/^(\d+(?:\.\d+)?)%$/);
36
- if (match) return Math.floor((parentSize * parseFloat(match[1]!)) / 100);
36
+ if (match) {
37
+ const percentage = match[1];
38
+ if (percentage === undefined) {
39
+ throw new Error(`Invalid percentage size: ${value}`);
40
+ }
41
+ return Math.floor((parentSize * parseFloat(percentage)) / 100);
42
+ }
37
43
  return undefined;
38
44
  }
39
45
 
@@ -46,6 +52,25 @@ function getProps(node: Node): ContainerProps | null {
46
52
  return node.props;
47
53
  }
48
54
 
55
+ function requiredAt<T>(
56
+ items: readonly T[],
57
+ index: number,
58
+ description: string,
59
+ ): T {
60
+ const item = items[index];
61
+ if (item === undefined) {
62
+ throw new Error(`Missing ${description} at index ${index}`);
63
+ }
64
+ return item;
65
+ }
66
+
67
+ function getMainAxisGap(
68
+ gap: number,
69
+ justify: ContainerProps["justifyContent"] | undefined,
70
+ ): number {
71
+ return justify === "space-between" ? 0 : gap;
72
+ }
73
+
49
74
  // --- Intrinsic size computation ---
50
75
 
51
76
  /**
@@ -93,6 +118,7 @@ function intrinsicMainSize(
93
118
  // If it's the cross axis, take the max of children on that axis.
94
119
  const props = node.props;
95
120
  const gap = props.gap ?? 0;
121
+ const mainAxisGap = getMainAxisGap(gap, props.justifyContent);
96
122
  const containerIsVertical = node.type === "vstack";
97
123
  const axisMatchesMain = isVertical === containerIsVertical;
98
124
 
@@ -108,7 +134,7 @@ function intrinsicMainSize(
108
134
  // Sum children along the main axis + gaps
109
135
  let total = 0;
110
136
  for (let i = 0; i < node.children.length; i++) {
111
- const child = node.children[i]!;
137
+ const child = requiredAt(node.children, i, "child node");
112
138
  const cProps = getProps(child);
113
139
 
114
140
  let childMain: number;
@@ -131,7 +157,7 @@ function intrinsicMainSize(
131
157
  childMain = clamp(childMain, minMain, maxMain);
132
158
  }
133
159
  total += childMain;
134
- if (i < node.children.length - 1) total += gap;
160
+ if (i < node.children.length - 1) total += mainAxisGap;
135
161
  }
136
162
  return total + padMain;
137
163
  }
@@ -167,12 +193,13 @@ function intrinsicMainSize(
167
193
  }
168
194
  wrapHeights.push(h);
169
195
  }
170
- const wrapRows = assignWrapRows(wrapWidths, innerCross, gap);
196
+ const wrapRows = assignWrapRows(wrapWidths, innerCross, mainAxisGap);
171
197
  let total = 0;
172
198
  for (let ri = 0; ri < wrapRows.length; ri++) {
173
199
  let maxH = 0;
174
- for (const idx of wrapRows[ri]!) {
175
- if (wrapHeights[idx]! > maxH) maxH = wrapHeights[idx]!;
200
+ for (const idx of requiredAt(wrapRows, ri, "wrap row")) {
201
+ const height = requiredAt(wrapHeights, idx, "wrap height");
202
+ if (height > maxH) maxH = height;
176
203
  }
177
204
  total += maxH;
178
205
  if (ri < wrapRows.length - 1) total += gap;
@@ -230,7 +257,7 @@ function assignWrapRows(
230
257
  let rowWidth = 0;
231
258
 
232
259
  for (let i = 0; i < widths.length; i++) {
233
- const w = widths[i]!;
260
+ const w = requiredAt(widths, i, "wrap width");
234
261
  if (currentRow.length === 0) {
235
262
  currentRow.push(i);
236
263
  rowWidth = w;
@@ -262,7 +289,8 @@ function largestRemainder(fractions: number[], total: number): number[] {
262
289
 
263
290
  for (const { i } of indices) {
264
291
  if (remainder <= 0) break;
265
- floored[i]!++;
292
+ const value = requiredAt(floored, i, "rounded size");
293
+ floored[i] = value + 1;
266
294
  remainder--;
267
295
  }
268
296
 
@@ -339,6 +367,7 @@ function layoutWrapHStack(
339
367
  const gap = props.gap ?? 0;
340
368
  const align = props.alignItems ?? "stretch";
341
369
  const justify = props.justifyContent ?? "start";
370
+ const itemGap = getMainAxisGap(gap, justify);
342
371
 
343
372
  // Padding
344
373
  const padX = props.padding?.x ?? 0;
@@ -384,25 +413,26 @@ function layoutWrapHStack(
384
413
  }
385
414
 
386
415
  // Phase 2: Assign children to rows
387
- const rows = assignWrapRows(baseWidths, innerW, gap);
416
+ const rows = assignWrapRows(baseWidths, innerW, itemGap);
388
417
 
389
418
  // Phase 3: Layout each row independently
390
419
  const layoutChildren: LayoutNode[] = [];
391
420
  let rowY = 0;
392
421
 
393
422
  for (let ri = 0; ri < rows.length; ri++) {
394
- const rowIdx = rows[ri]!;
395
- const rowGapTotal = gap * (rowIdx.length - 1);
423
+ const rowIdx = requiredAt(rows, ri, "wrap row");
424
+ const rowGapTotal = itemGap * (rowIdx.length - 1);
396
425
  const rowAvail = innerW - rowGapTotal;
397
426
 
398
427
  // Compute fixed and flex totals for this row
399
428
  let fixedMain = 0;
400
429
  let totalFlex = 0;
401
430
  for (const idx of rowIdx) {
402
- if (flexValues[idx]! > 0) {
403
- totalFlex += flexValues[idx]!;
431
+ const flexValue = requiredAt(flexValues, idx, "flex value");
432
+ if (flexValue > 0) {
433
+ totalFlex += flexValue;
404
434
  } else {
405
- fixedMain += baseWidths[idx]!;
435
+ fixedMain += requiredAt(baseWidths, idx, "base width");
406
436
  }
407
437
  }
408
438
 
@@ -413,17 +443,25 @@ function layoutWrapHStack(
413
443
  if (totalFlex > 0) {
414
444
  const flexPositions: number[] = [];
415
445
  for (let ci = 0; ci < rowIdx.length; ci++) {
416
- if (flexValues[rowIdx[ci]!]! > 0) flexPositions.push(ci);
446
+ const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
447
+ if (requiredAt(flexValues, rowIndex, "flex value") > 0) {
448
+ flexPositions.push(ci);
449
+ }
417
450
  }
418
- const rawSizes = flexPositions.map(
419
- (ci) => (flexValues[rowIdx[ci]!]! / totalFlex) * flexSpace,
420
- );
451
+ const rawSizes = flexPositions.map((ci) => {
452
+ const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
453
+ return (
454
+ (requiredAt(flexValues, rowIndex, "flex value") / totalFlex) *
455
+ flexSpace
456
+ );
457
+ });
421
458
  const rounded = largestRemainder(rawSizes, flexSpace);
422
459
 
423
460
  for (let fi = 0; fi < flexPositions.length; fi++) {
424
- const ci = flexPositions[fi]!;
425
- let size = rounded[fi]!;
426
- const cProps = getProps(children[rowIdx[ci]!]!);
461
+ const ci = requiredAt(flexPositions, fi, "flex position");
462
+ let size = requiredAt(rounded, fi, "rounded flex size");
463
+ const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
464
+ const cProps = getProps(requiredAt(children, rowIndex, "child node"));
427
465
  if (cProps) {
428
466
  size = clamp(size, cProps.minWidth ?? 0, cProps.maxWidth ?? Infinity);
429
467
  }
@@ -434,20 +472,22 @@ function layoutWrapHStack(
434
472
  // Non-flex children keep their base width
435
473
  for (let ci = 0; ci < rowIdx.length; ci++) {
436
474
  if (childWidths[ci] === undefined) {
437
- childWidths[ci] = baseWidths[rowIdx[ci]!]!;
475
+ const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
476
+ childWidths[ci] = requiredAt(baseWidths, rowIndex, "base width");
438
477
  }
439
478
  }
440
479
 
441
480
  // Row height = max cross size of children in this row
442
481
  let rowHeight = 0;
443
482
  for (const idx of rowIdx) {
444
- if (crossSizes[idx]! > rowHeight) rowHeight = crossSizes[idx]!;
483
+ const crossSize = requiredAt(crossSizes, idx, "cross size");
484
+ if (crossSize > rowHeight) rowHeight = crossSize;
445
485
  }
446
486
 
447
487
  // justifyContent: compute main-axis starting offset
448
488
  let totalUsedWidth = rowGapTotal;
449
489
  for (let ci = 0; ci < rowIdx.length; ci++) {
450
- totalUsedWidth += childWidths[ci]!;
490
+ totalUsedWidth += requiredAt(childWidths, ci, "child width");
451
491
  }
452
492
  const remainingMain = Math.max(0, innerW - totalUsedWidth);
453
493
 
@@ -470,17 +510,17 @@ function layoutWrapHStack(
470
510
  // Position children in this row
471
511
  let xOffset = mainStart;
472
512
  for (let ci = 0; ci < rowIdx.length; ci++) {
473
- const idx = rowIdx[ci]!;
474
- const childW = childWidths[ci]!;
513
+ const idx = requiredAt(rowIdx, ci, "wrap row index");
514
+ const childW = requiredAt(childWidths, ci, "child width");
475
515
 
476
516
  // Cross-axis sizing: stretch fills row height, others keep their size
477
517
  let childH: number;
478
518
  if (align === "stretch") {
479
- const cProps = getProps(children[idx]!);
519
+ const cProps = getProps(requiredAt(children, idx, "child node"));
480
520
  const explicitH = resolveSizeValue(cProps?.height, innerH);
481
521
  childH = explicitH ?? rowHeight;
482
522
  } else {
483
- childH = crossSizes[idx]!;
523
+ childH = requiredAt(crossSizes, idx, "cross size");
484
524
  }
485
525
 
486
526
  // Cross-axis alignment within the row
@@ -495,14 +535,20 @@ function layoutWrapHStack(
495
535
  const childY = innerY + rowY + crossOffset;
496
536
 
497
537
  layoutChildren.push(
498
- layoutNode(children[idx]!, childX, childY, childW, childH),
538
+ layoutNode(
539
+ requiredAt(children, idx, "child node"),
540
+ childX,
541
+ childY,
542
+ childW,
543
+ childH,
544
+ ),
499
545
  );
500
546
 
501
547
  xOffset += childW;
502
548
  if (ci < rowIdx.length - 1) {
503
- xOffset += gap;
549
+ xOffset += itemGap;
504
550
  if (betweenGaps) {
505
- xOffset += betweenGaps[ci]!;
551
+ xOffset += requiredAt(betweenGaps, ci, "justified gap");
506
552
  }
507
553
  }
508
554
  }
@@ -572,7 +618,9 @@ function layoutNode(
572
618
 
573
619
  // Gap
574
620
  const gap = props.gap ?? 0;
575
- const totalGap = gap * (children.length - 1);
621
+ const justify = props.justifyContent ?? "start";
622
+ const mainAxisGap = getMainAxisGap(gap, justify);
623
+ const totalGap = mainAxisGap * (children.length - 1);
576
624
  const mainAvail = (isVertical ? innerH : innerW) - totalGap;
577
625
 
578
626
  // --- Measure phase: compute each child's main-axis and cross-axis size ---
@@ -675,8 +723,9 @@ function layoutNode(
675
723
  const rounded = largestRemainder(rawSizes, flexSpace);
676
724
 
677
725
  for (let i = 0; i < flexInfos.length; i++) {
678
- let size = rounded[i]!;
679
- const cProps = getProps(flexInfos[i]!.node);
726
+ let size = requiredAt(rounded, i, "rounded flex size");
727
+ const flexInfo = requiredAt(flexInfos, i, "flex child info");
728
+ const cProps = getProps(flexInfo.node);
680
729
  if (cProps) {
681
730
  const minMain = isVertical
682
731
  ? (cProps.minHeight ?? 0)
@@ -686,7 +735,7 @@ function layoutNode(
686
735
  : (cProps.maxWidth ?? Infinity);
687
736
  size = clamp(size, minMain, maxMain);
688
737
  }
689
- flexInfos[i]!.mainSize = size;
738
+ flexInfo.mainSize = size;
690
739
  }
691
740
  }
692
741
 
@@ -700,7 +749,6 @@ function layoutNode(
700
749
  const remainingMain = Math.max(0, mainInner - totalContent);
701
750
 
702
751
  // justifyContent: compute main-axis starting offset and per-gap extra space
703
- const justify = props.justifyContent ?? "start";
704
752
  let mainStart = 0;
705
753
  let betweenGaps: number[] | null = null;
706
754
 
@@ -722,7 +770,7 @@ function layoutNode(
722
770
  let mainOffset = mainStart;
723
771
 
724
772
  for (let i = 0; i < infos.length; i++) {
725
- const info = infos[i]!;
773
+ const info = requiredAt(infos, i, "child layout info");
726
774
 
727
775
  // Cross-axis alignment
728
776
  let crossOffset = 0;
@@ -742,9 +790,9 @@ function layoutNode(
742
790
 
743
791
  mainOffset += info.mainSize;
744
792
  if (i < infos.length - 1) {
745
- mainOffset += gap;
793
+ mainOffset += mainAxisGap;
746
794
  if (betweenGaps) {
747
- mainOffset += betweenGaps[i]!;
795
+ mainOffset += requiredAt(betweenGaps, i, "justified gap");
748
796
  }
749
797
  }
750
798
  }
package/src/paint.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
2
- import type { Cell } from "./cell-buffer.js";
3
- import { CellBuffer } from "./cell-buffer.js";
2
+ import type { Cell, CellBuffer } from "./cell-buffer.js";
4
3
  import type { LayoutNode, Rect } from "./layout.js";
5
4
  import { getMaxScrollOffset } from "./scroll.js";
6
5
  import { layoutText } from "./text-layout.js";
@@ -233,23 +232,30 @@ function paintScrollbar(
233
232
  ): void {
234
233
  const { rect, children } = ln;
235
234
  const isVertical = ln.node.type === "vstack";
235
+ const props =
236
+ ln.node.type === "vstack" || ln.node.type === "hstack"
237
+ ? ln.node.props
238
+ : null;
239
+ if (!props) return;
236
240
 
237
241
  if (isVertical) {
238
- // Compute total content height from children
242
+ // Compute total scrollable height from children plus bottom padding.
243
+ // Child positions already include top padding, so add only the trailing pad.
239
244
  let contentHeight = 0;
240
245
  for (const child of children) {
241
246
  const childBottom = child.rect.y + child.rect.height - rect.y;
242
247
  if (childBottom > contentHeight) contentHeight = childBottom;
243
248
  }
249
+ const scrollHeight = contentHeight + (props.padding?.y ?? 0);
244
250
  const viewportH = rect.height;
245
- if (contentHeight <= viewportH) return; // no scrollbar needed
251
+ if (scrollHeight <= viewportH) return; // no scrollbar needed
246
252
 
247
253
  // Thumb size and position
248
254
  const thumbSize = Math.max(
249
255
  1,
250
- Math.round((viewportH / contentHeight) * viewportH),
256
+ Math.round((viewportH / scrollHeight) * viewportH),
251
257
  );
252
- const maxOffset = contentHeight - viewportH;
258
+ const maxOffset = scrollHeight - viewportH;
253
259
  const thumbPos =
254
260
  maxOffset > 0
255
261
  ? Math.round((scrollOffset / maxOffset) * (viewportH - thumbSize))
@@ -277,14 +283,15 @@ function paintScrollbar(
277
283
  const childRight = child.rect.x + child.rect.width - rect.x;
278
284
  if (childRight > contentWidth) contentWidth = childRight;
279
285
  }
286
+ const scrollWidth = contentWidth + (props.padding?.x ?? 0);
280
287
  const viewportW = rect.width;
281
- if (contentWidth <= viewportW) return;
288
+ if (scrollWidth <= viewportW) return;
282
289
 
283
290
  const thumbSize = Math.max(
284
291
  1,
285
- Math.round((viewportW / contentWidth) * viewportW),
292
+ Math.round((viewportW / scrollWidth) * viewportW),
286
293
  );
287
- const maxOffset = contentWidth - viewportW;
294
+ const maxOffset = scrollWidth - viewportW;
288
295
  const thumbPos =
289
296
  maxOffset > 0
290
297
  ? Math.round((scrollOffset / maxOffset) * (viewportW - thumbSize))
@@ -414,8 +421,11 @@ function paintText(
414
421
 
415
422
  // Paint lines, clipped to rect (grapheme-aware)
416
423
  for (let row = 0; row < textLayout.lineCount && row < h; row++) {
417
- const line = textLayout.lines[row]!.text;
418
- paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
424
+ const line = textLayout.lines[row];
425
+ if (!line) {
426
+ break;
427
+ }
428
+ paintLineGraphemes(line.text, x, y + row, w, clipRect, props, buf);
419
429
  }
420
430
  }
421
431
 
@@ -494,8 +504,11 @@ function paintTextInput(
494
504
  for (let row = 0; row < ch; row++) {
495
505
  const lineIdx = scrollOffset + row;
496
506
  if (lineIdx >= textLayout.lineCount) break;
497
- const line = textLayout.lines[lineIdx]!.text;
498
- paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
507
+ const line = textLayout.lines[lineIdx];
508
+ if (!line) {
509
+ break;
510
+ }
511
+ paintLineGraphemes(line.text, cx, cy + row, cw, clipRect, props, buf);
499
512
  }
500
513
 
501
514
  // Paint cursor if focused — invert colors at cursor position so it's
@@ -536,8 +549,6 @@ function paintTextInput(
536
549
 
537
550
  // --- Framework-managed state ---
538
551
 
539
- import type { ContainerProps } from "@cel-tui/types";
540
-
541
552
  /**
542
553
  * TextInput state is keyed on the `onChange` function reference, which is
543
554
  * a stable identity across re-renders (the app provides the same closure).
@@ -12,9 +12,13 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
12
12
  * responds to mouse wheel automatically.
13
13
  *
14
14
  * TextInput is always focusable. When focused, it consumes insertable text
15
- * plus editing/navigation keys (arrows, backspace, delete, Enter, Tab).
16
- * Modifier combos (e.g., `ctrl+s`) and non-insertable control keys bubble
17
- * up to ancestor `onKeyPress` handlers.
15
+ * plus editing/navigation keys (arrows, backspace, delete, Enter, Tab),
16
+ * along with a small set of readline-style shortcuts: `ctrl+a` / `ctrl+e`,
17
+ * `alt+b` / `alt+f`, `ctrl+left` / `ctrl+right`, `ctrl+w`, and `alt+d`.
18
+ * Word movement and deletion use whitespace-delimited boundaries, and
19
+ * `up` / `down` follow visual wrapped lines. Other modifier combos (e.g.,
20
+ * `ctrl+s`) and non-insertable control keys bubble up to ancestor
21
+ * `onKeyPress` handlers.
18
22
  *
19
23
  * Use `onKeyPress` to intercept keys before editing. The handler receives a
20
24
  * normalized semantic key string; inserted text preserves the original
package/src/scroll.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ContainerProps } from "@cel-tui/types";
1
2
  import type { LayoutNode } from "./layout.js";
2
3
  import { layoutText } from "./text-layout.js";
3
4
 
@@ -5,8 +6,11 @@ function isVerticalScrollTarget(target: LayoutNode): boolean {
5
6
  return target.node.type === "vstack" || target.node.type === "textinput";
6
7
  }
7
8
 
8
- function getScrollTargetProps(target: LayoutNode): Record<string, any> {
9
- return target.node.props as Record<string, any>;
9
+ function getScrollTargetProps(target: LayoutNode): ContainerProps {
10
+ if (target.node.type === "text") {
11
+ throw new Error("Text nodes cannot be scroll targets");
12
+ }
13
+ return target.node.props;
10
14
  }
11
15
 
12
16
  function getScrollViewportMainAxisSize(target: LayoutNode): number {
@@ -26,7 +30,7 @@ function getScrollViewportMainAxisSize(target: LayoutNode): number {
26
30
  */
27
31
  export function getScrollStep(target: LayoutNode): number {
28
32
  const rawStep = getScrollTargetProps(target).scrollStep;
29
- if (Number.isFinite(rawStep) && rawStep > 0) {
33
+ if (typeof rawStep === "number" && Number.isFinite(rawStep) && rawStep > 0) {
30
34
  return Math.max(1, Math.floor(rawStep));
31
35
  }
32
36
 
@@ -55,9 +59,9 @@ export function getMaxScrollOffset(target: LayoutNode): number {
55
59
  }
56
60
 
57
61
  const isVertical = target.node.type === "vstack";
58
- const props = target.node.type !== "text" ? target.node.props : null;
59
- const padX = (props as any)?.padding?.x ?? 0;
60
- const padY = (props as any)?.padding?.y ?? 0;
62
+ const props = getScrollTargetProps(target);
63
+ const padX = props.padding?.x ?? 0;
64
+ const padY = props.padding?.y ?? 0;
61
65
 
62
66
  if (isVertical) {
63
67
  let contentHeight = 0;