@cel-tui/core 0.3.0 → 0.4.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 +103 -107
- package/src/emitter.ts +65 -6
- package/src/keys.ts +141 -64
- package/src/layout.ts +7 -24
- package/src/paint.ts +13 -151
- package/src/primitives/text-input.ts +7 -5
- package/src/scroll.ts +77 -0
- package/src/terminal.ts +11 -3
- package/src/text-layout.ts +250 -0
package/src/keys.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Key parsing for
|
|
2
|
+
* Key parsing for Kitty-first terminal input with legacy compatibility.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The decoder accepts a mixed keyboard stream containing:
|
|
5
5
|
* - **CSI u** (`ESC [ codepoint ; modifiers u`) for modified special keys
|
|
6
6
|
* and modifier combos (Ctrl+letter, Alt+letter, Shift+Tab, Ctrl+Enter, etc.)
|
|
7
7
|
* - **CSI letter** (`ESC [ A/B/C/D/H/F` or `ESC [ 1 ; modifiers letter`) for
|
|
@@ -9,16 +9,26 @@
|
|
|
9
9
|
* - **CSI tilde** (`ESC [ number ~` or `ESC [ number ; modifiers ~`) for
|
|
10
10
|
* Delete, PageUp, PageDown, and function keys
|
|
11
11
|
* - **Legacy bytes** for unmodified special keys (Tab=0x09, Enter=0x0D,
|
|
12
|
-
* Escape=0x1B, Backspace=0x7F)
|
|
13
|
-
* - **
|
|
12
|
+
* Escape=0x1B, Backspace=0x7F)
|
|
13
|
+
* - **Recoverable control bytes** for legacy `ctrl+letter` shortcuts
|
|
14
|
+
* - **ESC-prefixed Alt combinations** such as `ESC x`
|
|
15
|
+
* - **Raw printable text**
|
|
14
16
|
*
|
|
15
17
|
* Modifier bitmask (wire value = bitmask + 1):
|
|
16
|
-
* shift=1, alt=2, ctrl=4
|
|
18
|
+
* shift=1, alt=2, ctrl=4 -> e.g., ctrl = bitmask 4, wire param = 5
|
|
17
19
|
*
|
|
18
20
|
* @module
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
/** A decoded keyboard event from the terminal input stream. */
|
|
24
|
+
export interface KeyInput {
|
|
25
|
+
/** Normalized semantic key string (e.g. `"ctrl+s"`, `"enter"`, `"a"`). */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Original insertable text, when this key should insert text. */
|
|
28
|
+
text?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Codepoint -> named key mappings ---
|
|
22
32
|
|
|
23
33
|
/** Named key lookup from Unicode codepoint (for CSI u sequences). */
|
|
24
34
|
const CODEPOINT_NAMES: Record<number, string> = {
|
|
@@ -60,7 +70,7 @@ const TILDE_NAMES: Record<number, string> = {
|
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
/**
|
|
63
|
-
* Legacy byte
|
|
73
|
+
* Legacy byte -> named key mapping for unmodified special keys.
|
|
64
74
|
*
|
|
65
75
|
* At Kitty level 1, unmodified special keys that have well-known legacy
|
|
66
76
|
* encodings retain those encodings. Only modified variants (e.g., Shift+Tab,
|
|
@@ -68,8 +78,8 @@ const TILDE_NAMES: Record<number, string> = {
|
|
|
68
78
|
* bytes that arrive for the unmodified case.
|
|
69
79
|
*/
|
|
70
80
|
const LEGACY_BYTE_NAMES: Record<number, string> = {
|
|
71
|
-
9: "tab", // \t — also Ctrl+I in legacy, but
|
|
72
|
-
13: "enter", // \r — also Ctrl+M in legacy, but
|
|
81
|
+
9: "tab", // \t — also Ctrl+I in legacy, but collapsed to tab here
|
|
82
|
+
13: "enter", // \r — also Ctrl+M in legacy, but collapsed to enter here
|
|
73
83
|
27: "escape", // \x1b — bare ESC byte
|
|
74
84
|
127: "backspace", // \x7f — DEL byte
|
|
75
85
|
};
|
|
@@ -99,9 +109,7 @@ function decodeModifiers(param: number): string {
|
|
|
99
109
|
return parts.length > 0 ? parts.join("+") + "+" : "";
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
/**
|
|
103
|
-
* Build a key string from a modifier prefix and base key name.
|
|
104
|
-
*/
|
|
112
|
+
/** Build a key string from a modifier prefix and base key name. */
|
|
105
113
|
function withModifiers(modParam: number, base: string): string {
|
|
106
114
|
return decodeModifiers(modParam) + base;
|
|
107
115
|
}
|
|
@@ -117,88 +125,157 @@ const CSI_LETTER_RE = /^(?:1;(\d+))?([A-H])$/;
|
|
|
117
125
|
/** Match CSI tilde format: ESC [ <number> ; <modifiers> ~ */
|
|
118
126
|
const CSI_TILDE_RE = /^(\d+)(?:;(\d+))?~$/;
|
|
119
127
|
|
|
120
|
-
function parseCsiSequence(seq: string):
|
|
128
|
+
function parseCsiSequence(seq: string): KeyInput {
|
|
121
129
|
// Legacy Shift+Tab (CSI Z) — sent by tmux and some terminals
|
|
122
|
-
if (seq === "Z") return "shift+tab";
|
|
130
|
+
if (seq === "Z") return { key: "shift+tab" };
|
|
123
131
|
|
|
124
132
|
// CSI u format: codepoint [; modifiers] u
|
|
125
133
|
let match = CSI_U_RE.exec(seq);
|
|
126
134
|
if (match) {
|
|
127
135
|
const codepoint = parseInt(match[1]!, 10);
|
|
128
|
-
const modParam = match[2] ? parseInt(match[2]
|
|
136
|
+
const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
|
|
129
137
|
|
|
130
|
-
// Check named keys first
|
|
131
138
|
const name = CODEPOINT_NAMES[codepoint];
|
|
132
|
-
if (name)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
if (name) {
|
|
140
|
+
return { key: withModifiers(modParam, name) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const char = String.fromCodePoint(codepoint);
|
|
144
|
+
const key = withModifiers(modParam, char.toLowerCase());
|
|
145
|
+
if (modParam <= 1) {
|
|
146
|
+
return { key, text: char };
|
|
147
|
+
}
|
|
148
|
+
return { key };
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
// CSI letter format: [1 ; modifiers] <letter>
|
|
140
152
|
match = CSI_LETTER_RE.exec(seq);
|
|
141
153
|
if (match) {
|
|
142
|
-
const modParam = match[1] ? parseInt(match[1]
|
|
154
|
+
const modParam = match[1] ? parseInt(match[1]!, 10) : 0;
|
|
143
155
|
const letter = match[2]!;
|
|
144
156
|
const name = LETTER_NAMES[letter];
|
|
145
|
-
if (name) return withModifiers(modParam, name);
|
|
157
|
+
if (name) return { key: withModifiers(modParam, name) };
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
// CSI tilde format: number [; modifiers] ~
|
|
149
161
|
match = CSI_TILDE_RE.exec(seq);
|
|
150
162
|
if (match) {
|
|
151
163
|
const num = parseInt(match[1]!, 10);
|
|
152
|
-
const modParam = match[2] ? parseInt(match[2]
|
|
164
|
+
const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
|
|
153
165
|
const name = TILDE_NAMES[num];
|
|
154
|
-
if (name) return withModifiers(modParam, name);
|
|
166
|
+
if (name) return { key: withModifiers(modParam, name) };
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
return `unknown:${seq}
|
|
169
|
+
return { key: `unknown:${seq}` };
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* Parse raw terminal input data into a normalized key string.
|
|
164
|
-
*
|
|
165
|
-
* Handles the Kitty keyboard protocol level 1 (disambiguate) encoding:
|
|
166
|
-
* - CSI u sequences for special keys and modifier combos
|
|
167
|
-
* - CSI letter sequences for arrow keys and Home/End (with optional modifiers)
|
|
168
|
-
* - CSI tilde sequences for Delete, PageUp/Down, and function keys
|
|
169
|
-
* - Raw printable characters (unmodified, arrive as raw bytes at level 1)
|
|
170
|
-
*
|
|
171
|
-
* @param data - Raw terminal input string.
|
|
172
|
-
* @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
|
|
173
|
-
*/
|
|
174
|
-
export function parseKey(data: string): string {
|
|
175
|
-
// CSI sequences: ESC [ ...
|
|
176
|
-
if (data.startsWith("\x1b[")) {
|
|
177
|
-
return parseCsiSequence(data.slice(2));
|
|
172
|
+
function decodeLegacyControlByte(code: number): string | null {
|
|
173
|
+
if (code >= 1 && code <= 26) {
|
|
174
|
+
return `ctrl+${String.fromCharCode(code + 96)}`;
|
|
178
175
|
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const code = data.charCodeAt(0);
|
|
179
|
+
function parseRawKeyInput(data: string): KeyInput {
|
|
180
|
+
const code = data.charCodeAt(0);
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (legacy) return legacy;
|
|
182
|
+
const legacy = LEGACY_BYTE_NAMES[code];
|
|
183
|
+
if (legacy) return { key: legacy };
|
|
187
184
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
const ctrl = decodeLegacyControlByte(code);
|
|
186
|
+
if (ctrl) return { key: ctrl };
|
|
187
|
+
|
|
188
|
+
const named = CHAR_NAMES[data];
|
|
189
|
+
if (named) return { key: named, text: data };
|
|
190
|
+
|
|
191
|
+
return { key: data.toLowerCase(), text: data };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseAltKeyInput(data: string): KeyInput {
|
|
195
|
+
const base = parseRawKeyInput(data);
|
|
196
|
+
return { key: normalizeKey(`alt+${base.key}`) };
|
|
197
|
+
}
|
|
191
198
|
|
|
192
|
-
|
|
193
|
-
|
|
199
|
+
function readCodePoint(
|
|
200
|
+
data: string,
|
|
201
|
+
index: number,
|
|
202
|
+
): { value: string; nextIndex: number } | null {
|
|
203
|
+
if (index >= data.length) return null;
|
|
204
|
+
const codepoint = data.codePointAt(index);
|
|
205
|
+
if (codepoint === undefined) return null;
|
|
206
|
+
const value = String.fromCodePoint(codepoint);
|
|
207
|
+
return { value, nextIndex: index + value.length };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readCsiSequence(
|
|
211
|
+
data: string,
|
|
212
|
+
index: number,
|
|
213
|
+
): { value: string; nextIndex: number } | null {
|
|
214
|
+
if (!data.startsWith("\x1b[", index)) return null;
|
|
215
|
+
|
|
216
|
+
for (let i = index + 2; i < data.length; i++) {
|
|
217
|
+
const code = data.charCodeAt(i);
|
|
218
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
219
|
+
return { value: data.slice(index + 2, i + 1), nextIndex: i + 1 };
|
|
220
|
+
}
|
|
194
221
|
}
|
|
195
222
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Decode a raw keyboard data chunk into ordered key events.
|
|
228
|
+
*
|
|
229
|
+
* A single chunk may contain multiple key presses batched together.
|
|
230
|
+
*/
|
|
231
|
+
export function decodeKeyEvents(data: string): KeyInput[] {
|
|
232
|
+
const events: KeyInput[] = [];
|
|
233
|
+
let index = 0;
|
|
234
|
+
|
|
235
|
+
while (index < data.length) {
|
|
236
|
+
const csi = readCsiSequence(data, index);
|
|
237
|
+
if (csi) {
|
|
238
|
+
events.push(parseCsiSequence(csi.value));
|
|
239
|
+
index = csi.nextIndex;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const char = data[index]!;
|
|
244
|
+
if (char === "\x1b") {
|
|
245
|
+
const next = readCodePoint(data, index + 1);
|
|
246
|
+
if (next && next.value !== "[") {
|
|
247
|
+
events.push(parseAltKeyInput(next.value));
|
|
248
|
+
index = next.nextIndex;
|
|
249
|
+
} else {
|
|
250
|
+
events.push({ key: "escape" });
|
|
251
|
+
index += 1;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const codePoint = readCodePoint(data, index);
|
|
257
|
+
if (!codePoint) break;
|
|
258
|
+
events.push(parseRawKeyInput(codePoint.value));
|
|
259
|
+
index = codePoint.nextIndex;
|
|
199
260
|
}
|
|
200
261
|
|
|
201
|
-
return
|
|
262
|
+
return events;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Public API ---
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse a single raw key sequence into a normalized key string.
|
|
269
|
+
*
|
|
270
|
+
* This is a convenience wrapper around {@link decodeKeyEvents} for callers that
|
|
271
|
+
* already know the input contains one logical key event.
|
|
272
|
+
*
|
|
273
|
+
* @param data - Raw terminal input string.
|
|
274
|
+
* @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
|
|
275
|
+
*/
|
|
276
|
+
export function parseKey(data: string): string {
|
|
277
|
+
const events = decodeKeyEvents(data);
|
|
278
|
+
return events[0]?.key ?? `unknown:${data}`;
|
|
202
279
|
}
|
|
203
280
|
|
|
204
281
|
/**
|
|
@@ -217,7 +294,6 @@ export function normalizeKey(key: string): string {
|
|
|
217
294
|
const base = parts[parts.length - 1]!;
|
|
218
295
|
const mods = parts.slice(0, -1);
|
|
219
296
|
|
|
220
|
-
// Canonical order: ctrl, alt, shift
|
|
221
297
|
const ordered: string[] = [];
|
|
222
298
|
if (mods.includes("ctrl")) ordered.push("ctrl");
|
|
223
299
|
if (mods.includes("alt")) ordered.push("alt");
|
|
@@ -227,14 +303,15 @@ export function normalizeKey(key: string): string {
|
|
|
227
303
|
}
|
|
228
304
|
|
|
229
305
|
/**
|
|
230
|
-
* Check
|
|
231
|
-
*
|
|
306
|
+
* Check whether a semantic key represents TextInput editing/navigation.
|
|
307
|
+
*
|
|
308
|
+
* Single-character semantic keys represent insertable text, while named keys
|
|
309
|
+
* like `"enter"` and `"left"` represent editing/navigation actions. Modifier
|
|
310
|
+
* combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble.
|
|
232
311
|
*/
|
|
233
312
|
export function isEditingKey(key: string): boolean {
|
|
234
|
-
// Single printable characters
|
|
235
313
|
if (key.length === 1) return true;
|
|
236
314
|
|
|
237
|
-
// Navigation and editing keys consumed by TextInput
|
|
238
315
|
const editingKeys = new Set([
|
|
239
316
|
"enter",
|
|
240
317
|
"backspace",
|
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
|
-
}
|
|
@@ -11,12 +11,14 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
|
|
|
11
11
|
* Scroll is always uncontrolled — the view follows the cursor and
|
|
12
12
|
* responds to mouse wheel automatically.
|
|
13
13
|
*
|
|
14
|
-
* TextInput is always focusable. When focused, text
|
|
15
|
-
*
|
|
16
|
-
* Modifier combos (e.g., `ctrl+s`)
|
|
14
|
+
* TextInput is always focusable. When focused, it consumes insertable text
|
|
15
|
+
* plus editing/navigation keys (arrows, backspace, delete, Enter, Tab).
|
|
16
|
+
* Modifier combos (e.g., `ctrl+s`) and non-insertable control keys bubble
|
|
17
|
+
* up to ancestor `onKeyPress` handlers.
|
|
17
18
|
*
|
|
18
|
-
* Use `onKeyPress` to intercept keys before editing.
|
|
19
|
-
*
|
|
19
|
+
* Use `onKeyPress` to intercept keys before editing. The handler receives a
|
|
20
|
+
* normalized semantic key string; inserted text preserves the original
|
|
21
|
+
* characters. Return `false` to prevent the default editing action.
|
|
20
22
|
*
|
|
21
23
|
* @param props - Value, callbacks, sizing, styling, and focus props.
|
|
22
24
|
* @returns A text input node for the UI tree.
|
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
|
+
}
|