pi-vim 0.1.9 → 0.2.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/README.md +62 -60
- package/index.ts +545 -129
- package/motions.ts +55 -13
- package/package.json +1 -1
- package/types.ts +1 -1
package/index.ts
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* - hjkl: navigation in normal mode
|
|
14
14
|
* - 0/$: line start/end
|
|
15
15
|
* - ^: first non-whitespace char of line
|
|
16
|
+
* - _: first non-whitespace (with count: down count-1 lines first); linewise with d/c/y
|
|
16
17
|
* - x: delete char under cursor
|
|
17
18
|
* - D: delete to end of line
|
|
18
19
|
* - S: substitute line (delete line content + insert mode)
|
|
19
20
|
* - s: substitute char (delete char + insert mode)
|
|
20
|
-
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`, `f/t/F/T{char}`)
|
|
21
|
+
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`/`d_`, `f/t/F/T{char}`)
|
|
21
22
|
* - c{motion}: change with same motion set as `d` (then enter insert mode)
|
|
22
23
|
* - y{motion}: yank with same motion set as `d` (no text mutation)
|
|
23
24
|
* - f{char}: jump to next {char} on line
|
|
@@ -72,11 +73,11 @@ import {
|
|
|
72
73
|
CHAR_MOTION_KEYS,
|
|
73
74
|
ESC_LEFT,
|
|
74
75
|
ESC_RIGHT,
|
|
75
|
-
ESC_DELETE,
|
|
76
76
|
ESC_UP,
|
|
77
77
|
CTRL_A,
|
|
78
78
|
CTRL_E,
|
|
79
79
|
CTRL_K,
|
|
80
|
+
CTRL_R,
|
|
80
81
|
CTRL_UNDERSCORE,
|
|
81
82
|
NEWLINE,
|
|
82
83
|
ESC_DOWN,
|
|
@@ -86,6 +87,7 @@ import {
|
|
|
86
87
|
findCharMotionTarget,
|
|
87
88
|
findParagraphMotionTarget,
|
|
88
89
|
findFirstNonWhitespaceColumn,
|
|
90
|
+
getLineGraphemes,
|
|
89
91
|
type WordMotionClass,
|
|
90
92
|
} from "./motions.js";
|
|
91
93
|
import {
|
|
@@ -99,6 +101,24 @@ const BRACKETED_PASTE_END = "\x1b[201~";
|
|
|
99
101
|
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
|
|
100
102
|
const MAX_COUNT = 9999;
|
|
101
103
|
|
|
104
|
+
type EditorSnapshot = {
|
|
105
|
+
text: string;
|
|
106
|
+
cursor: { line: number; col: number };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type TransitionState = "none" | "undo" | "redo";
|
|
110
|
+
|
|
111
|
+
type ModalEditorInternals = {
|
|
112
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
113
|
+
preferredVisualCol?: number | null;
|
|
114
|
+
lastAction?: string | null;
|
|
115
|
+
historyIndex?: number;
|
|
116
|
+
onChange?: (text: string) => void;
|
|
117
|
+
tui?: { requestRender?: () => void };
|
|
118
|
+
pushUndoSnapshot?: () => void;
|
|
119
|
+
setCursorCol?: (col: number) => void;
|
|
120
|
+
};
|
|
121
|
+
|
|
102
122
|
export class ModalEditor extends CustomEditor {
|
|
103
123
|
private mode: Mode = "insert";
|
|
104
124
|
private pendingMotion: PendingMotion = null;
|
|
@@ -108,10 +128,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
108
128
|
private operatorCount: string = "";
|
|
109
129
|
private pendingG: boolean = false;
|
|
110
130
|
private pendingGCount: string = "";
|
|
131
|
+
private pendingReplace: boolean = false;
|
|
111
132
|
private lastCharMotion: LastCharMotion | null = null;
|
|
112
133
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
113
134
|
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
114
|
-
private
|
|
135
|
+
private wordBoundaryCache = new WordBoundaryCache();
|
|
136
|
+
private readonly redoStack: EditorSnapshot[] = [];
|
|
137
|
+
private currentTransition: TransitionState = "none";
|
|
138
|
+
private onChangeHooked: boolean = false;
|
|
115
139
|
|
|
116
140
|
// Unnamed register
|
|
117
141
|
private unnamedRegister: string = "";
|
|
@@ -126,6 +150,183 @@ export class ModalEditor extends CustomEditor {
|
|
|
126
150
|
getMode(): Mode { return this.mode; }
|
|
127
151
|
getText(): string { return this.getLines().join("\n"); }
|
|
128
152
|
|
|
153
|
+
override setText(text: string): void {
|
|
154
|
+
this.clearRedoStack();
|
|
155
|
+
super.setText(text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private captureSnapshot(): EditorSnapshot {
|
|
159
|
+
const cursor = this.getCursor();
|
|
160
|
+
return {
|
|
161
|
+
text: this.getText(),
|
|
162
|
+
cursor: { line: cursor.line, col: cursor.col },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private requireRedoRestoreState(
|
|
167
|
+
editor: ModalEditorInternals,
|
|
168
|
+
): { lines: string[]; cursorLine?: number; cursorCol?: number } {
|
|
169
|
+
const state = editor.state;
|
|
170
|
+
if (!state || !Array.isArray(state.lines)) {
|
|
171
|
+
throw new Error("Redo restore prerequisite: editor state unavailable");
|
|
172
|
+
}
|
|
173
|
+
return state;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private restoreSnapshot(snapshot: EditorSnapshot): void {
|
|
177
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
178
|
+
const state = this.requireRedoRestoreState(editor);
|
|
179
|
+
|
|
180
|
+
const lines = snapshot.text.split("\n");
|
|
181
|
+
state.lines = lines.length > 0 ? lines : [""];
|
|
182
|
+
|
|
183
|
+
const maxLine = Math.max(0, state.lines.length - 1);
|
|
184
|
+
const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
|
|
185
|
+
const line = state.lines[cursorLine] ?? "";
|
|
186
|
+
const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
|
|
187
|
+
|
|
188
|
+
state.cursorLine = cursorLine;
|
|
189
|
+
if (typeof editor.setCursorCol === "function") {
|
|
190
|
+
editor.setCursorCol(cursorCol);
|
|
191
|
+
} else {
|
|
192
|
+
state.cursorCol = cursorCol;
|
|
193
|
+
editor.preferredVisualCol = null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.invalidateWordBoundaryCache();
|
|
197
|
+
|
|
198
|
+
editor.historyIndex = -1;
|
|
199
|
+
editor.lastAction = null;
|
|
200
|
+
editor.onChange?.(this.getText());
|
|
201
|
+
editor.tui?.requestRender?.();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
|
|
205
|
+
return a.text !== b.text
|
|
206
|
+
|| a.cursor.line !== b.cursor.line
|
|
207
|
+
|| a.cursor.col !== b.cursor.col;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private withTransition<T>(
|
|
211
|
+
transition: Exclude<TransitionState, "none">,
|
|
212
|
+
action: () => T,
|
|
213
|
+
): T {
|
|
214
|
+
const previousTransition = this.currentTransition;
|
|
215
|
+
this.currentTransition = transition;
|
|
216
|
+
try {
|
|
217
|
+
return action();
|
|
218
|
+
} finally {
|
|
219
|
+
this.currentTransition = previousTransition;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private performUndo(): void {
|
|
224
|
+
this.withTransition("undo", () => {
|
|
225
|
+
const beforeUndo = this.captureSnapshot();
|
|
226
|
+
super.handleInput(CTRL_UNDERSCORE);
|
|
227
|
+
const afterUndo = this.captureSnapshot();
|
|
228
|
+
|
|
229
|
+
if (this.snapshotChanged(beforeUndo, afterUndo)) {
|
|
230
|
+
this.redoStack.push(beforeUndo);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private performRedo(count: number = this.takeTotalCount(1)): void {
|
|
236
|
+
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
237
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
238
|
+
|
|
239
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
240
|
+
const snapshot = this.redoStack[this.redoStack.length - 1];
|
|
241
|
+
if (!snapshot) break;
|
|
242
|
+
|
|
243
|
+
this.withTransition("redo", () => {
|
|
244
|
+
this.requireRedoRestoreState(editor);
|
|
245
|
+
if (typeof editor.pushUndoSnapshot !== "function") {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"Redo restore prerequisite: pushUndoSnapshot unavailable",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
editor.pushUndoSnapshot();
|
|
251
|
+
this.restoreSnapshot(snapshot);
|
|
252
|
+
this.redoStack.pop();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private clearRedoStack(): void {
|
|
258
|
+
this.redoStack.length = 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private invalidateWordBoundaryCache(): void {
|
|
262
|
+
this.wordBoundaryCache = new WordBoundaryCache();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private ensureOnChangeHook(): void {
|
|
266
|
+
if (this.onChangeHooked) return;
|
|
267
|
+
|
|
268
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
269
|
+
const originalOnChange = editor.onChange;
|
|
270
|
+
|
|
271
|
+
editor.onChange = (text: string) => {
|
|
272
|
+
originalOnChange?.(text);
|
|
273
|
+
this.centralInvalidationCheck();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
this.onChangeHooked = true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private centralInvalidationCheck(): void {
|
|
280
|
+
if (this.redoStack.length === 0) return;
|
|
281
|
+
if (this.currentTransition !== "none") return;
|
|
282
|
+
this.clearRedoStack();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private applySyntheticEdit(mutation: () => void): void {
|
|
286
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
287
|
+
if (!editor.state || !Array.isArray(editor.state.lines)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"Synthetic edit prerequisite: editor state unavailable",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof editor.pushUndoSnapshot !== "function") {
|
|
294
|
+
throw new Error(
|
|
295
|
+
"Synthetic edit prerequisite: pushUndoSnapshot unavailable",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const textBefore = this.getText();
|
|
300
|
+
const preCursorLine = editor.state.cursorLine;
|
|
301
|
+
const preCursorCol = editor.state.cursorCol;
|
|
302
|
+
|
|
303
|
+
mutation();
|
|
304
|
+
|
|
305
|
+
if (this.getText() === textBefore) return;
|
|
306
|
+
|
|
307
|
+
// Text changed — push undo boundary for pre-mutation state.
|
|
308
|
+
// Briefly swap pre-mutation state in for the snapshot, then
|
|
309
|
+
// restore the post-mutation result.
|
|
310
|
+
const postLines = editor.state.lines.slice();
|
|
311
|
+
const postCursorLine = editor.state.cursorLine;
|
|
312
|
+
const postCursorCol = editor.state.cursorCol;
|
|
313
|
+
const postPreferredCol = editor.preferredVisualCol;
|
|
314
|
+
|
|
315
|
+
const preLines = textBefore.split("\n");
|
|
316
|
+
editor.state.lines = preLines.length > 0 ? preLines : [""];
|
|
317
|
+
editor.state.cursorLine = preCursorLine;
|
|
318
|
+
editor.state.cursorCol = preCursorCol;
|
|
319
|
+
editor.pushUndoSnapshot();
|
|
320
|
+
|
|
321
|
+
editor.state.lines = postLines;
|
|
322
|
+
editor.state.cursorLine = postCursorLine;
|
|
323
|
+
editor.state.cursorCol = postCursorCol;
|
|
324
|
+
editor.preferredVisualCol = postPreferredCol;
|
|
325
|
+
|
|
326
|
+
editor.onChange?.(this.getText());
|
|
327
|
+
editor.tui?.requestRender?.();
|
|
328
|
+
}
|
|
329
|
+
|
|
129
330
|
private clearPendingState(): void {
|
|
130
331
|
this.pendingMotion = null;
|
|
131
332
|
this.pendingTextObject = null;
|
|
@@ -134,6 +335,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
134
335
|
this.operatorCount = "";
|
|
135
336
|
this.pendingG = false;
|
|
136
337
|
this.pendingGCount = "";
|
|
338
|
+
this.pendingReplace = false;
|
|
137
339
|
}
|
|
138
340
|
|
|
139
341
|
private isEscapeLikeInput(data: string): boolean {
|
|
@@ -176,6 +378,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
176
378
|
}
|
|
177
379
|
|
|
178
380
|
handleInput(data: string): void {
|
|
381
|
+
this.ensureOnChangeHook();
|
|
382
|
+
|
|
179
383
|
if (this.mode !== "insert") {
|
|
180
384
|
if (this.discardingBracketedPasteInNormalMode) {
|
|
181
385
|
if (this.isEscapeLikeInput(data)) {
|
|
@@ -227,19 +431,43 @@ export class ModalEditor extends CustomEditor {
|
|
|
227
431
|
}
|
|
228
432
|
// Alt+o: open new line below (stay in insert mode)
|
|
229
433
|
if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
|
|
230
|
-
|
|
231
|
-
super.handleInput(NEWLINE);
|
|
434
|
+
this.openLineBelow();
|
|
232
435
|
return;
|
|
233
436
|
}
|
|
234
437
|
// Alt+Shift+o: open new line above (stay in insert mode)
|
|
235
438
|
// \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
|
|
236
439
|
if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
440
|
+
this.openLineAbove();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
super.handleInput(data);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (this.pendingReplace) {
|
|
448
|
+
this.pendingReplace = false;
|
|
449
|
+
if (!this.isPrintableInput(data)) {
|
|
450
|
+
this.prefixCount = "";
|
|
451
|
+
this.operatorCount = "";
|
|
240
452
|
return;
|
|
241
453
|
}
|
|
242
|
-
|
|
454
|
+
|
|
455
|
+
const count = this.takeTotalCount(1);
|
|
456
|
+
const cursor = this.getCursor();
|
|
457
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
458
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
|
|
459
|
+
if (!range) return;
|
|
460
|
+
|
|
461
|
+
const before = line.slice(0, range.start);
|
|
462
|
+
const after = line.slice(range.end);
|
|
463
|
+
const replacement = data.repeat(count);
|
|
464
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
465
|
+
const text = this.getText();
|
|
466
|
+
const newText = text.slice(0, lineStartAbs) + before + replacement + after
|
|
467
|
+
+ text.slice(lineStartAbs + line.length);
|
|
468
|
+
const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
|
|
469
|
+
this.replaceTextInBuffer(newText, newCursorAbs);
|
|
470
|
+
return;
|
|
243
471
|
}
|
|
244
472
|
|
|
245
473
|
if (this.pendingTextObject) {
|
|
@@ -292,6 +520,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
292
520
|
|| this.operatorCount
|
|
293
521
|
|| this.pendingG
|
|
294
522
|
|| this.pendingGCount
|
|
523
|
+
|| this.pendingReplace
|
|
295
524
|
) {
|
|
296
525
|
this.clearPendingState();
|
|
297
526
|
return;
|
|
@@ -314,7 +543,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
314
543
|
}
|
|
315
544
|
|
|
316
545
|
private isPrintableInput(data: string): boolean {
|
|
317
|
-
return this.isPrintableChunk(data) &&
|
|
546
|
+
return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
|
|
318
547
|
}
|
|
319
548
|
|
|
320
549
|
private isDigit(data: string): boolean {
|
|
@@ -464,6 +693,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
464
693
|
return;
|
|
465
694
|
}
|
|
466
695
|
|
|
696
|
+
if (data === "_") {
|
|
697
|
+
const count = this.takeTotalCount(1);
|
|
698
|
+
this.deleteLinewiseByDelta(count - 1);
|
|
699
|
+
this.pendingOperator = null;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
467
703
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
468
704
|
this.pendingMotion = data as PendingMotion;
|
|
469
705
|
return;
|
|
@@ -526,6 +762,28 @@ export class ModalEditor extends CustomEditor {
|
|
|
526
762
|
this.mode = "insert";
|
|
527
763
|
return;
|
|
528
764
|
}
|
|
765
|
+
|
|
766
|
+
if (data === "_") {
|
|
767
|
+
const count = this.takeTotalCount(1);
|
|
768
|
+
if (count <= 1) {
|
|
769
|
+
this.cutLine();
|
|
770
|
+
} else {
|
|
771
|
+
const currentLine = this.getCursor().line;
|
|
772
|
+
const lines = this.getLines();
|
|
773
|
+
const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
|
|
774
|
+
this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
|
|
775
|
+
const before = lines.slice(0, currentLine);
|
|
776
|
+
const after = lines.slice(clampedEnd + 1);
|
|
777
|
+
const newLines = [...before, "", ...after];
|
|
778
|
+
const newText = newLines.join("\n");
|
|
779
|
+
const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
|
|
780
|
+
this.replaceTextInBuffer(newText, cursorAbs);
|
|
781
|
+
}
|
|
782
|
+
this.pendingOperator = null;
|
|
783
|
+
this.mode = "insert";
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
529
787
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
530
788
|
this.pendingMotion = data as PendingMotion;
|
|
531
789
|
return;
|
|
@@ -624,6 +882,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
624
882
|
|
|
625
883
|
const supportsCountedStandaloneEdit = (
|
|
626
884
|
data === "x"
|
|
885
|
+
|| data === "r"
|
|
627
886
|
|| data === "s"
|
|
628
887
|
|| data === "S"
|
|
629
888
|
|| data === "D"
|
|
@@ -631,6 +890,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
631
890
|
|| data === "p"
|
|
632
891
|
|| data === "P"
|
|
633
892
|
|| data === "J"
|
|
893
|
+
|| data === CTRL_R
|
|
894
|
+
|| matchesKey(data, "ctrl+r")
|
|
634
895
|
);
|
|
635
896
|
const supportsCountedCharMotion = (
|
|
636
897
|
CHAR_MOTION_KEYS.has(data)
|
|
@@ -652,6 +913,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
652
913
|
|| data === "k"
|
|
653
914
|
|| data === "l"
|
|
654
915
|
);
|
|
916
|
+
const supportsCountedUnderscore = data === "_";
|
|
655
917
|
|
|
656
918
|
if (supportsCountedNav) {
|
|
657
919
|
const count = this.takeTotalCount(1);
|
|
@@ -661,16 +923,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
661
923
|
} else if (data === "l") {
|
|
662
924
|
this.moveCursorBy(clamped);
|
|
663
925
|
} else {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const cursorLine = this.getCursor().line;
|
|
667
|
-
const safeCount = data === "j"
|
|
668
|
-
? Math.min(clamped, lines.length - 1 - cursorLine)
|
|
669
|
-
: Math.min(clamped, cursorLine);
|
|
670
|
-
const seq = data === "j" ? ESC_DOWN : ESC_UP;
|
|
671
|
-
for (let i = 0; i < safeCount; i++) {
|
|
672
|
-
super.handleInput(seq);
|
|
673
|
-
}
|
|
926
|
+
const delta = data === "j" ? clamped : -clamped;
|
|
927
|
+
this.moveCursorVertically(delta);
|
|
674
928
|
}
|
|
675
929
|
return;
|
|
676
930
|
}
|
|
@@ -685,6 +939,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
685
939
|
&& !supportsCountedCharMotion
|
|
686
940
|
&& !supportsCountedWordMotion
|
|
687
941
|
&& !supportsCountedParagraphMotion
|
|
942
|
+
&& !supportsCountedUnderscore
|
|
688
943
|
) {
|
|
689
944
|
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
690
945
|
this.prefixCount = "";
|
|
@@ -711,6 +966,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
711
966
|
return;
|
|
712
967
|
}
|
|
713
968
|
|
|
969
|
+
if (data === "r") {
|
|
970
|
+
this.pendingReplace = true;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
714
974
|
if (data === "d") {
|
|
715
975
|
this.pendingOperator = "d";
|
|
716
976
|
return;
|
|
@@ -754,8 +1014,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
754
1014
|
return;
|
|
755
1015
|
}
|
|
756
1016
|
|
|
757
|
-
if (data === "u") {
|
|
758
|
-
|
|
1017
|
+
if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) {
|
|
1018
|
+
this.performUndo();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (data === CTRL_R || matchesKey(data, "ctrl+r")) {
|
|
1023
|
+
this.performRedo();
|
|
759
1024
|
return;
|
|
760
1025
|
}
|
|
761
1026
|
|
|
@@ -769,6 +1034,15 @@ export class ModalEditor extends CustomEditor {
|
|
|
769
1034
|
return;
|
|
770
1035
|
}
|
|
771
1036
|
|
|
1037
|
+
if (data === "_") {
|
|
1038
|
+
const count = this.takeTotalCount(1);
|
|
1039
|
+
if (count > 1) {
|
|
1040
|
+
this.moveCursorVertically(count - 1);
|
|
1041
|
+
}
|
|
1042
|
+
this.moveCursorToFirstNonWhitespace();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
772
1046
|
if (data === "w") {
|
|
773
1047
|
const count = this.takeTotalCount(1);
|
|
774
1048
|
return this.moveWord("forward", "start", count, "word");
|
|
@@ -788,6 +1062,17 @@ export class ModalEditor extends CustomEditor {
|
|
|
788
1062
|
super.handleInput(data);
|
|
789
1063
|
}
|
|
790
1064
|
|
|
1065
|
+
private openLineBelow(): void {
|
|
1066
|
+
super.handleInput(CTRL_E);
|
|
1067
|
+
super.handleInput(NEWLINE);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private openLineAbove(): void {
|
|
1071
|
+
super.handleInput(CTRL_A);
|
|
1072
|
+
super.handleInput(NEWLINE);
|
|
1073
|
+
super.handleInput(ESC_UP);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
791
1076
|
private handleMappedKey(key: string): void {
|
|
792
1077
|
const seq = NORMAL_KEYS[key];
|
|
793
1078
|
switch (key) {
|
|
@@ -809,14 +1094,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
809
1094
|
super.handleInput(CTRL_A);
|
|
810
1095
|
break;
|
|
811
1096
|
case "o":
|
|
812
|
-
|
|
813
|
-
super.handleInput(NEWLINE);
|
|
1097
|
+
this.openLineBelow();
|
|
814
1098
|
this.mode = "insert";
|
|
815
1099
|
break;
|
|
816
1100
|
case "O":
|
|
817
|
-
|
|
818
|
-
super.handleInput(NEWLINE);
|
|
819
|
-
super.handleInput(ESC_UP);
|
|
1101
|
+
this.openLineAbove();
|
|
820
1102
|
this.mode = "insert";
|
|
821
1103
|
break;
|
|
822
1104
|
case "D":
|
|
@@ -840,6 +1122,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
840
1122
|
case "x":
|
|
841
1123
|
this.cutCharUnderCursor();
|
|
842
1124
|
break;
|
|
1125
|
+
case "j":
|
|
1126
|
+
this.moveCursorVertically(1);
|
|
1127
|
+
break;
|
|
1128
|
+
case "k":
|
|
1129
|
+
this.moveCursorVertically(-1);
|
|
1130
|
+
break;
|
|
843
1131
|
default:
|
|
844
1132
|
if (seq) super.handleInput(seq);
|
|
845
1133
|
}
|
|
@@ -856,7 +1144,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
856
1144
|
}
|
|
857
1145
|
|
|
858
1146
|
if (targetCol !== null && targetCol !== col) {
|
|
859
|
-
this.
|
|
1147
|
+
this.moveCursorToCol(targetCol);
|
|
860
1148
|
}
|
|
861
1149
|
}
|
|
862
1150
|
|
|
@@ -884,10 +1172,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
884
1172
|
const cursorLine = state.cursorLine as number;
|
|
885
1173
|
const cursorCol = state.cursorCol as number;
|
|
886
1174
|
const line = state.lines[cursorLine] ?? "";
|
|
1175
|
+
if (this.hasMultiCodeUnitGraphemes(line)) return false;
|
|
1176
|
+
|
|
887
1177
|
const target = cursorCol + delta;
|
|
888
1178
|
|
|
889
|
-
// Only short-circuit line-local movement
|
|
890
|
-
//
|
|
1179
|
+
// Only short-circuit line-local movement when each grapheme is one code
|
|
1180
|
+
// unit; otherwise let the base editor keep cursor boundaries valid.
|
|
891
1181
|
if (target < 0 || target > line.length) return false;
|
|
892
1182
|
|
|
893
1183
|
state.cursorCol = target;
|
|
@@ -907,38 +1197,100 @@ export class ModalEditor extends CustomEditor {
|
|
|
907
1197
|
}
|
|
908
1198
|
}
|
|
909
1199
|
|
|
910
|
-
private
|
|
911
|
-
|
|
912
|
-
if (lines.length === 0) {
|
|
913
|
-
super.handleInput(CTRL_A);
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
1200
|
+
private moveCursorVertically(delta: number): void {
|
|
1201
|
+
if (delta === 0) return;
|
|
916
1202
|
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
1203
|
+
const editor = this as unknown as {
|
|
1204
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1205
|
+
preferredVisualCol?: number | null;
|
|
1206
|
+
lastAction?: string | null;
|
|
1207
|
+
tui?: { requestRender?: () => void };
|
|
1208
|
+
};
|
|
920
1209
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
} else if (delta < 0) {
|
|
1210
|
+
const state = editor.state;
|
|
1211
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1212
|
+
const seq = delta > 0 ? ESC_DOWN : ESC_UP;
|
|
926
1213
|
for (let i = 0; i < Math.abs(delta); i++) {
|
|
927
|
-
super.handleInput(
|
|
1214
|
+
super.handleInput(seq);
|
|
928
1215
|
}
|
|
1216
|
+
return;
|
|
929
1217
|
}
|
|
930
1218
|
|
|
931
|
-
|
|
1219
|
+
const currentLine = state.cursorLine ?? 0;
|
|
1220
|
+
const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
|
|
1221
|
+
if (targetLine === currentLine) return;
|
|
1222
|
+
|
|
1223
|
+
const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
|
|
1224
|
+
const targetLineText = state.lines[targetLine] ?? "";
|
|
1225
|
+
editor.lastAction = null;
|
|
1226
|
+
state.cursorLine = targetLine;
|
|
1227
|
+
state.cursorCol = Math.min(preferredCol, targetLineText.length);
|
|
1228
|
+
editor.preferredVisualCol = preferredCol;
|
|
1229
|
+
editor.tui?.requestRender?.();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private moveCursorToCol(col: number): void {
|
|
1233
|
+
const editor = this as unknown as {
|
|
1234
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1235
|
+
preferredVisualCol?: number | null;
|
|
1236
|
+
lastAction?: string | null;
|
|
1237
|
+
tui?: { requestRender?: () => void };
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
const state = editor.state;
|
|
1241
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1242
|
+
|
|
1243
|
+
editor.lastAction = null;
|
|
1244
|
+
state.cursorCol = col;
|
|
1245
|
+
editor.preferredVisualCol = col;
|
|
1246
|
+
editor.tui?.requestRender?.();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private moveCursorToAbsoluteIndex(abs: number): void {
|
|
1250
|
+
const editor = this as unknown as {
|
|
1251
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1252
|
+
preferredVisualCol?: number | null;
|
|
1253
|
+
lastAction?: string | null;
|
|
1254
|
+
tui?: { requestRender?: () => void };
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
const state = editor.state;
|
|
1258
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1259
|
+
|
|
1260
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
|
|
1261
|
+
editor.lastAction = null;
|
|
1262
|
+
state.cursorLine = line;
|
|
1263
|
+
state.cursorCol = col;
|
|
1264
|
+
editor.preferredVisualCol = col;
|
|
1265
|
+
editor.tui?.requestRender?.();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
private moveCursorToLineStart(lineIndex: number): void {
|
|
1269
|
+
const editor = this as unknown as {
|
|
1270
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1271
|
+
preferredVisualCol?: number | null;
|
|
1272
|
+
lastAction?: string | null;
|
|
1273
|
+
tui?: { requestRender?: () => void };
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
const state = editor.state;
|
|
1277
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1278
|
+
super.handleInput(CTRL_A);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
|
|
1283
|
+
editor.lastAction = null;
|
|
1284
|
+
state.cursorLine = targetLine;
|
|
1285
|
+
state.cursorCol = 0;
|
|
1286
|
+
editor.preferredVisualCol = null;
|
|
1287
|
+
editor.tui?.requestRender?.();
|
|
932
1288
|
}
|
|
933
1289
|
|
|
934
1290
|
private moveCursorToFirstNonWhitespace(): void {
|
|
935
1291
|
const { line, col } = this.getCurrentLineAndCol();
|
|
936
1292
|
const targetCol = findFirstNonWhitespaceColumn(line);
|
|
937
|
-
this.
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
private moveCursorToBufferStart(): void {
|
|
941
|
-
this.moveCursorToLineStart(0);
|
|
1293
|
+
this.moveCursorToCol(targetCol);
|
|
942
1294
|
}
|
|
943
1295
|
|
|
944
1296
|
private moveCursorToBufferEnd(): void {
|
|
@@ -951,43 +1303,39 @@ export class ModalEditor extends CustomEditor {
|
|
|
951
1303
|
const steps = Math.max(0, count - 1);
|
|
952
1304
|
if (steps === 0) return;
|
|
953
1305
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
};
|
|
1306
|
+
this.applySyntheticEdit(() => {
|
|
1307
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
1308
|
+
const state = editor.state;
|
|
1309
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
959
1310
|
|
|
960
|
-
|
|
961
|
-
|
|
1311
|
+
const currentLine = state.cursorLine ?? 0;
|
|
1312
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
962
1313
|
|
|
963
|
-
|
|
964
|
-
|
|
1314
|
+
for (let i = 0; i < steps; i++) {
|
|
1315
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
965
1316
|
|
|
966
|
-
|
|
967
|
-
|
|
1317
|
+
const left = state.lines[currentLine]!;
|
|
1318
|
+
const right = state.lines[currentLine + 1]!;
|
|
1319
|
+
let joined: string;
|
|
968
1320
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1321
|
+
if (normalize) {
|
|
1322
|
+
const trimmedRight = right.trimStart();
|
|
1323
|
+
const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
|
|
1324
|
+
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
1325
|
+
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
1326
|
+
joinPoint = left.length;
|
|
1327
|
+
} else {
|
|
1328
|
+
joined = left + right;
|
|
1329
|
+
joinPoint = left.length;
|
|
1330
|
+
}
|
|
972
1331
|
|
|
973
|
-
|
|
974
|
-
const trimmedRight = right.trimStart();
|
|
975
|
-
const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
|
|
976
|
-
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
977
|
-
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
978
|
-
joinPoint = left.length;
|
|
979
|
-
} else {
|
|
980
|
-
joined = left + right;
|
|
981
|
-
joinPoint = left.length;
|
|
1332
|
+
state.lines.splice(currentLine, 2, joined);
|
|
982
1333
|
}
|
|
983
1334
|
|
|
984
|
-
state.
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
state.cursorCol = joinPoint;
|
|
989
|
-
editor.preferredVisualCol = joinPoint;
|
|
990
|
-
editor.tui?.requestRender?.();
|
|
1335
|
+
state.cursorLine = currentLine;
|
|
1336
|
+
state.cursorCol = joinPoint;
|
|
1337
|
+
editor.preferredVisualCol = joinPoint;
|
|
1338
|
+
});
|
|
991
1339
|
}
|
|
992
1340
|
|
|
993
1341
|
private isWordChar(ch: string): boolean {
|
|
@@ -1133,7 +1481,6 @@ export class ModalEditor extends CustomEditor {
|
|
|
1133
1481
|
private tryFindWordTargetLineLocal(
|
|
1134
1482
|
direction: WordMotionDirection,
|
|
1135
1483
|
target: WordMotionTarget,
|
|
1136
|
-
allowSameColumn: boolean = false,
|
|
1137
1484
|
semanticClass: WordMotionClass = "word",
|
|
1138
1485
|
): number | null {
|
|
1139
1486
|
const cursor = this.getCursor();
|
|
@@ -1146,7 +1493,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1146
1493
|
col,
|
|
1147
1494
|
direction,
|
|
1148
1495
|
target,
|
|
1149
|
-
|
|
1496
|
+
false,
|
|
1150
1497
|
semanticClass,
|
|
1151
1498
|
);
|
|
1152
1499
|
if (targetCol === null) return null;
|
|
@@ -1164,10 +1511,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
1164
1511
|
semanticClass: WordMotionClass = "word",
|
|
1165
1512
|
): boolean {
|
|
1166
1513
|
const col = this.getCursor().col;
|
|
1167
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target,
|
|
1514
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
|
|
1168
1515
|
if (targetCol === null || targetCol === col) return false;
|
|
1169
1516
|
|
|
1170
|
-
this.
|
|
1517
|
+
this.moveCursorToCol(targetCol);
|
|
1171
1518
|
return true;
|
|
1172
1519
|
}
|
|
1173
1520
|
|
|
@@ -1235,7 +1582,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1235
1582
|
semanticClass,
|
|
1236
1583
|
);
|
|
1237
1584
|
if (targetAbs !== currentAbs) {
|
|
1238
|
-
this.
|
|
1585
|
+
this.moveCursorToAbsoluteIndex(targetAbs);
|
|
1239
1586
|
}
|
|
1240
1587
|
return;
|
|
1241
1588
|
}
|
|
@@ -1253,6 +1600,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1253
1600
|
return { line, col };
|
|
1254
1601
|
}
|
|
1255
1602
|
|
|
1603
|
+
private hasMultiCodeUnitGraphemes(line: string): boolean {
|
|
1604
|
+
return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
private getGraphemeRangeAtCol(
|
|
1608
|
+
line: string,
|
|
1609
|
+
col: number,
|
|
1610
|
+
count: number,
|
|
1611
|
+
clampToLine: boolean = false,
|
|
1612
|
+
): { start: number; end: number } | null {
|
|
1613
|
+
const clampedCol = Math.max(0, Math.min(col, line.length));
|
|
1614
|
+
const segments = getLineGraphemes(line);
|
|
1615
|
+
const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
|
|
1616
|
+
if (startIndex === -1) return null;
|
|
1617
|
+
|
|
1618
|
+
let endIndex = startIndex + Math.max(1, count) - 1;
|
|
1619
|
+
if (endIndex >= segments.length) {
|
|
1620
|
+
if (!clampToLine) return null;
|
|
1621
|
+
endIndex = segments.length - 1;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return {
|
|
1625
|
+
start: segments[startIndex]!.start,
|
|
1626
|
+
end: segments[endIndex]!.end,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1256
1630
|
private isCursorOnNonWhitespace(): boolean {
|
|
1257
1631
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1258
1632
|
const ch = line[col];
|
|
@@ -1265,13 +1639,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1265
1639
|
}
|
|
1266
1640
|
|
|
1267
1641
|
private cutCharUnderCursor(): void {
|
|
1268
|
-
const count = this.takeTotalCount(1);
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1642
|
+
const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
|
|
1643
|
+
const cursor = this.getCursor();
|
|
1644
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
1645
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
|
|
1646
|
+
if (!range) return;
|
|
1272
1647
|
|
|
1273
|
-
const
|
|
1274
|
-
this.
|
|
1648
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1649
|
+
const text = this.getText();
|
|
1650
|
+
this.writeToRegister(line.slice(range.start, range.end));
|
|
1651
|
+
this.replaceTextInBuffer(
|
|
1652
|
+
text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
|
|
1653
|
+
lineStartAbs + range.start,
|
|
1654
|
+
);
|
|
1275
1655
|
}
|
|
1276
1656
|
|
|
1277
1657
|
private cutToEndOfLine(): void {
|
|
@@ -1352,19 +1732,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1352
1732
|
this.writeToRegister(payload);
|
|
1353
1733
|
|
|
1354
1734
|
if (endAbs > startAbs) {
|
|
1355
|
-
const
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
this.moveCursorBy(startAbs - cursorAbs);
|
|
1359
|
-
}
|
|
1735
|
+
const text = this.getText();
|
|
1736
|
+
const newText = text.slice(0, startAbs) + text.slice(endAbs);
|
|
1737
|
+
this.replaceTextInBuffer(newText, startAbs);
|
|
1360
1738
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
super.handleInput(ESC_DELETE);
|
|
1364
|
-
}
|
|
1739
|
+
// Ensure cursor is at column 0 of the landing line
|
|
1740
|
+
super.handleInput(CTRL_A);
|
|
1365
1741
|
}
|
|
1366
|
-
|
|
1367
|
-
super.handleInput(CTRL_A);
|
|
1368
1742
|
}
|
|
1369
1743
|
|
|
1370
1744
|
private yankLineRange(startLine: number, endLine: number): void {
|
|
@@ -1427,7 +1801,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1427
1801
|
}
|
|
1428
1802
|
|
|
1429
1803
|
const text = this.getText();
|
|
1430
|
-
const currentAbs = this.
|
|
1804
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1431
1805
|
const targetAbs = this.findWordTargetInText(
|
|
1432
1806
|
text,
|
|
1433
1807
|
currentAbs,
|
|
@@ -1495,6 +1869,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1495
1869
|
return;
|
|
1496
1870
|
}
|
|
1497
1871
|
|
|
1872
|
+
if (data === "_") {
|
|
1873
|
+
const count = this.takeTotalCount(1);
|
|
1874
|
+
this.yankLinewiseByDelta(count - 1);
|
|
1875
|
+
this.pendingOperator = null;
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1498
1879
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1499
1880
|
this.pendingMotion = data as PendingMotion;
|
|
1500
1881
|
return;
|
|
@@ -1555,7 +1936,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1555
1936
|
}
|
|
1556
1937
|
|
|
1557
1938
|
const text = this.getText();
|
|
1558
|
-
const currentAbs = this.
|
|
1939
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1559
1940
|
const targetAbs = this.findWordTargetInText(
|
|
1560
1941
|
text,
|
|
1561
1942
|
currentAbs,
|
|
@@ -1587,7 +1968,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1587
1968
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1588
1969
|
const start = Math.min(col, targetCol);
|
|
1589
1970
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1590
|
-
|
|
1971
|
+
let end = Math.min(rawEnd, line.length);
|
|
1972
|
+
|
|
1973
|
+
if (inclusive) {
|
|
1974
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
1975
|
+
end = targetRange?.end ?? end;
|
|
1976
|
+
}
|
|
1591
1977
|
|
|
1592
1978
|
if (end <= start) return;
|
|
1593
1979
|
|
|
@@ -1604,6 +1990,47 @@ export class ModalEditor extends CustomEditor {
|
|
|
1604
1990
|
this.writeToRegister(text.slice(start, end));
|
|
1605
1991
|
}
|
|
1606
1992
|
|
|
1993
|
+
private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
|
|
1994
|
+
const lines = text.length === 0 ? [""] : text.split("\n");
|
|
1995
|
+
let remaining = Math.max(0, Math.min(abs, text.length));
|
|
1996
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
1997
|
+
const line = lines[lineIndex] ?? "";
|
|
1998
|
+
if (remaining <= line.length) return { line: lineIndex, col: remaining };
|
|
1999
|
+
remaining -= line.length + 1;
|
|
2000
|
+
}
|
|
2001
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
2002
|
+
return { line: lastLine, col: (lines[lastLine] ?? "").length };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
private replaceTextInBuffer(text: string, cursorAbs: number): void {
|
|
2006
|
+
const editor = this as unknown as {
|
|
2007
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2008
|
+
preferredVisualCol?: number | null;
|
|
2009
|
+
historyIndex?: number;
|
|
2010
|
+
lastAction?: string | null;
|
|
2011
|
+
onChange?: (text: string) => void;
|
|
2012
|
+
tui?: { requestRender?: () => void };
|
|
2013
|
+
pushUndoSnapshot?: () => void;
|
|
2014
|
+
autocompleteState?: unknown;
|
|
2015
|
+
updateAutocomplete?: () => void;
|
|
2016
|
+
};
|
|
2017
|
+
const state = editor.state;
|
|
2018
|
+
if (!state) return;
|
|
2019
|
+
const currentText = this.getText();
|
|
2020
|
+
if (currentText !== text) editor.pushUndoSnapshot?.();
|
|
2021
|
+
const nextLines = text.length === 0 ? [""] : text.split("\n");
|
|
2022
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
|
|
2023
|
+
editor.historyIndex = -1;
|
|
2024
|
+
editor.lastAction = null;
|
|
2025
|
+
state.lines = nextLines;
|
|
2026
|
+
state.cursorLine = line;
|
|
2027
|
+
state.cursorCol = col;
|
|
2028
|
+
editor.preferredVisualCol = null;
|
|
2029
|
+
editor.onChange?.(text);
|
|
2030
|
+
if (editor.autocompleteState) editor.updateAutocomplete?.();
|
|
2031
|
+
editor.tui?.requestRender?.();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1607
2034
|
private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
|
|
1608
2035
|
const text = this.getText();
|
|
1609
2036
|
const start = Math.min(currentAbs, targetAbs);
|
|
@@ -1614,16 +2041,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1614
2041
|
|
|
1615
2042
|
this.writeToRegister(text.slice(start, end));
|
|
1616
2043
|
|
|
1617
|
-
|
|
1618
|
-
const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
1619
|
-
if (cursorAbs !== start) {
|
|
1620
|
-
this.moveCursorBy(start - cursorAbs);
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
const count = end - start;
|
|
1624
|
-
for (let i = 0; i < count; i++) {
|
|
1625
|
-
super.handleInput(ESC_DELETE);
|
|
1626
|
-
}
|
|
2044
|
+
this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
|
|
1627
2045
|
}
|
|
1628
2046
|
|
|
1629
2047
|
private getWordObjectRange(
|
|
@@ -1750,24 +2168,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1750
2168
|
}
|
|
1751
2169
|
|
|
1752
2170
|
private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
|
|
1753
|
-
const
|
|
1754
|
-
|
|
2171
|
+
const cursor = this.getCursor();
|
|
2172
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
2173
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1755
2174
|
const start = Math.min(col, targetCol);
|
|
1756
2175
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
if (end <= start) return;
|
|
2176
|
+
let end = Math.min(rawEnd, line.length);
|
|
1760
2177
|
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
this.moveCursorBy(start - col);
|
|
2178
|
+
if (inclusive) {
|
|
2179
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
2180
|
+
end = targetRange?.end ?? end;
|
|
1765
2181
|
}
|
|
1766
2182
|
|
|
1767
|
-
|
|
1768
|
-
for (let i = 0; i < count; i++) {
|
|
1769
|
-
super.handleInput(ESC_DELETE);
|
|
1770
|
-
}
|
|
2183
|
+
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
1771
2184
|
}
|
|
1772
2185
|
|
|
1773
2186
|
render(width: number): string[] {
|
|
@@ -1788,6 +2201,9 @@ export class ModalEditor extends CustomEditor {
|
|
|
1788
2201
|
const prefixCount = this.prefixCount;
|
|
1789
2202
|
const operatorCount = this.operatorCount;
|
|
1790
2203
|
|
|
2204
|
+
if (this.pendingReplace) {
|
|
2205
|
+
return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
|
|
2206
|
+
}
|
|
1791
2207
|
if (this.pendingOperator && this.pendingMotion) {
|
|
1792
2208
|
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
1793
2209
|
}
|