@cel-tui/core 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
package/src/cel.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { Node } from "@cel-tui/types";
1
+ import type { Node, Theme } from "@cel-tui/types";
2
2
  import { CellBuffer } from "./cell-buffer.js";
3
- import { emitBuffer, emitDiff } from "./emitter.js";
3
+ import { emitBuffer, emitDiff, defaultTheme } from "./emitter.js";
4
4
  import {
5
5
  hitTest,
6
6
  findClickHandler,
@@ -16,6 +16,7 @@ import {
16
16
  setTextInputCursor,
17
17
  getTextInputScroll,
18
18
  setTextInputScroll,
19
+ getTextInputCursorScreenPos,
19
20
  } from "./paint.js";
20
21
  import {
21
22
  insertChar,
@@ -30,6 +31,7 @@ import { visibleWidth } from "./width.js";
30
31
  type RenderFn = () => Node | Node[];
31
32
 
32
33
  let terminal: Terminal | null = null;
34
+ let activeTheme: Theme = defaultTheme;
33
35
  let renderFn: RenderFn | null = null;
34
36
  let renderScheduled = false;
35
37
  let prevBuffer: CellBuffer | null = null;
@@ -105,12 +107,75 @@ function doRender(): void {
105
107
 
106
108
  // Emit to terminal — differential when possible
107
109
  if (prevBuffer) {
108
- const output = emitDiff(prevBuffer, currentBuffer);
110
+ const output = emitDiff(prevBuffer, currentBuffer, activeTheme);
109
111
  if (output.length > 0) terminal.write(output);
110
112
  } else {
111
- const output = emitBuffer(currentBuffer);
113
+ const output = emitBuffer(currentBuffer, activeTheme);
112
114
  terminal.write(output);
113
115
  }
116
+
117
+ // Position the native terminal cursor at the focused TextInput's cursor.
118
+ // This gives us a blinking cursor for free (terminal-managed blink).
119
+ positionTerminalCursor();
120
+ }
121
+
122
+ /**
123
+ * After each render, show the native terminal cursor at the focused
124
+ * TextInput's cursor position (gives blinking for free). When no
125
+ * TextInput is focused, hide the cursor.
126
+ */
127
+ function positionTerminalCursor(): void {
128
+ if (!terminal) return;
129
+
130
+ // Find focused TextInput in the layout tree (check stamped state during
131
+ // render — we need to check controlled focus in the current layouts)
132
+ const focusedTI = findFocusedTextInputLayout();
133
+ if (focusedTI) {
134
+ const props = focusedTI.node
135
+ .props as import("@cel-tui/types").TextInputProps;
136
+ const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
137
+ if (pos) {
138
+ // CUP: move cursor to (row, col) — 1-indexed
139
+ terminal.write(`\x1b[${pos.y + 1};${pos.x + 1}H`);
140
+ terminal.showCursor();
141
+ return;
142
+ }
143
+ }
144
+ terminal.hideCursor();
145
+ }
146
+
147
+ /**
148
+ * Find the focused TextInput in the current layout tree.
149
+ * Checks controlled focus (props.focused) and uncontrolled
150
+ * (framework-tracked index). Returns the LayoutNode or null.
151
+ */
152
+ function findFocusedTextInputLayout(): LayoutNode | null {
153
+ // Check controlled focus: scan all layers for TextInput with focused: true
154
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
155
+ const found = findFocusedTIInTree(currentLayouts[i]!);
156
+ if (found) return found;
157
+ }
158
+ // Check uncontrolled focus
159
+ if (frameworkFocusIndex >= 0) {
160
+ const topLayer = currentLayouts[currentLayouts.length - 1];
161
+ if (topLayer) {
162
+ const focusables = collectFocusable(topLayer);
163
+ if (frameworkFocusIndex < focusables.length) {
164
+ const target = focusables[frameworkFocusIndex]!;
165
+ if (target.node.type === "textinput") return target;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
173
+ if (ln.node.type === "textinput" && ln.node.props.focused) return ln;
174
+ for (const child of ln.children) {
175
+ const found = findFocusedTIInTree(child);
176
+ if (found) return found;
177
+ }
178
+ return null;
114
179
  }
115
180
 
116
181
  // --- Input handling ---
@@ -804,9 +869,12 @@ export const cel = {
804
869
  * enters raw mode, and starts mouse tracking.
805
870
  *
806
871
  * @param term - Terminal to render to (ProcessTerminal or MockTerminal).
872
+ * @param options - Optional configuration.
873
+ * @param options.theme - Color theme mapping. Defaults to the ANSI 16 theme.
807
874
  */
808
- init(term: Terminal): void {
875
+ init(term: Terminal, options?: { theme?: Theme }): void {
809
876
  terminal = term;
877
+ activeTheme = options?.theme ?? defaultTheme;
810
878
  terminal.start(handleInput, () => cel.render());
811
879
  },
812
880
 
@@ -852,6 +920,7 @@ export const cel = {
852
920
  stampedNode = null;
853
921
  uncontrolledScrollOffsets.clear();
854
922
  stampedScrollNodes = [];
923
+ activeTheme = defaultTheme;
855
924
  },
856
925
 
857
926
  /** @internal */
package/src/emitter.ts CHANGED
@@ -1,56 +1,76 @@
1
- import type { Color } from "@cel-tui/types";
1
+ import type { Color, Theme, ThemeValue } from "@cel-tui/types";
2
2
  import type { Cell } from "./cell-buffer.js";
3
3
  import { CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
4
4
 
5
- // --- Color mapping ---
6
-
7
- const FG_CODES: Record<Color, number> = {
8
- black: 30,
9
- red: 31,
10
- green: 32,
11
- yellow: 33,
12
- blue: 34,
13
- magenta: 35,
14
- cyan: 36,
15
- white: 37,
16
- brightBlack: 90,
17
- brightRed: 91,
18
- brightGreen: 92,
19
- brightYellow: 93,
20
- brightBlue: 94,
21
- brightMagenta: 95,
22
- brightCyan: 96,
23
- brightWhite: 97,
24
- };
5
+ // --- Default ANSI 16 theme ---
25
6
 
26
- const BG_CODES: Record<Color, number> = {
27
- black: 40,
28
- red: 41,
29
- green: 42,
30
- yellow: 43,
31
- blue: 44,
32
- magenta: 45,
33
- cyan: 46,
34
- white: 47,
35
- brightBlack: 100,
36
- brightRed: 101,
37
- brightGreen: 102,
38
- brightYellow: 103,
39
- brightBlue: 104,
40
- brightMagenta: 105,
41
- brightCyan: 106,
42
- brightWhite: 107,
7
+ /**
8
+ * The default theme — maps each color slot to its matching ANSI palette
9
+ * index. With this theme, colors inherit the terminal's configured
10
+ * color scheme automatically.
11
+ */
12
+ export const defaultTheme: Theme = {
13
+ color00: 0,
14
+ color01: 1,
15
+ color02: 2,
16
+ color03: 3,
17
+ color04: 4,
18
+ color05: 5,
19
+ color06: 6,
20
+ color07: 7,
21
+ color08: 8,
22
+ color09: 9,
23
+ color10: 10,
24
+ color11: 11,
25
+ color12: 12,
26
+ color13: 13,
27
+ color14: 14,
28
+ color15: 15,
43
29
  };
44
30
 
31
+ // --- Color resolution ---
32
+
33
+ /** Parse a hex color string "#rrggbb" into [r, g, b]. */
34
+ function parseHex(hex: string): [number, number, number] {
35
+ const r = parseInt(hex.slice(1, 3), 16);
36
+ const g = parseInt(hex.slice(3, 5), 16);
37
+ const b = parseInt(hex.slice(5, 7), 16);
38
+ return [r, g, b];
39
+ }
40
+
41
+ /** Resolve a color slot to an SGR code fragment for foreground. */
42
+ function fgSgr(color: Color, theme: Theme): string {
43
+ const val: ThemeValue = theme[color];
44
+ if (typeof val === "number") {
45
+ // ANSI palette index 0-15
46
+ return String(val < 8 ? 30 + val : 82 + val);
47
+ }
48
+ // Hex true color
49
+ const [r, g, b] = parseHex(val);
50
+ return `38;2;${r};${g};${b}`;
51
+ }
52
+
53
+ /** Resolve a color slot to an SGR code fragment for background. */
54
+ function bgSgr(color: Color, theme: Theme): string {
55
+ const val: ThemeValue = theme[color];
56
+ if (typeof val === "number") {
57
+ // ANSI palette index 0-15
58
+ return String(val < 8 ? 40 + val : 92 + val);
59
+ }
60
+ // Hex true color
61
+ const [r, g, b] = parseHex(val);
62
+ return `48;2;${r};${g};${b}`;
63
+ }
64
+
45
65
  // --- SGR generation ---
46
66
 
47
- function sgrForCell(cell: Cell): string {
48
- const codes: number[] = [];
49
- if (cell.bold) codes.push(1);
50
- if (cell.italic) codes.push(3);
51
- if (cell.underline) codes.push(4);
52
- if (cell.fgColor) codes.push(FG_CODES[cell.fgColor]);
53
- if (cell.bgColor) codes.push(BG_CODES[cell.bgColor]);
67
+ function sgrForCell(cell: Cell, theme: Theme): string {
68
+ const codes: string[] = [];
69
+ if (cell.bold) codes.push("1");
70
+ if (cell.italic) codes.push("3");
71
+ if (cell.underline) codes.push("4");
72
+ if (cell.fgColor) codes.push(fgSgr(cell.fgColor, theme));
73
+ if (cell.bgColor) codes.push(bgSgr(cell.bgColor, theme));
54
74
  if (codes.length === 0) return "";
55
75
  return `\x1b[${codes.join(";")}m`;
56
76
  }
@@ -93,9 +113,13 @@ const RESET = "\x1b[0m";
93
113
  * only emitting SGR codes when the style changes.
94
114
  *
95
115
  * @param buf - The cell buffer to render.
116
+ * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
96
117
  * @returns A complete ANSI string ready to write to the terminal.
97
118
  */
98
- export function emitBuffer(buf: CellBuffer): string {
119
+ export function emitBuffer(
120
+ buf: CellBuffer,
121
+ theme: Theme = defaultTheme,
122
+ ): string {
99
123
  let out = SYNC_START + CURSOR_HOME;
100
124
 
101
125
  let lastStyle: Cell | null = null;
@@ -115,7 +139,7 @@ export function emitBuffer(buf: CellBuffer): string {
115
139
  if (lastStyle !== null && hasStyle(lastStyle)) {
116
140
  out += RESET;
117
141
  }
118
- const sgr = sgrForCell(cell);
142
+ const sgr = sgrForCell(cell, theme);
119
143
  if (sgr) out += sgr;
120
144
  lastStyle = cell;
121
145
  }
@@ -142,9 +166,14 @@ export function emitBuffer(buf: CellBuffer): string {
142
166
  *
143
167
  * @param prev - The previous buffer.
144
168
  * @param next - The new buffer.
169
+ * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
145
170
  * @returns An ANSI string with only the changed cells.
146
171
  */
147
- export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
172
+ export function emitDiff(
173
+ prev: CellBuffer,
174
+ next: CellBuffer,
175
+ theme: Theme = defaultTheme,
176
+ ): string {
148
177
  let out = SYNC_START;
149
178
 
150
179
  const changes = prev.diff(next);
@@ -178,7 +207,7 @@ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
178
207
  if (lastStyle !== null && hasStyle(lastStyle)) {
179
208
  out += RESET;
180
209
  }
181
- const sgr = sgrForCell(cell);
210
+ const sgr = sgrForCell(cell, theme);
182
211
  if (sgr) out += sgr;
183
212
  lastStyle = cell;
184
213
  }
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@
28
28
 
29
29
  export type {
30
30
  Color,
31
+ ThemeValue,
32
+ Theme,
31
33
  StyleProps,
32
34
  SizeValue,
33
35
  ContainerProps,
@@ -44,6 +46,6 @@ export { Text } from "./primitives/text.js";
44
46
  export { TextInput } from "./primitives/text-input.js";
45
47
  export { cel } from "./cel.js";
46
48
  export { CellBuffer, EMPTY_CELL, type Cell } from "./cell-buffer.js";
47
- export { emitBuffer } from "./emitter.js";
49
+ export { emitBuffer, defaultTheme } from "./emitter.js";
48
50
  export { visibleWidth, extractAnsiCode } from "./width.js";
49
51
  export { type Terminal, ProcessTerminal, MockTerminal } from "./terminal.js";
package/src/layout.ts CHANGED
@@ -200,6 +200,17 @@ function intrinsicMainSize(
200
200
  resolveSizeValue(cProps?.width, 0) ??
201
201
  intrinsicMainSize(child, false, innerCross);
202
202
  }
203
+ // Apply child's cross-axis constraints (e.g. maxHeight on a TextInput
204
+ // inside an HStack) so the container's intrinsic size respects them.
205
+ if (cProps) {
206
+ const minCross = isVertical
207
+ ? (cProps.minHeight ?? 0)
208
+ : (cProps.minWidth ?? 0);
209
+ const maxCross = isVertical
210
+ ? (cProps.maxHeight ?? Infinity)
211
+ : (cProps.maxWidth ?? Infinity);
212
+ childSize = clamp(childSize, minCross, maxCross);
213
+ }
203
214
  if (childSize > maxSize) maxSize = childSize;
204
215
  }
205
216
  return maxSize + padMain;
@@ -336,9 +347,13 @@ function layoutWrapHStack(
336
347
  baseWidths.push(baseW);
337
348
 
338
349
  // Always compute real cross size (explicit or intrinsic) for row height
339
- const cross =
350
+ let cross =
340
351
  resolveSizeValue(cProps?.height, innerH) ??
341
352
  intrinsicMainSize(child, true, innerW);
353
+ // Apply cross-axis constraints (e.g. maxHeight)
354
+ if (cProps) {
355
+ cross = clamp(cross, cProps.minHeight ?? 0, cProps.maxHeight ?? Infinity);
356
+ }
342
357
  crossSizes.push(cross);
343
358
  }
344
359
 
@@ -566,6 +581,16 @@ function layoutNode(
566
581
  resolveSizeValue(cProps?.height, innerH) ??
567
582
  (useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
568
583
  }
584
+ // Apply cross-axis constraints
585
+ if (cProps) {
586
+ const minCross = isVertical
587
+ ? (cProps.minWidth ?? 0)
588
+ : (cProps.minHeight ?? 0);
589
+ const maxCross = isVertical
590
+ ? (cProps.maxWidth ?? Infinity)
591
+ : (cProps.maxHeight ?? Infinity);
592
+ cross = clamp(cross, minCross, maxCross);
593
+ }
569
594
  infos.push({ node: child, mainSize: 0, crossSize: cross, flex });
570
595
  } else {
571
596
  // Main-axis: explicit → percentage → intrinsic
@@ -589,7 +614,7 @@ function layoutNode(
589
614
  (useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
590
615
  }
591
616
 
592
- // Apply constraints
617
+ // Apply main-axis constraints
593
618
  if (cProps) {
594
619
  const minMain = isVertical
595
620
  ? (cProps.minHeight ?? 0)
@@ -599,6 +624,16 @@ function layoutNode(
599
624
  : (cProps.maxWidth ?? Infinity);
600
625
  main = clamp(main, minMain, maxMain);
601
626
  }
627
+ // Apply cross-axis constraints
628
+ if (cProps) {
629
+ const minCross = isVertical
630
+ ? (cProps.minWidth ?? 0)
631
+ : (cProps.minHeight ?? 0);
632
+ const maxCross = isVertical
633
+ ? (cProps.maxWidth ?? Infinity)
634
+ : (cProps.maxHeight ?? Infinity);
635
+ cross = clamp(cross, minCross, maxCross);
636
+ }
602
637
 
603
638
  fixedMain += main;
604
639
  infos.push({ node: child, mainSize: main, crossSize: cross, flex });
package/src/paint.ts CHANGED
@@ -287,7 +287,7 @@ function paintScrollbar(
287
287
  const isThumb = row >= thumbPos && row < thumbPos + thumbSize;
288
288
  buf.set(barX, absY, {
289
289
  char: isThumb ? "┃" : "│",
290
- fgColor: isThumb ? "white" : "brightBlack",
290
+ fgColor: isThumb ? null : "color08",
291
291
  bgColor: null,
292
292
  bold: false,
293
293
  italic: false,
@@ -322,7 +322,7 @@ function paintScrollbar(
322
322
  const isThumb = col >= thumbPos && col < thumbPos + thumbSize;
323
323
  buf.set(absX, barY, {
324
324
  char: isThumb ? "━" : "─",
325
- fgColor: isThumb ? "white" : "brightBlack",
325
+ fgColor: isThumb ? null : "color08",
326
326
  bgColor: null,
327
327
  bold: false,
328
328
  italic: false,
@@ -477,6 +477,13 @@ function paintTextInput(
477
477
  const ch = Math.max(0, h - padY * 2);
478
478
  if (cw <= 0 || ch <= 0) return;
479
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
+
480
487
  const value = props.value;
481
488
  const showPlaceholder = value.length === 0 && props.placeholder;
482
489
 
@@ -538,22 +545,38 @@ function paintTextInput(
538
545
  paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
539
546
  }
540
547
 
541
- // 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.
542
552
  if (props.focused) {
543
553
  const cursorOffset = getTextInputCursor(props);
544
554
  const pos = offsetToWrappedPos(value, cursorOffset, cw);
545
555
  const screenRow = pos.line - scrollOffset;
546
556
  if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
547
- const existing = buf.get(cx + pos.col, cy + screenRow);
548
- // Invert colors for cursor visibility
549
- buf.set(cx + pos.col, cy + screenRow, {
550
- char: existing.char === " " && !existing.bgColor ? " " : existing.char,
551
- fgColor: existing.bgColor ?? "black",
552
- bgColor: existing.fgColor ?? "white",
553
- bold: existing.bold,
554
- italic: existing.italic,
555
- underline: existing.underline,
556
- });
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
+ }
557
580
  }
558
581
  }
559
582
  }
@@ -609,9 +632,44 @@ type OnChangeFn = (value: string) => void;
609
632
  const textInputCursors = new WeakMap<OnChangeFn, number>();
610
633
  const textInputScrolls = new WeakMap<OnChangeFn, number>();
611
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
+
612
666
  /** Get the cursor offset for a TextInput (framework-managed). */
613
667
  export function getTextInputCursor(props: TextInputProps): number {
614
- 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);
615
673
  }
616
674
 
617
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/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