@cel-tui/core 0.6.2 → 0.7.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 +191 -67
- package/src/cell-buffer.ts +7 -2
- package/src/emitter.ts +1 -2
- package/src/hit-test.ts +30 -15
- package/src/index.ts +15 -16
- package/src/keys.ts +41 -11
- package/src/layout.ts +88 -40
- package/src/paint.ts +26 -15
- package/src/primitives/text-input.ts +7 -3
- package/src/scroll.ts +10 -6
- package/src/text-edit.ts +99 -41
- package/src/text-layout.ts +65 -8
- package/src/width.ts +9 -4
package/src/text-edit.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { layoutText } from "./text-layout.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Framework-managed editing state for TextInput.
|
|
3
5
|
*/
|
|
@@ -9,6 +11,7 @@ export interface EditState {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
14
|
+
const WHITESPACE_RE = /^\s+$/u;
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Get the grapheme boundary before the given cursor position.
|
|
@@ -39,6 +42,46 @@ function nextGraphemeBoundary(value: string, cursor: number): number {
|
|
|
39
42
|
return value.length;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
function isWhitespaceGrapheme(segment: string): boolean {
|
|
46
|
+
return WHITESPACE_RE.test(segment);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findWordBoundaryBackward(value: string, cursor: number): number {
|
|
50
|
+
let boundary = cursor;
|
|
51
|
+
|
|
52
|
+
while (boundary > 0) {
|
|
53
|
+
const prev = prevGraphemeBoundary(value, boundary);
|
|
54
|
+
if (!isWhitespaceGrapheme(value.slice(prev, boundary))) break;
|
|
55
|
+
boundary = prev;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
while (boundary > 0) {
|
|
59
|
+
const prev = prevGraphemeBoundary(value, boundary);
|
|
60
|
+
if (isWhitespaceGrapheme(value.slice(prev, boundary))) break;
|
|
61
|
+
boundary = prev;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return boundary;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findWordBoundaryForward(value: string, cursor: number): number {
|
|
68
|
+
let boundary = cursor;
|
|
69
|
+
|
|
70
|
+
while (boundary < value.length) {
|
|
71
|
+
const next = nextGraphemeBoundary(value, boundary);
|
|
72
|
+
if (!isWhitespaceGrapheme(value.slice(boundary, next))) break;
|
|
73
|
+
boundary = next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
while (boundary < value.length) {
|
|
77
|
+
const next = nextGraphemeBoundary(value, boundary);
|
|
78
|
+
if (isWhitespaceGrapheme(value.slice(boundary, next))) break;
|
|
79
|
+
boundary = next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return boundary;
|
|
83
|
+
}
|
|
84
|
+
|
|
42
85
|
/**
|
|
43
86
|
* Insert a character (or string) at the cursor position.
|
|
44
87
|
*/
|
|
@@ -78,6 +121,32 @@ export function deleteForward(state: EditState): EditState {
|
|
|
78
121
|
};
|
|
79
122
|
}
|
|
80
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Delete the whitespace-delimited word before the cursor.
|
|
126
|
+
*/
|
|
127
|
+
export function deleteWordBackward(state: EditState): EditState {
|
|
128
|
+
const { value, cursor } = state;
|
|
129
|
+
const boundary = findWordBoundaryBackward(value, cursor);
|
|
130
|
+
if (boundary === cursor) return state;
|
|
131
|
+
return {
|
|
132
|
+
value: value.slice(0, boundary) + value.slice(cursor),
|
|
133
|
+
cursor: boundary,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Delete the whitespace-delimited word after the cursor.
|
|
139
|
+
*/
|
|
140
|
+
export function deleteWordForward(state: EditState): EditState {
|
|
141
|
+
const { value, cursor } = state;
|
|
142
|
+
const boundary = findWordBoundaryForward(value, cursor);
|
|
143
|
+
if (boundary === cursor) return state;
|
|
144
|
+
return {
|
|
145
|
+
value: value.slice(0, cursor) + value.slice(boundary),
|
|
146
|
+
cursor,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
81
150
|
/**
|
|
82
151
|
* Move the cursor in the given direction.
|
|
83
152
|
* Left/right movement respects grapheme boundaries.
|
|
@@ -115,8 +184,24 @@ export function moveCursor(
|
|
|
115
184
|
}
|
|
116
185
|
|
|
117
186
|
/**
|
|
118
|
-
*
|
|
119
|
-
|
|
187
|
+
* Move the cursor by one whitespace-delimited word.
|
|
188
|
+
*/
|
|
189
|
+
export function moveCursorByWord(
|
|
190
|
+
state: EditState,
|
|
191
|
+
direction: "backward" | "forward",
|
|
192
|
+
): EditState {
|
|
193
|
+
const { value, cursor } = state;
|
|
194
|
+
const nextCursor =
|
|
195
|
+
direction === "backward"
|
|
196
|
+
? findWordBoundaryBackward(value, cursor)
|
|
197
|
+
: findWordBoundaryForward(value, cursor);
|
|
198
|
+
|
|
199
|
+
if (nextCursor === cursor) return state;
|
|
200
|
+
return { value, cursor: nextCursor };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Vertical cursor movement follows the visual wrapped lines used for painting.
|
|
120
205
|
*/
|
|
121
206
|
function moveVertical(
|
|
122
207
|
state: EditState,
|
|
@@ -124,49 +209,22 @@ function moveVertical(
|
|
|
124
209
|
width: number,
|
|
125
210
|
): EditState {
|
|
126
211
|
const { value, cursor } = state;
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const lineLen = lines[i]!.length;
|
|
136
|
-
if (cursor >= offset && cursor <= offset + lineLen) {
|
|
137
|
-
cursorLine = i;
|
|
138
|
-
cursorCol = cursor - offset;
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
offset += lineLen + 1; // +1 for \n
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Move line
|
|
145
|
-
let targetLine = cursorLine;
|
|
146
|
-
if (direction === "up") {
|
|
147
|
-
targetLine = Math.max(0, cursorLine - 1);
|
|
148
|
-
} else {
|
|
149
|
-
targetLine = Math.min(lines.length - 1, cursorLine + 1);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (targetLine === cursorLine) {
|
|
153
|
-
// Can't move — go to start (up) or end (down)
|
|
212
|
+
const textLayout = layoutText(value, width, "word");
|
|
213
|
+
const pos = textLayout.offsetToPosition(cursor);
|
|
214
|
+
const targetLine =
|
|
215
|
+
direction === "up"
|
|
216
|
+
? Math.max(0, pos.line - 1)
|
|
217
|
+
: Math.min(textLayout.lineCount - 1, pos.line + 1);
|
|
218
|
+
|
|
219
|
+
if (targetLine === pos.line) {
|
|
154
220
|
return {
|
|
155
221
|
value,
|
|
156
222
|
cursor: direction === "up" ? 0 : value.length,
|
|
157
223
|
};
|
|
158
224
|
}
|
|
159
225
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Compute new offset
|
|
165
|
-
let newOffset = 0;
|
|
166
|
-
for (let i = 0; i < targetLine; i++) {
|
|
167
|
-
newOffset += lines[i]!.length + 1;
|
|
168
|
-
}
|
|
169
|
-
newOffset += targetCol;
|
|
170
|
-
|
|
171
|
-
return { value, cursor: newOffset };
|
|
226
|
+
return {
|
|
227
|
+
value,
|
|
228
|
+
cursor: textLayout.positionToOffset(targetLine, pos.col),
|
|
229
|
+
};
|
|
172
230
|
}
|
package/src/text-layout.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface TextLayoutResult {
|
|
|
18
18
|
lines: VisualLine[];
|
|
19
19
|
lineCount: number;
|
|
20
20
|
offsetToPosition(offset: number): VisualPosition;
|
|
21
|
+
positionToOffset(line: number, col: number): number;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface GraphemeInfo {
|
|
@@ -38,6 +39,18 @@ interface Range {
|
|
|
38
39
|
end: number;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
function requiredAt<T>(
|
|
43
|
+
items: readonly T[],
|
|
44
|
+
index: number,
|
|
45
|
+
description: string,
|
|
46
|
+
): T {
|
|
47
|
+
const item = items[index];
|
|
48
|
+
if (item === undefined) {
|
|
49
|
+
throw new Error(`Missing ${description} at index ${index}`);
|
|
50
|
+
}
|
|
51
|
+
return item;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function layoutText(
|
|
42
55
|
value: string,
|
|
43
56
|
width: number,
|
|
@@ -78,6 +91,9 @@ export function layoutText(
|
|
|
78
91
|
offsetToPosition(offset: number): VisualPosition {
|
|
79
92
|
return offsetToPosition(lines, clamp(offset, 0, value.length));
|
|
80
93
|
},
|
|
94
|
+
positionToOffset(line: number, col: number): number {
|
|
95
|
+
return positionToOffset(lines, line, col);
|
|
96
|
+
},
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
@@ -129,7 +145,12 @@ function wrapGraphemes(
|
|
|
129
145
|
|
|
130
146
|
const prefixWidths = [0];
|
|
131
147
|
for (const grapheme of graphemes) {
|
|
132
|
-
|
|
148
|
+
const previousWidth = requiredAt(
|
|
149
|
+
prefixWidths,
|
|
150
|
+
prefixWidths.length - 1,
|
|
151
|
+
"prefix width",
|
|
152
|
+
);
|
|
153
|
+
prefixWidths.push(previousWidth + grapheme.width);
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
const ranges: Array<[number, number]> = [];
|
|
@@ -140,10 +161,12 @@ function wrapGraphemes(
|
|
|
140
161
|
let lastBreak = -1;
|
|
141
162
|
|
|
142
163
|
while (lineEnd < graphemes.length) {
|
|
143
|
-
const nextWidth =
|
|
164
|
+
const nextWidth =
|
|
165
|
+
requiredAt(prefixWidths, lineEnd + 1, "prefix width") -
|
|
166
|
+
requiredAt(prefixWidths, lineStart, "prefix width");
|
|
144
167
|
if (nextWidth > width) break;
|
|
145
168
|
lineEnd++;
|
|
146
|
-
if (graphemes
|
|
169
|
+
if (requiredAt(graphemes, lineEnd - 1, "grapheme").isWhitespace) {
|
|
147
170
|
lastBreak = lineEnd;
|
|
148
171
|
}
|
|
149
172
|
}
|
|
@@ -203,8 +226,12 @@ function makeVisualLine(
|
|
|
203
226
|
}
|
|
204
227
|
|
|
205
228
|
const lineGraphemes = graphemes.slice(startIndex, endIndex);
|
|
206
|
-
const startOffset = lineGraphemes
|
|
207
|
-
const endOffset =
|
|
229
|
+
const startOffset = requiredAt(lineGraphemes, 0, "line grapheme").startOffset;
|
|
230
|
+
const endOffset = requiredAt(
|
|
231
|
+
lineGraphemes,
|
|
232
|
+
lineGraphemes.length - 1,
|
|
233
|
+
"line grapheme",
|
|
234
|
+
).endOffset;
|
|
208
235
|
|
|
209
236
|
return {
|
|
210
237
|
line: {
|
|
@@ -222,13 +249,13 @@ function offsetToPosition(
|
|
|
222
249
|
offset: number,
|
|
223
250
|
): VisualPosition {
|
|
224
251
|
for (let i = 1; i < lines.length; i++) {
|
|
225
|
-
if (offset === lines
|
|
252
|
+
if (offset === requiredAt(lines, i, "visual line").line.startOffset) {
|
|
226
253
|
return { line: i, col: 0 };
|
|
227
254
|
}
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
for (let i = 0; i < lines.length; i++) {
|
|
231
|
-
const entry = lines
|
|
258
|
+
const entry = requiredAt(lines, i, "visual line");
|
|
232
259
|
const { line, graphemes } = entry;
|
|
233
260
|
if (offset < line.startOffset || offset > line.endOffset) continue;
|
|
234
261
|
|
|
@@ -241,10 +268,40 @@ function offsetToPosition(
|
|
|
241
268
|
return { line: i, col };
|
|
242
269
|
}
|
|
243
270
|
|
|
244
|
-
const last = lines
|
|
271
|
+
const last = requiredAt(lines, lines.length - 1, "visual line");
|
|
245
272
|
return { line: lines.length - 1, col: last.line.width };
|
|
246
273
|
}
|
|
247
274
|
|
|
275
|
+
function positionToOffset(
|
|
276
|
+
lines: VisualLineData[],
|
|
277
|
+
line: number,
|
|
278
|
+
col: number,
|
|
279
|
+
): number {
|
|
280
|
+
const lineIndex = clamp(line, 0, lines.length - 1);
|
|
281
|
+
const entry = requiredAt(lines, lineIndex, "visual line");
|
|
282
|
+
const targetCol = Math.max(0, col);
|
|
283
|
+
|
|
284
|
+
if (targetCol === 0 || entry.graphemes.length === 0) {
|
|
285
|
+
return entry.line.startOffset;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let currentCol = 0;
|
|
289
|
+
for (const grapheme of entry.graphemes) {
|
|
290
|
+
const nextCol = currentCol + grapheme.width;
|
|
291
|
+
if (targetCol < nextCol) {
|
|
292
|
+
return targetCol - currentCol < nextCol - targetCol
|
|
293
|
+
? grapheme.startOffset
|
|
294
|
+
: grapheme.endOffset;
|
|
295
|
+
}
|
|
296
|
+
if (targetCol === nextCol) {
|
|
297
|
+
return grapheme.endOffset;
|
|
298
|
+
}
|
|
299
|
+
currentCol = nextCol;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return entry.line.endOffset;
|
|
303
|
+
}
|
|
304
|
+
|
|
248
305
|
function clamp(value: number, min: number, max: number): number {
|
|
249
306
|
return Math.max(min, Math.min(max, value));
|
|
250
307
|
}
|
package/src/width.ts
CHANGED
|
@@ -20,7 +20,11 @@ export function extractAnsiCode(
|
|
|
20
20
|
// CSI: ESC [ ... <terminal byte>
|
|
21
21
|
if (next === "[") {
|
|
22
22
|
let j = pos + 2;
|
|
23
|
-
while (j < str.length
|
|
23
|
+
while (j < str.length) {
|
|
24
|
+
const char = str[j];
|
|
25
|
+
if (char === undefined || /[A-Za-z]/.test(char)) break;
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
24
28
|
if (j < str.length)
|
|
25
29
|
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
|
26
30
|
return null;
|
|
@@ -64,7 +68,8 @@ const leadingNonPrintingRegex =
|
|
|
64
68
|
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
|
|
65
69
|
|
|
66
70
|
function couldBeEmoji(segment: string): boolean {
|
|
67
|
-
const cp = segment.codePointAt(0)
|
|
71
|
+
const cp = segment.codePointAt(0);
|
|
72
|
+
if (cp === undefined) return false;
|
|
68
73
|
return (
|
|
69
74
|
(cp >= 0x1f000 && cp <= 0x1fbff) ||
|
|
70
75
|
(cp >= 0x2300 && cp <= 0x23ff) ||
|
|
@@ -94,8 +99,8 @@ function graphemeWidth(segment: string): number {
|
|
|
94
99
|
|
|
95
100
|
if (segment.length > 1) {
|
|
96
101
|
for (const char of segment.slice(1)) {
|
|
97
|
-
const c = char.codePointAt(0)
|
|
98
|
-
if (c >= 0xff00 && c <= 0xffef) {
|
|
102
|
+
const c = char.codePointAt(0);
|
|
103
|
+
if (c !== undefined && c >= 0xff00 && c <= 0xffef) {
|
|
99
104
|
width += eastAsianWidth(c);
|
|
100
105
|
}
|
|
101
106
|
}
|