@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/package.json +3 -2
- package/src/cel.ts +250 -36
- package/src/emitter.ts +84 -49
- package/src/hit-test.ts +65 -12
- package/src/index.ts +3 -1
- package/src/keys.ts +181 -74
- package/src/layout.ts +325 -5
- package/src/paint.ts +155 -52
- package/src/primitives/stacks.ts +1 -1
- package/src/primitives/text-input.ts +1 -1
- package/src/primitives/text.ts +1 -1
- package/src/terminal.ts +12 -1
- package/src/text-edit.ts +52 -44
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 ?
|
|
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 ?
|
|
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
|
-
|
|
439
|
-
|
|
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) <=
|
|
516
|
+
if (visibleWidth(rawLine) <= cw) {
|
|
450
517
|
lines.push(rawLine);
|
|
451
518
|
} else {
|
|
452
|
-
wrapLine(rawLine,
|
|
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,
|
|
528
|
+
const cursorPos = offsetToWrappedPos(value, cursorOffset, cw);
|
|
462
529
|
// Scroll down if cursor is below viewport
|
|
463
|
-
if (cursorPos.line >= scrollOffset +
|
|
464
|
-
scrollOffset = cursorPos.line -
|
|
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 <
|
|
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,
|
|
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,
|
|
554
|
+
const pos = offsetToWrappedPos(value, cursorOffset, cw);
|
|
485
555
|
const screenRow = pos.line - scrollOffset;
|
|
486
|
-
if (screenRow >= 0 && screenRow <
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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. */
|
package/src/primitives/stacks.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
37
|
+
* placeholder: Text("type a message...", { fgColor: "color08" }),
|
|
38
38
|
* })
|
|
39
39
|
*/
|
|
40
40
|
export function TextInput(props: TextInputProps): TextInputNode {
|
package/src/primitives/text.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
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,
|
|
30
|
-
cursor:
|
|
62
|
+
value: value.slice(0, boundary) + value.slice(cursor),
|
|
63
|
+
cursor: boundary,
|
|
31
64
|
};
|
|
32
65
|
}
|
|
33
66
|
|
|
34
67
|
/**
|
|
35
|
-
* Delete the
|
|
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(
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
return { value, cursor:
|
|
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
|
-
}
|