@cel-tui/core 0.3.0 → 0.4.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 +2 -2
- package/src/cel.ts +36 -70
- package/src/emitter.ts +65 -6
- package/src/layout.ts +7 -24
- package/src/paint.ts +13 -151
- package/src/scroll.ts +77 -0
- package/src/text-layout.ts +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
37
|
+
"@cel-tui/types": "0.4.0",
|
|
38
38
|
"get-east-asian-width": "^1.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/src/cel.ts
CHANGED
|
@@ -26,10 +26,18 @@ import {
|
|
|
26
26
|
type EditState,
|
|
27
27
|
} from "./text-edit.js";
|
|
28
28
|
import type { Terminal } from "./terminal.js";
|
|
29
|
-
import {
|
|
29
|
+
import { getMaxScrollOffset, getScrollStep } from "./scroll.js";
|
|
30
30
|
|
|
31
31
|
type RenderFn = () => Node | Node[];
|
|
32
32
|
|
|
33
|
+
type TerminalCursorState =
|
|
34
|
+
| { visible: false }
|
|
35
|
+
| {
|
|
36
|
+
visible: true;
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
33
41
|
let terminal: Terminal | null = null;
|
|
34
42
|
let activeTheme: Theme = defaultTheme;
|
|
35
43
|
let renderFn: RenderFn | null = null;
|
|
@@ -60,6 +68,9 @@ const uncontrolledScrollOffsets = new Map<string, number>();
|
|
|
60
68
|
/** Nodes whose props were stamped with scrollOffset during the last paint. */
|
|
61
69
|
let stampedScrollNodes: { node: Node; key: string }[] = [];
|
|
62
70
|
|
|
71
|
+
/** The cursor state currently expected on the terminal. */
|
|
72
|
+
let lastTerminalCursor: TerminalCursorState | null = { visible: false };
|
|
73
|
+
|
|
63
74
|
function doRender(): void {
|
|
64
75
|
renderScheduled = false;
|
|
65
76
|
if (!renderFn || !terminal) return;
|
|
@@ -105,43 +116,38 @@ function doRender(): void {
|
|
|
105
116
|
unstampUncontrolledFocus();
|
|
106
117
|
unstampUncontrolledScroll();
|
|
107
118
|
|
|
119
|
+
const nextCursor = getDesiredTerminalCursor();
|
|
120
|
+
|
|
108
121
|
// Emit to terminal — differential when possible
|
|
109
122
|
if (prevBuffer) {
|
|
110
|
-
const output = emitDiff(prevBuffer, currentBuffer, activeTheme
|
|
123
|
+
const output = emitDiff(prevBuffer, currentBuffer, activeTheme, {
|
|
124
|
+
cursor: nextCursor,
|
|
125
|
+
previousCursor: lastTerminalCursor,
|
|
126
|
+
});
|
|
111
127
|
if (output.length > 0) terminal.write(output);
|
|
112
128
|
} else {
|
|
113
|
-
const output = emitBuffer(currentBuffer, activeTheme
|
|
129
|
+
const output = emitBuffer(currentBuffer, activeTheme, {
|
|
130
|
+
cursor: nextCursor,
|
|
131
|
+
previousCursor: lastTerminalCursor,
|
|
132
|
+
});
|
|
114
133
|
terminal.write(output);
|
|
115
134
|
}
|
|
116
135
|
|
|
117
|
-
|
|
118
|
-
// This gives us a blinking cursor for free (terminal-managed blink).
|
|
119
|
-
positionTerminalCursor();
|
|
136
|
+
lastTerminalCursor = nextCursor;
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
140
|
+
* Resolve the final terminal cursor state for the current frame.
|
|
141
|
+
* The cursor is shown only for a focused TextInput whose cursor is visible
|
|
142
|
+
* within the clipped viewport.
|
|
126
143
|
*/
|
|
127
|
-
function
|
|
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)
|
|
144
|
+
function getDesiredTerminalCursor(): TerminalCursorState {
|
|
132
145
|
const focusedTI = findFocusedTextInputLayout();
|
|
133
|
-
if (focusedTI) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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();
|
|
146
|
+
if (!focusedTI) return { visible: false };
|
|
147
|
+
|
|
148
|
+
const props = focusedTI.node.props as import("@cel-tui/types").TextInputProps;
|
|
149
|
+
const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
|
|
150
|
+
return pos ? { visible: true, x: pos.x, y: pos.y } : { visible: false };
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/**
|
|
@@ -453,49 +459,6 @@ function changeFocus(target: LayoutNode | null): void {
|
|
|
453
459
|
}
|
|
454
460
|
}
|
|
455
461
|
|
|
456
|
-
/**
|
|
457
|
-
* Compute the maximum scroll offset for a scrollable container or TextInput.
|
|
458
|
-
* Returns 0 if content fits within the viewport.
|
|
459
|
-
*/
|
|
460
|
-
function getMaxScrollOffset(target: LayoutNode): number {
|
|
461
|
-
const { rect, children } = target;
|
|
462
|
-
|
|
463
|
-
// TextInput: compute content height from wrapped text value
|
|
464
|
-
if (target.node.type === "textinput") {
|
|
465
|
-
const w = rect.width;
|
|
466
|
-
const value = target.node.props.value;
|
|
467
|
-
let lineCount = 0;
|
|
468
|
-
for (const rawLine of value.split("\n")) {
|
|
469
|
-
const lw = visibleWidth(rawLine);
|
|
470
|
-
lineCount += w > 0 && lw > w ? Math.ceil(lw / w) : 1;
|
|
471
|
-
}
|
|
472
|
-
return Math.max(0, lineCount - rect.height);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const isVertical = target.node.type === "vstack";
|
|
476
|
-
const props = target.node.type !== "text" ? target.node.props : null;
|
|
477
|
-
const padX = (props as any)?.padding?.x ?? 0;
|
|
478
|
-
const padY = (props as any)?.padding?.y ?? 0;
|
|
479
|
-
|
|
480
|
-
if (isVertical) {
|
|
481
|
-
let contentHeight = 0;
|
|
482
|
-
for (const child of children) {
|
|
483
|
-
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
484
|
-
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
485
|
-
}
|
|
486
|
-
// Viewport is the inner height (minus bottom padding)
|
|
487
|
-
// Content starts at padY, so contentHeight includes top padding offset
|
|
488
|
-
return Math.max(0, contentHeight + padY - rect.height);
|
|
489
|
-
} else {
|
|
490
|
-
let contentWidth = 0;
|
|
491
|
-
for (const child of children) {
|
|
492
|
-
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
493
|
-
if (childRight > contentWidth) contentWidth = childRight;
|
|
494
|
-
}
|
|
495
|
-
return Math.max(0, contentWidth + padX - rect.width);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
462
|
/**
|
|
500
463
|
* Resolve the current scroll offset for a layout node.
|
|
501
464
|
* Checks controlled (props.scrollOffset), then uncontrolled (path-based map).
|
|
@@ -539,7 +502,8 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
539
502
|
if (event.type === "scroll-up" || event.type === "scroll-down") {
|
|
540
503
|
const target = findScrollTarget(path);
|
|
541
504
|
if (target) {
|
|
542
|
-
const
|
|
505
|
+
const step = getScrollStep(target);
|
|
506
|
+
const delta = event.type === "scroll-up" ? -step : step;
|
|
543
507
|
const maxOffset = getMaxScrollOffset(target);
|
|
544
508
|
|
|
545
509
|
if (target.node.type === "textinput") {
|
|
@@ -890,6 +854,7 @@ export const cel = {
|
|
|
890
854
|
init(term: Terminal, options?: { theme?: Theme }): void {
|
|
891
855
|
terminal = term;
|
|
892
856
|
activeTheme = options?.theme ?? defaultTheme;
|
|
857
|
+
lastTerminalCursor = { visible: false };
|
|
893
858
|
terminal.start(handleInput, () => cel.render());
|
|
894
859
|
},
|
|
895
860
|
|
|
@@ -935,6 +900,7 @@ export const cel = {
|
|
|
935
900
|
stampedNode = null;
|
|
936
901
|
uncontrolledScrollOffsets.clear();
|
|
937
902
|
stampedScrollNodes = [];
|
|
903
|
+
lastTerminalCursor = { visible: false };
|
|
938
904
|
activeTheme = defaultTheme;
|
|
939
905
|
},
|
|
940
906
|
|
package/src/emitter.ts
CHANGED
|
@@ -100,8 +100,61 @@ function hasStyle(cell: Cell): boolean {
|
|
|
100
100
|
const SYNC_START = "\x1b[?2026h";
|
|
101
101
|
const SYNC_END = "\x1b[?2026l";
|
|
102
102
|
const CURSOR_HOME = "\x1b[H";
|
|
103
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
104
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
105
|
+
const SAVE_CURSOR = "\x1b7";
|
|
106
|
+
const RESTORE_CURSOR = "\x1b8";
|
|
103
107
|
const RESET = "\x1b[0m";
|
|
104
108
|
|
|
109
|
+
type CursorState =
|
|
110
|
+
| { visible: false }
|
|
111
|
+
| {
|
|
112
|
+
visible: true;
|
|
113
|
+
x: number;
|
|
114
|
+
y: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
interface EmitOptions {
|
|
118
|
+
cursor?: CursorState;
|
|
119
|
+
previousCursor?: CursorState | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameVisibleCursor(
|
|
123
|
+
a: CursorState | null | undefined,
|
|
124
|
+
b: CursorState | null | undefined,
|
|
125
|
+
): boolean {
|
|
126
|
+
return (
|
|
127
|
+
a?.visible === true && b?.visible === true && a.x === b.x && a.y === b.y
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getCursorControls(
|
|
132
|
+
options: EmitOptions | undefined,
|
|
133
|
+
hasPaintOutput: boolean,
|
|
134
|
+
): { prefix: string; suffix: string } {
|
|
135
|
+
const cursor = options?.cursor ?? { visible: false };
|
|
136
|
+
const previous = options?.previousCursor ?? null;
|
|
137
|
+
|
|
138
|
+
if (sameVisibleCursor(cursor, previous)) {
|
|
139
|
+
return hasPaintOutput
|
|
140
|
+
? { prefix: SAVE_CURSOR, suffix: RESTORE_CURSOR }
|
|
141
|
+
: { prefix: "", suffix: "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (cursor.visible) {
|
|
145
|
+
return {
|
|
146
|
+
prefix: "",
|
|
147
|
+
suffix:
|
|
148
|
+
`\x1b[${cursor.y + 1};${cursor.x + 1}H` +
|
|
149
|
+
(previous?.visible === true ? "" : SHOW_CURSOR),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return previous?.visible === true
|
|
154
|
+
? { prefix: "", suffix: HIDE_CURSOR }
|
|
155
|
+
: { prefix: "", suffix: "" };
|
|
156
|
+
}
|
|
157
|
+
|
|
105
158
|
/**
|
|
106
159
|
* Emit a full cell buffer as an ANSI string for terminal output.
|
|
107
160
|
*
|
|
@@ -114,13 +167,16 @@ const RESET = "\x1b[0m";
|
|
|
114
167
|
*
|
|
115
168
|
* @param buf - The cell buffer to render.
|
|
116
169
|
* @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
|
|
170
|
+
* @param options - Optional terminal cursor state to apply before the synchronized output ends.
|
|
117
171
|
* @returns A complete ANSI string ready to write to the terminal.
|
|
118
172
|
*/
|
|
119
173
|
export function emitBuffer(
|
|
120
174
|
buf: CellBuffer,
|
|
121
175
|
theme: Theme = defaultTheme,
|
|
176
|
+
options?: EmitOptions,
|
|
122
177
|
): string {
|
|
123
|
-
|
|
178
|
+
const cursorControls = getCursorControls(options, true);
|
|
179
|
+
let out = SYNC_START + cursorControls.prefix + CURSOR_HOME;
|
|
124
180
|
|
|
125
181
|
let lastStyle: Cell | null = null;
|
|
126
182
|
|
|
@@ -153,7 +209,7 @@ export function emitBuffer(
|
|
|
153
209
|
out += RESET;
|
|
154
210
|
}
|
|
155
211
|
|
|
156
|
-
out += SYNC_END;
|
|
212
|
+
out += cursorControls.suffix + SYNC_END;
|
|
157
213
|
return out;
|
|
158
214
|
}
|
|
159
215
|
|
|
@@ -167,18 +223,21 @@ export function emitBuffer(
|
|
|
167
223
|
* @param prev - The previous buffer.
|
|
168
224
|
* @param next - The new buffer.
|
|
169
225
|
* @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
|
|
226
|
+
* @param options - Optional terminal cursor state to apply before the synchronized output ends.
|
|
170
227
|
* @returns An ANSI string with only the changed cells.
|
|
171
228
|
*/
|
|
172
229
|
export function emitDiff(
|
|
173
230
|
prev: CellBuffer,
|
|
174
231
|
next: CellBuffer,
|
|
175
232
|
theme: Theme = defaultTheme,
|
|
233
|
+
options?: EmitOptions,
|
|
176
234
|
): string {
|
|
177
|
-
let out = SYNC_START;
|
|
178
|
-
|
|
179
235
|
const changes = prev.diff(next);
|
|
236
|
+
const cursorControls = getCursorControls(options, changes.length > 0);
|
|
237
|
+
let out = SYNC_START + cursorControls.prefix;
|
|
238
|
+
|
|
180
239
|
if (changes.length === 0) {
|
|
181
|
-
return out + SYNC_END;
|
|
240
|
+
return out + cursorControls.suffix + SYNC_END;
|
|
182
241
|
}
|
|
183
242
|
|
|
184
243
|
let lastStyle: Cell | null = null;
|
|
@@ -222,6 +281,6 @@ export function emitDiff(
|
|
|
222
281
|
out += RESET;
|
|
223
282
|
}
|
|
224
283
|
|
|
225
|
-
out += SYNC_END;
|
|
284
|
+
out += cursorControls.suffix + SYNC_END;
|
|
226
285
|
return out;
|
|
227
286
|
}
|
package/src/layout.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Node, ContainerProps, SizeValue } from "@cel-tui/types";
|
|
2
|
+
import { layoutText } from "./text-layout.js";
|
|
2
3
|
import { visibleWidth } from "./width.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -58,20 +59,11 @@ function intrinsicMainSize(
|
|
|
58
59
|
): number {
|
|
59
60
|
if (node.type === "text") {
|
|
60
61
|
if (isVertical) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const line of lines) {
|
|
67
|
-
total += Math.max(
|
|
68
|
-
1,
|
|
69
|
-
Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
return total;
|
|
73
|
-
}
|
|
74
|
-
return lines.length;
|
|
62
|
+
return layoutText(
|
|
63
|
+
node.content,
|
|
64
|
+
Math.max(1, crossSize),
|
|
65
|
+
node.props.wrap ?? "none",
|
|
66
|
+
).lineCount;
|
|
75
67
|
}
|
|
76
68
|
// Width (intrinsic)
|
|
77
69
|
if (node.props.repeat === "fill") return 0;
|
|
@@ -91,16 +83,7 @@ function intrinsicMainSize(
|
|
|
91
83
|
if (isVertical) {
|
|
92
84
|
const val = node.props.value || "";
|
|
93
85
|
const innerCrossForTI = Math.max(1, crossSize - tiPadX);
|
|
94
|
-
|
|
95
|
-
const lines = val.split("\n");
|
|
96
|
-
let total = 0;
|
|
97
|
-
for (const line of lines) {
|
|
98
|
-
total += Math.max(
|
|
99
|
-
1,
|
|
100
|
-
Math.ceil(visibleWidth(line) / Math.max(1, innerCrossForTI)),
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
return total + tiPadY;
|
|
86
|
+
return layoutText(val, innerCrossForTI, "word").lineCount + tiPadY;
|
|
104
87
|
}
|
|
105
88
|
return 0 + tiPadX;
|
|
106
89
|
}
|
package/src/paint.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
|
|
|
2
2
|
import type { Cell } from "./cell-buffer.js";
|
|
3
3
|
import { CellBuffer } from "./cell-buffer.js";
|
|
4
4
|
import type { LayoutNode, Rect } from "./layout.js";
|
|
5
|
+
import { getMaxScrollOffset } from "./scroll.js";
|
|
6
|
+
import { layoutText } from "./text-layout.js";
|
|
5
7
|
import { visibleWidth } from "./width.js";
|
|
6
8
|
|
|
7
9
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
@@ -104,32 +106,6 @@ function fillBackground(
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
/**
|
|
108
|
-
* Compute the maximum scroll offset for a scrollable container.
|
|
109
|
-
* This is the content size minus the viewport size along the main axis.
|
|
110
|
-
*/
|
|
111
|
-
function computeMaxScrollOffset(ln: LayoutNode, isVertical: boolean): number {
|
|
112
|
-
const { rect, children } = ln;
|
|
113
|
-
const props = ln.node.type !== "text" ? ln.node.props : null;
|
|
114
|
-
const padX = (props as any)?.padding?.x ?? 0;
|
|
115
|
-
const padY = (props as any)?.padding?.y ?? 0;
|
|
116
|
-
|
|
117
|
-
if (isVertical) {
|
|
118
|
-
let contentHeight = 0;
|
|
119
|
-
for (const child of children) {
|
|
120
|
-
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
121
|
-
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
122
|
-
}
|
|
123
|
-
return Math.max(0, contentHeight + padY - rect.height);
|
|
124
|
-
}
|
|
125
|
-
let contentWidth = 0;
|
|
126
|
-
for (const child of children) {
|
|
127
|
-
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
128
|
-
if (childRight > contentWidth) contentWidth = childRight;
|
|
129
|
-
}
|
|
130
|
-
return Math.max(0, contentWidth + padX - rect.width);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
109
|
function paintLayoutNode(
|
|
134
110
|
ln: LayoutNode,
|
|
135
111
|
buf: CellBuffer,
|
|
@@ -203,7 +179,7 @@ function paintLayoutNode(
|
|
|
203
179
|
if (isScrollable) {
|
|
204
180
|
const raw = containerProps.scrollOffset ?? 0;
|
|
205
181
|
// Clamp to valid range so apps can pass large values to mean "scroll to end"
|
|
206
|
-
const maxOffset =
|
|
182
|
+
const maxOffset = getMaxScrollOffset(ln);
|
|
207
183
|
scrollOffset = Math.max(0, Math.min(raw, maxOffset));
|
|
208
184
|
}
|
|
209
185
|
|
|
@@ -434,26 +410,11 @@ function paintText(
|
|
|
434
410
|
text = content.repeat(props.repeat);
|
|
435
411
|
}
|
|
436
412
|
|
|
437
|
-
|
|
438
|
-
const rawLines = text.split("\n");
|
|
439
|
-
|
|
440
|
-
// Word-wrap if enabled
|
|
441
|
-
const lines: string[] = [];
|
|
442
|
-
if (props.wrap === "word") {
|
|
443
|
-
for (const rawLine of rawLines) {
|
|
444
|
-
if (visibleWidth(rawLine) <= w) {
|
|
445
|
-
lines.push(rawLine);
|
|
446
|
-
} else {
|
|
447
|
-
wrapLine(rawLine, w, lines);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
} else {
|
|
451
|
-
lines.push(...rawLines);
|
|
452
|
-
}
|
|
413
|
+
const textLayout = layoutText(text, w, props.wrap ?? "none");
|
|
453
414
|
|
|
454
415
|
// Paint lines, clipped to rect (grapheme-aware)
|
|
455
|
-
for (let row = 0; row <
|
|
456
|
-
const line = lines[row]
|
|
416
|
+
for (let row = 0; row < textLayout.lineCount && row < h; row++) {
|
|
417
|
+
const line = textLayout.lines[row]!.text;
|
|
457
418
|
paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
|
|
458
419
|
}
|
|
459
420
|
}
|
|
@@ -510,22 +471,14 @@ function paintTextInput(
|
|
|
510
471
|
return;
|
|
511
472
|
}
|
|
512
473
|
|
|
513
|
-
|
|
514
|
-
const lines: string[] = [];
|
|
515
|
-
for (const rawLine of value.split("\n")) {
|
|
516
|
-
if (visibleWidth(rawLine) <= cw) {
|
|
517
|
-
lines.push(rawLine);
|
|
518
|
-
} else {
|
|
519
|
-
wrapLine(rawLine, cw, lines);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
474
|
+
const textLayout = layoutText(value, cw, "word");
|
|
522
475
|
|
|
523
476
|
// Framework-managed scroll: auto-scroll to keep cursor visible
|
|
524
477
|
let scrollOffset = getTextInputScroll(props);
|
|
525
478
|
|
|
526
479
|
if (props.focused) {
|
|
527
480
|
const cursorOffset = getTextInputCursor(props);
|
|
528
|
-
const cursorPos =
|
|
481
|
+
const cursorPos = textLayout.offsetToPosition(cursorOffset);
|
|
529
482
|
// Scroll down if cursor is below viewport
|
|
530
483
|
if (cursorPos.line >= scrollOffset + ch) {
|
|
531
484
|
scrollOffset = cursorPos.line - ch + 1;
|
|
@@ -540,8 +493,8 @@ function paintTextInput(
|
|
|
540
493
|
// Paint visible lines (grapheme-aware) in content area
|
|
541
494
|
for (let row = 0; row < ch; row++) {
|
|
542
495
|
const lineIdx = scrollOffset + row;
|
|
543
|
-
if (lineIdx >=
|
|
544
|
-
const line = lines[lineIdx]
|
|
496
|
+
if (lineIdx >= textLayout.lineCount) break;
|
|
497
|
+
const line = textLayout.lines[lineIdx]!.text;
|
|
545
498
|
paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
|
|
546
499
|
}
|
|
547
500
|
|
|
@@ -551,7 +504,7 @@ function paintTextInput(
|
|
|
551
504
|
// for blinking.
|
|
552
505
|
if (props.focused) {
|
|
553
506
|
const cursorOffset = getTextInputCursor(props);
|
|
554
|
-
const pos =
|
|
507
|
+
const pos = textLayout.offsetToPosition(cursorOffset);
|
|
555
508
|
const screenRow = pos.line - scrollOffset;
|
|
556
509
|
if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
|
|
557
510
|
const absX = cx + pos.col;
|
|
@@ -581,43 +534,6 @@ function paintTextInput(
|
|
|
581
534
|
}
|
|
582
535
|
}
|
|
583
536
|
|
|
584
|
-
/**
|
|
585
|
-
* Map a cursor offset in the raw value to a (line, col) position
|
|
586
|
-
* in the word-wrapped output.
|
|
587
|
-
*/
|
|
588
|
-
function offsetToWrappedPos(
|
|
589
|
-
value: string,
|
|
590
|
-
cursor: number,
|
|
591
|
-
width: number,
|
|
592
|
-
): { line: number; col: number } {
|
|
593
|
-
const rawLines = value.split("\n");
|
|
594
|
-
let offset = 0;
|
|
595
|
-
let wrappedLine = 0;
|
|
596
|
-
|
|
597
|
-
for (const rawLine of rawLines) {
|
|
598
|
-
if (cursor <= offset + rawLine.length) {
|
|
599
|
-
// Cursor is in this raw line
|
|
600
|
-
const colInRaw = cursor - offset;
|
|
601
|
-
if (width <= 0) return { line: wrappedLine, col: colInRaw };
|
|
602
|
-
// Compute visible width of text before cursor
|
|
603
|
-
const textBeforeCursor = rawLine.slice(0, colInRaw);
|
|
604
|
-
const vw = visibleWidth(textBeforeCursor);
|
|
605
|
-
const extraLines = Math.floor(vw / width);
|
|
606
|
-
return { line: wrappedLine + extraLines, col: vw % width };
|
|
607
|
-
}
|
|
608
|
-
// Count wrapped lines for this raw line
|
|
609
|
-
const lineVW = visibleWidth(rawLine);
|
|
610
|
-
if (lineVW <= width || width <= 0) {
|
|
611
|
-
wrappedLine += 1;
|
|
612
|
-
} else {
|
|
613
|
-
wrappedLine += Math.ceil(lineVW / width);
|
|
614
|
-
}
|
|
615
|
-
offset += rawLine.length + 1; // +1 for \n
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return { line: wrappedLine, col: 0 };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
537
|
// --- Framework-managed state ---
|
|
622
538
|
|
|
623
539
|
import type { ContainerProps } from "@cel-tui/types";
|
|
@@ -653,7 +569,8 @@ export function getTextInputCursorScreenPos(
|
|
|
653
569
|
if (cw <= 0 || ch <= 0) return null;
|
|
654
570
|
|
|
655
571
|
const cursorOffset = getTextInputCursor(props);
|
|
656
|
-
const
|
|
572
|
+
const textLayout = layoutText(props.value, cw, "word");
|
|
573
|
+
const pos = textLayout.offsetToPosition(cursorOffset);
|
|
657
574
|
const scrollOffset = getTextInputScroll(props);
|
|
658
575
|
const screenRow = pos.line - scrollOffset;
|
|
659
576
|
|
|
@@ -692,58 +609,3 @@ export function setTextInputScroll(
|
|
|
692
609
|
): void {
|
|
693
610
|
textInputScrolls.set(props.onChange, scroll);
|
|
694
611
|
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Simple word-wrap: break a line into multiple lines at word boundaries.
|
|
698
|
-
*/
|
|
699
|
-
function wrapLine(line: string, width: number, out: string[]): void {
|
|
700
|
-
if (width <= 0) return;
|
|
701
|
-
|
|
702
|
-
let current = "";
|
|
703
|
-
let currentW = 0;
|
|
704
|
-
const words = line.split(" ");
|
|
705
|
-
|
|
706
|
-
for (const word of words) {
|
|
707
|
-
const wordW = visibleWidth(word);
|
|
708
|
-
if (currentW === 0) {
|
|
709
|
-
current = word;
|
|
710
|
-
currentW = wordW;
|
|
711
|
-
} else if (currentW + 1 + wordW <= width) {
|
|
712
|
-
current += " " + word;
|
|
713
|
-
currentW += 1 + wordW;
|
|
714
|
-
} else {
|
|
715
|
-
out.push(current);
|
|
716
|
-
current = word;
|
|
717
|
-
currentW = wordW;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Handle words longer than width (break by grapheme)
|
|
721
|
-
while (currentW > width) {
|
|
722
|
-
let taken = "";
|
|
723
|
-
let takenW = 0;
|
|
724
|
-
let rest = "";
|
|
725
|
-
let inRest = false;
|
|
726
|
-
for (const { segment } of segmenter.segment(current)) {
|
|
727
|
-
if (inRest) {
|
|
728
|
-
rest += segment;
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
const gw = visibleWidth(segment);
|
|
732
|
-
if (takenW + gw > width) {
|
|
733
|
-
rest += segment;
|
|
734
|
-
inRest = true;
|
|
735
|
-
} else {
|
|
736
|
-
taken += segment;
|
|
737
|
-
takenW += gw;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
out.push(taken);
|
|
741
|
-
current = rest;
|
|
742
|
-
currentW = visibleWidth(rest);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
if (current.length > 0) {
|
|
747
|
-
out.push(current);
|
|
748
|
-
}
|
|
749
|
-
}
|
package/src/scroll.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { LayoutNode } from "./layout.js";
|
|
2
|
+
import { layoutText } from "./text-layout.js";
|
|
3
|
+
|
|
4
|
+
function isVerticalScrollTarget(target: LayoutNode): boolean {
|
|
5
|
+
return target.node.type === "vstack" || target.node.type === "textinput";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getScrollTargetProps(target: LayoutNode): Record<string, any> {
|
|
9
|
+
return target.node.props as Record<string, any>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getScrollViewportMainAxisSize(target: LayoutNode): number {
|
|
13
|
+
const props = getScrollTargetProps(target);
|
|
14
|
+
const isVertical = isVerticalScrollTarget(target);
|
|
15
|
+
const mainAxisSize = isVertical ? target.rect.height : target.rect.width;
|
|
16
|
+
const mainAxisPadding = isVertical
|
|
17
|
+
? (props.padding?.y ?? 0) * 2
|
|
18
|
+
: (props.padding?.x ?? 0) * 2;
|
|
19
|
+
return Math.max(1, mainAxisSize - mainAxisPadding);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the mouse wheel scroll step for a scrollable layout node.
|
|
24
|
+
* Uses the explicit `scrollStep` prop when provided, otherwise an
|
|
25
|
+
* adaptive default based on the visible main-axis viewport size.
|
|
26
|
+
*/
|
|
27
|
+
export function getScrollStep(target: LayoutNode): number {
|
|
28
|
+
const rawStep = getScrollTargetProps(target).scrollStep;
|
|
29
|
+
if (Number.isFinite(rawStep) && rawStep > 0) {
|
|
30
|
+
return Math.max(1, Math.floor(rawStep));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const viewport = getScrollViewportMainAxisSize(target);
|
|
34
|
+
return Math.max(3, Math.min(8, Math.floor(viewport / 3)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the maximum scroll offset for a scrollable layout node.
|
|
39
|
+
* Returns 0 if content fits within the viewport.
|
|
40
|
+
*/
|
|
41
|
+
export function getMaxScrollOffset(target: LayoutNode): number {
|
|
42
|
+
const { rect, children } = target;
|
|
43
|
+
|
|
44
|
+
if (target.node.type === "textinput") {
|
|
45
|
+
const padX = (target.node.props.padding?.x ?? 0) * 2;
|
|
46
|
+
const padY = (target.node.props.padding?.y ?? 0) * 2;
|
|
47
|
+
const contentWidth = Math.max(1, rect.width - padX);
|
|
48
|
+
const contentHeight = Math.max(0, rect.height - padY);
|
|
49
|
+
const lineCount = layoutText(
|
|
50
|
+
target.node.props.value,
|
|
51
|
+
contentWidth,
|
|
52
|
+
"word",
|
|
53
|
+
).lineCount;
|
|
54
|
+
return Math.max(0, lineCount - contentHeight);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isVertical = target.node.type === "vstack";
|
|
58
|
+
const props = target.node.type !== "text" ? target.node.props : null;
|
|
59
|
+
const padX = (props as any)?.padding?.x ?? 0;
|
|
60
|
+
const padY = (props as any)?.padding?.y ?? 0;
|
|
61
|
+
|
|
62
|
+
if (isVertical) {
|
|
63
|
+
let contentHeight = 0;
|
|
64
|
+
for (const child of children) {
|
|
65
|
+
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
66
|
+
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
67
|
+
}
|
|
68
|
+
return Math.max(0, contentHeight + padY - rect.height);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let contentWidth = 0;
|
|
72
|
+
for (const child of children) {
|
|
73
|
+
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
74
|
+
if (childRight > contentWidth) contentWidth = childRight;
|
|
75
|
+
}
|
|
76
|
+
return Math.max(0, contentWidth + padX - rect.width);
|
|
77
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { visibleWidth } from "./width.js";
|
|
2
|
+
|
|
3
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
|
|
5
|
+
export interface VisualLine {
|
|
6
|
+
text: string;
|
|
7
|
+
startOffset: number;
|
|
8
|
+
endOffset: number;
|
|
9
|
+
width: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface VisualPosition {
|
|
13
|
+
line: number;
|
|
14
|
+
col: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TextLayoutResult {
|
|
18
|
+
lines: VisualLine[];
|
|
19
|
+
lineCount: number;
|
|
20
|
+
offsetToPosition(offset: number): VisualPosition;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GraphemeInfo {
|
|
24
|
+
text: string;
|
|
25
|
+
startOffset: number;
|
|
26
|
+
endOffset: number;
|
|
27
|
+
width: number;
|
|
28
|
+
isWhitespace: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface VisualLineData {
|
|
32
|
+
line: VisualLine;
|
|
33
|
+
graphemes: GraphemeInfo[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Range {
|
|
37
|
+
start: number;
|
|
38
|
+
end: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function layoutText(
|
|
42
|
+
value: string,
|
|
43
|
+
width: number,
|
|
44
|
+
wrap: "none" | "word",
|
|
45
|
+
): TextLayoutResult {
|
|
46
|
+
const hardLines = splitHardLines(value);
|
|
47
|
+
const lines: VisualLineData[] = [];
|
|
48
|
+
|
|
49
|
+
for (const hardLine of hardLines) {
|
|
50
|
+
const graphemes = segmentLine(value, hardLine.start, hardLine.end);
|
|
51
|
+
if (wrap === "word") {
|
|
52
|
+
lines.push(
|
|
53
|
+
...wrapGraphemes(value, graphemes, Math.max(1, width), hardLine),
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(
|
|
57
|
+
...buildVisualLines(
|
|
58
|
+
value,
|
|
59
|
+
graphemes,
|
|
60
|
+
[[0, graphemes.length]],
|
|
61
|
+
hardLine,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (lines.length === 0) {
|
|
68
|
+
const empty: VisualLineData = {
|
|
69
|
+
line: { text: "", startOffset: 0, endOffset: 0, width: 0 },
|
|
70
|
+
graphemes: [],
|
|
71
|
+
};
|
|
72
|
+
lines.push(empty);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
lines: lines.map((entry) => entry.line),
|
|
77
|
+
lineCount: lines.length,
|
|
78
|
+
offsetToPosition(offset: number): VisualPosition {
|
|
79
|
+
return offsetToPosition(lines, clamp(offset, 0, value.length));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitHardLines(value: string): Range[] {
|
|
85
|
+
const lines: Range[] = [];
|
|
86
|
+
let start = 0;
|
|
87
|
+
for (let i = 0; i < value.length; i++) {
|
|
88
|
+
if (value[i] === "\n") {
|
|
89
|
+
lines.push({ start, end: i });
|
|
90
|
+
start = i + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
lines.push({ start, end: value.length });
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function segmentLine(
|
|
98
|
+
value: string,
|
|
99
|
+
start: number,
|
|
100
|
+
end: number,
|
|
101
|
+
): GraphemeInfo[] {
|
|
102
|
+
const graphemes: GraphemeInfo[] = [];
|
|
103
|
+
const text = value.slice(start, end);
|
|
104
|
+
let offset = start;
|
|
105
|
+
|
|
106
|
+
for (const { segment } of segmenter.segment(text)) {
|
|
107
|
+
graphemes.push({
|
|
108
|
+
text: segment,
|
|
109
|
+
startOffset: offset,
|
|
110
|
+
endOffset: offset + segment.length,
|
|
111
|
+
width: visibleWidth(segment),
|
|
112
|
+
isWhitespace: /^\s+$/u.test(segment),
|
|
113
|
+
});
|
|
114
|
+
offset += segment.length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return graphemes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function wrapGraphemes(
|
|
121
|
+
value: string,
|
|
122
|
+
graphemes: GraphemeInfo[],
|
|
123
|
+
width: number,
|
|
124
|
+
hardLine: Range,
|
|
125
|
+
): VisualLineData[] {
|
|
126
|
+
if (graphemes.length === 0) {
|
|
127
|
+
return [makeVisualLine(value, graphemes, 0, 0, hardLine.start)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const prefixWidths = [0];
|
|
131
|
+
for (const grapheme of graphemes) {
|
|
132
|
+
prefixWidths.push(prefixWidths[prefixWidths.length - 1]! + grapheme.width);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ranges: Array<[number, number]> = [];
|
|
136
|
+
let lineStart = 0;
|
|
137
|
+
|
|
138
|
+
while (lineStart < graphemes.length) {
|
|
139
|
+
let lineEnd = lineStart;
|
|
140
|
+
let lastBreak = -1;
|
|
141
|
+
|
|
142
|
+
while (lineEnd < graphemes.length) {
|
|
143
|
+
const nextWidth = prefixWidths[lineEnd + 1]! - prefixWidths[lineStart]!;
|
|
144
|
+
if (nextWidth > width) break;
|
|
145
|
+
lineEnd++;
|
|
146
|
+
if (graphemes[lineEnd - 1]!.isWhitespace) {
|
|
147
|
+
lastBreak = lineEnd;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (lineEnd >= graphemes.length) {
|
|
152
|
+
ranges.push([lineStart, graphemes.length]);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (lineEnd === lineStart) {
|
|
157
|
+
ranges.push([lineStart, lineStart + 1]);
|
|
158
|
+
lineStart++;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (lastBreak > lineStart) {
|
|
163
|
+
ranges.push([lineStart, lastBreak]);
|
|
164
|
+
lineStart = lastBreak;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ranges.push([lineStart, lineEnd]);
|
|
169
|
+
lineStart = lineEnd;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return buildVisualLines(value, graphemes, ranges, hardLine);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildVisualLines(
|
|
176
|
+
value: string,
|
|
177
|
+
graphemes: GraphemeInfo[],
|
|
178
|
+
ranges: Array<[number, number]>,
|
|
179
|
+
hardLine: Range,
|
|
180
|
+
): VisualLineData[] {
|
|
181
|
+
return ranges.map(([startIndex, endIndex]) =>
|
|
182
|
+
makeVisualLine(value, graphemes, startIndex, endIndex, hardLine.start),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function makeVisualLine(
|
|
187
|
+
value: string,
|
|
188
|
+
graphemes: GraphemeInfo[],
|
|
189
|
+
startIndex: number,
|
|
190
|
+
endIndex: number,
|
|
191
|
+
hardLineStart: number,
|
|
192
|
+
): VisualLineData {
|
|
193
|
+
if (startIndex === endIndex) {
|
|
194
|
+
return {
|
|
195
|
+
line: {
|
|
196
|
+
text: "",
|
|
197
|
+
startOffset: hardLineStart,
|
|
198
|
+
endOffset: hardLineStart,
|
|
199
|
+
width: 0,
|
|
200
|
+
},
|
|
201
|
+
graphemes: [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const lineGraphemes = graphemes.slice(startIndex, endIndex);
|
|
206
|
+
const startOffset = lineGraphemes[0]!.startOffset;
|
|
207
|
+
const endOffset = lineGraphemes[lineGraphemes.length - 1]!.endOffset;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
line: {
|
|
211
|
+
text: value.slice(startOffset, endOffset),
|
|
212
|
+
startOffset,
|
|
213
|
+
endOffset,
|
|
214
|
+
width: lineGraphemes.reduce((sum, grapheme) => sum + grapheme.width, 0),
|
|
215
|
+
},
|
|
216
|
+
graphemes: lineGraphemes,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function offsetToPosition(
|
|
221
|
+
lines: VisualLineData[],
|
|
222
|
+
offset: number,
|
|
223
|
+
): VisualPosition {
|
|
224
|
+
for (let i = 1; i < lines.length; i++) {
|
|
225
|
+
if (offset === lines[i]!.line.startOffset) {
|
|
226
|
+
return { line: i, col: 0 };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
const entry = lines[i]!;
|
|
232
|
+
const { line, graphemes } = entry;
|
|
233
|
+
if (offset < line.startOffset || offset > line.endOffset) continue;
|
|
234
|
+
|
|
235
|
+
let col = 0;
|
|
236
|
+
for (const grapheme of graphemes) {
|
|
237
|
+
if (offset <= grapheme.startOffset) break;
|
|
238
|
+
if (offset < grapheme.endOffset) break;
|
|
239
|
+
col += grapheme.width;
|
|
240
|
+
}
|
|
241
|
+
return { line: i, col };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const last = lines[lines.length - 1]!;
|
|
245
|
+
return { line: lines.length - 1, col: last.line.width };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function clamp(value: number, min: number, max: number): number {
|
|
249
|
+
return Math.max(min, Math.min(max, value));
|
|
250
|
+
}
|