@oh-my-pi/pi-tui 13.7.4 → 13.7.6
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/CHANGELOG.md +5 -0
- package/package.json +3 -3
- package/src/components/editor.ts +86 -79
- package/src/components/input.ts +49 -114
- package/src/utils.ts +162 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.7.5] - 2026-03-04
|
|
6
|
+
### Changed
|
|
7
|
+
|
|
8
|
+
- Extracted word navigation logic into reusable `moveWordLeft` and `moveWordRight` utility functions for consistent cursor movement across components
|
|
9
|
+
|
|
5
10
|
## [13.6.2] - 2026-03-03
|
|
6
11
|
### Fixed
|
|
7
12
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "13.7.
|
|
4
|
+
"version": "13.7.6",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"test": "bun test test/*.test.ts"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "13.7.
|
|
37
|
-
"@oh-my-pi/pi-utils": "13.7.
|
|
36
|
+
"@oh-my-pi/pi-natives": "13.7.6",
|
|
37
|
+
"@oh-my-pi/pi-utils": "13.7.6",
|
|
38
38
|
"marked": "^17.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/components/editor.ts
CHANGED
|
@@ -6,7 +6,15 @@ import { matchesKey } from "../keys";
|
|
|
6
6
|
import { KillRing } from "../kill-ring";
|
|
7
7
|
import type { SymbolTheme } from "../symbols";
|
|
8
8
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getSegmenter,
|
|
11
|
+
getWordNavKind,
|
|
12
|
+
moveWordLeft,
|
|
13
|
+
moveWordRight,
|
|
14
|
+
padding,
|
|
15
|
+
truncateToWidth,
|
|
16
|
+
visibleWidth,
|
|
17
|
+
} from "../utils";
|
|
10
18
|
import { SelectList, type SelectListTheme } from "./select-list";
|
|
11
19
|
|
|
12
20
|
const segmenter = getSegmenter();
|
|
@@ -51,7 +59,7 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
51
59
|
|
|
52
60
|
for (const seg of segmenter.segment(line)) {
|
|
53
61
|
const grapheme = seg.segment;
|
|
54
|
-
const graphemeIsWhitespace =
|
|
62
|
+
const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace";
|
|
55
63
|
|
|
56
64
|
if (currentToken === "") {
|
|
57
65
|
inWhitespace = graphemeIsWhitespace;
|
|
@@ -89,6 +97,27 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
89
97
|
let chunkStartIndex = 0;
|
|
90
98
|
let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
|
|
91
99
|
|
|
100
|
+
function consumePrefixToWidth(text: string, availableWidth: number): { text: string; len: number } {
|
|
101
|
+
let prefix = "";
|
|
102
|
+
let prefixWidth = 0;
|
|
103
|
+
let len = 0;
|
|
104
|
+
for (const seg of segmenter.segment(text)) {
|
|
105
|
+
const grapheme = seg.segment;
|
|
106
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
107
|
+
if (prefixWidth + graphemeWidth > availableWidth) break;
|
|
108
|
+
prefix += grapheme;
|
|
109
|
+
prefixWidth += graphemeWidth;
|
|
110
|
+
len += grapheme.length;
|
|
111
|
+
if (prefixWidth === availableWidth) break;
|
|
112
|
+
}
|
|
113
|
+
return { text: prefix, len };
|
|
114
|
+
}
|
|
115
|
+
function hasWideGrapheme(text: string): boolean {
|
|
116
|
+
for (const seg of segmenter.segment(text)) {
|
|
117
|
+
if (visibleWidth(seg.segment) > 1) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
92
121
|
for (const token of tokens) {
|
|
93
122
|
const tokenWidth = visibleWidth(token.text);
|
|
94
123
|
|
|
@@ -101,28 +130,46 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
101
130
|
|
|
102
131
|
// If this single token is wider than maxWidth, we need to break it
|
|
103
132
|
if (tokenWidth > maxWidth) {
|
|
104
|
-
//
|
|
133
|
+
// If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
|
|
134
|
+
let consumedPrefix = "";
|
|
135
|
+
let consumedPrefixLen = 0; // JS string index (code units) consumed from token.text
|
|
136
|
+
if (currentChunk && currentWidth < maxWidth) {
|
|
137
|
+
const remainingWidth = maxWidth - currentWidth;
|
|
138
|
+
const consumed = consumePrefixToWidth(token.text, remainingWidth);
|
|
139
|
+
consumedPrefix = consumed.text;
|
|
140
|
+
consumedPrefixLen = consumed.len;
|
|
141
|
+
}
|
|
142
|
+
// First, push any accumulated chunk (optionally filled with the prefix).
|
|
105
143
|
if (currentChunk) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
if (consumedPrefix) {
|
|
145
|
+
chunks.push({
|
|
146
|
+
text: currentChunk + consumedPrefix,
|
|
147
|
+
startIndex: chunkStartIndex,
|
|
148
|
+
endIndex: token.startIndex + consumedPrefixLen,
|
|
149
|
+
});
|
|
150
|
+
currentChunk = "";
|
|
151
|
+
currentWidth = 0;
|
|
152
|
+
chunkStartIndex = token.startIndex + consumedPrefixLen;
|
|
153
|
+
} else {
|
|
154
|
+
chunks.push({
|
|
155
|
+
text: currentChunk,
|
|
156
|
+
startIndex: chunkStartIndex,
|
|
157
|
+
endIndex: token.startIndex,
|
|
158
|
+
});
|
|
159
|
+
currentChunk = "";
|
|
160
|
+
currentWidth = 0;
|
|
161
|
+
chunkStartIndex = token.startIndex;
|
|
162
|
+
}
|
|
114
163
|
}
|
|
115
|
-
|
|
116
|
-
|
|
164
|
+
// Break the remaining long token by grapheme
|
|
165
|
+
const remainingText = consumedPrefixLen > 0 ? token.text.slice(consumedPrefixLen) : token.text;
|
|
117
166
|
let tokenChunk = "";
|
|
118
167
|
let tokenChunkWidth = 0;
|
|
119
|
-
let tokenChunkStart = token.startIndex;
|
|
120
|
-
let tokenCharIndex = token.startIndex;
|
|
121
|
-
|
|
122
|
-
for (const seg of segmenter.segment(token.text)) {
|
|
168
|
+
let tokenChunkStart = token.startIndex + consumedPrefixLen;
|
|
169
|
+
let tokenCharIndex = token.startIndex + consumedPrefixLen;
|
|
170
|
+
for (const seg of segmenter.segment(remainingText)) {
|
|
123
171
|
const grapheme = seg.segment;
|
|
124
172
|
const graphemeWidth = visibleWidth(grapheme);
|
|
125
|
-
|
|
126
173
|
if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
|
|
127
174
|
chunks.push({
|
|
128
175
|
text: tokenChunk,
|
|
@@ -138,7 +185,6 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
138
185
|
}
|
|
139
186
|
tokenCharIndex += grapheme.length;
|
|
140
187
|
}
|
|
141
|
-
|
|
142
188
|
// Keep remainder as start of next chunk
|
|
143
189
|
if (tokenChunk) {
|
|
144
190
|
currentChunk = tokenChunk;
|
|
@@ -150,6 +196,25 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
150
196
|
|
|
151
197
|
// Check if adding this token would exceed width
|
|
152
198
|
if (currentWidth + tokenWidth > maxWidth) {
|
|
199
|
+
// For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
|
|
200
|
+
// the whole token to the next line. This avoids leaving a short ASCII word alone.
|
|
201
|
+
if (currentChunk && !token.isWhitespace && currentWidth < maxWidth && hasWideGrapheme(token.text)) {
|
|
202
|
+
const remainingWidth = maxWidth - currentWidth;
|
|
203
|
+
const consumed = consumePrefixToWidth(token.text, remainingWidth);
|
|
204
|
+
if (consumed.text) {
|
|
205
|
+
chunks.push({
|
|
206
|
+
text: currentChunk + consumed.text,
|
|
207
|
+
startIndex: chunkStartIndex,
|
|
208
|
+
endIndex: token.startIndex + consumed.len,
|
|
209
|
+
});
|
|
210
|
+
const remainder = token.text.slice(consumed.len);
|
|
211
|
+
currentChunk = remainder;
|
|
212
|
+
currentWidth = visibleWidth(remainder);
|
|
213
|
+
chunkStartIndex = token.startIndex + consumed.len;
|
|
214
|
+
atLineStart = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
153
218
|
// Push current chunk (trimming trailing whitespace for display)
|
|
154
219
|
const trimmedChunk = currentChunk.trimEnd();
|
|
155
220
|
if (trimmedChunk || chunks.length === 0) {
|
|
@@ -159,7 +224,6 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
159
224
|
endIndex: chunkStartIndex + currentChunk.length,
|
|
160
225
|
});
|
|
161
226
|
}
|
|
162
|
-
|
|
163
227
|
// Start new line - skip leading whitespace
|
|
164
228
|
atLineStart = true;
|
|
165
229
|
if (token.isWhitespace) {
|
|
@@ -1942,35 +2006,7 @@ export class Editor implements Component, Focusable {
|
|
|
1942
2006
|
return;
|
|
1943
2007
|
}
|
|
1944
2008
|
|
|
1945
|
-
|
|
1946
|
-
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
|
1947
|
-
let newCol = this.#state.cursorCol;
|
|
1948
|
-
|
|
1949
|
-
// Skip trailing whitespace
|
|
1950
|
-
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1951
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
if (graphemes.length > 0) {
|
|
1955
|
-
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1956
|
-
if (isPunctuationChar(lastGrapheme)) {
|
|
1957
|
-
// Skip punctuation run
|
|
1958
|
-
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1959
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1960
|
-
}
|
|
1961
|
-
} else {
|
|
1962
|
-
// Skip word run
|
|
1963
|
-
while (
|
|
1964
|
-
graphemes.length > 0 &&
|
|
1965
|
-
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1966
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1967
|
-
) {
|
|
1968
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
this.#setCursorCol(newCol);
|
|
2009
|
+
this.#setCursorCol(moveWordLeft(currentLine, this.#state.cursorCol));
|
|
1974
2010
|
}
|
|
1975
2011
|
|
|
1976
2012
|
/**
|
|
@@ -2019,36 +2055,7 @@ export class Editor implements Component, Focusable {
|
|
|
2019
2055
|
return;
|
|
2020
2056
|
}
|
|
2021
2057
|
|
|
2022
|
-
|
|
2023
|
-
const segments = segmenter.segment(textAfterCursor);
|
|
2024
|
-
const iterator = segments[Symbol.iterator]();
|
|
2025
|
-
let next = iterator.next();
|
|
2026
|
-
let newCol = this.#state.cursorCol;
|
|
2027
|
-
|
|
2028
|
-
// Skip leading whitespace
|
|
2029
|
-
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
2030
|
-
newCol += next.value.segment.length;
|
|
2031
|
-
next = iterator.next();
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
if (!next.done) {
|
|
2035
|
-
const firstGrapheme = next.value.segment;
|
|
2036
|
-
if (isPunctuationChar(firstGrapheme)) {
|
|
2037
|
-
// Skip punctuation run
|
|
2038
|
-
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
2039
|
-
newCol += next.value.segment.length;
|
|
2040
|
-
next = iterator.next();
|
|
2041
|
-
}
|
|
2042
|
-
} else {
|
|
2043
|
-
// Skip word run
|
|
2044
|
-
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
|
2045
|
-
newCol += next.value.segment.length;
|
|
2046
|
-
next = iterator.next();
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
this.#setCursorCol(newCol);
|
|
2058
|
+
this.#setCursorCol(moveWordRight(currentLine, this.#state.cursorCol));
|
|
2052
2059
|
}
|
|
2053
2060
|
|
|
2054
2061
|
// Helper method to check if cursor is at start of message (for slash command detection)
|
package/src/components/input.ts
CHANGED
|
@@ -2,7 +2,15 @@ import { BracketedPasteHandler } from "../bracketed-paste";
|
|
|
2
2
|
import { getEditorKeybindings } from "../keybindings";
|
|
3
3
|
import { KillRing } from "../kill-ring";
|
|
4
4
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getSegmenter,
|
|
7
|
+
getWordNavKind,
|
|
8
|
+
moveWordLeft,
|
|
9
|
+
moveWordRight,
|
|
10
|
+
padding,
|
|
11
|
+
sliceWithWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "../utils";
|
|
6
14
|
|
|
7
15
|
const segmenter = getSegmenter();
|
|
8
16
|
|
|
@@ -173,7 +181,7 @@ export class Input implements Component, Focusable {
|
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
#insertCharacter(text: string): void {
|
|
176
|
-
const isWordChunk = [...text].every(
|
|
184
|
+
const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
|
|
177
185
|
// Undo coalescing: consecutive word typing coalesces into one undo unit.
|
|
178
186
|
if (!isWordChunk || this.#lastAction !== "type-word") {
|
|
179
187
|
this.#pushUndo();
|
|
@@ -336,68 +344,15 @@ export class Input implements Component, Focusable {
|
|
|
336
344
|
return;
|
|
337
345
|
}
|
|
338
346
|
this.#lastAction = null;
|
|
339
|
-
|
|
340
|
-
const textBeforeCursor = this.#value.slice(0, this.#cursor);
|
|
341
|
-
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
|
342
|
-
|
|
343
|
-
// Skip trailing whitespace
|
|
344
|
-
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
345
|
-
this.#cursor -= graphemes.pop()?.segment.length || 0;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (graphemes.length > 0) {
|
|
349
|
-
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
350
|
-
if (isPunctuationChar(lastGrapheme)) {
|
|
351
|
-
// Skip punctuation run
|
|
352
|
-
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
353
|
-
this.#cursor -= graphemes.pop()?.segment.length || 0;
|
|
354
|
-
}
|
|
355
|
-
} else {
|
|
356
|
-
// Skip word run
|
|
357
|
-
while (
|
|
358
|
-
graphemes.length > 0 &&
|
|
359
|
-
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
360
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
361
|
-
) {
|
|
362
|
-
this.#cursor -= graphemes.pop()?.segment.length || 0;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
347
|
+
this.#cursor = moveWordLeft(this.#value, this.#cursor);
|
|
366
348
|
}
|
|
367
349
|
|
|
368
350
|
#moveWordForwards(): void {
|
|
369
351
|
if (this.#cursor >= this.#value.length) {
|
|
370
352
|
return;
|
|
371
353
|
}
|
|
372
|
-
|
|
373
354
|
this.#lastAction = null;
|
|
374
|
-
|
|
375
|
-
const segments = segmenter.segment(textAfterCursor);
|
|
376
|
-
const iterator = segments[Symbol.iterator]();
|
|
377
|
-
let next = iterator.next();
|
|
378
|
-
|
|
379
|
-
// Skip leading whitespace
|
|
380
|
-
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
381
|
-
this.#cursor += next.value.segment.length;
|
|
382
|
-
next = iterator.next();
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (!next.done) {
|
|
386
|
-
const firstGrapheme = next.value.segment;
|
|
387
|
-
if (isPunctuationChar(firstGrapheme)) {
|
|
388
|
-
// Skip punctuation run
|
|
389
|
-
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
390
|
-
this.#cursor += next.value.segment.length;
|
|
391
|
-
next = iterator.next();
|
|
392
|
-
}
|
|
393
|
-
} else {
|
|
394
|
-
// Skip word run
|
|
395
|
-
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
|
396
|
-
this.#cursor += next.value.segment.length;
|
|
397
|
-
next = iterator.next();
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
355
|
+
this.#cursor = moveWordRight(this.#value, this.#cursor);
|
|
401
356
|
}
|
|
402
357
|
|
|
403
358
|
#handlePaste(pastedText: string): void {
|
|
@@ -425,61 +380,37 @@ export class Input implements Component, Focusable {
|
|
|
425
380
|
return [prompt];
|
|
426
381
|
}
|
|
427
382
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
const findValidEnd = (end: number) => {
|
|
454
|
-
while (end > 0) {
|
|
455
|
-
const charCode = this.#value.charCodeAt(end - 1);
|
|
456
|
-
// this is high surrogate, might be split.
|
|
457
|
-
if (charCode >= 0xd800 && charCode < 0xdc00) {
|
|
458
|
-
end--;
|
|
459
|
-
continue;
|
|
460
|
-
}
|
|
461
|
-
break;
|
|
462
|
-
}
|
|
463
|
-
return end;
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
if (this.#cursor < halfWidth) {
|
|
467
|
-
// Cursor near start
|
|
468
|
-
visibleText = this.#value.slice(0, findValidEnd(scrollWidth));
|
|
469
|
-
cursorDisplay = this.#cursor;
|
|
470
|
-
} else if (this.#cursor > this.#value.length - halfWidth) {
|
|
471
|
-
// Cursor near end
|
|
472
|
-
const start = findValidStart(this.#value.length - scrollWidth);
|
|
473
|
-
visibleText = this.#value.slice(start);
|
|
474
|
-
cursorDisplay = this.#cursor - start;
|
|
475
|
-
} else {
|
|
476
|
-
// Cursor in middle
|
|
477
|
-
const start = findValidStart(this.#cursor - halfWidth);
|
|
478
|
-
visibleText = this.#value.slice(start, findValidEnd(start + scrollWidth));
|
|
479
|
-
cursorDisplay = this.#cursor - start;
|
|
383
|
+
const cursorIndex = this.#cursor;
|
|
384
|
+
// Ensure we always have a grapheme to invert at the cursor (space at end).
|
|
385
|
+
const displayValue = cursorIndex >= this.#value.length ? `${this.#value} ` : this.#value;
|
|
386
|
+
|
|
387
|
+
const totalCols = visibleWidth(displayValue);
|
|
388
|
+
const cursorCols = visibleWidth(displayValue.slice(0, cursorIndex));
|
|
389
|
+
|
|
390
|
+
// Width of the grapheme at the cursor, for ensuring it fits in the viewport.
|
|
391
|
+
const cursorIter = segmenter.segment(displayValue.slice(cursorIndex))[Symbol.iterator]();
|
|
392
|
+
const cursorG = cursorIter.next().value?.segment ?? " ";
|
|
393
|
+
const cursorGWidth = visibleWidth(cursorG);
|
|
394
|
+
|
|
395
|
+
const maxStart = Math.max(0, totalCols - availableWidth);
|
|
396
|
+
let startCol = 0;
|
|
397
|
+
if (totalCols > availableWidth) {
|
|
398
|
+
const half = Math.floor(availableWidth / 2);
|
|
399
|
+
startCol = Math.max(0, Math.min(maxStart, cursorCols - half));
|
|
400
|
+
|
|
401
|
+
// Ensure the cursor grapheme is inside the viewport (and fits fully if wide).
|
|
402
|
+
const maxCursorRel = Math.max(0, availableWidth - cursorGWidth);
|
|
403
|
+
const cursorRel = cursorCols - startCol;
|
|
404
|
+
if (cursorRel > maxCursorRel) {
|
|
405
|
+
startCol = Math.max(0, Math.min(maxStart, cursorCols - maxCursorRel));
|
|
480
406
|
}
|
|
481
407
|
}
|
|
482
408
|
|
|
409
|
+
const visibleText = sliceWithWidth(displayValue, startCol, availableWidth, true).text;
|
|
410
|
+
const prefixText = sliceWithWidth(displayValue, startCol, Math.max(0, cursorCols - startCol), true).text;
|
|
411
|
+
let cursorDisplay = prefixText.length;
|
|
412
|
+
cursorDisplay = Math.max(0, Math.min(cursorDisplay, visibleText.length));
|
|
413
|
+
|
|
483
414
|
// Build line with fake cursor
|
|
484
415
|
// Insert cursor character at cursor position
|
|
485
416
|
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
|
|
@@ -491,16 +422,20 @@ export class Input implements Component, Focusable {
|
|
|
491
422
|
|
|
492
423
|
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
|
493
424
|
const marker = this.focused ? CURSOR_MARKER : "";
|
|
494
|
-
|
|
495
425
|
// Use inverse video to show cursor
|
|
496
426
|
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
|
497
|
-
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
|
|
498
427
|
|
|
499
|
-
//
|
|
500
|
-
const
|
|
428
|
+
// Clamp only the trailing text (measured in terminal cells), keeping the cursor marker intact.
|
|
429
|
+
const beforeWidth = visibleWidth(beforeCursor);
|
|
430
|
+
const cursorWidth = visibleWidth(atCursor);
|
|
431
|
+
const remainingAfterWidth = Math.max(0, availableWidth - beforeWidth - cursorWidth);
|
|
432
|
+
const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
|
|
433
|
+
const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
|
|
434
|
+
const textWithCursor = beforeCursor + marker + cursorChar + clampedAfterCursor;
|
|
435
|
+
|
|
436
|
+
const visualLength = visibleWidth(renderedNoMarker);
|
|
501
437
|
const pad = padding(Math.max(0, availableWidth - visualLength));
|
|
502
438
|
const line = prompt + textWithCursor + pad;
|
|
503
|
-
|
|
504
439
|
return [line];
|
|
505
440
|
}
|
|
506
441
|
}
|
package/src/utils.ts
CHANGED
|
@@ -97,6 +97,167 @@ export function isPunctuationChar(char: string): boolean {
|
|
|
97
97
|
return ASCII_PUNCTUATION[code] ?? false;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
|
|
101
|
+
|
|
102
|
+
const WORD_NAV_RE_WHITESPACE = /^\p{White_Space}$/u;
|
|
103
|
+
const WORD_NAV_RE_PUNCT = /^\p{P}$/u;
|
|
104
|
+
const WORD_NAV_RE_SYMBOL = /^\p{S}$/u;
|
|
105
|
+
const WORD_NAV_RE_LETTER = /^\p{L}$/u;
|
|
106
|
+
const WORD_NAV_RE_NUMBER = /^\p{N}$/u;
|
|
107
|
+
const WORD_NAV_RE_HAN = /^\p{Script=Han}$/u;
|
|
108
|
+
const WORD_NAV_RE_HIRAGANA = /^\p{Script=Hiragana}$/u;
|
|
109
|
+
const WORD_NAV_RE_KATAKANA = /^\p{Script=Katakana}$/u;
|
|
110
|
+
const WORD_NAV_RE_HANGUL = /^\p{Script=Hangul}$/u;
|
|
111
|
+
|
|
112
|
+
function firstCodePointChar(str: string): string {
|
|
113
|
+
const cp = str.codePointAt(0);
|
|
114
|
+
if (cp === undefined) return "";
|
|
115
|
+
return String.fromCodePoint(cp);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
|
|
120
|
+
* This intentionally avoids language-specific word segmentation for predictability across scripts.
|
|
121
|
+
*/
|
|
122
|
+
export function getWordNavKind(grapheme: string): WordNavKind {
|
|
123
|
+
if (!grapheme) return "other";
|
|
124
|
+
const ch = firstCodePointChar(grapheme);
|
|
125
|
+
if (!ch) return "other";
|
|
126
|
+
if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
|
|
127
|
+
if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
|
|
128
|
+
if (
|
|
129
|
+
WORD_NAV_RE_HAN.test(ch) ||
|
|
130
|
+
WORD_NAV_RE_HIRAGANA.test(ch) ||
|
|
131
|
+
WORD_NAV_RE_KATAKANA.test(ch) ||
|
|
132
|
+
WORD_NAV_RE_HANGUL.test(ch)
|
|
133
|
+
) {
|
|
134
|
+
return "cjk";
|
|
135
|
+
}
|
|
136
|
+
if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
|
|
137
|
+
return "other";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WORD_NAV_JOINERS = new Set(["'", "’", "-", "‐", "‑"]);
|
|
141
|
+
|
|
142
|
+
export function isWordNavJoiner(grapheme: string): boolean {
|
|
143
|
+
const ch = firstCodePointChar(grapheme);
|
|
144
|
+
return WORD_NAV_JOINERS.has(ch);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Move the cursor one "word" to the left using Unicode-aware coarse navigation.
|
|
149
|
+
*
|
|
150
|
+
* Returns a new cursor index in the range [0, text.length].
|
|
151
|
+
*/
|
|
152
|
+
export function moveWordLeft(text: string, cursor: number): number {
|
|
153
|
+
const len = text.length;
|
|
154
|
+
if (len === 0) return 0;
|
|
155
|
+
let i = Math.min(Math.max(cursor, 0), len);
|
|
156
|
+
if (i === 0) return 0;
|
|
157
|
+
|
|
158
|
+
const graphemes = [...segmenter.segment(text.slice(0, i))];
|
|
159
|
+
if (graphemes.length === 0) return 0;
|
|
160
|
+
|
|
161
|
+
// Skip trailing whitespace.
|
|
162
|
+
while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") {
|
|
163
|
+
i -= graphemes.pop()?.segment.length || 0;
|
|
164
|
+
}
|
|
165
|
+
if (i === 0 || graphemes.length === 0) return i;
|
|
166
|
+
|
|
167
|
+
const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || "");
|
|
168
|
+
if (kind === "delimiter" || kind === "cjk") {
|
|
169
|
+
while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) {
|
|
170
|
+
i -= graphemes.pop()?.segment.length || 0;
|
|
171
|
+
}
|
|
172
|
+
return i;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (kind === "word") {
|
|
176
|
+
// Skip word run (letters/numbers/underscore), keeping common joiners inside words.
|
|
177
|
+
let hasRightWord = false;
|
|
178
|
+
while (graphemes.length > 0) {
|
|
179
|
+
const g = graphemes[graphemes.length - 1]?.segment || "";
|
|
180
|
+
const k = getWordNavKind(g);
|
|
181
|
+
if (k === "word") {
|
|
182
|
+
hasRightWord = true;
|
|
183
|
+
i -= graphemes.pop()?.segment.length || 0;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) {
|
|
187
|
+
const left = graphemes[graphemes.length - 2]?.segment || "";
|
|
188
|
+
if (getWordNavKind(left) === "word") {
|
|
189
|
+
i -= graphemes.pop()?.segment.length || 0;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
return i;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Fallback: move by one grapheme.
|
|
199
|
+
i -= graphemes.pop()?.segment.length || 0;
|
|
200
|
+
return Math.max(0, i);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Move the cursor one "word" to the right using Unicode-aware coarse navigation.
|
|
205
|
+
*
|
|
206
|
+
* Returns a new cursor index in the range [0, text.length].
|
|
207
|
+
*/
|
|
208
|
+
export function moveWordRight(text: string, cursor: number): number {
|
|
209
|
+
const len = text.length;
|
|
210
|
+
if (len === 0) return 0;
|
|
211
|
+
let i = Math.min(Math.max(cursor, 0), len);
|
|
212
|
+
if (i === len) return len;
|
|
213
|
+
|
|
214
|
+
const iterator = segmenter.segment(text.slice(i))[Symbol.iterator]();
|
|
215
|
+
let next = iterator.next();
|
|
216
|
+
|
|
217
|
+
// Skip leading whitespace.
|
|
218
|
+
while (!next.done && getWordNavKind(next.value.segment) === "whitespace") {
|
|
219
|
+
i += next.value.segment.length;
|
|
220
|
+
next = iterator.next();
|
|
221
|
+
}
|
|
222
|
+
if (next.done) return i;
|
|
223
|
+
|
|
224
|
+
const firstKind = getWordNavKind(next.value.segment);
|
|
225
|
+
if (firstKind === "delimiter" || firstKind === "cjk") {
|
|
226
|
+
while (!next.done && getWordNavKind(next.value.segment) === firstKind) {
|
|
227
|
+
i += next.value.segment.length;
|
|
228
|
+
next = iterator.next();
|
|
229
|
+
}
|
|
230
|
+
return i;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (firstKind === "word") {
|
|
234
|
+
let hasLeftWord = false;
|
|
235
|
+
while (!next.done) {
|
|
236
|
+
const segment = next.value.segment;
|
|
237
|
+
const k = getWordNavKind(segment);
|
|
238
|
+
if (k === "word") {
|
|
239
|
+
hasLeftWord = true;
|
|
240
|
+
i += segment.length;
|
|
241
|
+
next = iterator.next();
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) {
|
|
245
|
+
const lookahead = iterator.next();
|
|
246
|
+
if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") {
|
|
247
|
+
i += segment.length;
|
|
248
|
+
next = lookahead;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
return i;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Fallback: move by one grapheme.
|
|
258
|
+
return i + next.value.segment.length;
|
|
259
|
+
}
|
|
260
|
+
|
|
100
261
|
/**
|
|
101
262
|
* Apply background color to a line, padding to full width.
|
|
102
263
|
*
|
|
@@ -117,6 +278,7 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
|
|
|
117
278
|
|
|
118
279
|
/**
|
|
119
280
|
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
|
281
|
+
*
|
|
120
282
|
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
|
121
283
|
*/
|
|
122
284
|
export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
|