@cel-tui/core 0.3.0 → 0.4.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/src/keys.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Key parsing for the Kitty keyboard protocol (level 1 — disambiguate).
2
+ * Key parsing for Kitty-first terminal input with legacy compatibility.
3
3
  *
4
- * At level 1, the terminal sends:
4
+ * The decoder accepts a mixed keyboard stream containing:
5
5
  * - **CSI u** (`ESC [ codepoint ; modifiers u`) for modified special keys
6
6
  * and modifier combos (Ctrl+letter, Alt+letter, Shift+Tab, Ctrl+Enter, etc.)
7
7
  * - **CSI letter** (`ESC [ A/B/C/D/H/F` or `ESC [ 1 ; modifiers letter`) for
@@ -9,16 +9,26 @@
9
9
  * - **CSI tilde** (`ESC [ number ~` or `ESC [ number ; modifiers ~`) for
10
10
  * Delete, PageUp, PageDown, and function keys
11
11
  * - **Legacy bytes** for unmodified special keys (Tab=0x09, Enter=0x0D,
12
- * Escape=0x1B, Backspace=0x7F) — these retain their traditional encoding
13
- * - **Raw bytes** for unmodified printable characters
12
+ * Escape=0x1B, Backspace=0x7F)
13
+ * - **Recoverable control bytes** for legacy `ctrl+letter` shortcuts
14
+ * - **ESC-prefixed Alt combinations** such as `ESC x`
15
+ * - **Raw printable text**
14
16
  *
15
17
  * Modifier bitmask (wire value = bitmask + 1):
16
- * shift=1, alt=2, ctrl=4 e.g., ctrl = bitmask 4, wire param = 5
18
+ * shift=1, alt=2, ctrl=4 -> e.g., ctrl = bitmask 4, wire param = 5
17
19
  *
18
20
  * @module
19
21
  */
20
22
 
21
- // --- Codepoint named key mappings ---
23
+ /** A decoded keyboard event from the terminal input stream. */
24
+ export interface KeyInput {
25
+ /** Normalized semantic key string (e.g. `"ctrl+s"`, `"enter"`, `"a"`). */
26
+ key: string;
27
+ /** Original insertable text, when this key should insert text. */
28
+ text?: string;
29
+ }
30
+
31
+ // --- Codepoint -> named key mappings ---
22
32
 
23
33
  /** Named key lookup from Unicode codepoint (for CSI u sequences). */
24
34
  const CODEPOINT_NAMES: Record<number, string> = {
@@ -60,7 +70,7 @@ const TILDE_NAMES: Record<number, string> = {
60
70
  };
61
71
 
62
72
  /**
63
- * Legacy byte named key mapping for unmodified special keys.
73
+ * Legacy byte -> named key mapping for unmodified special keys.
64
74
  *
65
75
  * At Kitty level 1, unmodified special keys that have well-known legacy
66
76
  * encodings retain those encodings. Only modified variants (e.g., Shift+Tab,
@@ -68,8 +78,8 @@ const TILDE_NAMES: Record<number, string> = {
68
78
  * bytes that arrive for the unmodified case.
69
79
  */
70
80
  const LEGACY_BYTE_NAMES: Record<number, string> = {
71
- 9: "tab", // \t — also Ctrl+I in legacy, but at level 1 Ctrl+I → CSI u
72
- 13: "enter", // \r — also Ctrl+M in legacy, but at level 1 Ctrl+M → CSI u
81
+ 9: "tab", // \t — also Ctrl+I in legacy, but collapsed to tab here
82
+ 13: "enter", // \r — also Ctrl+M in legacy, but collapsed to enter here
73
83
  27: "escape", // \x1b — bare ESC byte
74
84
  127: "backspace", // \x7f — DEL byte
75
85
  };
@@ -99,9 +109,7 @@ function decodeModifiers(param: number): string {
99
109
  return parts.length > 0 ? parts.join("+") + "+" : "";
100
110
  }
101
111
 
102
- /**
103
- * Build a key string from a modifier prefix and base key name.
104
- */
112
+ /** Build a key string from a modifier prefix and base key name. */
105
113
  function withModifiers(modParam: number, base: string): string {
106
114
  return decodeModifiers(modParam) + base;
107
115
  }
@@ -117,88 +125,157 @@ const CSI_LETTER_RE = /^(?:1;(\d+))?([A-H])$/;
117
125
  /** Match CSI tilde format: ESC [ <number> ; <modifiers> ~ */
118
126
  const CSI_TILDE_RE = /^(\d+)(?:;(\d+))?~$/;
119
127
 
120
- function parseCsiSequence(seq: string): string {
128
+ function parseCsiSequence(seq: string): KeyInput {
121
129
  // Legacy Shift+Tab (CSI Z) — sent by tmux and some terminals
122
- if (seq === "Z") return "shift+tab";
130
+ if (seq === "Z") return { key: "shift+tab" };
123
131
 
124
132
  // CSI u format: codepoint [; modifiers] u
125
133
  let match = CSI_U_RE.exec(seq);
126
134
  if (match) {
127
135
  const codepoint = parseInt(match[1]!, 10);
128
- const modParam = match[2] ? parseInt(match[2], 10) : 0;
136
+ const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
129
137
 
130
- // Check named keys first
131
138
  const name = CODEPOINT_NAMES[codepoint];
132
- if (name) return withModifiers(modParam, name);
133
-
134
- // Printable character — convert codepoint to char, lowercase
135
- const char = String.fromCodePoint(codepoint).toLowerCase();
136
- return withModifiers(modParam, char);
139
+ if (name) {
140
+ return { key: withModifiers(modParam, name) };
141
+ }
142
+
143
+ const char = String.fromCodePoint(codepoint);
144
+ const key = withModifiers(modParam, char.toLowerCase());
145
+ if (modParam <= 1) {
146
+ return { key, text: char };
147
+ }
148
+ return { key };
137
149
  }
138
150
 
139
151
  // CSI letter format: [1 ; modifiers] <letter>
140
152
  match = CSI_LETTER_RE.exec(seq);
141
153
  if (match) {
142
- const modParam = match[1] ? parseInt(match[1], 10) : 0;
154
+ const modParam = match[1] ? parseInt(match[1]!, 10) : 0;
143
155
  const letter = match[2]!;
144
156
  const name = LETTER_NAMES[letter];
145
- if (name) return withModifiers(modParam, name);
157
+ if (name) return { key: withModifiers(modParam, name) };
146
158
  }
147
159
 
148
160
  // CSI tilde format: number [; modifiers] ~
149
161
  match = CSI_TILDE_RE.exec(seq);
150
162
  if (match) {
151
163
  const num = parseInt(match[1]!, 10);
152
- const modParam = match[2] ? parseInt(match[2], 10) : 0;
164
+ const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
153
165
  const name = TILDE_NAMES[num];
154
- if (name) return withModifiers(modParam, name);
166
+ if (name) return { key: withModifiers(modParam, name) };
155
167
  }
156
168
 
157
- return `unknown:${seq}`;
169
+ return { key: `unknown:${seq}` };
158
170
  }
159
171
 
160
- // --- Public API ---
161
-
162
- /**
163
- * Parse raw terminal input data into a normalized key string.
164
- *
165
- * Handles the Kitty keyboard protocol level 1 (disambiguate) encoding:
166
- * - CSI u sequences for special keys and modifier combos
167
- * - CSI letter sequences for arrow keys and Home/End (with optional modifiers)
168
- * - CSI tilde sequences for Delete, PageUp/Down, and function keys
169
- * - Raw printable characters (unmodified, arrive as raw bytes at level 1)
170
- *
171
- * @param data - Raw terminal input string.
172
- * @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
173
- */
174
- export function parseKey(data: string): string {
175
- // CSI sequences: ESC [ ...
176
- if (data.startsWith("\x1b[")) {
177
- return parseCsiSequence(data.slice(2));
172
+ function decodeLegacyControlByte(code: number): string | null {
173
+ if (code >= 1 && code <= 26) {
174
+ return `ctrl+${String.fromCharCode(code + 96)}`;
178
175
  }
176
+ return null;
177
+ }
179
178
 
180
- // Single-character input
181
- if (data.length === 1) {
182
- const code = data.charCodeAt(0);
179
+ function parseRawKeyInput(data: string): KeyInput {
180
+ const code = data.charCodeAt(0);
183
181
 
184
- // Legacy bytes for unmodified special keys (Kitty level 1 retains these)
185
- const legacy = LEGACY_BYTE_NAMES[code];
186
- if (legacy) return legacy;
182
+ const legacy = LEGACY_BYTE_NAMES[code];
183
+ if (legacy) return { key: legacy };
187
184
 
188
- // Named printable characters
189
- const named = CHAR_NAMES[data];
190
- if (named) return named;
185
+ const ctrl = decodeLegacyControlByte(code);
186
+ if (ctrl) return { key: ctrl };
187
+
188
+ const named = CHAR_NAMES[data];
189
+ if (named) return { key: named, text: data };
190
+
191
+ return { key: data.toLowerCase(), text: data };
192
+ }
193
+
194
+ function parseAltKeyInput(data: string): KeyInput {
195
+ const base = parseRawKeyInput(data);
196
+ return { key: normalizeKey(`alt+${base.key}`) };
197
+ }
191
198
 
192
- // Printable characters — return lowercase
193
- return data.toLowerCase();
199
+ function readCodePoint(
200
+ data: string,
201
+ index: number,
202
+ ): { value: string; nextIndex: number } | null {
203
+ if (index >= data.length) return null;
204
+ const codepoint = data.codePointAt(index);
205
+ if (codepoint === undefined) return null;
206
+ const value = String.fromCodePoint(codepoint);
207
+ return { value, nextIndex: index + value.length };
208
+ }
209
+
210
+ function readCsiSequence(
211
+ data: string,
212
+ index: number,
213
+ ): { value: string; nextIndex: number } | null {
214
+ if (!data.startsWith("\x1b[", index)) return null;
215
+
216
+ for (let i = index + 2; i < data.length; i++) {
217
+ const code = data.charCodeAt(i);
218
+ if (code >= 0x40 && code <= 0x7e) {
219
+ return { value: data.slice(index + 2, i + 1), nextIndex: i + 1 };
220
+ }
194
221
  }
195
222
 
196
- // Multi-byte UTF-8 (e.g., emoji, CJK) — return as-is lowercase
197
- if (data.length > 1) {
198
- return data.toLowerCase();
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Decode a raw keyboard data chunk into ordered key events.
228
+ *
229
+ * A single chunk may contain multiple key presses batched together.
230
+ */
231
+ export function decodeKeyEvents(data: string): KeyInput[] {
232
+ const events: KeyInput[] = [];
233
+ let index = 0;
234
+
235
+ while (index < data.length) {
236
+ const csi = readCsiSequence(data, index);
237
+ if (csi) {
238
+ events.push(parseCsiSequence(csi.value));
239
+ index = csi.nextIndex;
240
+ continue;
241
+ }
242
+
243
+ const char = data[index]!;
244
+ if (char === "\x1b") {
245
+ const next = readCodePoint(data, index + 1);
246
+ if (next && next.value !== "[") {
247
+ events.push(parseAltKeyInput(next.value));
248
+ index = next.nextIndex;
249
+ } else {
250
+ events.push({ key: "escape" });
251
+ index += 1;
252
+ }
253
+ continue;
254
+ }
255
+
256
+ const codePoint = readCodePoint(data, index);
257
+ if (!codePoint) break;
258
+ events.push(parseRawKeyInput(codePoint.value));
259
+ index = codePoint.nextIndex;
199
260
  }
200
261
 
201
- return `unknown:${data}`;
262
+ return events;
263
+ }
264
+
265
+ // --- Public API ---
266
+
267
+ /**
268
+ * Parse a single raw key sequence into a normalized key string.
269
+ *
270
+ * This is a convenience wrapper around {@link decodeKeyEvents} for callers that
271
+ * already know the input contains one logical key event.
272
+ *
273
+ * @param data - Raw terminal input string.
274
+ * @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
275
+ */
276
+ export function parseKey(data: string): string {
277
+ const events = decodeKeyEvents(data);
278
+ return events[0]?.key ?? `unknown:${data}`;
202
279
  }
203
280
 
204
281
  /**
@@ -217,7 +294,6 @@ export function normalizeKey(key: string): string {
217
294
  const base = parts[parts.length - 1]!;
218
295
  const mods = parts.slice(0, -1);
219
296
 
220
- // Canonical order: ctrl, alt, shift
221
297
  const ordered: string[] = [];
222
298
  if (mods.includes("ctrl")) ordered.push("ctrl");
223
299
  if (mods.includes("alt")) ordered.push("alt");
@@ -227,14 +303,15 @@ export function normalizeKey(key: string): string {
227
303
  }
228
304
 
229
305
  /**
230
- * Check if a parsed key is a text-editing key that TextInput should consume.
231
- * Modifier combos (ctrl+s, alt+x) are NOT editing keys and should bubble.
306
+ * Check whether a semantic key represents TextInput editing/navigation.
307
+ *
308
+ * 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.
232
311
  */
233
312
  export function isEditingKey(key: string): boolean {
234
- // Single printable characters
235
313
  if (key.length === 1) return true;
236
314
 
237
- // Navigation and editing keys consumed by TextInput
238
315
  const editingKeys = new Set([
239
316
  "enter",
240
317
  "backspace",
package/src/layout.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Node, ContainerProps, SizeValue } from "@cel-tui/types";
2
+ import { layoutText } from "./text-layout.js";
2
3
  import { visibleWidth } from "./width.js";
3
4
 
4
5
  /**
@@ -58,20 +59,11 @@ function intrinsicMainSize(
58
59
  ): number {
59
60
  if (node.type === "text") {
60
61
  if (isVertical) {
61
- // Height = number of lines
62
- if (node.content.length === 0) return 1;
63
- const lines = node.content.split("\n");
64
- if (node.props.wrap === "word") {
65
- let total = 0;
66
- for (const line of lines) {
67
- total += Math.max(
68
- 1,
69
- Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
70
- );
71
- }
72
- return total;
73
- }
74
- return lines.length;
62
+ return layoutText(
63
+ node.content,
64
+ Math.max(1, crossSize),
65
+ node.props.wrap ?? "none",
66
+ ).lineCount;
75
67
  }
76
68
  // Width (intrinsic)
77
69
  if (node.props.repeat === "fill") return 0;
@@ -91,16 +83,7 @@ function intrinsicMainSize(
91
83
  if (isVertical) {
92
84
  const val = node.props.value || "";
93
85
  const innerCrossForTI = Math.max(1, crossSize - tiPadX);
94
- if (val.length === 0) return 1 + tiPadY;
95
- const lines = val.split("\n");
96
- let total = 0;
97
- for (const line of lines) {
98
- total += Math.max(
99
- 1,
100
- Math.ceil(visibleWidth(line) / Math.max(1, innerCrossForTI)),
101
- );
102
- }
103
- return total + tiPadY;
86
+ return layoutText(val, innerCrossForTI, "word").lineCount + tiPadY;
104
87
  }
105
88
  return 0 + tiPadX;
106
89
  }
package/src/paint.ts CHANGED
@@ -2,6 +2,8 @@ import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
2
2
  import type { Cell } from "./cell-buffer.js";
3
3
  import { CellBuffer } from "./cell-buffer.js";
4
4
  import type { LayoutNode, Rect } from "./layout.js";
5
+ import { getMaxScrollOffset } from "./scroll.js";
6
+ import { layoutText } from "./text-layout.js";
5
7
  import { visibleWidth } from "./width.js";
6
8
 
7
9
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -104,32 +106,6 @@ function fillBackground(
104
106
  }
105
107
  }
106
108
 
107
- /**
108
- * Compute the maximum scroll offset for a scrollable container.
109
- * This is the content size minus the viewport size along the main axis.
110
- */
111
- function computeMaxScrollOffset(ln: LayoutNode, isVertical: boolean): number {
112
- const { rect, children } = ln;
113
- const props = ln.node.type !== "text" ? ln.node.props : null;
114
- const padX = (props as any)?.padding?.x ?? 0;
115
- const padY = (props as any)?.padding?.y ?? 0;
116
-
117
- if (isVertical) {
118
- let contentHeight = 0;
119
- for (const child of children) {
120
- const childBottom = child.rect.y + child.rect.height - rect.y;
121
- if (childBottom > contentHeight) contentHeight = childBottom;
122
- }
123
- return Math.max(0, contentHeight + padY - rect.height);
124
- }
125
- let contentWidth = 0;
126
- for (const child of children) {
127
- const childRight = child.rect.x + child.rect.width - rect.x;
128
- if (childRight > contentWidth) contentWidth = childRight;
129
- }
130
- return Math.max(0, contentWidth + padX - rect.width);
131
- }
132
-
133
109
  function paintLayoutNode(
134
110
  ln: LayoutNode,
135
111
  buf: CellBuffer,
@@ -203,7 +179,7 @@ function paintLayoutNode(
203
179
  if (isScrollable) {
204
180
  const raw = containerProps.scrollOffset ?? 0;
205
181
  // Clamp to valid range so apps can pass large values to mean "scroll to end"
206
- const maxOffset = computeMaxScrollOffset(ln, isVertical);
182
+ const maxOffset = getMaxScrollOffset(ln);
207
183
  scrollOffset = Math.max(0, Math.min(raw, maxOffset));
208
184
  }
209
185
 
@@ -434,26 +410,11 @@ function paintText(
434
410
  text = content.repeat(props.repeat);
435
411
  }
436
412
 
437
- // Split into lines
438
- const rawLines = text.split("\n");
439
-
440
- // Word-wrap if enabled
441
- const lines: string[] = [];
442
- if (props.wrap === "word") {
443
- for (const rawLine of rawLines) {
444
- if (visibleWidth(rawLine) <= w) {
445
- lines.push(rawLine);
446
- } else {
447
- wrapLine(rawLine, w, lines);
448
- }
449
- }
450
- } else {
451
- lines.push(...rawLines);
452
- }
413
+ const textLayout = layoutText(text, w, props.wrap ?? "none");
453
414
 
454
415
  // Paint lines, clipped to rect (grapheme-aware)
455
- for (let row = 0; row < lines.length && row < h; row++) {
456
- const line = lines[row]!;
416
+ for (let row = 0; row < textLayout.lineCount && row < h; row++) {
417
+ const line = textLayout.lines[row]!.text;
457
418
  paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
458
419
  }
459
420
  }
@@ -510,22 +471,14 @@ function paintTextInput(
510
471
  return;
511
472
  }
512
473
 
513
- // Word-wrap value (always on for TextInput) using content width
514
- const lines: string[] = [];
515
- for (const rawLine of value.split("\n")) {
516
- if (visibleWidth(rawLine) <= cw) {
517
- lines.push(rawLine);
518
- } else {
519
- wrapLine(rawLine, cw, lines);
520
- }
521
- }
474
+ const textLayout = layoutText(value, cw, "word");
522
475
 
523
476
  // Framework-managed scroll: auto-scroll to keep cursor visible
524
477
  let scrollOffset = getTextInputScroll(props);
525
478
 
526
479
  if (props.focused) {
527
480
  const cursorOffset = getTextInputCursor(props);
528
- const cursorPos = offsetToWrappedPos(value, cursorOffset, cw);
481
+ const cursorPos = textLayout.offsetToPosition(cursorOffset);
529
482
  // Scroll down if cursor is below viewport
530
483
  if (cursorPos.line >= scrollOffset + ch) {
531
484
  scrollOffset = cursorPos.line - ch + 1;
@@ -540,8 +493,8 @@ function paintTextInput(
540
493
  // Paint visible lines (grapheme-aware) in content area
541
494
  for (let row = 0; row < ch; row++) {
542
495
  const lineIdx = scrollOffset + row;
543
- if (lineIdx >= lines.length) break;
544
- const line = lines[lineIdx]!;
496
+ if (lineIdx >= textLayout.lineCount) break;
497
+ const line = textLayout.lines[lineIdx]!.text;
545
498
  paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
546
499
  }
547
500
 
@@ -551,7 +504,7 @@ function paintTextInput(
551
504
  // for blinking.
552
505
  if (props.focused) {
553
506
  const cursorOffset = getTextInputCursor(props);
554
- const pos = offsetToWrappedPos(value, cursorOffset, cw);
507
+ const pos = textLayout.offsetToPosition(cursorOffset);
555
508
  const screenRow = pos.line - scrollOffset;
556
509
  if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
557
510
  const absX = cx + pos.col;
@@ -581,43 +534,6 @@ function paintTextInput(
581
534
  }
582
535
  }
583
536
 
584
- /**
585
- * Map a cursor offset in the raw value to a (line, col) position
586
- * in the word-wrapped output.
587
- */
588
- function offsetToWrappedPos(
589
- value: string,
590
- cursor: number,
591
- width: number,
592
- ): { line: number; col: number } {
593
- const rawLines = value.split("\n");
594
- let offset = 0;
595
- let wrappedLine = 0;
596
-
597
- for (const rawLine of rawLines) {
598
- if (cursor <= offset + rawLine.length) {
599
- // Cursor is in this raw line
600
- const colInRaw = cursor - offset;
601
- if (width <= 0) return { line: wrappedLine, col: colInRaw };
602
- // Compute visible width of text before cursor
603
- const textBeforeCursor = rawLine.slice(0, colInRaw);
604
- const vw = visibleWidth(textBeforeCursor);
605
- const extraLines = Math.floor(vw / width);
606
- return { line: wrappedLine + extraLines, col: vw % width };
607
- }
608
- // Count wrapped lines for this raw line
609
- const lineVW = visibleWidth(rawLine);
610
- if (lineVW <= width || width <= 0) {
611
- wrappedLine += 1;
612
- } else {
613
- wrappedLine += Math.ceil(lineVW / width);
614
- }
615
- offset += rawLine.length + 1; // +1 for \n
616
- }
617
-
618
- return { line: wrappedLine, col: 0 };
619
- }
620
-
621
537
  // --- Framework-managed state ---
622
538
 
623
539
  import type { ContainerProps } from "@cel-tui/types";
@@ -653,7 +569,8 @@ export function getTextInputCursorScreenPos(
653
569
  if (cw <= 0 || ch <= 0) return null;
654
570
 
655
571
  const cursorOffset = getTextInputCursor(props);
656
- const pos = offsetToWrappedPos(props.value, cursorOffset, cw);
572
+ const textLayout = layoutText(props.value, cw, "word");
573
+ const pos = textLayout.offsetToPosition(cursorOffset);
657
574
  const scrollOffset = getTextInputScroll(props);
658
575
  const screenRow = pos.line - scrollOffset;
659
576
 
@@ -692,58 +609,3 @@ export function setTextInputScroll(
692
609
  ): void {
693
610
  textInputScrolls.set(props.onChange, scroll);
694
611
  }
695
-
696
- /**
697
- * Simple word-wrap: break a line into multiple lines at word boundaries.
698
- */
699
- function wrapLine(line: string, width: number, out: string[]): void {
700
- if (width <= 0) return;
701
-
702
- let current = "";
703
- let currentW = 0;
704
- const words = line.split(" ");
705
-
706
- for (const word of words) {
707
- const wordW = visibleWidth(word);
708
- if (currentW === 0) {
709
- current = word;
710
- currentW = wordW;
711
- } else if (currentW + 1 + wordW <= width) {
712
- current += " " + word;
713
- currentW += 1 + wordW;
714
- } else {
715
- out.push(current);
716
- current = word;
717
- currentW = wordW;
718
- }
719
-
720
- // Handle words longer than width (break by grapheme)
721
- while (currentW > width) {
722
- let taken = "";
723
- let takenW = 0;
724
- let rest = "";
725
- let inRest = false;
726
- for (const { segment } of segmenter.segment(current)) {
727
- if (inRest) {
728
- rest += segment;
729
- continue;
730
- }
731
- const gw = visibleWidth(segment);
732
- if (takenW + gw > width) {
733
- rest += segment;
734
- inRest = true;
735
- } else {
736
- taken += segment;
737
- takenW += gw;
738
- }
739
- }
740
- out.push(taken);
741
- current = rest;
742
- currentW = visibleWidth(rest);
743
- }
744
- }
745
-
746
- if (current.length > 0) {
747
- out.push(current);
748
- }
749
- }
@@ -11,12 +11,14 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
11
11
  * Scroll is always uncontrolled — the view follows the cursor and
12
12
  * responds to mouse wheel automatically.
13
13
  *
14
- * TextInput is always focusable. When focused, text-editing keys
15
- * (printable characters, arrows, backspace, Enter, Tab) are consumed.
16
- * Modifier combos (e.g., `ctrl+s`) bubble up to ancestor `onKeyPress` handlers.
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.
17
18
  *
18
- * Use `onKeyPress` to intercept keys before editing. Return `false` to
19
- * prevent the default editing action for that key.
19
+ * Use `onKeyPress` to intercept keys before editing. The handler receives a
20
+ * normalized semantic key string; inserted text preserves the original
21
+ * characters. Return `false` to prevent the default editing action.
20
22
  *
21
23
  * @param props - Value, callbacks, sizing, styling, and focus props.
22
24
  * @returns A text input node for the UI tree.
package/src/scroll.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { LayoutNode } from "./layout.js";
2
+ import { layoutText } from "./text-layout.js";
3
+
4
+ function isVerticalScrollTarget(target: LayoutNode): boolean {
5
+ return target.node.type === "vstack" || target.node.type === "textinput";
6
+ }
7
+
8
+ function getScrollTargetProps(target: LayoutNode): Record<string, any> {
9
+ return target.node.props as Record<string, any>;
10
+ }
11
+
12
+ function getScrollViewportMainAxisSize(target: LayoutNode): number {
13
+ const props = getScrollTargetProps(target);
14
+ const isVertical = isVerticalScrollTarget(target);
15
+ const mainAxisSize = isVertical ? target.rect.height : target.rect.width;
16
+ const mainAxisPadding = isVertical
17
+ ? (props.padding?.y ?? 0) * 2
18
+ : (props.padding?.x ?? 0) * 2;
19
+ return Math.max(1, mainAxisSize - mainAxisPadding);
20
+ }
21
+
22
+ /**
23
+ * Resolve the mouse wheel scroll step for a scrollable layout node.
24
+ * Uses the explicit `scrollStep` prop when provided, otherwise an
25
+ * adaptive default based on the visible main-axis viewport size.
26
+ */
27
+ export function getScrollStep(target: LayoutNode): number {
28
+ const rawStep = getScrollTargetProps(target).scrollStep;
29
+ if (Number.isFinite(rawStep) && rawStep > 0) {
30
+ return Math.max(1, Math.floor(rawStep));
31
+ }
32
+
33
+ const viewport = getScrollViewportMainAxisSize(target);
34
+ return Math.max(3, Math.min(8, Math.floor(viewport / 3)));
35
+ }
36
+
37
+ /**
38
+ * Compute the maximum scroll offset for a scrollable layout node.
39
+ * Returns 0 if content fits within the viewport.
40
+ */
41
+ export function getMaxScrollOffset(target: LayoutNode): number {
42
+ const { rect, children } = target;
43
+
44
+ if (target.node.type === "textinput") {
45
+ const padX = (target.node.props.padding?.x ?? 0) * 2;
46
+ const padY = (target.node.props.padding?.y ?? 0) * 2;
47
+ const contentWidth = Math.max(1, rect.width - padX);
48
+ const contentHeight = Math.max(0, rect.height - padY);
49
+ const lineCount = layoutText(
50
+ target.node.props.value,
51
+ contentWidth,
52
+ "word",
53
+ ).lineCount;
54
+ return Math.max(0, lineCount - contentHeight);
55
+ }
56
+
57
+ 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;
61
+
62
+ if (isVertical) {
63
+ let contentHeight = 0;
64
+ for (const child of children) {
65
+ const childBottom = child.rect.y + child.rect.height - rect.y;
66
+ if (childBottom > contentHeight) contentHeight = childBottom;
67
+ }
68
+ return Math.max(0, contentHeight + padY - rect.height);
69
+ }
70
+
71
+ let contentWidth = 0;
72
+ for (const child of children) {
73
+ const childRight = child.rect.x + child.rect.width - rect.x;
74
+ if (childRight > contentWidth) contentWidth = childRight;
75
+ }
76
+ return Math.max(0, contentWidth + padX - rect.width);
77
+ }