@cel-tui/core 0.1.0 → 0.2.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/paint.ts CHANGED
@@ -104,6 +104,32 @@ function fillBackground(
104
104
  }
105
105
  }
106
106
 
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
+
107
133
  function paintLayoutNode(
108
134
  ln: LayoutNode,
109
135
  buf: CellBuffer,
@@ -150,7 +176,7 @@ function paintLayoutNode(
150
176
  italic: node.props.italic ?? tiEffective.italic,
151
177
  underline: node.props.underline ?? tiEffective.underline,
152
178
  };
153
- paintTextInput(tiProps, rect, clipped, buf);
179
+ paintTextInput(tiProps, rect, clipped, buf, tiEffective);
154
180
  break;
155
181
  }
156
182
  case "vstack":
@@ -172,10 +198,14 @@ function paintLayoutNode(
172
198
  const isContainer = node.type === "vstack" || node.type === "hstack";
173
199
  const containerProps = isContainer ? node.props : null;
174
200
  const isScrollable = containerProps?.overflow === "scroll";
175
- const scrollOffset = isScrollable
176
- ? (containerProps.scrollOffset ?? getContainerScroll(containerProps))
177
- : 0;
178
201
  const isVertical = node.type === "vstack";
202
+ let scrollOffset = 0;
203
+ if (isScrollable) {
204
+ const raw = containerProps.scrollOffset ?? 0;
205
+ // Clamp to valid range so apps can pass large values to mean "scroll to end"
206
+ const maxOffset = computeMaxScrollOffset(ln, isVertical);
207
+ scrollOffset = Math.max(0, Math.min(raw, maxOffset));
208
+ }
179
209
 
180
210
  // Recurse into children, using this node's clipped rect as the clip for children
181
211
  for (const child of ln.children) {
@@ -257,7 +287,7 @@ function paintScrollbar(
257
287
  const isThumb = row >= thumbPos && row < thumbPos + thumbSize;
258
288
  buf.set(barX, absY, {
259
289
  char: isThumb ? "┃" : "│",
260
- fgColor: isThumb ? "white" : "brightBlack",
290
+ fgColor: isThumb ? null : "color08",
261
291
  bgColor: null,
262
292
  bold: false,
263
293
  italic: false,
@@ -292,7 +322,7 @@ function paintScrollbar(
292
322
  const isThumb = col >= thumbPos && col < thumbPos + thumbSize;
293
323
  buf.set(absX, barY, {
294
324
  char: isThumb ? "━" : "─",
295
- fgColor: isThumb ? "white" : "brightBlack",
325
+ fgColor: isThumb ? null : "color08",
296
326
  bgColor: null,
297
327
  bold: false,
298
328
  italic: false,
@@ -322,11 +352,6 @@ function makeCell(
322
352
  };
323
353
  }
324
354
 
325
- /**
326
- * Paint a single line of text into the buffer using grapheme segmentation.
327
- * Correctly handles wide characters (CJK, emoji) by advancing the column
328
- * by the grapheme's visible width.
329
- */
330
355
  /**
331
356
  * Paint a single line of text into the buffer using grapheme segmentation.
332
357
  * Correctly handles wide characters (CJK, emoji) by advancing the column
@@ -361,6 +386,20 @@ function paintLineGraphemes(
361
386
  if (absX + gw > clipLeft) {
362
387
  // At least partially visible in clip rect
363
388
  buf.set(absX, y, makeCell(segment, props));
389
+ // Write continuation markers for wide characters (2+ columns)
390
+ for (let w = 1; w < gw; w++) {
391
+ const cx = absX + w;
392
+ if (cx < clipRight) {
393
+ buf.set(cx, y, {
394
+ char: "",
395
+ fgColor: props.fgColor ?? null,
396
+ bgColor: props.bgColor ?? null,
397
+ bold: props.bold ?? false,
398
+ italic: props.italic ?? false,
399
+ underline: props.underline ?? false,
400
+ });
401
+ }
402
+ }
364
403
  }
365
404
  col += gw;
366
405
  }
@@ -424,32 +463,60 @@ function paintTextInput(
424
463
  rect: Rect,
425
464
  clipRect: Rect,
426
465
  buf: CellBuffer,
466
+ inherited: StyleProps = EMPTY_STYLE,
427
467
  ): void {
428
468
  const { x, y, width: w, height: h } = rect;
429
469
  if (w <= 0 || h <= 0) return;
430
470
 
471
+ // Apply padding — content is inset from the rect edges
472
+ const padX = props.padding?.x ?? 0;
473
+ const padY = props.padding?.y ?? 0;
474
+ const cx = x + padX;
475
+ const cy = y + padY;
476
+ const cw = Math.max(0, w - padX * 2);
477
+ const ch = Math.max(0, h - padY * 2);
478
+ if (cw <= 0 || ch <= 0) return;
479
+
480
+ // Fill the TextInput rect with background color (like containers do)
481
+ // so that cursor inversion and empty cells have correct colors.
482
+ const effectiveBg = props.bgColor ?? inherited.bgColor;
483
+ if (effectiveBg) {
484
+ fillBackground(rect, clipRect, effectiveBg, buf);
485
+ }
486
+
431
487
  const value = props.value;
432
488
  const showPlaceholder = value.length === 0 && props.placeholder;
433
489
 
434
490
  if (showPlaceholder && props.placeholder) {
435
- // Paint placeholder text
491
+ // Paint placeholder text in padded content area, inheriting
492
+ // styles from the TextInput's resolved style chain
493
+ const contentRect: Rect = { x: cx, y: cy, width: cw, height: ch };
494
+ const phProps = props.placeholder.props;
495
+ const effectivePh = {
496
+ ...phProps,
497
+ fgColor: phProps.fgColor ?? inherited.fgColor,
498
+ bgColor: phProps.bgColor ?? inherited.bgColor,
499
+ bold: phProps.bold ?? inherited.bold,
500
+ italic: phProps.italic ?? inherited.italic,
501
+ underline: phProps.underline ?? inherited.underline,
502
+ };
436
503
  paintText(
437
504
  props.placeholder.content,
438
- props.placeholder.props,
439
- rect,
505
+ effectivePh,
506
+ contentRect,
440
507
  clipRect,
441
508
  buf,
442
509
  );
443
510
  return;
444
511
  }
445
512
 
446
- // Word-wrap value (always on for TextInput)
513
+ // Word-wrap value (always on for TextInput) using content width
447
514
  const lines: string[] = [];
448
515
  for (const rawLine of value.split("\n")) {
449
- if (visibleWidth(rawLine) <= w) {
516
+ if (visibleWidth(rawLine) <= cw) {
450
517
  lines.push(rawLine);
451
518
  } else {
452
- wrapLine(rawLine, w, lines);
519
+ wrapLine(rawLine, cw, lines);
453
520
  }
454
521
  }
455
522
 
@@ -458,10 +525,10 @@ function paintTextInput(
458
525
 
459
526
  if (props.focused) {
460
527
  const cursorOffset = getTextInputCursor(props);
461
- const cursorPos = offsetToWrappedPos(value, cursorOffset, w);
528
+ const cursorPos = offsetToWrappedPos(value, cursorOffset, cw);
462
529
  // Scroll down if cursor is below viewport
463
- if (cursorPos.line >= scrollOffset + h) {
464
- scrollOffset = cursorPos.line - h + 1;
530
+ if (cursorPos.line >= scrollOffset + ch) {
531
+ scrollOffset = cursorPos.line - ch + 1;
465
532
  }
466
533
  // Scroll up if cursor is above viewport
467
534
  if (cursorPos.line < scrollOffset) {
@@ -470,30 +537,46 @@ function paintTextInput(
470
537
  setTextInputScroll(props, scrollOffset);
471
538
  }
472
539
 
473
- // Paint visible lines (grapheme-aware)
474
- for (let row = 0; row < h; row++) {
540
+ // Paint visible lines (grapheme-aware) in content area
541
+ for (let row = 0; row < ch; row++) {
475
542
  const lineIdx = scrollOffset + row;
476
543
  if (lineIdx >= lines.length) break;
477
544
  const line = lines[lineIdx]!;
478
- paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
545
+ paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
479
546
  }
480
547
 
481
- // Paint cursor if focused
548
+ // Paint cursor if focused — invert colors at cursor position so it's
549
+ // always visible regardless of terminal cursor configuration. The
550
+ // framework also positions the native terminal cursor here (in cel.ts)
551
+ // for blinking.
482
552
  if (props.focused) {
483
553
  const cursorOffset = getTextInputCursor(props);
484
- const pos = offsetToWrappedPos(value, cursorOffset, w);
554
+ const pos = offsetToWrappedPos(value, cursorOffset, cw);
485
555
  const screenRow = pos.line - scrollOffset;
486
- if (screenRow >= 0 && screenRow < h && pos.col < w) {
487
- const existing = buf.get(x + pos.col, y + screenRow);
488
- // Invert colors for cursor visibility
489
- buf.set(x + pos.col, y + screenRow, {
490
- char: existing.char === " " && !existing.bgColor ? " " : existing.char,
491
- fgColor: existing.bgColor ?? "black",
492
- bgColor: existing.fgColor ?? "white",
493
- bold: existing.bold,
494
- italic: existing.italic,
495
- underline: existing.underline,
496
- });
556
+ if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
557
+ const absX = cx + pos.col;
558
+ const absY = cy + screenRow;
559
+ if (
560
+ absX >= clipRect.x &&
561
+ absX < clipRect.x + clipRect.width &&
562
+ absY >= clipRect.y &&
563
+ absY < clipRect.y + clipRect.height
564
+ ) {
565
+ const existing = buf.get(absX, absY);
566
+ // Resolve null colors against inherited style so the inversion
567
+ // always produces visible contrast (e.g. on bg-filled empty cells
568
+ // where fgColor is null).
569
+ const resolvedFg = existing.fgColor ?? inherited.fgColor ?? "color07";
570
+ const resolvedBg = existing.bgColor ?? inherited.bgColor ?? "color00";
571
+ buf.set(absX, absY, {
572
+ char: existing.char,
573
+ fgColor: resolvedBg,
574
+ bgColor: resolvedFg,
575
+ bold: existing.bold,
576
+ italic: existing.italic,
577
+ underline: existing.underline,
578
+ });
579
+ }
497
580
  }
498
581
  }
499
582
  }
@@ -539,21 +622,6 @@ function offsetToWrappedPos(
539
622
 
540
623
  import type { ContainerProps } from "@cel-tui/types";
541
624
 
542
- const containerScrolls = new WeakMap<ContainerProps, number>();
543
-
544
- /** Get the scroll offset for an uncontrolled scrollable container. */
545
- export function getContainerScroll(props: ContainerProps): number {
546
- return containerScrolls.get(props) ?? 0;
547
- }
548
-
549
- /** Set the scroll offset for an uncontrolled scrollable container. */
550
- export function setContainerScroll(
551
- props: ContainerProps,
552
- scroll: number,
553
- ): void {
554
- containerScrolls.set(props, scroll);
555
- }
556
-
557
625
  /**
558
626
  * TextInput state is keyed on the `onChange` function reference, which is
559
627
  * a stable identity across re-renders (the app provides the same closure).
@@ -564,9 +632,44 @@ type OnChangeFn = (value: string) => void;
564
632
  const textInputCursors = new WeakMap<OnChangeFn, number>();
565
633
  const textInputScrolls = new WeakMap<OnChangeFn, number>();
566
634
 
635
+ /**
636
+ * Compute the screen position of the cursor for a focused TextInput.
637
+ * Returns `{ x, y }` in 0-indexed screen coordinates, or `null` if
638
+ * the cursor is not visible (clipped or not focused).
639
+ */
640
+ export function getTextInputCursorScreenPos(
641
+ props: TextInputProps,
642
+ rect: Rect,
643
+ ): { x: number; y: number } | null {
644
+ const { x, y, width: w, height: h } = rect;
645
+ if (w <= 0 || h <= 0) return null;
646
+
647
+ const padX = props.padding?.x ?? 0;
648
+ const padY = props.padding?.y ?? 0;
649
+ const cx = x + padX;
650
+ const cy = y + padY;
651
+ const cw = Math.max(0, w - padX * 2);
652
+ const ch = Math.max(0, h - padY * 2);
653
+ if (cw <= 0 || ch <= 0) return null;
654
+
655
+ const cursorOffset = getTextInputCursor(props);
656
+ const pos = offsetToWrappedPos(props.value, cursorOffset, cw);
657
+ const scrollOffset = getTextInputScroll(props);
658
+ const screenRow = pos.line - scrollOffset;
659
+
660
+ if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
661
+ return { x: cx + pos.col, y: cy + screenRow };
662
+ }
663
+ return null;
664
+ }
665
+
567
666
  /** Get the cursor offset for a TextInput (framework-managed). */
568
667
  export function getTextInputCursor(props: TextInputProps): number {
569
- return textInputCursors.get(props.onChange) ?? props.value.length;
668
+ const stored = textInputCursors.get(props.onChange);
669
+ if (stored === undefined) return props.value.length;
670
+ // Clamp to value length — the app may have cleared or shortened the value
671
+ // externally (e.g. after submit) while the WeakMap still holds the old cursor.
672
+ return Math.min(stored, props.value.length);
570
673
  }
571
674
 
572
675
  /** Set the cursor offset for a TextInput. */
@@ -34,7 +34,7 @@ export function VStack(props: ContainerProps, children: Node[]): ContainerNode {
34
34
  * HStack({ height: 1, gap: 1 }, [
35
35
  * Text("Name", { bold: true }),
36
36
  * VStack({ flex: 1 }, []),
37
- * Text("value", { fgColor: "brightBlack" }),
37
+ * Text("value", { fgColor: "color08" }),
38
38
  * ])
39
39
  */
40
40
  export function HStack(props: ContainerProps, children: Node[]): ContainerNode {
@@ -34,7 +34,7 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
34
34
  * onChange: handleChange,
35
35
  * onSubmit: handleSend,
36
36
  * submitKey: "ctrl+enter",
37
- * placeholder: Text("type a message...", { fgColor: "brightBlack" }),
37
+ * placeholder: Text("type a message...", { fgColor: "color08" }),
38
38
  * })
39
39
  */
40
40
  export function TextInput(props: TextInputProps): TextInputNode {
@@ -16,7 +16,7 @@ import type { TextNode, TextProps } from "@cel-tui/types";
16
16
  *
17
17
  * @example
18
18
  * // Simple styled text
19
- * Text("Hello", { bold: true, fgColor: "cyan" })
19
+ * Text("Hello", { bold: true, fgColor: "color06" })
20
20
  *
21
21
  * // Horizontal divider
22
22
  * Text("─", { repeat: "fill" })
package/src/terminal.ts CHANGED
@@ -11,7 +11,7 @@ export interface Terminal {
11
11
  get columns(): number;
12
12
  /** Terminal height in rows. */
13
13
  get rows(): number;
14
- /** Enter raw mode, enable mouse tracking, hide cursor. */
14
+ /** Enter raw mode, enable Kitty keyboard protocol, enable mouse tracking, hide cursor. */
15
15
  start(onInput: (data: string) => void, onResize: () => void): void;
16
16
  /** Restore terminal state. */
17
17
  stop(): void;
@@ -23,6 +23,9 @@ export interface Terminal {
23
23
 
24
24
  /**
25
25
  * Real terminal using process.stdin/stdout.
26
+ *
27
+ * Enables the Kitty keyboard protocol (level 1) for unambiguous key input,
28
+ * SGR mouse tracking, and raw mode. All modes are restored on stop/crash.
26
29
  */
27
30
  export class ProcessTerminal implements Terminal {
28
31
  private wasRaw = false;
@@ -61,6 +64,10 @@ export class ProcessTerminal implements Terminal {
61
64
  process.stdin.on("data", onInput);
62
65
  process.stdout.on("resize", this.resizeHandler);
63
66
 
67
+ // Switch to alternate screen buffer (restored on exit)
68
+ this.write("\x1b[?1049h");
69
+ // Enable Kitty keyboard protocol level 1 (disambiguate) with push flag
70
+ this.write("\x1b[>1u");
64
71
  // Enable mouse tracking (normal mode) + SGR encoding
65
72
  this.write("\x1b[?1000h\x1b[?1006h");
66
73
  this.hideCursor();
@@ -95,7 +102,11 @@ export class ProcessTerminal implements Terminal {
95
102
 
96
103
  // Disable mouse tracking + SGR encoding
97
104
  this.write("\x1b[?1006l\x1b[?1000l");
105
+ // Pop Kitty keyboard protocol mode
106
+ this.write("\x1b[<u");
98
107
  this.showCursor();
108
+ // Restore main screen buffer
109
+ this.write("\x1b[?1049l");
99
110
 
100
111
  if (this.resizeHandler) {
101
112
  process.stdout.removeListener("resize", this.resizeHandler);
package/src/text-edit.ts CHANGED
@@ -8,6 +8,37 @@ export interface EditState {
8
8
  cursor: number;
9
9
  }
10
10
 
11
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
12
+
13
+ /**
14
+ * Get the grapheme boundary before the given cursor position.
15
+ * Returns the start offset of the grapheme that contains or precedes
16
+ * the cursor, or 0 if at the beginning.
17
+ */
18
+ function prevGraphemeBoundary(value: string, cursor: number): number {
19
+ if (cursor <= 0) return 0;
20
+ let lastStart = 0;
21
+ for (const { index, segment } of segmenter.segment(value)) {
22
+ const end = index + segment.length;
23
+ if (end >= cursor) return index;
24
+ lastStart = end;
25
+ }
26
+ return lastStart;
27
+ }
28
+
29
+ /**
30
+ * Get the grapheme boundary after the given cursor position.
31
+ * Returns the end offset of the grapheme that starts at or after
32
+ * the cursor, or value.length if at the end.
33
+ */
34
+ function nextGraphemeBoundary(value: string, cursor: number): number {
35
+ for (const { index, segment } of segmenter.segment(value)) {
36
+ const end = index + segment.length;
37
+ if (index >= cursor) return end;
38
+ }
39
+ return value.length;
40
+ }
41
+
11
42
  /**
12
43
  * Insert a character (or string) at the cursor position.
13
44
  */
@@ -20,31 +51,36 @@ export function insertChar(state: EditState, char: string): EditState {
20
51
  }
21
52
 
22
53
  /**
23
- * Delete the character before the cursor (Backspace).
54
+ * Delete the grapheme before the cursor (Backspace).
55
+ * Handles multi-codepoint characters (emoji, ZWJ sequences, combining marks).
24
56
  */
25
57
  export function deleteBackward(state: EditState): EditState {
26
58
  const { value, cursor } = state;
27
59
  if (cursor === 0) return state;
60
+ const boundary = prevGraphemeBoundary(value, cursor);
28
61
  return {
29
- value: value.slice(0, cursor - 1) + value.slice(cursor),
30
- cursor: cursor - 1,
62
+ value: value.slice(0, boundary) + value.slice(cursor),
63
+ cursor: boundary,
31
64
  };
32
65
  }
33
66
 
34
67
  /**
35
- * Delete the character after the cursor (Delete key).
68
+ * Delete the grapheme after the cursor (Delete key).
69
+ * Handles multi-codepoint characters (emoji, ZWJ sequences, combining marks).
36
70
  */
37
71
  export function deleteForward(state: EditState): EditState {
38
72
  const { value, cursor } = state;
39
73
  if (cursor >= value.length) return state;
74
+ const boundary = nextGraphemeBoundary(value, cursor);
40
75
  return {
41
- value: value.slice(0, cursor) + value.slice(cursor + 1),
76
+ value: value.slice(0, cursor) + value.slice(boundary),
42
77
  cursor,
43
78
  };
44
79
  }
45
80
 
46
81
  /**
47
82
  * Move the cursor in the given direction.
83
+ * Left/right movement respects grapheme boundaries.
48
84
  *
49
85
  * @param state - Current edit state.
50
86
  * @param direction - Movement direction.
@@ -58,10 +94,16 @@ export function moveCursor(
58
94
  const { value, cursor } = state;
59
95
 
60
96
  switch (direction) {
61
- case "left":
62
- return { value, cursor: Math.max(0, cursor - 1) };
63
- case "right":
64
- return { value, cursor: Math.min(value.length, cursor + 1) };
97
+ case "left": {
98
+ if (cursor <= 0) return state;
99
+ const boundary = prevGraphemeBoundary(value, cursor);
100
+ return { value, cursor: boundary };
101
+ }
102
+ case "right": {
103
+ if (cursor >= value.length) return state;
104
+ const boundary = nextGraphemeBoundary(value, cursor);
105
+ return { value, cursor: boundary };
106
+ }
65
107
  case "home":
66
108
  return { value, cursor: 0 };
67
109
  case "end":
@@ -89,17 +131,6 @@ function moveVertical(
89
131
  let cursorLine = 0;
90
132
  let cursorCol = 0;
91
133
 
92
- for (let i = 0; i < lines.length; i++) {
93
- const lineLen = lines[i]!.length;
94
- if (offset + lineLen >= cursor && cursorLine === 0 && offset <= cursor) {
95
- cursorLine = i;
96
- cursorCol = cursor - offset;
97
- }
98
- offset += lineLen + 1; // +1 for \n
99
- }
100
-
101
- // Re-scan to get correct line (the above loop has a bug for first line)
102
- offset = 0;
103
134
  for (let i = 0; i < lines.length; i++) {
104
135
  const lineLen = lines[i]!.length;
105
136
  if (cursor >= offset && cursor <= offset + lineLen) {
@@ -107,7 +138,7 @@ function moveVertical(
107
138
  cursorCol = cursor - offset;
108
139
  break;
109
140
  }
110
- offset += lineLen + 1;
141
+ offset += lineLen + 1; // +1 for \n
111
142
  }
112
143
 
113
144
  // Move line
@@ -139,26 +170,3 @@ function moveVertical(
139
170
 
140
171
  return { value, cursor: newOffset };
141
172
  }
142
-
143
- /**
144
- * Map a cursor offset to (line, column) in the raw text (split by \n).
145
- * Used for painting the cursor.
146
- */
147
- export function cursorToLineCol(
148
- value: string,
149
- cursor: number,
150
- ): { line: number; col: number } {
151
- const lines = value.split("\n");
152
- let offset = 0;
153
-
154
- for (let i = 0; i < lines.length; i++) {
155
- const lineLen = lines[i]!.length;
156
- if (cursor <= offset + lineLen) {
157
- return { line: i, col: cursor - offset };
158
- }
159
- offset += lineLen + 1;
160
- }
161
-
162
- // Cursor at very end
163
- return { line: lines.length - 1, col: lines[lines.length - 1]!.length };
164
- }