@cel-tui/core 0.1.1 → 0.2.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/package.json +2 -2
- package/src/cel.ts +74 -5
- package/src/emitter.ts +78 -49
- package/src/index.ts +3 -1
- package/src/layout.ts +37 -2
- package/src/paint.ts +72 -14
- package/src/primitives/stacks.ts +1 -1
- package/src/primitives/text-input.ts +1 -1
- package/src/primitives/text.ts +1 -1
- package/src/text-edit.ts +52 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"layout"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@cel-tui/types": "0.1
|
|
37
|
+
"@cel-tui/types": "0.2.1",
|
|
38
38
|
"get-east-asian-width": "^1.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
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
|
-
// ---
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
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(
|
|
53
|
-
if (cell.bgColor) codes.push(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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. */
|
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/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
|