@mariozechner/pi-tui 0.57.1 → 0.58.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/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +49 -10
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/editor.d.ts +14 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +181 -48
- package/dist/components/editor.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +24 -41
- package/dist/components/input.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +16 -4
- package/dist/keys.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,9 +3,69 @@ import { decodeKittyPrintable, matchesKey } from "../keys.js";
|
|
|
3
3
|
import { KillRing } from "../kill-ring.js";
|
|
4
4
|
import { CURSOR_MARKER } from "../tui.js";
|
|
5
5
|
import { UndoStack } from "../undo-stack.js";
|
|
6
|
-
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
|
6
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
|
|
7
7
|
import { SelectList } from "./select-list.js";
|
|
8
|
-
const
|
|
8
|
+
const baseSegmenter = getSegmenter();
|
|
9
|
+
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
|
10
|
+
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
|
+
/** Non-global version for single-segment testing. */
|
|
12
|
+
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
|
|
13
|
+
/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
|
|
14
|
+
function isPasteMarker(segment) {
|
|
15
|
+
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
|
|
19
|
+
* within paste markers into single atomic segments. This makes cursor
|
|
20
|
+
* movement, deletion, word-wrap, etc. treat paste markers as single units.
|
|
21
|
+
*
|
|
22
|
+
* Only markers whose numeric ID exists in `validIds` are merged.
|
|
23
|
+
*/
|
|
24
|
+
function segmentWithMarkers(text, validIds) {
|
|
25
|
+
// Fast path: no paste markers in the text or no valid IDs.
|
|
26
|
+
if (validIds.size === 0 || !text.includes("[paste #")) {
|
|
27
|
+
return baseSegmenter.segment(text);
|
|
28
|
+
}
|
|
29
|
+
// Find all marker spans with valid IDs.
|
|
30
|
+
const markers = [];
|
|
31
|
+
for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
32
|
+
const id = Number.parseInt(m[1], 10);
|
|
33
|
+
if (!validIds.has(id))
|
|
34
|
+
continue;
|
|
35
|
+
markers.push({ start: m.index, end: m.index + m[0].length });
|
|
36
|
+
}
|
|
37
|
+
if (markers.length === 0) {
|
|
38
|
+
return baseSegmenter.segment(text);
|
|
39
|
+
}
|
|
40
|
+
// Build merged segment list.
|
|
41
|
+
const baseSegments = baseSegmenter.segment(text);
|
|
42
|
+
const result = [];
|
|
43
|
+
let markerIdx = 0;
|
|
44
|
+
for (const seg of baseSegments) {
|
|
45
|
+
// Skip past markers that are entirely before this segment.
|
|
46
|
+
while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
|
|
47
|
+
markerIdx++;
|
|
48
|
+
}
|
|
49
|
+
const marker = markerIdx < markers.length ? markers[markerIdx] : null;
|
|
50
|
+
if (marker && seg.index >= marker.start && seg.index < marker.end) {
|
|
51
|
+
// This segment falls inside a marker.
|
|
52
|
+
// If this is the first segment of the marker, emit a merged segment.
|
|
53
|
+
if (seg.index === marker.start) {
|
|
54
|
+
const markerText = text.slice(marker.start, marker.end);
|
|
55
|
+
result.push({
|
|
56
|
+
segment: markerText,
|
|
57
|
+
index: marker.start,
|
|
58
|
+
input: text,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Otherwise skip (already merged into the first segment).
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
result.push(seg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
9
69
|
/**
|
|
10
70
|
* Split a line into word-wrapped chunks.
|
|
11
71
|
* Wraps at word boundaries when possible, falling back to character-level
|
|
@@ -13,9 +73,11 @@ const segmenter = getSegmenter();
|
|
|
13
73
|
*
|
|
14
74
|
* @param line - The text line to wrap
|
|
15
75
|
* @param maxWidth - Maximum visible width per chunk
|
|
76
|
+
* @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
|
|
77
|
+
* When omitted the default Intl.Segmenter is used.
|
|
16
78
|
* @returns Array of chunks with text and position information
|
|
17
79
|
*/
|
|
18
|
-
export function wordWrapLine(line, maxWidth) {
|
|
80
|
+
export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
19
81
|
if (!line || maxWidth <= 0) {
|
|
20
82
|
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
21
83
|
}
|
|
@@ -24,7 +86,7 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
24
86
|
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
25
87
|
}
|
|
26
88
|
const chunks = [];
|
|
27
|
-
const segments = [...
|
|
89
|
+
const segments = preSegmented ?? [...baseSegmenter.segment(line)];
|
|
28
90
|
let currentWidth = 0;
|
|
29
91
|
let chunkStart = 0;
|
|
30
92
|
// Wrap opportunity: the position after the last whitespace before a non-whitespace
|
|
@@ -36,30 +98,51 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
36
98
|
const grapheme = seg.segment;
|
|
37
99
|
const gWidth = visibleWidth(grapheme);
|
|
38
100
|
const charIndex = seg.index;
|
|
39
|
-
const isWs = isWhitespaceChar(grapheme);
|
|
101
|
+
const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
|
|
40
102
|
// Overflow check before advancing.
|
|
41
103
|
if (currentWidth + gWidth > maxWidth) {
|
|
42
|
-
if (wrapOppIndex >= 0) {
|
|
43
|
-
// Backtrack to last wrap opportunity
|
|
104
|
+
if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
|
|
105
|
+
// Backtrack to last wrap opportunity (the remaining content
|
|
106
|
+
// plus the current grapheme still fits within maxWidth).
|
|
44
107
|
chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
|
|
45
108
|
chunkStart = wrapOppIndex;
|
|
46
109
|
currentWidth -= wrapOppWidth;
|
|
47
110
|
}
|
|
48
111
|
else if (chunkStart < charIndex) {
|
|
49
|
-
// No wrap opportunity: force-break at current position.
|
|
112
|
+
// No viable wrap opportunity: force-break at current position.
|
|
113
|
+
// This also handles the case where backtracking to a word
|
|
114
|
+
// boundary wouldn't help because the remaining content plus
|
|
115
|
+
// the current grapheme (e.g. a wide character) still exceeds
|
|
116
|
+
// maxWidth.
|
|
50
117
|
chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
|
|
51
118
|
chunkStart = charIndex;
|
|
52
119
|
currentWidth = 0;
|
|
53
120
|
}
|
|
54
121
|
wrapOppIndex = -1;
|
|
55
122
|
}
|
|
123
|
+
if (gWidth > maxWidth) {
|
|
124
|
+
// Single atomic segment wider than maxWidth (e.g. paste marker
|
|
125
|
+
// in a narrow terminal). Re-wrap it at grapheme granularity.
|
|
126
|
+
// The segment remains logically atomic for cursor
|
|
127
|
+
// movement / editing — the split is purely visual for word-wrap layout.
|
|
128
|
+
const subChunks = wordWrapLine(grapheme, maxWidth);
|
|
129
|
+
for (let j = 0; j < subChunks.length - 1; j++) {
|
|
130
|
+
const sc = subChunks[j];
|
|
131
|
+
chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
|
|
132
|
+
}
|
|
133
|
+
const last = subChunks[subChunks.length - 1];
|
|
134
|
+
chunkStart = charIndex + last.startIndex;
|
|
135
|
+
currentWidth = visibleWidth(last.text);
|
|
136
|
+
wrapOppIndex = -1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
56
139
|
// Advance.
|
|
57
140
|
currentWidth += gWidth;
|
|
58
141
|
// Record wrap opportunity: whitespace followed by non-whitespace.
|
|
59
142
|
// Multiple spaces join (no break between them); the break point is
|
|
60
143
|
// after the last space before the next word.
|
|
61
144
|
const next = segments[i + 1];
|
|
62
|
-
if (isWs && next && !isWhitespaceChar(next.segment)) {
|
|
145
|
+
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
|
63
146
|
wrapOppIndex = next.index;
|
|
64
147
|
wrapOppWidth = currentWidth;
|
|
65
148
|
}
|
|
@@ -121,6 +204,14 @@ export class Editor {
|
|
|
121
204
|
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
122
205
|
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
123
206
|
}
|
|
207
|
+
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
208
|
+
validPasteIds() {
|
|
209
|
+
return new Set(this.pastes.keys());
|
|
210
|
+
}
|
|
211
|
+
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
|
212
|
+
segment(text) {
|
|
213
|
+
return segmentWithMarkers(text, this.validPasteIds());
|
|
214
|
+
}
|
|
124
215
|
getPaddingX() {
|
|
125
216
|
return this.paddingX;
|
|
126
217
|
}
|
|
@@ -196,7 +287,7 @@ export class Editor {
|
|
|
196
287
|
}
|
|
197
288
|
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
198
289
|
setTextInternal(text) {
|
|
199
|
-
const lines = text.
|
|
290
|
+
const lines = text.split("\n");
|
|
200
291
|
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
201
292
|
this.state.cursorLine = this.state.lines.length - 1;
|
|
202
293
|
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
|
|
@@ -247,7 +338,12 @@ export class Editor {
|
|
|
247
338
|
if (this.scrollOffset > 0) {
|
|
248
339
|
const indicator = `─── ↑ ${this.scrollOffset} more `;
|
|
249
340
|
const remaining = width - visibleWidth(indicator);
|
|
250
|
-
|
|
341
|
+
if (remaining >= 0) {
|
|
342
|
+
result.push(this.borderColor(indicator + "─".repeat(remaining)));
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
result.push(this.borderColor(truncateToWidth(indicator, width)));
|
|
346
|
+
}
|
|
251
347
|
}
|
|
252
348
|
else {
|
|
253
349
|
result.push(horizontal.repeat(width));
|
|
@@ -268,7 +364,7 @@ export class Editor {
|
|
|
268
364
|
if (after.length > 0) {
|
|
269
365
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
270
366
|
// Get the first grapheme from 'after'
|
|
271
|
-
const afterGraphemes = [...
|
|
367
|
+
const afterGraphemes = [...this.segment(after)];
|
|
272
368
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
273
369
|
const restAfter = after.slice(firstGrapheme.length);
|
|
274
370
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
@@ -607,7 +703,7 @@ export class Editor {
|
|
|
607
703
|
}
|
|
608
704
|
else {
|
|
609
705
|
// Line needs wrapping - use word-aware wrapping
|
|
610
|
-
const chunks = wordWrapLine(line, contentWidth);
|
|
706
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
|
611
707
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
612
708
|
const chunk = chunks[chunkIndex];
|
|
613
709
|
if (!chunk)
|
|
@@ -659,17 +755,20 @@ export class Editor {
|
|
|
659
755
|
getText() {
|
|
660
756
|
return this.state.lines.join("\n");
|
|
661
757
|
}
|
|
758
|
+
expandPasteMarkers(text) {
|
|
759
|
+
let result = text;
|
|
760
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
761
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
762
|
+
result = result.replace(markerRegex, () => pasteContent);
|
|
763
|
+
}
|
|
764
|
+
return result;
|
|
765
|
+
}
|
|
662
766
|
/**
|
|
663
767
|
* Get text with paste markers expanded to their actual content.
|
|
664
768
|
* Use this when you need the full content (e.g., for external editor).
|
|
665
769
|
*/
|
|
666
770
|
getExpandedText() {
|
|
667
|
-
|
|
668
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
669
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
670
|
-
result = result.replace(markerRegex, pasteContent);
|
|
671
|
-
}
|
|
672
|
-
return result;
|
|
771
|
+
return this.expandPasteMarkers(this.state.lines.join("\n"));
|
|
673
772
|
}
|
|
674
773
|
getLines() {
|
|
675
774
|
return [...this.state.lines];
|
|
@@ -680,11 +779,12 @@ export class Editor {
|
|
|
680
779
|
setText(text) {
|
|
681
780
|
this.lastAction = null;
|
|
682
781
|
this.historyIndex = -1; // Exit history browsing mode
|
|
782
|
+
const normalized = this.normalizeText(text);
|
|
683
783
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
|
684
|
-
if (this.getText() !==
|
|
784
|
+
if (this.getText() !== normalized) {
|
|
685
785
|
this.pushUndoSnapshot();
|
|
686
786
|
}
|
|
687
|
-
this.setTextInternal(
|
|
787
|
+
this.setTextInternal(normalized);
|
|
688
788
|
}
|
|
689
789
|
/**
|
|
690
790
|
* Insert text at the current cursor position.
|
|
@@ -699,6 +799,14 @@ export class Editor {
|
|
|
699
799
|
this.historyIndex = -1;
|
|
700
800
|
this.insertTextAtCursorInternal(text);
|
|
701
801
|
}
|
|
802
|
+
/**
|
|
803
|
+
* Normalize text for editor storage:
|
|
804
|
+
* - Normalize line endings (\r\n and \r -> \n)
|
|
805
|
+
* - Expand tabs to 4 spaces
|
|
806
|
+
*/
|
|
807
|
+
normalizeText(text) {
|
|
808
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
809
|
+
}
|
|
702
810
|
/**
|
|
703
811
|
* Internal text insertion at cursor. Handles single and multi-line text.
|
|
704
812
|
* Does not push undo snapshots or trigger autocomplete - caller is responsible.
|
|
@@ -707,8 +815,8 @@ export class Editor {
|
|
|
707
815
|
insertTextAtCursorInternal(text) {
|
|
708
816
|
if (!text)
|
|
709
817
|
return;
|
|
710
|
-
// Normalize line endings
|
|
711
|
-
const normalized =
|
|
818
|
+
// Normalize line endings and tabs
|
|
819
|
+
const normalized = this.normalizeText(text);
|
|
712
820
|
const insertedLines = normalized.split("\n");
|
|
713
821
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
714
822
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -799,12 +907,10 @@ export class Editor {
|
|
|
799
907
|
this.historyIndex = -1; // Exit history browsing mode
|
|
800
908
|
this.lastAction = null;
|
|
801
909
|
this.pushUndoSnapshot();
|
|
802
|
-
// Clean the pasted text
|
|
803
|
-
const cleanText =
|
|
804
|
-
// Convert tabs to spaces (4 spaces per tab)
|
|
805
|
-
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
910
|
+
// Clean the pasted text: normalize line endings, expand tabs
|
|
911
|
+
const cleanText = this.normalizeText(pastedText);
|
|
806
912
|
// Filter out non-printable characters except newlines
|
|
807
|
-
let filteredText =
|
|
913
|
+
let filteredText = cleanText
|
|
808
914
|
.split("")
|
|
809
915
|
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
810
916
|
.join("");
|
|
@@ -871,11 +977,7 @@ export class Editor {
|
|
|
871
977
|
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
|
|
872
978
|
}
|
|
873
979
|
submitValue() {
|
|
874
|
-
|
|
875
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
876
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
877
|
-
result = result.replace(markerRegex, pasteContent);
|
|
878
|
-
}
|
|
980
|
+
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
|
879
981
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
880
982
|
this.pastes.clear();
|
|
881
983
|
this.pasteCounter = 0;
|
|
@@ -897,7 +999,7 @@ export class Editor {
|
|
|
897
999
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
898
1000
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
899
1001
|
// Find the last grapheme in the text before cursor
|
|
900
|
-
const graphemes = [...
|
|
1002
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
901
1003
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
902
1004
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
903
1005
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
@@ -966,6 +1068,21 @@ export class Editor {
|
|
|
966
1068
|
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
967
1069
|
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
968
1070
|
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1071
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1072
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1073
|
+
// Single-grapheme segments don't need snapping.
|
|
1074
|
+
const segments = [...this.segment(logicalLine)];
|
|
1075
|
+
for (const seg of segments) {
|
|
1076
|
+
if (seg.index > this.state.cursorCol)
|
|
1077
|
+
break;
|
|
1078
|
+
if (seg.segment.length <= 1)
|
|
1079
|
+
continue;
|
|
1080
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1081
|
+
// jump to the start of the segment when moving up, to the end when moving down.
|
|
1082
|
+
this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
969
1086
|
}
|
|
970
1087
|
}
|
|
971
1088
|
/**
|
|
@@ -1152,7 +1269,7 @@ export class Editor {
|
|
|
1152
1269
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1153
1270
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1154
1271
|
// Find the first grapheme at cursor
|
|
1155
|
-
const graphemes = [...
|
|
1272
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1156
1273
|
const firstGrapheme = graphemes[0];
|
|
1157
1274
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1158
1275
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1207,7 +1324,7 @@ export class Editor {
|
|
|
1207
1324
|
}
|
|
1208
1325
|
else {
|
|
1209
1326
|
// Line needs wrapping - use word-aware wrapping
|
|
1210
|
-
const chunks = wordWrapLine(line, width);
|
|
1327
|
+
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
|
1211
1328
|
for (const chunk of chunks) {
|
|
1212
1329
|
visualLines.push({
|
|
1213
1330
|
logicalLine: i,
|
|
@@ -1256,7 +1373,7 @@ export class Editor {
|
|
|
1256
1373
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1257
1374
|
if (this.state.cursorCol < currentLine.length) {
|
|
1258
1375
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1259
|
-
const graphemes = [...
|
|
1376
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1260
1377
|
const firstGrapheme = graphemes[0];
|
|
1261
1378
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1262
1379
|
}
|
|
@@ -1277,7 +1394,7 @@ export class Editor {
|
|
|
1277
1394
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1278
1395
|
if (this.state.cursorCol > 0) {
|
|
1279
1396
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1280
|
-
const graphemes = [...
|
|
1397
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
1281
1398
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1282
1399
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1283
1400
|
}
|
|
@@ -1316,17 +1433,25 @@ export class Editor {
|
|
|
1316
1433
|
return;
|
|
1317
1434
|
}
|
|
1318
1435
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1319
|
-
const graphemes = [...
|
|
1436
|
+
const graphemes = [...this.segment(textBeforeCursor)];
|
|
1320
1437
|
let newCol = this.state.cursorCol;
|
|
1321
1438
|
// Skip trailing whitespace
|
|
1322
|
-
while (graphemes.length > 0 &&
|
|
1439
|
+
while (graphemes.length > 0 &&
|
|
1440
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1441
|
+
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1323
1442
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1324
1443
|
}
|
|
1325
1444
|
if (graphemes.length > 0) {
|
|
1326
1445
|
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1327
|
-
if (
|
|
1446
|
+
if (isPasteMarker(lastGrapheme)) {
|
|
1447
|
+
// Paste marker is a single atomic word
|
|
1448
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1449
|
+
}
|
|
1450
|
+
else if (isPunctuationChar(lastGrapheme)) {
|
|
1328
1451
|
// Skip punctuation run
|
|
1329
|
-
while (graphemes.length > 0 &&
|
|
1452
|
+
while (graphemes.length > 0 &&
|
|
1453
|
+
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1454
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1330
1455
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1331
1456
|
}
|
|
1332
1457
|
}
|
|
@@ -1334,7 +1459,8 @@ export class Editor {
|
|
|
1334
1459
|
// Skip word run
|
|
1335
1460
|
while (graphemes.length > 0 &&
|
|
1336
1461
|
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1337
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1462
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1463
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1338
1464
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1339
1465
|
}
|
|
1340
1466
|
}
|
|
@@ -1497,27 +1623,34 @@ export class Editor {
|
|
|
1497
1623
|
return;
|
|
1498
1624
|
}
|
|
1499
1625
|
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
|
1500
|
-
const segments =
|
|
1626
|
+
const segments = this.segment(textAfterCursor);
|
|
1501
1627
|
const iterator = segments[Symbol.iterator]();
|
|
1502
1628
|
let next = iterator.next();
|
|
1503
1629
|
let newCol = this.state.cursorCol;
|
|
1504
1630
|
// Skip leading whitespace
|
|
1505
|
-
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
1631
|
+
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
|
1506
1632
|
newCol += next.value.segment.length;
|
|
1507
1633
|
next = iterator.next();
|
|
1508
1634
|
}
|
|
1509
1635
|
if (!next.done) {
|
|
1510
1636
|
const firstGrapheme = next.value.segment;
|
|
1511
|
-
if (
|
|
1637
|
+
if (isPasteMarker(firstGrapheme)) {
|
|
1638
|
+
// Paste marker is a single atomic word
|
|
1639
|
+
newCol += firstGrapheme.length;
|
|
1640
|
+
}
|
|
1641
|
+
else if (isPunctuationChar(firstGrapheme)) {
|
|
1512
1642
|
// Skip punctuation run
|
|
1513
|
-
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
1643
|
+
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
|
1514
1644
|
newCol += next.value.segment.length;
|
|
1515
1645
|
next = iterator.next();
|
|
1516
1646
|
}
|
|
1517
1647
|
}
|
|
1518
1648
|
else {
|
|
1519
1649
|
// Skip word run
|
|
1520
|
-
while (!next.done &&
|
|
1650
|
+
while (!next.done &&
|
|
1651
|
+
!isWhitespaceChar(next.value.segment) &&
|
|
1652
|
+
!isPunctuationChar(next.value.segment) &&
|
|
1653
|
+
!isPasteMarker(next.value.segment)) {
|
|
1521
1654
|
newCol += next.value.segment.length;
|
|
1522
1655
|
next = iterator.next();
|
|
1523
1656
|
}
|