@cel-tui/core 0.6.1 → 0.7.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/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
- * Vertical cursor movement maps cursor offset to line/column,
119
- * moves up or down a line, then maps back to offset.
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 lines = value.split("\n");
128
-
129
- // Find which line and column the cursor is on
130
- let offset = 0;
131
- let cursorLine = 0;
132
- let cursorCol = 0;
133
-
134
- for (let i = 0; i < lines.length; i++) {
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
- // Map column to new line, clamping to line length
161
- const targetLineLen = lines[targetLine]!.length;
162
- const targetCol = Math.min(cursorCol, targetLineLen);
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
  }
@@ -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
- prefixWidths.push(prefixWidths[prefixWidths.length - 1]! + grapheme.width);
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 = prefixWidths[lineEnd + 1]! - prefixWidths[lineStart]!;
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[lineEnd - 1]!.isWhitespace) {
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[0]!.startOffset;
207
- const endOffset = lineGraphemes[lineGraphemes.length - 1]!.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[i]!.line.startOffset) {
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[i]!;
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[lines.length - 1]!;
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 && !/[A-Za-z]/.test(str[j]!)) j++;
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
  }