@wwwyo/pi-vim 0.0.0-stage → 0.14.2
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/LICENSE +21 -0
- package/README.md +552 -2
- package/clipboard-mirror.ts +449 -0
- package/clipboard-policy.ts +23 -0
- package/cursor-shape.ts +138 -0
- package/index.ts +4359 -0
- package/input-keys.ts +50 -0
- package/mode-change-command.ts +120 -0
- package/mode-colors.ts +97 -0
- package/mode-label.ts +54 -0
- package/motions.ts +305 -0
- package/package.json +55 -4
- package/settings.ts +346 -0
- package/text-objects.ts +408 -0
- package/types.ts +50 -0
- package/visual.ts +68 -0
- package/word-boundary-cache.ts +196 -0
package/index.ts
ADDED
|
@@ -0,0 +1,4359 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
Key,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
ClipboardMirror,
|
|
13
|
+
type ClipboardReadFn,
|
|
14
|
+
readClipboardInChildProcess,
|
|
15
|
+
writeClipboardInChildProcess,
|
|
16
|
+
} from "./clipboard-mirror.js";
|
|
17
|
+
import {
|
|
18
|
+
type ClipboardMirrorPolicy,
|
|
19
|
+
DEFAULT_CLIPBOARD_MIRROR_POLICY,
|
|
20
|
+
type RegisterWriteSource,
|
|
21
|
+
resolveClipboardMirrorPolicy,
|
|
22
|
+
} from "./clipboard-policy.js";
|
|
23
|
+
import {
|
|
24
|
+
BLOCK_CURSOR_SHAPE,
|
|
25
|
+
type CursorShapeCleanup,
|
|
26
|
+
type CursorShapeRuntime,
|
|
27
|
+
type CursorShapeSequence,
|
|
28
|
+
enableCursorShapeSupport,
|
|
29
|
+
getCursorShapeRuntime,
|
|
30
|
+
hasPromptCursorMarker,
|
|
31
|
+
INSERT_CURSOR_SHAPE,
|
|
32
|
+
stripSoftwareCursorWhenHardwareCursorIsUsed,
|
|
33
|
+
} from "./cursor-shape.js";
|
|
34
|
+
import {
|
|
35
|
+
isBackspaceLikeInput,
|
|
36
|
+
isCountStarter,
|
|
37
|
+
isDigit,
|
|
38
|
+
isEnterLikeInput,
|
|
39
|
+
isEscapeLikeInput,
|
|
40
|
+
isPrintableChunk,
|
|
41
|
+
isPrintableInput,
|
|
42
|
+
} from "./input-keys.js";
|
|
43
|
+
import {
|
|
44
|
+
cancelModeChangeCommands,
|
|
45
|
+
createModeChangeHandler,
|
|
46
|
+
setModeChangeCommandRunnerForTests,
|
|
47
|
+
} from "./mode-change-command.js";
|
|
48
|
+
import {
|
|
49
|
+
buildModeColorizers,
|
|
50
|
+
buildOffBorderColor,
|
|
51
|
+
isNeutralBorder,
|
|
52
|
+
type ModeColorizers,
|
|
53
|
+
type ModeColorKey,
|
|
54
|
+
resolveModeColors,
|
|
55
|
+
} from "./mode-colors.js";
|
|
56
|
+
import { fitModeLabel } from "./mode-label.js";
|
|
57
|
+
import {
|
|
58
|
+
findCharMotionTarget,
|
|
59
|
+
findFirstNonWhitespaceColumn,
|
|
60
|
+
findParagraphMotionTarget,
|
|
61
|
+
getLineGraphemes,
|
|
62
|
+
reverseCharMotion,
|
|
63
|
+
type WordMotionClass,
|
|
64
|
+
} from "./motions.js";
|
|
65
|
+
import {
|
|
66
|
+
DEFAULT_EX_COMMAND_SETTINGS,
|
|
67
|
+
DEFAULT_INSERT_ESCAPE_SETTINGS,
|
|
68
|
+
type ExCommandSettings,
|
|
69
|
+
type InsertEscapeSettings,
|
|
70
|
+
readPiVimSettings,
|
|
71
|
+
resolveExCommandSettings,
|
|
72
|
+
resolveInsertEscapeSettings,
|
|
73
|
+
resolveSurfaceSyncMaps,
|
|
74
|
+
type SurfaceSyncMap,
|
|
75
|
+
} from "./settings.js";
|
|
76
|
+
import {
|
|
77
|
+
resolveDelimitedTextObjectRange,
|
|
78
|
+
resolveMatchingPairMotionTarget,
|
|
79
|
+
resolveWordTextObjectRange,
|
|
80
|
+
type TextObjectKind,
|
|
81
|
+
type TextObjectRange,
|
|
82
|
+
type WordTextObjectClass,
|
|
83
|
+
} from "./text-objects.js";
|
|
84
|
+
import type {
|
|
85
|
+
CharMotion,
|
|
86
|
+
LastCharMotion,
|
|
87
|
+
Mode,
|
|
88
|
+
PendingMotion,
|
|
89
|
+
PendingOperator,
|
|
90
|
+
} from "./types.js";
|
|
91
|
+
import {
|
|
92
|
+
CHAR_MOTION_KEYS,
|
|
93
|
+
CTRL_A,
|
|
94
|
+
CTRL_E,
|
|
95
|
+
CTRL_K,
|
|
96
|
+
CTRL_R,
|
|
97
|
+
CTRL_UNDERSCORE,
|
|
98
|
+
ESC_DOWN,
|
|
99
|
+
ESC_LEFT,
|
|
100
|
+
ESC_RIGHT,
|
|
101
|
+
ESC_UP,
|
|
102
|
+
NEWLINE,
|
|
103
|
+
NORMAL_KEYS,
|
|
104
|
+
} from "./types.js";
|
|
105
|
+
import {
|
|
106
|
+
clampVisualPosition,
|
|
107
|
+
getInclusiveEndColumn,
|
|
108
|
+
getVisualLineRange,
|
|
109
|
+
isVisualMode,
|
|
110
|
+
orderVisualEndpoints,
|
|
111
|
+
type VisualMode,
|
|
112
|
+
type VisualPosition,
|
|
113
|
+
} from "./visual.js";
|
|
114
|
+
import {
|
|
115
|
+
WordBoundaryCache,
|
|
116
|
+
type WordMotionDirection,
|
|
117
|
+
type WordMotionTarget,
|
|
118
|
+
} from "./word-boundary-cache.js";
|
|
119
|
+
|
|
120
|
+
export { setModeChangeCommandRunnerForTests };
|
|
121
|
+
|
|
122
|
+
const MODE_COLOR_KEYS: readonly ModeColorKey[] = [
|
|
123
|
+
"insert",
|
|
124
|
+
"normal",
|
|
125
|
+
"visual",
|
|
126
|
+
"ex",
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
130
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
131
|
+
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
|
|
132
|
+
const MAX_COUNT = 9999;
|
|
133
|
+
const TEXT_INSERT_REPEAT_KEYS = new Set(["i", "a", "A", "I"]);
|
|
134
|
+
const OPEN_LINE_REPEAT_KEYS = new Set(["o", "O"]);
|
|
135
|
+
const REPEATABLE_COMMAND_START_KEYS = new Set([
|
|
136
|
+
"d",
|
|
137
|
+
"c",
|
|
138
|
+
"r",
|
|
139
|
+
"p",
|
|
140
|
+
"P",
|
|
141
|
+
"J",
|
|
142
|
+
"g",
|
|
143
|
+
...TEXT_INSERT_REPEAT_KEYS,
|
|
144
|
+
...OPEN_LINE_REPEAT_KEYS,
|
|
145
|
+
"D",
|
|
146
|
+
"C",
|
|
147
|
+
"S",
|
|
148
|
+
"s",
|
|
149
|
+
"x",
|
|
150
|
+
"X",
|
|
151
|
+
]);
|
|
152
|
+
// Normal-mode commands that must never run while a visual selection is live.
|
|
153
|
+
// They are swallowed instead of falling through to the normal-mode dispatch.
|
|
154
|
+
const VISUAL_IGNORED_KEYS = new Set([
|
|
155
|
+
"p",
|
|
156
|
+
"P",
|
|
157
|
+
"r",
|
|
158
|
+
"J",
|
|
159
|
+
"u",
|
|
160
|
+
"U",
|
|
161
|
+
"~",
|
|
162
|
+
">",
|
|
163
|
+
"<",
|
|
164
|
+
".",
|
|
165
|
+
":",
|
|
166
|
+
"i",
|
|
167
|
+
"a",
|
|
168
|
+
"A",
|
|
169
|
+
"I",
|
|
170
|
+
"R",
|
|
171
|
+
CTRL_R,
|
|
172
|
+
CTRL_UNDERSCORE,
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// Pi's builtin slash commands. `pi.getCommands()` reports only extension,
|
|
176
|
+
// prompt and skill commands; the builtin list is not re-exported from the
|
|
177
|
+
// package's public entry point, so it is mirrored here. A name missing from
|
|
178
|
+
// this mirror simply falls through to "Unsupported ex command".
|
|
179
|
+
const EX_BUILTIN_COMMAND_NAMES: ReadonlySet<string> = new Set([
|
|
180
|
+
"settings",
|
|
181
|
+
"model",
|
|
182
|
+
"scoped-models",
|
|
183
|
+
"export",
|
|
184
|
+
"import",
|
|
185
|
+
"share",
|
|
186
|
+
"copy",
|
|
187
|
+
"name",
|
|
188
|
+
"session",
|
|
189
|
+
"changelog",
|
|
190
|
+
"hotkeys",
|
|
191
|
+
"fork",
|
|
192
|
+
"clone",
|
|
193
|
+
"tree",
|
|
194
|
+
"login",
|
|
195
|
+
"logout",
|
|
196
|
+
"new",
|
|
197
|
+
"compact",
|
|
198
|
+
"resume",
|
|
199
|
+
"reload",
|
|
200
|
+
"quit",
|
|
201
|
+
"trust",
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
type EditorSnapshot = {
|
|
205
|
+
text: string;
|
|
206
|
+
cursor: { line: number; col: number };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type CommandDispatchResult = void | PromiseLike<void>;
|
|
210
|
+
|
|
211
|
+
type DispatchState = {
|
|
212
|
+
snapshot: EditorSnapshot;
|
|
213
|
+
restore: () => void;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
type RepeatRecording = {
|
|
217
|
+
/** Raw key inputs that reconstruct one repeatable normal-mode change. */
|
|
218
|
+
keys: string[];
|
|
219
|
+
/** Replay keys without command counts, used when `{count}.` overrides them. */
|
|
220
|
+
countOverrideKeys: string[];
|
|
221
|
+
/** Buffer mutation version before the command started. */
|
|
222
|
+
startChangeVersion: number;
|
|
223
|
+
/** True while the command is collecting insert-mode input until Escape. */
|
|
224
|
+
captureInsert: boolean;
|
|
225
|
+
/** True when a command completed even if it emitted no mutation event. */
|
|
226
|
+
forceCommit: boolean;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
type RepeatableCommand = Pick<RepeatRecording, "keys" | "countOverrideKeys">;
|
|
230
|
+
|
|
231
|
+
type TransitionState = "none" | "undo" | "redo" | "restore";
|
|
232
|
+
|
|
233
|
+
type ModalEditorInternals = {
|
|
234
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
235
|
+
preferredVisualCol?: number | null;
|
|
236
|
+
lastAction?: string | null;
|
|
237
|
+
historyIndex?: number;
|
|
238
|
+
onChange?: (text: string) => void;
|
|
239
|
+
tui?: { requestRender?: () => void };
|
|
240
|
+
pushUndoSnapshot?: () => void;
|
|
241
|
+
setCursorCol?: (col: number) => void;
|
|
242
|
+
undoStack?: { readonly length: number; pop(): unknown; stack: unknown[] };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
type CustomEditorConstructorArgs = ConstructorParameters<typeof CustomEditor>;
|
|
246
|
+
|
|
247
|
+
type ModalEditorOptions = {
|
|
248
|
+
labelColorizers?: ModeColorizers | null;
|
|
249
|
+
borderColorizers?: ModeColorizers | null;
|
|
250
|
+
// Per-mode paint policy for each surface. "mode" paints the mode color;
|
|
251
|
+
// "host" shows the host's current border color; "thinking" shows the host
|
|
252
|
+
// color only while the host border is away from its neutral resting default.
|
|
253
|
+
// Absent → every mode "mode" (paint the colorizers this editor was given).
|
|
254
|
+
borderSync?: SurfaceSyncMap | null;
|
|
255
|
+
labelSync?: SurfaceSyncMap | null;
|
|
256
|
+
// Renders Pi's neutral "thinking off" border color. Lets "thinking" tell the
|
|
257
|
+
// resting border apart from an active thinking level or another extension's
|
|
258
|
+
// highlight. When omitted, "thinking" treats every host border as neutral and
|
|
259
|
+
// always paints the mode color.
|
|
260
|
+
offBorderColor?: ((s: string) => string) | null;
|
|
261
|
+
// Reverse-video transform applied to the label. When the label defers to the
|
|
262
|
+
// host color it is wrapped with this so it keeps its block styling.
|
|
263
|
+
labelTransform?: ((s: string) => string) | null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export class ModalEditor extends CustomEditor {
|
|
267
|
+
private mode: Mode = "insert";
|
|
268
|
+
private pendingMotion: PendingMotion = null;
|
|
269
|
+
private pendingTextObject: TextObjectKind | null = null;
|
|
270
|
+
private pendingOperator: PendingOperator = null;
|
|
271
|
+
private prefixCount: string = "";
|
|
272
|
+
private operatorCount: string = "";
|
|
273
|
+
private pendingG: boolean = false;
|
|
274
|
+
private pendingGCount: string = "";
|
|
275
|
+
private pendingReplace: boolean = false;
|
|
276
|
+
private visualAnchor: VisualPosition | null = null;
|
|
277
|
+
private pendingExCommand: string | null = null;
|
|
278
|
+
private acceptingBracketedPasteInExCommand: boolean = false;
|
|
279
|
+
private pendingEscWhileAcceptingBracketedPasteInExCommand: boolean = false;
|
|
280
|
+
private discardingPasteAfterNewlineInExCommand: boolean = false;
|
|
281
|
+
private lastCharMotion: LastCharMotion | null = null;
|
|
282
|
+
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
283
|
+
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
284
|
+
private wordBoundaryCache = new WordBoundaryCache();
|
|
285
|
+
private readonly redoStack: EditorSnapshot[] = [];
|
|
286
|
+
private lastRepeatableCommand: RepeatableCommand | null = null;
|
|
287
|
+
private repeatRecording: RepeatRecording | null = null;
|
|
288
|
+
private implicitInsertSuppressed: boolean = false;
|
|
289
|
+
private replayingRepeat: boolean = false;
|
|
290
|
+
private repeatReplayFailed: boolean = false;
|
|
291
|
+
private bufferChangeVersion: number = 0;
|
|
292
|
+
/**
|
|
293
|
+
* One undo window per vim change, opened on the recorder's change boundaries
|
|
294
|
+
* (or a visual mutating operator). `floor` is the host `undoStack.length` at
|
|
295
|
+
* open; `openVersion` the `bufferChangeVersion`. On close the host stack is
|
|
296
|
+
* collapsed to `floor + 1`, so one host snapshot equals one vim change.
|
|
297
|
+
*/
|
|
298
|
+
private undoWindow: { floor: number; openVersion: number } | null = null;
|
|
299
|
+
private currentTransition: TransitionState = "none";
|
|
300
|
+
private insertEscapeSettings: InsertEscapeSettings = {
|
|
301
|
+
...DEFAULT_INSERT_ESCAPE_SETTINGS,
|
|
302
|
+
};
|
|
303
|
+
// Pending `jj` insert-escape: armed by a single `j` in insert mode with the
|
|
304
|
+
// host undo depth at arm time, so the rollback can trim its own pushes when
|
|
305
|
+
// no undo window owns the session (e.g. after a host injection tainted it).
|
|
306
|
+
private pendingInsertEscape: { at: number; undoDepth: number } | null = null;
|
|
307
|
+
private insertEscapeNow: () => number = () => Date.now();
|
|
308
|
+
private onChangeHooked: boolean = false;
|
|
309
|
+
private readonly labelColorizers: ModeColorizers | null;
|
|
310
|
+
private readonly borderColorizers: ModeColorizers | null;
|
|
311
|
+
private readonly offBorderColor: ((s: string) => string) | null;
|
|
312
|
+
// Latest host-assigned border color and whether it is the neutral resting
|
|
313
|
+
// default. Kept as mutable instance fields (not closure locals) so the label
|
|
314
|
+
// resolver can read the same thinking state the border uses.
|
|
315
|
+
private borderBase: ((s: string) => string) | null = null;
|
|
316
|
+
private borderBaseIsNeutral = true;
|
|
317
|
+
// Per-mode paint policy for each surface (border and label). Null → every
|
|
318
|
+
// mode defaults to "mode" (paint the mode color).
|
|
319
|
+
private readonly borderSync: SurfaceSyncMap | null;
|
|
320
|
+
private readonly labelSync: SurfaceSyncMap | null;
|
|
321
|
+
// Reverse-video transform used by the label, so its defer-to-host branch
|
|
322
|
+
// matches the label's usual background-block styling.
|
|
323
|
+
private labelTransform: ((s: string) => string) | null = null;
|
|
324
|
+
private readonly cursorShapeRuntime: CursorShapeRuntime | null;
|
|
325
|
+
private lastCursorShapeSequence: CursorShapeSequence | null = null;
|
|
326
|
+
private lastLineCache = { l: "", w: 0, label: "", result: "" };
|
|
327
|
+
|
|
328
|
+
private unnamedRegister: string = "";
|
|
329
|
+
private preferRegisterForPut = false;
|
|
330
|
+
private clipboardMirrorPolicy: ClipboardMirrorPolicy =
|
|
331
|
+
DEFAULT_CLIPBOARD_MIRROR_POLICY;
|
|
332
|
+
private readonly clipboardMirror = new ClipboardMirror(
|
|
333
|
+
writeClipboardInChildProcess,
|
|
334
|
+
);
|
|
335
|
+
private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
|
|
336
|
+
private quitFn: () => void = () => {};
|
|
337
|
+
private notifyFn: (message: string) => void = () => {};
|
|
338
|
+
private modeChangeFn: (mode: Mode, prevMode: Mode) => void = () => {};
|
|
339
|
+
private exCommandSettings: ExCommandSettings = DEFAULT_EX_COMMAND_SETTINGS;
|
|
340
|
+
private commandNamesFn: () => ReadonlySet<string> = () =>
|
|
341
|
+
EX_BUILTIN_COMMAND_NAMES;
|
|
342
|
+
// Pi has no public command-dispatch API. The editor's own submit path is the
|
|
343
|
+
// one mechanism that reaches every command kind (builtin, extension, skill,
|
|
344
|
+
// prompt), so dispatching `:name` is literally the user typing `/name` and
|
|
345
|
+
// pressing Enter. Injectable so tests need no live pi runtime.
|
|
346
|
+
private runCommandFn: (commandLine: string) => CommandDispatchResult = (
|
|
347
|
+
commandLine,
|
|
348
|
+
) => {
|
|
349
|
+
const submit = this.onSubmit as
|
|
350
|
+
| ((text: string) => CommandDispatchResult)
|
|
351
|
+
| undefined;
|
|
352
|
+
if (!submit) return;
|
|
353
|
+
this.setText(commandLine);
|
|
354
|
+
return submit(commandLine);
|
|
355
|
+
};
|
|
356
|
+
private pendingAsyncDispatches: number = 0;
|
|
357
|
+
private restoreLatestDispatchStateFn: (() => void) | null = null;
|
|
358
|
+
|
|
359
|
+
constructor(
|
|
360
|
+
tui: CustomEditorConstructorArgs[0],
|
|
361
|
+
theme: CustomEditorConstructorArgs[1],
|
|
362
|
+
kb: CustomEditorConstructorArgs[2],
|
|
363
|
+
opts?: ModalEditorOptions,
|
|
364
|
+
) {
|
|
365
|
+
super(tui, theme, kb);
|
|
366
|
+
this.cursorShapeRuntime = getCursorShapeRuntime(tui);
|
|
367
|
+
this.labelColorizers = opts?.labelColorizers ?? null;
|
|
368
|
+
this.borderColorizers = opts?.borderColorizers ?? null;
|
|
369
|
+
this.offBorderColor = opts?.offBorderColor ?? null;
|
|
370
|
+
this.borderSync = opts?.borderSync ?? null;
|
|
371
|
+
this.labelSync = opts?.labelSync ?? null;
|
|
372
|
+
this.labelTransform = opts?.labelTransform ?? null;
|
|
373
|
+
this.installModeBorderColorizer();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
|
|
377
|
+
this.clipboardMirror.setWriteFn(
|
|
378
|
+
async (text: string, signal: AbortSignal) => {
|
|
379
|
+
await fn(text, signal);
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
setClipboardWriteTimeoutMs(timeoutMs: number): void {
|
|
384
|
+
this.clipboardMirror.setTimeoutMs(timeoutMs);
|
|
385
|
+
}
|
|
386
|
+
setClipboardReadFn(fn: ClipboardReadFn): void {
|
|
387
|
+
this.clipboardReadFn = fn;
|
|
388
|
+
}
|
|
389
|
+
setClipboardMirrorPolicy(policy: ClipboardMirrorPolicy): void {
|
|
390
|
+
this.clipboardMirrorPolicy = policy;
|
|
391
|
+
}
|
|
392
|
+
getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
|
|
393
|
+
return this.clipboardMirrorPolicy;
|
|
394
|
+
}
|
|
395
|
+
setQuitFn(fn: () => void): void {
|
|
396
|
+
this.quitFn = fn;
|
|
397
|
+
}
|
|
398
|
+
setNotifyFn(fn: (message: string) => void): void {
|
|
399
|
+
this.notifyFn = fn;
|
|
400
|
+
}
|
|
401
|
+
setModeChangeFn(fn: (mode: Mode, prevMode: Mode) => void): void {
|
|
402
|
+
this.modeChangeFn = fn;
|
|
403
|
+
}
|
|
404
|
+
setRunCommandFn(fn: (commandLine: string) => CommandDispatchResult): void {
|
|
405
|
+
this.runCommandFn = fn;
|
|
406
|
+
}
|
|
407
|
+
setCommandNamesFn(fn: () => ReadonlySet<string>): void {
|
|
408
|
+
this.commandNamesFn = fn;
|
|
409
|
+
}
|
|
410
|
+
setExCommandSettings(settings: ExCommandSettings): void {
|
|
411
|
+
this.exCommandSettings = settings;
|
|
412
|
+
}
|
|
413
|
+
setInsertEscapeSettings(settings: InsertEscapeSettings): void {
|
|
414
|
+
this.insertEscapeSettings = settings;
|
|
415
|
+
}
|
|
416
|
+
setInsertEscapeClock(now: () => number): void {
|
|
417
|
+
this.insertEscapeNow = now;
|
|
418
|
+
}
|
|
419
|
+
getRegister(): string {
|
|
420
|
+
return this.unnamedRegister;
|
|
421
|
+
}
|
|
422
|
+
setRegister(text: string): void {
|
|
423
|
+
this.unnamedRegister = text;
|
|
424
|
+
this.preferRegisterForPut = false;
|
|
425
|
+
}
|
|
426
|
+
getMode(): Mode {
|
|
427
|
+
return this.mode;
|
|
428
|
+
}
|
|
429
|
+
getText(): string {
|
|
430
|
+
return this.getLines().join("\n");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private getActiveMode(): ModeColorKey {
|
|
434
|
+
if (this.pendingExCommand !== null) return "ex";
|
|
435
|
+
if (this.mode === "insert") return "insert";
|
|
436
|
+
if (isVisualMode(this.mode)) return "visual";
|
|
437
|
+
return "normal";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Resolve a surface (border or label) color for the active mode under its
|
|
442
|
+
* per-mode paint policy:
|
|
443
|
+
* - "mode" always paints the mode color;
|
|
444
|
+
* - "host" always shows the host's current border color;
|
|
445
|
+
* - "thinking" shows the host color while the host border is away from its
|
|
446
|
+
* neutral resting default, otherwise paints the mode color.
|
|
447
|
+
* A missing policy map defaults every mode to "mode". `deferWrap` is identity
|
|
448
|
+
* for the border and reverse-video for the label, so a deferred host color
|
|
449
|
+
* inherits the surface's normal styling.
|
|
450
|
+
*/
|
|
451
|
+
private resolveSurfaceColor(
|
|
452
|
+
colorizers: ModeColorizers,
|
|
453
|
+
syncMap: SurfaceSyncMap | null,
|
|
454
|
+
text: string,
|
|
455
|
+
deferWrap: (s: string) => string,
|
|
456
|
+
): string {
|
|
457
|
+
const mode = this.getActiveMode();
|
|
458
|
+
const modeColorFn = colorizers[mode] ?? ((s: string) => s);
|
|
459
|
+
const policy = syncMap ? syncMap[mode] : "mode";
|
|
460
|
+
if (
|
|
461
|
+
policy === "mode" ||
|
|
462
|
+
(policy === "thinking" && this.borderBaseIsNeutral)
|
|
463
|
+
) {
|
|
464
|
+
return modeColorFn(text);
|
|
465
|
+
}
|
|
466
|
+
// Defer to the host border color ("host" always; "thinking" only while the
|
|
467
|
+
// host border is non-neutral), wrapped the way this surface styles its text.
|
|
468
|
+
const host = this.borderBase ?? modeColorFn;
|
|
469
|
+
return deferWrap(host(text));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// The border trap recolors the border getter and tracks the host's assigned
|
|
473
|
+
// border base for both surfaces. It is unnecessary — and skipped, so the
|
|
474
|
+
// border reference stays exactly what the host set — only when every border
|
|
475
|
+
// mode is "host" and every label mode is "mode" (the released-`false`
|
|
476
|
+
// defaults): the border shows the host color untouched and the label needs no
|
|
477
|
+
// host tracking.
|
|
478
|
+
private borderTrapNeeded(): boolean {
|
|
479
|
+
const borderAllHost = MODE_COLOR_KEYS.every(
|
|
480
|
+
(mode) => (this.borderSync ? this.borderSync[mode] : "mode") === "host",
|
|
481
|
+
);
|
|
482
|
+
const labelAllMode = MODE_COLOR_KEYS.every(
|
|
483
|
+
(mode) => (this.labelSync ? this.labelSync[mode] : "mode") === "mode",
|
|
484
|
+
);
|
|
485
|
+
return !(borderAllHost && labelAllMode);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private installModeBorderColorizer(): void {
|
|
489
|
+
if (!this.borderColorizers) return;
|
|
490
|
+
if (!this.borderTrapNeeded()) return;
|
|
491
|
+
// Capture the values the getter/setter close over so the traps never
|
|
492
|
+
// depend on late `this` rebinding. The accessors themselves are arrow
|
|
493
|
+
// functions, so their `this` is this editor instance.
|
|
494
|
+
const borderColorizers = this.borderColorizers;
|
|
495
|
+
const offBorderColor = this.offBorderColor;
|
|
496
|
+
// Seed the instance fields from whatever the host assigned before the trap
|
|
497
|
+
// was installed (usually the inherited default border).
|
|
498
|
+
this.borderBase = this.borderColor;
|
|
499
|
+
this.borderBaseIsNeutral = isNeutralBorder(this.borderBase, offBorderColor);
|
|
500
|
+
const resolvedBorderColor = (text: string) =>
|
|
501
|
+
this.resolveSurfaceColor(
|
|
502
|
+
borderColorizers,
|
|
503
|
+
this.borderSync,
|
|
504
|
+
text,
|
|
505
|
+
(s) => s,
|
|
506
|
+
);
|
|
507
|
+
// Pi assigns its default border color after extension editor construction.
|
|
508
|
+
// Keep a mode-aware getter installed and treat later assignments as the
|
|
509
|
+
// host base color, otherwise the border sync is overwritten in real
|
|
510
|
+
// sessions even though direct editor tests pass. Reclassify on every host
|
|
511
|
+
// assignment so a thinking-level cycle off -> on -> off is honored.
|
|
512
|
+
Object.defineProperty(this, "borderColor", {
|
|
513
|
+
get: () => resolvedBorderColor,
|
|
514
|
+
set: (next: unknown) => {
|
|
515
|
+
if (typeof next === "function") {
|
|
516
|
+
this.borderBase = next as (s: string) => string;
|
|
517
|
+
this.borderBaseIsNeutral = isNeutralBorder(
|
|
518
|
+
this.borderBase,
|
|
519
|
+
offBorderColor,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private setMode(mode: Mode = "insert"): void {
|
|
527
|
+
const prev = this.mode;
|
|
528
|
+
this.mode = mode;
|
|
529
|
+
// Leaving insert ends any host-tainted session, so the next implicit
|
|
530
|
+
// insert (post-submit or a re-entry) records normally again.
|
|
531
|
+
if (prev === "insert" && mode !== "insert") {
|
|
532
|
+
this.implicitInsertSuppressed = false;
|
|
533
|
+
}
|
|
534
|
+
if (prev !== mode) {
|
|
535
|
+
try {
|
|
536
|
+
this.modeChangeFn(mode, prev);
|
|
537
|
+
} catch {
|
|
538
|
+
// mode-change side effects must never break editing
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
override setText(text: string): void {
|
|
544
|
+
this.discardUndoWindow();
|
|
545
|
+
this.clearRedoStack();
|
|
546
|
+
this.clearRepeatState();
|
|
547
|
+
this.pendingInsertEscape = null;
|
|
548
|
+
// A full host buffer reset starts a fresh session: later typing is a new
|
|
549
|
+
// implicit insert, not a continuation of a host-tainted one.
|
|
550
|
+
this.implicitInsertSuppressed = false;
|
|
551
|
+
super.setText(text);
|
|
552
|
+
this.refreshPendingDispatchRestore(true);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
override insertTextAtCursor(text: string): void {
|
|
556
|
+
this.discardUndoWindow();
|
|
557
|
+
this.cancelRepeatableCommand();
|
|
558
|
+
this.pendingInsertEscape = null;
|
|
559
|
+
// A host injection mid-insert taints the session: the injected text is not
|
|
560
|
+
// ours to replay, and neither is any typing that continues around it.
|
|
561
|
+
this.implicitInsertSuppressed = true;
|
|
562
|
+
super.insertTextAtCursor(text);
|
|
563
|
+
this.refreshPendingDispatchRestore(true);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* While an async EX dispatch is pending, the prompt the settle handler will
|
|
568
|
+
* reapply must track the *latest* buffer, not only what `handleInput`
|
|
569
|
+
* produced. A wrapper mutating the prompt out of band through the public
|
|
570
|
+
* setters refreshes the snapshot here, so a late-settling dispatch that
|
|
571
|
+
* clears the prompt restores the wrapper's newer text instead of the stale
|
|
572
|
+
* pre-dispatch state.
|
|
573
|
+
*
|
|
574
|
+
* A public setter that empties the buffer is skipped: that is the async
|
|
575
|
+
* clear the restore exists to compensate for (or a wrapper clearing), so
|
|
576
|
+
* capturing it would defeat the reapply. `handleInput` still refreshes on any
|
|
577
|
+
* result, including an intentional emptying edit like `dd`.
|
|
578
|
+
*/
|
|
579
|
+
private refreshPendingDispatchRestore(fromPublicSetter = false): void {
|
|
580
|
+
if (this.pendingAsyncDispatches === 0) return;
|
|
581
|
+
if (fromPublicSetter && this.getText() === "") return;
|
|
582
|
+
this.restoreLatestDispatchStateFn = this.captureDispatchState().restore;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private captureSnapshot(): EditorSnapshot {
|
|
586
|
+
const cursor = this.getCursor();
|
|
587
|
+
return {
|
|
588
|
+
text: this.getText(),
|
|
589
|
+
cursor: { line: cursor.line, col: cursor.col },
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private requireRedoRestoreState(editor: ModalEditorInternals): {
|
|
594
|
+
lines: string[];
|
|
595
|
+
cursorLine?: number;
|
|
596
|
+
cursorCol?: number;
|
|
597
|
+
} {
|
|
598
|
+
const state = editor.state;
|
|
599
|
+
if (!state || !Array.isArray(state.lines)) {
|
|
600
|
+
throw new Error("Redo restore prerequisite: editor state unavailable");
|
|
601
|
+
}
|
|
602
|
+
return state as {
|
|
603
|
+
lines: string[];
|
|
604
|
+
cursorLine?: number;
|
|
605
|
+
cursorCol?: number;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private restoreSnapshot(snapshot: EditorSnapshot): void {
|
|
610
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
611
|
+
const state = this.requireRedoRestoreState(editor);
|
|
612
|
+
|
|
613
|
+
const lines = snapshot.text.split("\n");
|
|
614
|
+
state.lines = lines.length > 0 ? lines : [""];
|
|
615
|
+
|
|
616
|
+
const maxLine = Math.max(0, state.lines.length - 1);
|
|
617
|
+
const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
|
|
618
|
+
const line = state.lines[cursorLine] ?? "";
|
|
619
|
+
const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
|
|
620
|
+
|
|
621
|
+
state.cursorLine = cursorLine;
|
|
622
|
+
if (typeof editor.setCursorCol === "function") {
|
|
623
|
+
editor.setCursorCol(cursorCol);
|
|
624
|
+
} else {
|
|
625
|
+
state.cursorCol = cursorCol;
|
|
626
|
+
editor.preferredVisualCol = null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
this.invalidateWordBoundaryCache();
|
|
630
|
+
|
|
631
|
+
editor.historyIndex = -1;
|
|
632
|
+
editor.lastAction = null;
|
|
633
|
+
editor.onChange?.(this.getText());
|
|
634
|
+
editor.tui?.requestRender?.();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
|
|
638
|
+
return (
|
|
639
|
+
a.text !== b.text ||
|
|
640
|
+
a.cursor.line !== b.cursor.line ||
|
|
641
|
+
a.cursor.col !== b.cursor.col
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private withTransition<T>(
|
|
646
|
+
transition: Exclude<TransitionState, "none">,
|
|
647
|
+
action: () => T,
|
|
648
|
+
): T {
|
|
649
|
+
const previousTransition = this.currentTransition;
|
|
650
|
+
this.currentTransition = transition;
|
|
651
|
+
try {
|
|
652
|
+
return action();
|
|
653
|
+
} finally {
|
|
654
|
+
this.currentTransition = previousTransition;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private performUndo(count: number = this.takeTotalCount(1)): void {
|
|
659
|
+
this.discardUndoWindow();
|
|
660
|
+
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
661
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
662
|
+
let changed = false;
|
|
663
|
+
this.withTransition("undo", () => {
|
|
664
|
+
const beforeUndo = this.captureSnapshot();
|
|
665
|
+
super.handleInput(CTRL_UNDERSCORE);
|
|
666
|
+
const afterUndo = this.captureSnapshot();
|
|
667
|
+
|
|
668
|
+
if (this.snapshotChanged(beforeUndo, afterUndo)) {
|
|
669
|
+
this.redoStack.push(beforeUndo);
|
|
670
|
+
changed = true;
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
if (!changed) break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private performRedo(count: number = this.takeTotalCount(1)): void {
|
|
678
|
+
this.discardUndoWindow();
|
|
679
|
+
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
680
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
681
|
+
|
|
682
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
683
|
+
const snapshot = this.redoStack[this.redoStack.length - 1];
|
|
684
|
+
if (!snapshot) break;
|
|
685
|
+
|
|
686
|
+
this.withTransition("redo", () => {
|
|
687
|
+
this.requireRedoRestoreState(editor);
|
|
688
|
+
if (typeof editor.pushUndoSnapshot !== "function") {
|
|
689
|
+
throw new Error(
|
|
690
|
+
"Redo restore prerequisite: pushUndoSnapshot unavailable",
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
editor.pushUndoSnapshot();
|
|
694
|
+
this.restoreSnapshot(snapshot);
|
|
695
|
+
this.redoStack.pop();
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private clearRedoStack(): void {
|
|
701
|
+
this.redoStack.length = 0;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private invalidateWordBoundaryCache(): void {
|
|
705
|
+
this.wordBoundaryCache = new WordBoundaryCache();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private ensureOnChangeHook(): void {
|
|
709
|
+
if (this.onChangeHooked) return;
|
|
710
|
+
|
|
711
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
712
|
+
const originalOnChange = editor.onChange;
|
|
713
|
+
|
|
714
|
+
editor.onChange = (text: string) => {
|
|
715
|
+
originalOnChange?.(text);
|
|
716
|
+
this.bufferChangeVersion++;
|
|
717
|
+
this.centralInvalidationCheck();
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
this.onChangeHooked = true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private centralInvalidationCheck(): void {
|
|
724
|
+
if (this.redoStack.length === 0) return;
|
|
725
|
+
if (this.currentTransition !== "none") return;
|
|
726
|
+
this.clearRedoStack();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private isRepeatRecordingInProgress(): boolean {
|
|
730
|
+
return (
|
|
731
|
+
this.pendingMotion !== null ||
|
|
732
|
+
this.pendingTextObject !== null ||
|
|
733
|
+
this.pendingOperator !== null ||
|
|
734
|
+
this.pendingG ||
|
|
735
|
+
this.pendingReplace
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private isRepeatableCommandStart(key: string): boolean {
|
|
740
|
+
return REPEATABLE_COMMAND_START_KEYS.has(key);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private isOperatorCountDigit(key: string): boolean {
|
|
744
|
+
return isDigit(key) && (key !== "0" || this.operatorCount.length > 0);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private shouldStripKeyFromCountOverride(key: string): boolean {
|
|
748
|
+
if (this.mode === "insert") return false;
|
|
749
|
+
if (this.pendingMotion || this.pendingTextObject || this.pendingReplace) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
if (!this.pendingOperator) return false;
|
|
753
|
+
return this.isOperatorCountDigit(key);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Every Enter encoding Pi submits on: legacy `\r` plus the Kitty keyboard
|
|
758
|
+
* protocol's CSI-u forms (`\x1b[13u`, numpad Enter, the modifier-0 variants).
|
|
759
|
+
* `\n` is deliberately excluded: pi-vim treats it as the shift+enter newline
|
|
760
|
+
* (see the implicit-insert newline dot-repeat test), so it stays captured as
|
|
761
|
+
* typed text rather than cancelling the recording.
|
|
762
|
+
*/
|
|
763
|
+
private isSubmitEnterInput(key: string): boolean {
|
|
764
|
+
return key !== "\n" && matchesKey(key, "enter");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private shouldCancelInsertRepeatInput(key: string): boolean {
|
|
768
|
+
// A submit must cancel the recording, not be captured into it. A bare `\r`
|
|
769
|
+
// check missed the Kitty CSI-u Enter (`\x1b[13u`), letting it be recorded
|
|
770
|
+
// and then re-submitted on replay.
|
|
771
|
+
if (this.isSubmitEnterInput(key)) return true;
|
|
772
|
+
return key === "\t" && this.isShowingAutocomplete();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Dot-repeat is recorded by watching the key stream for one normal-mode
|
|
777
|
+
* change. The raw key stream is kept for plain `.`, while a second stream
|
|
778
|
+
* strips normal-mode command counts so `{count}.` can replace the stored
|
|
779
|
+
* count like Vim instead of multiplying it by repeated replay.
|
|
780
|
+
*/
|
|
781
|
+
private prepareRepeatRecordingForInput(key: string): void {
|
|
782
|
+
if (this.replayingRepeat || this.pendingExCommand !== null) return;
|
|
783
|
+
|
|
784
|
+
// Visual-mode operators are not dot-repeatable; they clear the stored
|
|
785
|
+
// command instead of recording a normal-mode key stream that would replay
|
|
786
|
+
// as something else entirely.
|
|
787
|
+
if (isVisualMode(this.mode)) return;
|
|
788
|
+
|
|
789
|
+
if (this.mode === "insert") {
|
|
790
|
+
if (this.shouldCancelInsertRepeatInput(key)) {
|
|
791
|
+
this.clearRepeatState();
|
|
792
|
+
// A submit or a completion replaces the buffer context the armed `j`
|
|
793
|
+
// belonged to; a `j` typed after it starts a fresh sequence, if any.
|
|
794
|
+
this.pendingInsertEscape = null;
|
|
795
|
+
// A submit clears the host undo stack and an autocomplete accept injects
|
|
796
|
+
// host text; either way `floor` no longer indexes a valid snapshot, so
|
|
797
|
+
// discard the window. There is no cross-submit undo.
|
|
798
|
+
this.discardUndoWindow();
|
|
799
|
+
// Enter submits and opens a fresh prompt, so the next implicit insert
|
|
800
|
+
// is repeatable again. Tab-completion is a host edit inside the
|
|
801
|
+
// current session, so it taints the rest of that session — continued
|
|
802
|
+
// typing must not resurrect a recording.
|
|
803
|
+
this.implicitInsertSuppressed = !this.isSubmitEnterInput(key);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// Implicit insert (startup / post-submit): the editor is already in
|
|
807
|
+
// insert mode with no recording open, so a vim `i`/`a`/… entry never
|
|
808
|
+
// ran to seed one. Synthesize an `i` entry on the first real keystroke
|
|
809
|
+
// so the typed run becomes dot-repeatable, exactly as if the user had
|
|
810
|
+
// pressed `i` in normal mode. Escape finalizes it as the last change;
|
|
811
|
+
// Enter and Tab-completion stay excluded via the cancel check above, so
|
|
812
|
+
// a later replay can never submit. A session tainted by a mid-insert
|
|
813
|
+
// host edit stays suppressed until Escape leaves insert mode.
|
|
814
|
+
if (
|
|
815
|
+
!this.repeatRecording &&
|
|
816
|
+
!this.implicitInsertSuppressed &&
|
|
817
|
+
!isEscapeLikeInput(key)
|
|
818
|
+
) {
|
|
819
|
+
this.beginImplicitInsertRecording();
|
|
820
|
+
}
|
|
821
|
+
if (this.repeatRecording?.captureInsert) {
|
|
822
|
+
this.repeatRecording.keys.push(key);
|
|
823
|
+
this.repeatRecording.countOverrideKeys.push(key);
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (this.repeatRecording && !this.isRepeatRecordingInProgress()) {
|
|
829
|
+
this.cancelRepeatableCommand();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (this.repeatRecording) {
|
|
833
|
+
this.repeatRecording.keys.push(key);
|
|
834
|
+
if (!this.shouldStripKeyFromCountOverride(key)) {
|
|
835
|
+
this.repeatRecording.countOverrideKeys.push(key);
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (this.isRepeatRecordingInProgress()) return;
|
|
841
|
+
if (!this.isRepeatableCommandStart(key)) return;
|
|
842
|
+
|
|
843
|
+
this.repeatRecording = {
|
|
844
|
+
keys: [...this.prefixCount, key],
|
|
845
|
+
countOverrideKeys: [key],
|
|
846
|
+
startChangeVersion: this.bufferChangeVersion,
|
|
847
|
+
captureInsert: false,
|
|
848
|
+
forceCommit: false,
|
|
849
|
+
};
|
|
850
|
+
// A repeatable command starts a vim change; bracket it so every host push
|
|
851
|
+
// it produces (the delete of `cw`, the per-character pushes of `p`)
|
|
852
|
+
// collapses to one unit. This runs before the command body, so `floor`
|
|
853
|
+
// precedes the mutation.
|
|
854
|
+
this.openUndoWindow();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Open a recording for an implicit insert session as if `i` had been pressed.
|
|
859
|
+
* `captureInsert` starts true so the triggering keystroke and everything up
|
|
860
|
+
* to Escape is captured; replaying the stored `["i", …, Esc]` re-enters
|
|
861
|
+
* insert mode and re-types the run without ever submitting.
|
|
862
|
+
*/
|
|
863
|
+
private beginImplicitInsertRecording(): void {
|
|
864
|
+
this.repeatRecording = {
|
|
865
|
+
keys: ["i"],
|
|
866
|
+
countOverrideKeys: ["i"],
|
|
867
|
+
startChangeVersion: this.bufferChangeVersion,
|
|
868
|
+
captureInsert: true,
|
|
869
|
+
forceCommit: false,
|
|
870
|
+
};
|
|
871
|
+
this.openUndoWindow();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private clearRepeatState(): void {
|
|
875
|
+
if (this.replayingRepeat) return;
|
|
876
|
+
this.repeatRecording = null;
|
|
877
|
+
this.lastRepeatableCommand = null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private cancelRepeatableCommand(): void {
|
|
881
|
+
if (this.replayingRepeat) {
|
|
882
|
+
this.repeatReplayFailed = true;
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
this.repeatRecording = null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private forceCommitRepeatRecording(): void {
|
|
889
|
+
if (this.replayingRepeat || !this.repeatRecording) return;
|
|
890
|
+
this.repeatRecording.forceCommit = true;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Commit only after the watched command has completed and changed the buffer.
|
|
895
|
+
* This naturally ignores motions, yanks, failed edits, and aborted pending
|
|
896
|
+
* commands while keeping insert-mode changes open until Escape returns to
|
|
897
|
+
* normal mode.
|
|
898
|
+
*/
|
|
899
|
+
private finishRepeatRecordingAfterInput(): void {
|
|
900
|
+
if (this.replayingRepeat) return;
|
|
901
|
+
|
|
902
|
+
const recording = this.repeatRecording;
|
|
903
|
+
if (!recording) return;
|
|
904
|
+
|
|
905
|
+
if (this.mode === "insert") {
|
|
906
|
+
recording.captureInsert = true;
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (this.isRepeatRecordingInProgress()) return;
|
|
911
|
+
|
|
912
|
+
this.repeatRecording = null;
|
|
913
|
+
if (recording.keys.length === 0) return;
|
|
914
|
+
|
|
915
|
+
if (
|
|
916
|
+
recording.forceCommit ||
|
|
917
|
+
this.bufferChangeVersion !== recording.startChangeVersion
|
|
918
|
+
) {
|
|
919
|
+
this.lastRepeatableCommand = {
|
|
920
|
+
keys: [...recording.keys],
|
|
921
|
+
countOverrideKeys: [...recording.countOverrideKeys],
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Open a window for the starting change, unless one is already open (a
|
|
928
|
+
* multi-key change reuses its first key's window) or a replay is in flight
|
|
929
|
+
* (the outer replay window owns the span). `floor` is captured before this
|
|
930
|
+
* turn's mutation, so `undoStack[floor]` becomes the change's first (pre-
|
|
931
|
+
* change) host push. Resetting `lastAction` forces a pure insert's first
|
|
932
|
+
* character to push that anchor despite leftover fish-style coalescing.
|
|
933
|
+
*/
|
|
934
|
+
private openUndoWindow(): void {
|
|
935
|
+
if (this.replayingRepeat) return;
|
|
936
|
+
if (this.undoWindow !== null) return;
|
|
937
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
938
|
+
const stack = editor.undoStack?.stack;
|
|
939
|
+
if (!stack) return;
|
|
940
|
+
this.undoWindow = {
|
|
941
|
+
floor: stack.length,
|
|
942
|
+
openVersion: this.bufferChangeVersion,
|
|
943
|
+
};
|
|
944
|
+
editor.lastAction = null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Close the window at a change boundary. If the buffer mutated, collapse to
|
|
949
|
+
* `floor + 1` (keep only the pre-change snapshot); a no-op change trims any
|
|
950
|
+
* stray anchor back to `floor`, leaving prior history untouched.
|
|
951
|
+
*/
|
|
952
|
+
private closeUndoWindow(): void {
|
|
953
|
+
const window = this.undoWindow;
|
|
954
|
+
this.undoWindow = null;
|
|
955
|
+
if (!window) return;
|
|
956
|
+
if (this.bufferChangeVersion === window.openVersion) {
|
|
957
|
+
this.collapseUndoStackTo(window.floor);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.collapseUndoStackTo(window.floor + 1);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Drop the open window without collapsing (undo/redo/reset/dispatch/submit). */
|
|
964
|
+
private discardUndoWindow(): void {
|
|
965
|
+
this.undoWindow = null;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Trim the host undo stack to `target` entries. A direct length trim (no
|
|
970
|
+
* `setText`/`onChange`) so `centralInvalidationCheck` is not tripped.
|
|
971
|
+
*/
|
|
972
|
+
private collapseUndoStackTo(target: number): void {
|
|
973
|
+
const stack = (this as unknown as ModalEditorInternals).undoStack?.stack;
|
|
974
|
+
if (!stack) return;
|
|
975
|
+
if (stack.length > target) stack.length = target;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Settle the window in the input `finally`, mirroring the recorder: hold
|
|
980
|
+
* while in insert mode or while an operator/motion/text-object is pending;
|
|
981
|
+
* otherwise close. Suppressed during a replay (the outer window collapses).
|
|
982
|
+
*/
|
|
983
|
+
private settleUndoWindowAfterInput(): void {
|
|
984
|
+
if (this.replayingRepeat) return;
|
|
985
|
+
if (this.undoWindow === null) return;
|
|
986
|
+
if (this.mode === "insert") return;
|
|
987
|
+
if (this.isRepeatRecordingInProgress()) return;
|
|
988
|
+
this.closeUndoWindow();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private isTextInsertRepeatCommand(command: RepeatableCommand): boolean {
|
|
992
|
+
return TEXT_INSERT_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private isOpenLineRepeatCommand(command: RepeatableCommand): boolean {
|
|
996
|
+
return OPEN_LINE_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private getOpenLineRepeatReplayKeys(
|
|
1000
|
+
command: RepeatableCommand,
|
|
1001
|
+
overrideCount: number,
|
|
1002
|
+
): string[] {
|
|
1003
|
+
if (command.countOverrideKeys[0] !== "O" || overrideCount <= 1) {
|
|
1004
|
+
return Array.from(
|
|
1005
|
+
{ length: overrideCount },
|
|
1006
|
+
() => command.countOverrideKeys,
|
|
1007
|
+
).flat();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return Array.from({ length: overrideCount }, (_, index) =>
|
|
1011
|
+
index === 0
|
|
1012
|
+
? command.countOverrideKeys
|
|
1013
|
+
: ["j", ...command.countOverrideKeys],
|
|
1014
|
+
).flat();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private getRepeatReplayKeys(
|
|
1018
|
+
command: RepeatableCommand,
|
|
1019
|
+
hasOverrideCount: boolean,
|
|
1020
|
+
overrideCount: number,
|
|
1021
|
+
): string[] {
|
|
1022
|
+
if (!hasOverrideCount) return command.keys;
|
|
1023
|
+
|
|
1024
|
+
if (this.isOpenLineRepeatCommand(command)) {
|
|
1025
|
+
return this.getOpenLineRepeatReplayKeys(command, overrideCount);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (this.isTextInsertRepeatCommand(command)) {
|
|
1029
|
+
const [startKey, ...continuation] = command.countOverrideKeys;
|
|
1030
|
+
const finalKey = continuation[continuation.length - 1];
|
|
1031
|
+
const hasFinalEscape =
|
|
1032
|
+
finalKey !== undefined && isEscapeLikeInput(finalKey);
|
|
1033
|
+
const insertKeys = hasFinalEscape
|
|
1034
|
+
? continuation.slice(0, -1)
|
|
1035
|
+
: continuation;
|
|
1036
|
+
return [
|
|
1037
|
+
startKey,
|
|
1038
|
+
...Array.from({ length: overrideCount }, () => insertKeys).flat(),
|
|
1039
|
+
...(hasFinalEscape ? [finalKey] : []),
|
|
1040
|
+
];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return [...String(overrideCount), ...command.countOverrideKeys];
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Replay the last committed change. Plain `.` replays the original command,
|
|
1048
|
+
* including its count. A count before `.` replaces the original command count.
|
|
1049
|
+
* Replay runs with recording disabled so `.` itself never changes what is
|
|
1050
|
+
* stored as the last repeatable command.
|
|
1051
|
+
*/
|
|
1052
|
+
private repeatLastCommand(): void {
|
|
1053
|
+
const command = this.lastRepeatableCommand;
|
|
1054
|
+
const hasOverrideCount = this.hasPendingCount();
|
|
1055
|
+
const overrideCount = this.takeTotalCount(1);
|
|
1056
|
+
if (!command || command.keys.length === 0) return;
|
|
1057
|
+
|
|
1058
|
+
const replayKeys = this.getRepeatReplayKeys(
|
|
1059
|
+
command,
|
|
1060
|
+
hasOverrideCount,
|
|
1061
|
+
overrideCount,
|
|
1062
|
+
);
|
|
1063
|
+
const beforeReplay = this.captureSnapshot();
|
|
1064
|
+
const beforeRegister = this.unnamedRegister;
|
|
1065
|
+
const beforePreferRegisterForPut = this.preferRegisterForPut;
|
|
1066
|
+
|
|
1067
|
+
this.repeatRecording = null;
|
|
1068
|
+
this.repeatReplayFailed = false;
|
|
1069
|
+
// A `.` replay is one undo unit, separate from the change it repeats. Wrap
|
|
1070
|
+
// it in one outer window opened before `replayingRepeat` goes high;
|
|
1071
|
+
// per-keystroke windows are suppressed during replay, so only this one
|
|
1072
|
+
// collapses.
|
|
1073
|
+
this.openUndoWindow();
|
|
1074
|
+
this.replayingRepeat = true;
|
|
1075
|
+
try {
|
|
1076
|
+
for (const key of replayKeys) {
|
|
1077
|
+
this.handleInput(key);
|
|
1078
|
+
if (this.repeatReplayFailed) break;
|
|
1079
|
+
}
|
|
1080
|
+
} finally {
|
|
1081
|
+
this.replayingRepeat = false;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (this.repeatReplayFailed) {
|
|
1085
|
+
this.clearPendingState();
|
|
1086
|
+
this.withTransition("restore", () => {
|
|
1087
|
+
this.restoreSnapshot(beforeReplay);
|
|
1088
|
+
});
|
|
1089
|
+
this.unnamedRegister = beforeRegister;
|
|
1090
|
+
this.preferRegisterForPut = beforePreferRegisterForPut;
|
|
1091
|
+
this.repeatReplayFailed = false;
|
|
1092
|
+
this.discardUndoWindow();
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
this.closeUndoWindow();
|
|
1097
|
+
|
|
1098
|
+
if (hasOverrideCount) {
|
|
1099
|
+
this.lastRepeatableCommand = {
|
|
1100
|
+
keys: [...replayKeys],
|
|
1101
|
+
countOverrideKeys: [...command.countOverrideKeys],
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private applySyntheticEdit(mutation: () => void): void {
|
|
1107
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
1108
|
+
if (!editor.state || !Array.isArray(editor.state.lines)) {
|
|
1109
|
+
throw new Error("Synthetic edit prerequisite: editor state unavailable");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (typeof editor.pushUndoSnapshot !== "function") {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
"Synthetic edit prerequisite: pushUndoSnapshot unavailable",
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const textBefore = this.getText();
|
|
1119
|
+
const preCursorLine = editor.state.cursorLine;
|
|
1120
|
+
const preCursorCol = editor.state.cursorCol;
|
|
1121
|
+
|
|
1122
|
+
mutation();
|
|
1123
|
+
|
|
1124
|
+
if (this.getText() === textBefore) return;
|
|
1125
|
+
|
|
1126
|
+
const postLines = editor.state.lines.slice();
|
|
1127
|
+
const postCursorLine = editor.state.cursorLine;
|
|
1128
|
+
const postCursorCol = editor.state.cursorCol;
|
|
1129
|
+
const postPreferredCol = editor.preferredVisualCol;
|
|
1130
|
+
|
|
1131
|
+
const preLines = textBefore.split("\n");
|
|
1132
|
+
editor.state.lines = preLines.length > 0 ? preLines : [""];
|
|
1133
|
+
editor.state.cursorLine = preCursorLine;
|
|
1134
|
+
editor.state.cursorCol = preCursorCol;
|
|
1135
|
+
editor.pushUndoSnapshot();
|
|
1136
|
+
|
|
1137
|
+
editor.state.lines = postLines;
|
|
1138
|
+
editor.state.cursorLine = postCursorLine;
|
|
1139
|
+
editor.state.cursorCol = postCursorCol;
|
|
1140
|
+
editor.preferredVisualCol = postPreferredCol;
|
|
1141
|
+
|
|
1142
|
+
editor.onChange?.(this.getText());
|
|
1143
|
+
editor.tui?.requestRender?.();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private startPendingExCommand(): void {
|
|
1147
|
+
this.pendingExCommand = ":";
|
|
1148
|
+
this.endBracketedPasteInExCommand();
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private clearPendingExCommand(): void {
|
|
1152
|
+
const shouldDiscardBracketedPasteTail =
|
|
1153
|
+
this.acceptingBracketedPasteInExCommand ||
|
|
1154
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand;
|
|
1155
|
+
|
|
1156
|
+
this.pendingExCommand = null;
|
|
1157
|
+
this.endBracketedPasteInExCommand();
|
|
1158
|
+
|
|
1159
|
+
if (shouldDiscardBracketedPasteTail) {
|
|
1160
|
+
this.discardingBracketedPasteInNormalMode = true;
|
|
1161
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private clearPendingState(): void {
|
|
1166
|
+
this.pendingMotion = null;
|
|
1167
|
+
this.pendingTextObject = null;
|
|
1168
|
+
this.pendingOperator = null;
|
|
1169
|
+
this.prefixCount = "";
|
|
1170
|
+
this.operatorCount = "";
|
|
1171
|
+
this.pendingG = false;
|
|
1172
|
+
this.pendingGCount = "";
|
|
1173
|
+
this.pendingReplace = false;
|
|
1174
|
+
this.clearPendingExCommand();
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private endBracketedPasteInExCommand(): void {
|
|
1178
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
1179
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
1180
|
+
this.discardingPasteAfterNewlineInExCommand = false;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private retainDiscardedExPasteTailState(tail: string): void {
|
|
1184
|
+
const lastStart = tail.lastIndexOf(BRACKETED_PASTE_START);
|
|
1185
|
+
const lastEnd = tail.lastIndexOf(BRACKETED_PASTE_END);
|
|
1186
|
+
let endsWithStartPrefix = false;
|
|
1187
|
+
for (let length = 1; length < BRACKETED_PASTE_START.length; length++) {
|
|
1188
|
+
if (tail.endsWith(BRACKETED_PASTE_START.slice(0, length))) {
|
|
1189
|
+
endsWithStartPrefix = true;
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (lastStart <= lastEnd && !endsWithStartPrefix) return;
|
|
1194
|
+
|
|
1195
|
+
this.acceptingBracketedPasteInExCommand = true;
|
|
1196
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
1197
|
+
this.discardingPasteAfterNewlineInExCommand = true;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* First-line-wait: a pasted newline never submits. The payload up to the
|
|
1202
|
+
* first newline becomes the pending command; the rest of that paste is
|
|
1203
|
+
* dropped. Only a typed Enter reaches `submitPendingExCommand`.
|
|
1204
|
+
*/
|
|
1205
|
+
private takePastedExCommandText(payload: string): string {
|
|
1206
|
+
if (this.discardingPasteAfterNewlineInExCommand) return "";
|
|
1207
|
+
|
|
1208
|
+
const newline = payload.search(/[\r\n]/);
|
|
1209
|
+
if (newline === -1) return payload;
|
|
1210
|
+
|
|
1211
|
+
this.discardingPasteAfterNewlineInExCommand = true;
|
|
1212
|
+
return payload.slice(0, newline);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private normalizePendingExCommandInput(data: string): string | null {
|
|
1216
|
+
let chunk = data;
|
|
1217
|
+
let normalized = "";
|
|
1218
|
+
|
|
1219
|
+
while (true) {
|
|
1220
|
+
if (this.acceptingBracketedPasteInExCommand) {
|
|
1221
|
+
if (this.pendingEscWhileAcceptingBracketedPasteInExCommand) {
|
|
1222
|
+
if (chunk.startsWith(BRACKETED_PASTE_END_TAIL)) {
|
|
1223
|
+
const tail = chunk.slice(BRACKETED_PASTE_END_TAIL.length);
|
|
1224
|
+
this.endBracketedPasteInExCommand();
|
|
1225
|
+
this.retainDiscardedExPasteTailState(tail);
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
normalized += this.takePastedExCommandText("\x1b");
|
|
1230
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const end = chunk.indexOf(BRACKETED_PASTE_END);
|
|
1234
|
+
if (end !== -1) {
|
|
1235
|
+
normalized += this.takePastedExCommandText(chunk.slice(0, end));
|
|
1236
|
+
const tail = chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
1237
|
+
this.endBracketedPasteInExCommand();
|
|
1238
|
+
this.retainDiscardedExPasteTailState(tail);
|
|
1239
|
+
return normalized.length > 0 ? normalized : null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (isEscapeLikeInput(chunk)) {
|
|
1243
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = true;
|
|
1244
|
+
return normalized.length > 0 ? normalized : null;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
normalized += this.takePastedExCommandText(chunk);
|
|
1248
|
+
return normalized.length > 0 ? normalized : null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const start = chunk.indexOf(BRACKETED_PASTE_START);
|
|
1252
|
+
if (start === -1) {
|
|
1253
|
+
normalized += chunk;
|
|
1254
|
+
return normalized.length > 0 ? normalized : null;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
normalized += chunk.slice(0, start);
|
|
1258
|
+
chunk = chunk.slice(start + BRACKETED_PASTE_START.length);
|
|
1259
|
+
this.acceptingBracketedPasteInExCommand = true;
|
|
1260
|
+
if (chunk.length === 0) {
|
|
1261
|
+
return normalized.length > 0 ? normalized : null;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private stripBracketedPasteInNormalMode(data: string): {
|
|
1267
|
+
filtered: string | null;
|
|
1268
|
+
stripped: boolean;
|
|
1269
|
+
} {
|
|
1270
|
+
let chunk = data;
|
|
1271
|
+
let stripped = false;
|
|
1272
|
+
|
|
1273
|
+
while (true) {
|
|
1274
|
+
if (this.discardingBracketedPasteInNormalMode) {
|
|
1275
|
+
stripped = true;
|
|
1276
|
+
const end = chunk.indexOf(BRACKETED_PASTE_END);
|
|
1277
|
+
if (end === -1) {
|
|
1278
|
+
return { filtered: null, stripped };
|
|
1279
|
+
}
|
|
1280
|
+
this.discardingBracketedPasteInNormalMode = false;
|
|
1281
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
1282
|
+
chunk = chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
1283
|
+
if (!chunk) return { filtered: null, stripped };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const start = chunk.indexOf(BRACKETED_PASTE_START);
|
|
1287
|
+
if (start === -1) {
|
|
1288
|
+
return { filtered: chunk, stripped };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
stripped = true;
|
|
1292
|
+
const end = chunk.indexOf(
|
|
1293
|
+
BRACKETED_PASTE_END,
|
|
1294
|
+
start + BRACKETED_PASTE_START.length,
|
|
1295
|
+
);
|
|
1296
|
+
if (end === -1) {
|
|
1297
|
+
this.discardingBracketedPasteInNormalMode = true;
|
|
1298
|
+
const leading = chunk.slice(0, start);
|
|
1299
|
+
return { filtered: leading.length > 0 ? leading : null, stripped };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
chunk =
|
|
1303
|
+
chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
1304
|
+
if (!chunk) return { filtered: null, stripped };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
handleInput(data: string): void {
|
|
1309
|
+
try {
|
|
1310
|
+
this.handleInputCore(data);
|
|
1311
|
+
} finally {
|
|
1312
|
+
this.refreshPendingDispatchRestore();
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private handleInputCore(data: string): void {
|
|
1317
|
+
this.ensureOnChangeHook();
|
|
1318
|
+
|
|
1319
|
+
if (this.pendingExCommand !== null) {
|
|
1320
|
+
const normalized = this.normalizePendingExCommandInput(data);
|
|
1321
|
+
if (normalized === null) return;
|
|
1322
|
+
data = normalized;
|
|
1323
|
+
} else if (this.mode !== "insert") {
|
|
1324
|
+
if (this.discardingBracketedPasteInNormalMode) {
|
|
1325
|
+
if (isEscapeLikeInput(data)) {
|
|
1326
|
+
if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
|
|
1327
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
1328
|
+
this.discardingBracketedPasteInNormalMode = false;
|
|
1329
|
+
this.clearPendingState();
|
|
1330
|
+
this.cancelRepeatableCommand();
|
|
1331
|
+
return;
|
|
1332
|
+
} else {
|
|
1333
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = true;
|
|
1334
|
+
this.clearPendingState();
|
|
1335
|
+
this.cancelRepeatableCommand();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
} else if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
|
|
1339
|
+
if (data.startsWith(BRACKETED_PASTE_END_TAIL)) {
|
|
1340
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
1341
|
+
this.discardingBracketedPasteInNormalMode = false;
|
|
1342
|
+
data = data.slice(BRACKETED_PASTE_END_TAIL.length);
|
|
1343
|
+
if (data.length === 0) {
|
|
1344
|
+
this.clearPendingState();
|
|
1345
|
+
this.cancelRepeatableCommand();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
} else {
|
|
1349
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const { filtered, stripped } = this.stripBracketedPasteInNormalMode(data);
|
|
1355
|
+
if (stripped) {
|
|
1356
|
+
this.clearPendingState();
|
|
1357
|
+
this.cancelRepeatableCommand();
|
|
1358
|
+
}
|
|
1359
|
+
if (filtered === null) return;
|
|
1360
|
+
data = filtered;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
this.prepareRepeatRecordingForInput(data);
|
|
1364
|
+
try {
|
|
1365
|
+
if (isEscapeLikeInput(data)) {
|
|
1366
|
+
this.handleEscape();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if ("insert" === this.mode) {
|
|
1371
|
+
if (this.tryInsertEscape(data)) return;
|
|
1372
|
+
if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
|
|
1373
|
+
super.handleInput(CTRL_E);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
|
|
1377
|
+
super.handleInput(CTRL_A);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
|
|
1381
|
+
this.openLineBelow();
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
|
|
1385
|
+
this.openLineAbove();
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
super.handleInput(data);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (this.pendingReplace) {
|
|
1393
|
+
this.pendingReplace = false;
|
|
1394
|
+
if (!isPrintableInput(data)) {
|
|
1395
|
+
this.prefixCount = "";
|
|
1396
|
+
this.operatorCount = "";
|
|
1397
|
+
this.cancelRepeatableCommand();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const count = this.takeTotalCount(1);
|
|
1402
|
+
const cursor = this.getCursor();
|
|
1403
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
1404
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
|
|
1405
|
+
if (!range) {
|
|
1406
|
+
this.cancelRepeatableCommand();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const before = line.slice(0, range.start);
|
|
1411
|
+
const after = line.slice(range.end);
|
|
1412
|
+
const replacement = data.repeat(count);
|
|
1413
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1414
|
+
const text = this.getText();
|
|
1415
|
+
const newText =
|
|
1416
|
+
text.slice(0, lineStartAbs) +
|
|
1417
|
+
before +
|
|
1418
|
+
replacement +
|
|
1419
|
+
after +
|
|
1420
|
+
text.slice(lineStartAbs + line.length);
|
|
1421
|
+
const newCursorAbs =
|
|
1422
|
+
lineStartAbs + before.length + data.length * (count - 1);
|
|
1423
|
+
this.forceCommitRepeatRecording();
|
|
1424
|
+
this.replaceTextInBuffer(newText, newCursorAbs);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (this.pendingExCommand !== null) {
|
|
1429
|
+
this.handlePendingExCommand(data);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (this.pendingTextObject) {
|
|
1434
|
+
this.handlePendingTextObject(data);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (this.pendingMotion) {
|
|
1439
|
+
this.handlePendingMotion(data);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (this.pendingOperator === "d") {
|
|
1444
|
+
this.handlePendingDelete(data);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (this.pendingOperator === "c") {
|
|
1449
|
+
this.handlePendingChange(data);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (this.pendingOperator === "y") {
|
|
1454
|
+
this.handlePendingYank(data);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (isVisualMode(this.mode) && this.handleVisualMode(data)) return;
|
|
1459
|
+
|
|
1460
|
+
this.handleNormalMode(data);
|
|
1461
|
+
} finally {
|
|
1462
|
+
this.finishRepeatRecordingAfterInput();
|
|
1463
|
+
this.settleUndoWindowAfterInput();
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
private clearUnderlyingPasteStateIfActive(): void {
|
|
1468
|
+
const editor = this as unknown as {
|
|
1469
|
+
isInPaste?: boolean;
|
|
1470
|
+
pasteBuffer?: string;
|
|
1471
|
+
pasteCounter?: number;
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
if (!editor.isInPaste) return;
|
|
1475
|
+
|
|
1476
|
+
editor.isInPaste = false;
|
|
1477
|
+
if (typeof editor.pasteBuffer === "string") {
|
|
1478
|
+
editor.pasteBuffer = "";
|
|
1479
|
+
}
|
|
1480
|
+
if (typeof editor.pasteCounter === "number") {
|
|
1481
|
+
editor.pasteCounter = 0;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private isHostPasteActive(): boolean {
|
|
1486
|
+
return (this as unknown as { isInPaste?: boolean }).isInPaste === true;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private hostUndoDepth(): number {
|
|
1490
|
+
const stack = (this as unknown as ModalEditorInternals).undoStack?.stack;
|
|
1491
|
+
return stack ? stack.length : 0;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Rollback-style `jj` → Normal. The first `j` is already in the buffer
|
|
1496
|
+
* (typed with no delay); a second lone `j` within `timeoutMs` inserts too
|
|
1497
|
+
* and then both are deleted. Rollback instead of delay keeps keystrokes
|
|
1498
|
+
* latency-free, at the cost of a transient buffer state no render ever sees
|
|
1499
|
+
* (`requestRender` only schedules). A lone `jj` arrives only as two bare
|
|
1500
|
+
* single-character chunks: a whole paste lands as one longer chunk (or
|
|
1501
|
+
* while the host tracks `isInPaste`), so pasted `jj` never arms this.
|
|
1502
|
+
*
|
|
1503
|
+
* Returns true only when the escape fired and fully handled `data`.
|
|
1504
|
+
*/
|
|
1505
|
+
private tryInsertEscape(data: string): boolean {
|
|
1506
|
+
if (
|
|
1507
|
+
data !== "j" ||
|
|
1508
|
+
this.replayingRepeat ||
|
|
1509
|
+
!this.insertEscapeSettings.enabled ||
|
|
1510
|
+
this.isHostPasteActive()
|
|
1511
|
+
) {
|
|
1512
|
+
this.pendingInsertEscape = null;
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
const pending = this.pendingInsertEscape;
|
|
1516
|
+
if (
|
|
1517
|
+
pending !== null &&
|
|
1518
|
+
this.insertEscapeNow() - pending.at <= this.insertEscapeSettings.timeoutMs
|
|
1519
|
+
) {
|
|
1520
|
+
this.pendingInsertEscape = null;
|
|
1521
|
+
super.handleInput(data);
|
|
1522
|
+
this.rollbackInsertEscape(pending.undoDepth);
|
|
1523
|
+
return true;
|
|
1524
|
+
}
|
|
1525
|
+
this.pendingInsertEscape = {
|
|
1526
|
+
at: this.insertEscapeNow(),
|
|
1527
|
+
undoDepth: this.hostUndoDepth(),
|
|
1528
|
+
};
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
private rollbackInsertEscape(undoDepth: number): void {
|
|
1533
|
+
// Both `j`s sit immediately before the cursor: the armed one plus the one
|
|
1534
|
+
// just inserted above. Verify before cutting so a surprising host rewrite
|
|
1535
|
+
// (an extension shortcut swallowing `j`, say) degrades to typed text in
|
|
1536
|
+
// insert mode, never to a wrong deletion or a wrong mode change.
|
|
1537
|
+
const text = this.getText();
|
|
1538
|
+
const cursorAbs = this.getAbsoluteIndexFromCursor();
|
|
1539
|
+
if (text.slice(cursorAbs - 2, cursorAbs) !== "jj") return;
|
|
1540
|
+
// Bypass the register: this deletes escape letters, not a user edit, so
|
|
1541
|
+
// the unnamed register must keep whatever the session last yanked.
|
|
1542
|
+
this.replaceTextInBuffer(
|
|
1543
|
+
text.slice(0, cursorAbs - 2) + text.slice(cursorAbs),
|
|
1544
|
+
cursorAbs - 2,
|
|
1545
|
+
);
|
|
1546
|
+
this.rewriteInsertEscapeInRecording();
|
|
1547
|
+
// The open insert window collapses these pushes on close; a windowless
|
|
1548
|
+
// session (host-tainted, no recording) trims them back to the arm depth
|
|
1549
|
+
// instead, so `u` can never resurrect the escape letters.
|
|
1550
|
+
if (this.undoWindow === null) this.collapseUndoStackTo(undoDepth);
|
|
1551
|
+
// Same landing as `handleEscape`: normal mode, cursor back one character.
|
|
1552
|
+
this.setMode("normal");
|
|
1553
|
+
if (this.getCursor().col > 0) this.moveCursorBy(-1);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private rewriteInsertEscapeInRecording(): void {
|
|
1557
|
+
const recording = this.repeatRecording;
|
|
1558
|
+
if (!recording) return;
|
|
1559
|
+
// The recorder already captured both `j`s as typed text. Swap each
|
|
1560
|
+
// trailing `jj` for the single Escape an `Esc` exit would have recorded,
|
|
1561
|
+
// so `.` replays the insert text without the escape letters.
|
|
1562
|
+
for (const stream of [recording.keys, recording.countOverrideKeys]) {
|
|
1563
|
+
if (
|
|
1564
|
+
stream.length >= 2 &&
|
|
1565
|
+
stream[stream.length - 1] === "j" &&
|
|
1566
|
+
stream[stream.length - 2] === "j"
|
|
1567
|
+
) {
|
|
1568
|
+
stream.splice(stream.length - 2, 2, "\x1b");
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
private handleEscape(): void {
|
|
1574
|
+
this.pendingInsertEscape = null;
|
|
1575
|
+
if (this.pendingExCommand !== null) {
|
|
1576
|
+
this.clearPendingExCommand();
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (
|
|
1581
|
+
this.pendingMotion ||
|
|
1582
|
+
this.pendingTextObject ||
|
|
1583
|
+
this.pendingOperator ||
|
|
1584
|
+
this.prefixCount ||
|
|
1585
|
+
this.operatorCount ||
|
|
1586
|
+
this.pendingG ||
|
|
1587
|
+
this.pendingGCount ||
|
|
1588
|
+
this.pendingReplace
|
|
1589
|
+
) {
|
|
1590
|
+
this.clearPendingState();
|
|
1591
|
+
this.cancelRepeatableCommand();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (isVisualMode(this.mode)) {
|
|
1595
|
+
this.exitVisualMode();
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if ("insert" === this.mode) {
|
|
1599
|
+
this.clearUnderlyingPasteStateIfActive();
|
|
1600
|
+
this.setMode("normal");
|
|
1601
|
+
if (this.getCursor().col > 0) this.moveCursorBy(-1);
|
|
1602
|
+
} else {
|
|
1603
|
+
super.handleInput("\x1b"); // pass escape to abort agent
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
private deleteLastPendingExCommandGrapheme(): void {
|
|
1608
|
+
const current = this.pendingExCommand ?? "";
|
|
1609
|
+
const graphemes = getLineGraphemes(current);
|
|
1610
|
+
|
|
1611
|
+
if (graphemes.length <= 1) {
|
|
1612
|
+
this.clearPendingExCommand();
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const previousGrapheme = graphemes[graphemes.length - 2];
|
|
1617
|
+
if (!previousGrapheme) {
|
|
1618
|
+
this.clearPendingExCommand();
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
this.pendingExCommand = current.slice(0, previousGrapheme.end);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
private handlePendingExCommandControlChunk(data: string): boolean {
|
|
1626
|
+
if (
|
|
1627
|
+
!data.includes("\r") &&
|
|
1628
|
+
!data.includes("\n") &&
|
|
1629
|
+
!data.includes("\x7f") &&
|
|
1630
|
+
!data.includes("\x08")
|
|
1631
|
+
) {
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
let printable = "";
|
|
1636
|
+
const flushPrintable = () => {
|
|
1637
|
+
if (!printable) return;
|
|
1638
|
+
this.pendingExCommand += printable;
|
|
1639
|
+
printable = "";
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
for (const char of data) {
|
|
1643
|
+
if (char === "\r" || char === "\n") {
|
|
1644
|
+
flushPrintable();
|
|
1645
|
+
this.submitPendingExCommand();
|
|
1646
|
+
return true;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (char === "\x7f" || char === "\x08") {
|
|
1650
|
+
flushPrintable();
|
|
1651
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1652
|
+
if (this.pendingExCommand === null) {
|
|
1653
|
+
return true;
|
|
1654
|
+
}
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const codePoint = char.codePointAt(0);
|
|
1659
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) {
|
|
1660
|
+
this.clearPendingExCommand();
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
printable += char;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
flushPrintable();
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
private handlePendingExCommand(data: string): void {
|
|
1672
|
+
if (isEnterLikeInput(data)) {
|
|
1673
|
+
this.submitPendingExCommand();
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (isBackspaceLikeInput(data)) {
|
|
1678
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
if (this.handlePendingExCommandControlChunk(data)) {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (!isPrintableChunk(data)) {
|
|
1687
|
+
this.clearPendingExCommand();
|
|
1688
|
+
this.handleInput(data);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
this.pendingExCommand += data;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
private hasNonEmptyPrompt(): boolean {
|
|
1696
|
+
return this.getText().trim().length > 0;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
private static readonly EX_QUIT_NAMES = new Set([
|
|
1700
|
+
"q",
|
|
1701
|
+
"qa",
|
|
1702
|
+
"quit",
|
|
1703
|
+
"qall",
|
|
1704
|
+
"quitall",
|
|
1705
|
+
]);
|
|
1706
|
+
|
|
1707
|
+
// Names pi-vim claims for real vim ex semantics later. They are never
|
|
1708
|
+
// dispatched to Pi, so a `/s` or `/w` extension installed today cannot
|
|
1709
|
+
// shadow a future `:s` substitute or `:w` write.
|
|
1710
|
+
private static readonly EX_RESERVED_NAMES = new Set([
|
|
1711
|
+
"s",
|
|
1712
|
+
"g",
|
|
1713
|
+
"v",
|
|
1714
|
+
"d",
|
|
1715
|
+
"m",
|
|
1716
|
+
"t",
|
|
1717
|
+
"co",
|
|
1718
|
+
"j",
|
|
1719
|
+
"w",
|
|
1720
|
+
"r",
|
|
1721
|
+
"normal",
|
|
1722
|
+
"sort",
|
|
1723
|
+
"&",
|
|
1724
|
+
">",
|
|
1725
|
+
"<",
|
|
1726
|
+
]);
|
|
1727
|
+
|
|
1728
|
+
private submitPendingExCommand(): void {
|
|
1729
|
+
const command = this.pendingExCommand?.slice(1).trim() ?? "";
|
|
1730
|
+
this.clearPendingExCommand();
|
|
1731
|
+
if (!command) return;
|
|
1732
|
+
|
|
1733
|
+
const force = command.endsWith("!");
|
|
1734
|
+
const quitName = force ? command.slice(0, -1) : command;
|
|
1735
|
+
|
|
1736
|
+
if (ModalEditor.EX_QUIT_NAMES.has(quitName)) {
|
|
1737
|
+
if (!force && this.hasNonEmptyPrompt()) {
|
|
1738
|
+
this.notifyFn(`Prompt is not empty; use :${command}! to quit anyway`);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
this.quitFn();
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// `:!cmd` submits `!cmd` through the same seam the slash bridge uses, so it
|
|
1747
|
+
// reaches Pi's bash mode: `onSubmit` runs a leading `!` as a shell command
|
|
1748
|
+
// (`!!` excludes it from context). The quit check above already claimed the
|
|
1749
|
+
// `:q!` family, so only a bare leading `!` lands here. `:!` with no command
|
|
1750
|
+
// is unsupported, and piDispatch gates it like every other bridge dispatch.
|
|
1751
|
+
if (command.startsWith("!")) {
|
|
1752
|
+
const shellCommand = command.replace(/^!+/, "").trim();
|
|
1753
|
+
if (shellCommand && this.exCommandSettings.piDispatch) {
|
|
1754
|
+
this.dispatchThroughSubmit(command);
|
|
1755
|
+
} else {
|
|
1756
|
+
this.notifyFn(`Unsupported ex command: :${command}`);
|
|
1757
|
+
}
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const separator = command.search(/\s/);
|
|
1762
|
+
const name = separator === -1 ? command : command.slice(0, separator);
|
|
1763
|
+
const args = separator === -1 ? "" : command.slice(separator + 1).trim();
|
|
1764
|
+
const bareName = name.endsWith("!") ? name.slice(0, -1) : name;
|
|
1765
|
+
|
|
1766
|
+
if (ModalEditor.EX_RESERVED_NAMES.has(bareName)) {
|
|
1767
|
+
this.notifyFn(`Reserved ex command: :${name}`);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (this.exCommandSettings.piDispatch && this.commandNamesFn().has(name)) {
|
|
1772
|
+
this.dispatchSlashCommand(name, args);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
this.notifyFn(`Unsupported ex command: :${command}`);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
private captureDispatchState(): DispatchState {
|
|
1780
|
+
const snapshot = this.captureSnapshot();
|
|
1781
|
+
const savedRedo = [...this.redoStack];
|
|
1782
|
+
const savedRepeat = this.lastRepeatableCommand;
|
|
1783
|
+
const undoStack = (this as unknown as ModalEditorInternals).undoStack;
|
|
1784
|
+
const savedUndoDepth = undoStack?.length ?? 0;
|
|
1785
|
+
|
|
1786
|
+
return {
|
|
1787
|
+
snapshot,
|
|
1788
|
+
restore: () => {
|
|
1789
|
+
this.restoreSnapshot(snapshot);
|
|
1790
|
+
while (undoStack && undoStack.length > savedUndoDepth) undoStack.pop();
|
|
1791
|
+
this.redoStack.length = 0;
|
|
1792
|
+
this.redoStack.push(...savedRedo);
|
|
1793
|
+
this.lastRepeatableCommand = savedRepeat;
|
|
1794
|
+
},
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
private dispatchSlashCommand(name: string, args: string): void {
|
|
1799
|
+
this.dispatchThroughSubmit(args ? `/${name} ${args}` : `/${name}`);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Run a command line (`/name args` or `!cmd`) through the editor's own submit
|
|
1804
|
+
* path, then put the composed prompt back. Reapply the latest prompt state
|
|
1805
|
+
* when an asynchronous submit settles too, because some builtin routes clear
|
|
1806
|
+
* the prompt only after awaiting work. No command reads that buffer as an
|
|
1807
|
+
* argument, so restoring the composed state is always semantically correct.
|
|
1808
|
+
*
|
|
1809
|
+
* The dispatch is a side effect the user never typed, so it must leave no
|
|
1810
|
+
* trace: besides the text and cursor, the undo depth, the redo stack and the
|
|
1811
|
+
* repeatable command all go back to what they were. Without that, the
|
|
1812
|
+
* dispatch's own `setText("")` would sit on the undo stack and swallow the
|
|
1813
|
+
* next `u`.
|
|
1814
|
+
*/
|
|
1815
|
+
private dispatchThroughSubmit(commandLine: string): void {
|
|
1816
|
+
// A dispatch is not a user change: discard any window so no collapse runs
|
|
1817
|
+
// mid-dispatch and no floor is stranded. The bridge's `savedUndoDepth`
|
|
1818
|
+
// restore then erases the phantom `setText("")` checkpoint.
|
|
1819
|
+
this.discardUndoWindow();
|
|
1820
|
+
const dispatchState = this.captureDispatchState();
|
|
1821
|
+
|
|
1822
|
+
if (
|
|
1823
|
+
this.exCommandSettings.copyInputToClipboard &&
|
|
1824
|
+
dispatchState.snapshot.text
|
|
1825
|
+
) {
|
|
1826
|
+
this.clipboardMirror.mirror(dispatchState.snapshot.text);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
let result: CommandDispatchResult;
|
|
1830
|
+
try {
|
|
1831
|
+
result = this.runCommandFn(commandLine);
|
|
1832
|
+
} finally {
|
|
1833
|
+
dispatchState.restore();
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (!result || typeof result.then !== "function") return;
|
|
1837
|
+
|
|
1838
|
+
this.pendingAsyncDispatches++;
|
|
1839
|
+
this.restoreLatestDispatchStateFn = dispatchState.restore;
|
|
1840
|
+
const settleDispatch = (): void => {
|
|
1841
|
+
try {
|
|
1842
|
+
if (this.getText() === "") this.restoreLatestDispatchStateFn?.();
|
|
1843
|
+
} finally {
|
|
1844
|
+
this.pendingAsyncDispatches--;
|
|
1845
|
+
if (this.pendingAsyncDispatches === 0) {
|
|
1846
|
+
this.restoreLatestDispatchStateFn = null;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
void Promise.resolve(result).then(settleDispatch, settleDispatch);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private takeTotalCount(defaultValue: number = 1): number {
|
|
1854
|
+
const prefixRaw = this.prefixCount;
|
|
1855
|
+
const operatorRaw = this.operatorCount;
|
|
1856
|
+
this.prefixCount = "";
|
|
1857
|
+
this.operatorCount = "";
|
|
1858
|
+
|
|
1859
|
+
if (!prefixRaw && !operatorRaw) return defaultValue;
|
|
1860
|
+
|
|
1861
|
+
const parse = (raw: string): number | null => {
|
|
1862
|
+
if (!raw) return null;
|
|
1863
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1864
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
1865
|
+
return parsed;
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
const prefix = parse(prefixRaw);
|
|
1869
|
+
const operator = parse(operatorRaw);
|
|
1870
|
+
|
|
1871
|
+
if (prefix === null && operator === null) return defaultValue;
|
|
1872
|
+
|
|
1873
|
+
const total =
|
|
1874
|
+
prefix !== null && operator !== null
|
|
1875
|
+
? prefix * operator
|
|
1876
|
+
: (prefix ?? operator ?? defaultValue);
|
|
1877
|
+
|
|
1878
|
+
if (!Number.isFinite(total) || total <= 0) return defaultValue;
|
|
1879
|
+
return Math.min(MAX_COUNT, total);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
private hasPendingCount(): boolean {
|
|
1883
|
+
return this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
private opDigit(data: string): boolean {
|
|
1887
|
+
if (!this.isOperatorCountDigit(data)) return false;
|
|
1888
|
+
this.operatorCount += data;
|
|
1889
|
+
return true;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
private cancelPendingOperator(data: string): void {
|
|
1893
|
+
this.pendingOperator = null;
|
|
1894
|
+
this.prefixCount = "";
|
|
1895
|
+
this.operatorCount = "";
|
|
1896
|
+
this.cancelRepeatableCommand();
|
|
1897
|
+
if (!isPrintableChunk(data)) {
|
|
1898
|
+
super.handleInput(data);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private handlePendingMotion(data: string): void {
|
|
1903
|
+
if (!isPrintableInput(data)) {
|
|
1904
|
+
this.pendingMotion = null;
|
|
1905
|
+
this.cancelPendingOperator(data);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const pendingMotion = this.pendingMotion;
|
|
1910
|
+
if (!pendingMotion) return;
|
|
1911
|
+
|
|
1912
|
+
if (this.pendingOperator === "d") {
|
|
1913
|
+
if (!this.deleteWithCharMotion(pendingMotion, data)) {
|
|
1914
|
+
this.cancelRepeatableCommand();
|
|
1915
|
+
}
|
|
1916
|
+
this.pendingOperator = null;
|
|
1917
|
+
} else if (this.pendingOperator === "c") {
|
|
1918
|
+
const changed = this.deleteWithCharMotion(pendingMotion, data);
|
|
1919
|
+
this.pendingOperator = null;
|
|
1920
|
+
if (changed) {
|
|
1921
|
+
this.setMode();
|
|
1922
|
+
} else {
|
|
1923
|
+
this.cancelRepeatableCommand();
|
|
1924
|
+
}
|
|
1925
|
+
} else if (this.pendingOperator === "y") {
|
|
1926
|
+
this.yankWithCharMotion(pendingMotion, data);
|
|
1927
|
+
this.pendingOperator = null;
|
|
1928
|
+
} else {
|
|
1929
|
+
this.executeCharMotion(pendingMotion, data);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
this.pendingMotion = null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
private handlePendingTextObject(data: string): void {
|
|
1936
|
+
const pendingTextObject = this.pendingTextObject;
|
|
1937
|
+
this.pendingTextObject = null;
|
|
1938
|
+
if (!pendingTextObject) {
|
|
1939
|
+
this.pendingOperator = null;
|
|
1940
|
+
this.cancelRepeatableCommand();
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const hasCount = this.hasPendingCount();
|
|
1945
|
+
|
|
1946
|
+
if (this.pendingOperator === "y" && hasCount) {
|
|
1947
|
+
this.cancelPendingOperator(data);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (data === "w" || data === "W") {
|
|
1952
|
+
const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
|
|
1953
|
+
const count = this.takeTotalCount(1);
|
|
1954
|
+
const range = this.getWordObjectRange(
|
|
1955
|
+
pendingTextObject,
|
|
1956
|
+
count,
|
|
1957
|
+
semanticClass,
|
|
1958
|
+
);
|
|
1959
|
+
if (!range || !this.pendingOperator) {
|
|
1960
|
+
// A word text object that cannot be satisfied (e.g. `2diw` on a lone
|
|
1961
|
+
// word, `daw` on trailing whitespace) leaves the buffer and register
|
|
1962
|
+
// untouched in nvim, but still drops the cursor on the last char of the
|
|
1963
|
+
// line where the object motion ran out. Match that cursor move so the
|
|
1964
|
+
// no-op stays in parity.
|
|
1965
|
+
if (range === null && this.pendingOperator !== null) {
|
|
1966
|
+
this.moveCursorToLastGraphemeOnLine();
|
|
1967
|
+
}
|
|
1968
|
+
this.pendingOperator = null;
|
|
1969
|
+
this.cancelRepeatableCommand();
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
this.applyResolvedTextObjectRange(range);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (hasCount) {
|
|
1978
|
+
this.cancelPendingOperator(data);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const range = resolveDelimitedTextObjectRange(
|
|
1983
|
+
this.getText(),
|
|
1984
|
+
this.getDelimitedTextObjectCursorAbs(),
|
|
1985
|
+
pendingTextObject,
|
|
1986
|
+
data,
|
|
1987
|
+
);
|
|
1988
|
+
if (!range) {
|
|
1989
|
+
this.cancelPendingOperator(data);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
this.applyResolvedTextObjectRange(range);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
private applyResolvedTextObjectRange(range: TextObjectRange): void {
|
|
1997
|
+
const pendingOperator = this.pendingOperator;
|
|
1998
|
+
this.pendingOperator = null;
|
|
1999
|
+
|
|
2000
|
+
if (!pendingOperator || range.endAbs < range.startAbs) {
|
|
2001
|
+
this.cancelRepeatableCommand();
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
if (range.endAbs === range.startAbs) {
|
|
2006
|
+
if (pendingOperator === "c") {
|
|
2007
|
+
this.moveCursorToAbsoluteIndex(range.startAbs);
|
|
2008
|
+
this.setMode();
|
|
2009
|
+
} else {
|
|
2010
|
+
this.cancelRepeatableCommand();
|
|
2011
|
+
}
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
if (pendingOperator === "d") {
|
|
2016
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
2017
|
+
this.clampCursorAfterTextObjectDeletion();
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (pendingOperator === "c") {
|
|
2022
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
2023
|
+
this.setMode();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
if (pendingOperator === "y") {
|
|
2028
|
+
this.yankRangeByAbsolute(range.startAbs, range.endAbs);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// After a normal-mode text-object delete whose range ended at EOL, the cursor
|
|
2033
|
+
// is left at the deletion start, which now equals the line length — one past
|
|
2034
|
+
// the last grapheme. nvim clamps back onto the last grapheme; without this a
|
|
2035
|
+
// following `x` no-ops on the phantom column. Mirrors cutCharUnderCursor's
|
|
2036
|
+
// `col >= line.length` clamp, but grapheme-aware.
|
|
2037
|
+
private clampCursorAfterTextObjectDeletion(): void {
|
|
2038
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
2039
|
+
if (line.length > 0 && col >= line.length) {
|
|
2040
|
+
this.moveCursorToPreviousGraphemeStart();
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
private handlePendingDelete(data: string): void {
|
|
2045
|
+
if (this.opDigit(data)) return;
|
|
2046
|
+
|
|
2047
|
+
if (data === "%") {
|
|
2048
|
+
this.applyPercentOp();
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (data === "d") {
|
|
2053
|
+
const count = this.takeTotalCount(1);
|
|
2054
|
+
this.deleteLinewiseByDelta(count - 1);
|
|
2055
|
+
this.pendingOperator = null;
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if (data === "j" || data === "k") {
|
|
2060
|
+
const hasDualCount =
|
|
2061
|
+
this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
2062
|
+
const count = this.takeTotalCount(1);
|
|
2063
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
2064
|
+
this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
|
|
2065
|
+
this.pendingOperator = null;
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
if (data === "G") {
|
|
2070
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
2071
|
+
this.cancelPendingOperator(data);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
this.deleteToBufferEndLinewise();
|
|
2076
|
+
this.pendingOperator = null;
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (data === "_") {
|
|
2081
|
+
const count = this.takeTotalCount(1);
|
|
2082
|
+
this.deleteLinewiseByDelta(count - 1);
|
|
2083
|
+
this.pendingOperator = null;
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
2088
|
+
this.pendingMotion = data as PendingMotion;
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const hasCount = this.hasPendingCount();
|
|
2093
|
+
const supportsCountedWordMotion =
|
|
2094
|
+
data === "w" ||
|
|
2095
|
+
data === "e" ||
|
|
2096
|
+
data === "b" ||
|
|
2097
|
+
data === "W" ||
|
|
2098
|
+
data === "E" ||
|
|
2099
|
+
data === "B";
|
|
2100
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
2101
|
+
const supportsCountedLineEnd = data === "$";
|
|
2102
|
+
const supportsIgnoredCountMotion = data === "0" || data === "^";
|
|
2103
|
+
|
|
2104
|
+
if (
|
|
2105
|
+
hasCount &&
|
|
2106
|
+
!supportsCountedWordMotion &&
|
|
2107
|
+
!supportsCountedTextObject &&
|
|
2108
|
+
!supportsCountedLineEnd &&
|
|
2109
|
+
!supportsIgnoredCountMotion
|
|
2110
|
+
) {
|
|
2111
|
+
this.cancelPendingOperator(data);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (supportsCountedTextObject) {
|
|
2116
|
+
this.pendingTextObject = data;
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
if (supportsCountedLineEnd) {
|
|
2121
|
+
const count = this.takeTotalCount(1);
|
|
2122
|
+
if (this.applyLineEndOperator(count, "delete")) {
|
|
2123
|
+
this.pendingOperator = null;
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
this.cancelPendingOperator(data);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
2131
|
+
if (supportsIgnoredCountMotion) this.takeTotalCount(1);
|
|
2132
|
+
if (this.deleteWithMotion(data, motionCount)) {
|
|
2133
|
+
this.pendingOperator = null;
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
this.cancelPendingOperator(data);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
private handlePendingChange(data: string): void {
|
|
2141
|
+
if (this.opDigit(data)) return;
|
|
2142
|
+
|
|
2143
|
+
if (data === "%") {
|
|
2144
|
+
this.applyPercentOp();
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
if (data === "c") {
|
|
2149
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
2150
|
+
this.cancelPendingOperator(data);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
this.cutLine();
|
|
2155
|
+
this.pendingOperator = null;
|
|
2156
|
+
this.setMode();
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
if (data === "_") {
|
|
2161
|
+
const count = this.takeTotalCount(1);
|
|
2162
|
+
if (count <= 1) {
|
|
2163
|
+
this.cutLine();
|
|
2164
|
+
} else {
|
|
2165
|
+
const currentLine = this.getCursor().line;
|
|
2166
|
+
const lines = this.getLines();
|
|
2167
|
+
const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
|
|
2168
|
+
this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
|
|
2169
|
+
const before = lines.slice(0, currentLine);
|
|
2170
|
+
const after = lines.slice(clampedEnd + 1);
|
|
2171
|
+
const newLines = [...before, "", ...after];
|
|
2172
|
+
const newText = newLines.join("\n");
|
|
2173
|
+
const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
|
|
2174
|
+
this.replaceTextInBuffer(newText, cursorAbs);
|
|
2175
|
+
}
|
|
2176
|
+
this.pendingOperator = null;
|
|
2177
|
+
this.setMode();
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
2182
|
+
this.pendingMotion = data as PendingMotion;
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const hasCount = this.hasPendingCount();
|
|
2187
|
+
const supportsCountedWordMotion =
|
|
2188
|
+
data === "w" ||
|
|
2189
|
+
data === "e" ||
|
|
2190
|
+
data === "b" ||
|
|
2191
|
+
data === "W" ||
|
|
2192
|
+
data === "E" ||
|
|
2193
|
+
data === "B";
|
|
2194
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
2195
|
+
const supportsCountedLineEnd = data === "$";
|
|
2196
|
+
const supportsIgnoredCountMotion = data === "0" || data === "^";
|
|
2197
|
+
|
|
2198
|
+
if (
|
|
2199
|
+
hasCount &&
|
|
2200
|
+
!supportsCountedWordMotion &&
|
|
2201
|
+
!supportsCountedTextObject &&
|
|
2202
|
+
!supportsCountedLineEnd &&
|
|
2203
|
+
!supportsIgnoredCountMotion
|
|
2204
|
+
) {
|
|
2205
|
+
this.cancelPendingOperator(data);
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
if (supportsCountedTextObject) {
|
|
2210
|
+
this.pendingTextObject = data;
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
if (supportsCountedLineEnd) {
|
|
2215
|
+
const count = this.takeTotalCount(1);
|
|
2216
|
+
if (this.applyLineEndOperator(count, "change")) {
|
|
2217
|
+
this.pendingOperator = null;
|
|
2218
|
+
this.setMode();
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
this.cancelPendingOperator(data);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
2226
|
+
if (supportsIgnoredCountMotion) this.takeTotalCount(1);
|
|
2227
|
+
const effectiveMotion =
|
|
2228
|
+
data === "W" && this.isCursorOnNonWhitespace() ? "E" : data;
|
|
2229
|
+
if (this.deleteWithMotion(effectiveMotion, motionCount)) {
|
|
2230
|
+
this.pendingOperator = null;
|
|
2231
|
+
this.setMode();
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
this.cancelPendingOperator(data);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
private enterVisualMode(mode: VisualMode): void {
|
|
2239
|
+
if (!isVisualMode(this.mode)) {
|
|
2240
|
+
const cursor = this.getCursor();
|
|
2241
|
+
this.visualAnchor = { line: cursor.line, col: cursor.col };
|
|
2242
|
+
}
|
|
2243
|
+
this.takeTotalCount(1);
|
|
2244
|
+
this.setMode(mode);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
private exitVisualMode(): void {
|
|
2248
|
+
this.visualAnchor = null;
|
|
2249
|
+
this.setMode("normal");
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private getVisualAnchor(): VisualPosition {
|
|
2253
|
+
const cursor = this.getCursor();
|
|
2254
|
+
return clampVisualPosition(this.visualAnchor ?? cursor, this.getLines());
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/** Absolute `[startAbs, endAbs)` span of the inclusive char-wise selection. */
|
|
2258
|
+
private getVisualCharwiseRange(): { startAbs: number; endAbs: number } {
|
|
2259
|
+
const { start, end } = orderVisualEndpoints(
|
|
2260
|
+
this.getVisualAnchor(),
|
|
2261
|
+
this.getCursor(),
|
|
2262
|
+
);
|
|
2263
|
+
const lines = this.getLines();
|
|
2264
|
+
const endLine = lines[end.line] ?? "";
|
|
2265
|
+
const includesNewline =
|
|
2266
|
+
end.col >= endLine.length && end.line < lines.length - 1;
|
|
2267
|
+
return {
|
|
2268
|
+
startAbs: this.getAbsoluteIndex(start.line, start.col),
|
|
2269
|
+
endAbs:
|
|
2270
|
+
this.getAbsoluteIndex(end.line, 0) +
|
|
2271
|
+
getInclusiveEndColumn(endLine, end.col) +
|
|
2272
|
+
(includesNewline ? 1 : 0),
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
private clampCursorToLastGrapheme(): void {
|
|
2277
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
2278
|
+
if (col < line.length) return;
|
|
2279
|
+
const graphemes = getLineGraphemes(line);
|
|
2280
|
+
this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/** Replace the selected lines with one empty line, then open insert there. */
|
|
2284
|
+
private changeVisualLines(startLine: number, endLine: number): void {
|
|
2285
|
+
const lines = this.getLines();
|
|
2286
|
+
this.writeToRegister(this.getLinewisePayload(startLine, endLine));
|
|
2287
|
+
|
|
2288
|
+
const before = lines.slice(0, startLine);
|
|
2289
|
+
const after = lines.slice(endLine + 1);
|
|
2290
|
+
const cursorAbs = before.reduce((abs, line) => abs + line.length + 1, 0);
|
|
2291
|
+
this.replaceTextInBuffer([...before, "", ...after].join("\n"), cursorAbs);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
private applyVisualOperator(
|
|
2295
|
+
operator: "d" | "y" | "c",
|
|
2296
|
+
linewise: boolean,
|
|
2297
|
+
): void {
|
|
2298
|
+
this.takeTotalCount(1);
|
|
2299
|
+
// Mutating visual edits are excluded from dot-repeat and supersede the
|
|
2300
|
+
// older change. A visual yank is not a change, so it preserves that repeat.
|
|
2301
|
+
if (operator !== "y") {
|
|
2302
|
+
this.clearRepeatState();
|
|
2303
|
+
// Visual `c`/`d` bypass the recorder but still mutate and are single undo
|
|
2304
|
+
// units. Open the window before the delete; the input `finally` closes it
|
|
2305
|
+
// — at once for visual `d`, or on the `<Esc>` ending the visual-`c` insert.
|
|
2306
|
+
this.openUndoWindow();
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Both branches resolve the selection before dropping the anchor.
|
|
2310
|
+
const anchor = this.getVisualAnchor();
|
|
2311
|
+
const cursor = this.getCursor();
|
|
2312
|
+
|
|
2313
|
+
if (linewise) {
|
|
2314
|
+
const { startLine, endLine } = getVisualLineRange(anchor, cursor);
|
|
2315
|
+
this.visualAnchor = null;
|
|
2316
|
+
if (operator === "c") {
|
|
2317
|
+
this.setMode("insert");
|
|
2318
|
+
this.changeVisualLines(startLine, endLine);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
this.setMode("normal");
|
|
2322
|
+
if (operator === "d") {
|
|
2323
|
+
this.deleteLineRange(startLine, endLine);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
this.yankLineRange(startLine, endLine);
|
|
2327
|
+
// Vim only pulls the cursor to the start of the yanked lines when it is
|
|
2328
|
+
// not already sitting on that end of the selection.
|
|
2329
|
+
if (cursor.line >= anchor.line) this.moveCursorToLineStart(startLine);
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const { startAbs, endAbs } = this.getVisualCharwiseRange();
|
|
2334
|
+
this.visualAnchor = null;
|
|
2335
|
+
if (operator === "c") {
|
|
2336
|
+
this.setMode("insert");
|
|
2337
|
+
this.deleteRangeByAbsolute(startAbs, endAbs);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
this.setMode("normal");
|
|
2341
|
+
if (operator === "d") {
|
|
2342
|
+
this.deleteRangeByAbsolute(startAbs, endAbs);
|
|
2343
|
+
this.clampCursorToLastGrapheme();
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
this.yankRangeByAbsolute(startAbs, endAbs);
|
|
2347
|
+
this.moveCursorToAbsoluteIndex(startAbs);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** Swap the anchor and the cursor so the other end of the selection moves. */
|
|
2351
|
+
private swapVisualEnds(): void {
|
|
2352
|
+
const anchor = this.getVisualAnchor();
|
|
2353
|
+
const cursor = this.getCursor();
|
|
2354
|
+
this.visualAnchor = { line: cursor.line, col: cursor.col };
|
|
2355
|
+
this.moveCursorToAbsoluteIndex(
|
|
2356
|
+
this.getAbsoluteIndex(anchor.line, anchor.col),
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* Visual-mode keys that are not motions. Returns true when the key was
|
|
2362
|
+
* consumed; motions and counts fall through to the normal-mode dispatch,
|
|
2363
|
+
* which moves the cursor and thereby resizes the selection.
|
|
2364
|
+
*/
|
|
2365
|
+
private handleVisualMode(data: string): boolean {
|
|
2366
|
+
if (this.pendingG || this.pendingMotion) return false;
|
|
2367
|
+
|
|
2368
|
+
const linewise = this.mode === "visual-line";
|
|
2369
|
+
|
|
2370
|
+
switch (data) {
|
|
2371
|
+
case "v":
|
|
2372
|
+
if (linewise) this.setMode("visual");
|
|
2373
|
+
else this.exitVisualMode();
|
|
2374
|
+
return true;
|
|
2375
|
+
case "V":
|
|
2376
|
+
if (linewise) this.exitVisualMode();
|
|
2377
|
+
else this.setMode("visual-line");
|
|
2378
|
+
return true;
|
|
2379
|
+
case "o":
|
|
2380
|
+
case "O":
|
|
2381
|
+
this.swapVisualEnds();
|
|
2382
|
+
return true;
|
|
2383
|
+
case "d":
|
|
2384
|
+
case "x":
|
|
2385
|
+
this.applyVisualOperator("d", linewise);
|
|
2386
|
+
return true;
|
|
2387
|
+
case "y":
|
|
2388
|
+
this.applyVisualOperator("y", linewise);
|
|
2389
|
+
return true;
|
|
2390
|
+
case "c":
|
|
2391
|
+
case "s":
|
|
2392
|
+
this.applyVisualOperator("c", linewise);
|
|
2393
|
+
return true;
|
|
2394
|
+
case "D":
|
|
2395
|
+
case "X":
|
|
2396
|
+
this.applyVisualOperator("d", true);
|
|
2397
|
+
return true;
|
|
2398
|
+
case "Y":
|
|
2399
|
+
this.applyVisualOperator("y", true);
|
|
2400
|
+
return true;
|
|
2401
|
+
case "C":
|
|
2402
|
+
case "S":
|
|
2403
|
+
this.applyVisualOperator("c", true);
|
|
2404
|
+
return true;
|
|
2405
|
+
default:
|
|
2406
|
+
if (
|
|
2407
|
+
VISUAL_IGNORED_KEYS.has(data) ||
|
|
2408
|
+
matchesKey(data, "ctrl+r") ||
|
|
2409
|
+
matchesKey(data, "ctrl+_")
|
|
2410
|
+
) {
|
|
2411
|
+
// Swallowed keys are inert in visual mode. Drop any pending count so
|
|
2412
|
+
// it cannot leak into the next motion: `v2pld` must extend by one and
|
|
2413
|
+
// delete one char, not swallow `p` yet still carry the 2 into `l`.
|
|
2414
|
+
this.prefixCount = "";
|
|
2415
|
+
this.operatorCount = "";
|
|
2416
|
+
return true;
|
|
2417
|
+
}
|
|
2418
|
+
return false;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
private handleNormalMode(data: string): void {
|
|
2423
|
+
if (this.pendingG) {
|
|
2424
|
+
if (isDigit(data)) {
|
|
2425
|
+
this.pendingGCount += data;
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
this.pendingG = false;
|
|
2430
|
+
const hadGCount = this.pendingGCount.length > 0;
|
|
2431
|
+
this.pendingGCount = "";
|
|
2432
|
+
|
|
2433
|
+
if (!hadGCount) {
|
|
2434
|
+
if (data === "g") {
|
|
2435
|
+
const count = this.takeTotalCount(1);
|
|
2436
|
+
this.moveCursorToLineStart(count - 1);
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
if (data === "J" && !isVisualMode(this.mode)) {
|
|
2441
|
+
this.joinLines(false);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if (data === "M") {
|
|
2446
|
+
// :h gM — halfway the text of the line; with a count of 1..100,
|
|
2447
|
+
// to that percentage of the line's text. nvim ignores counts
|
|
2448
|
+
// above 100 (falls back to halfway) and clamps 100gM to the
|
|
2449
|
+
// last character. nvim measures screen cells (linetabsize);
|
|
2450
|
+
// pi-vim measures graphemes — an intentional divergence for
|
|
2451
|
+
// tabs and double-width characters.
|
|
2452
|
+
const count = this.takeTotalCount(0);
|
|
2453
|
+
const { line } = this.getCurrentLineAndCol();
|
|
2454
|
+
const graphemes = getLineGraphemes(line);
|
|
2455
|
+
const target =
|
|
2456
|
+
count >= 1 && count <= 100
|
|
2457
|
+
? Math.floor((graphemes.length * count) / 100)
|
|
2458
|
+
: Math.floor(graphemes.length / 2);
|
|
2459
|
+
const clamped = Math.min(target, graphemes.length - 1);
|
|
2460
|
+
this.moveCursorToCol(graphemes[clamped]?.start ?? 0);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
this.clearPendingState();
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
if (this.prefixCount.length > 0) {
|
|
2470
|
+
if (isDigit(data)) {
|
|
2471
|
+
this.prefixCount += data;
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
if (data === "%") {
|
|
2476
|
+
this.prefixCount = "";
|
|
2477
|
+
this.operatorCount = "";
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
if (data === "d" || data === "y") {
|
|
2482
|
+
this.pendingOperator = data;
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
if (data === "c") {
|
|
2487
|
+
this.pendingOperator = "c";
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (data === "g") {
|
|
2492
|
+
this.pendingGCount = "";
|
|
2493
|
+
this.pendingG = true;
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (data === "G") {
|
|
2498
|
+
const count = this.takeTotalCount(1);
|
|
2499
|
+
this.moveCursorToLineStart(count - 1);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
const supportsCountedStandaloneEdit =
|
|
2504
|
+
data === "x" ||
|
|
2505
|
+
data === "X" ||
|
|
2506
|
+
data === "r" ||
|
|
2507
|
+
data === "s" ||
|
|
2508
|
+
data === "S" ||
|
|
2509
|
+
data === "D" ||
|
|
2510
|
+
data === "C" ||
|
|
2511
|
+
data === "p" ||
|
|
2512
|
+
data === "P" ||
|
|
2513
|
+
data === "Y" ||
|
|
2514
|
+
data === "J" ||
|
|
2515
|
+
data === "." ||
|
|
2516
|
+
data === "u" ||
|
|
2517
|
+
data === CTRL_UNDERSCORE ||
|
|
2518
|
+
matchesKey(data, "ctrl+_") ||
|
|
2519
|
+
data === CTRL_R ||
|
|
2520
|
+
matchesKey(data, "ctrl+r");
|
|
2521
|
+
const supportsCountedCharMotion =
|
|
2522
|
+
CHAR_MOTION_KEYS.has(data) || data === ";" || data === ",";
|
|
2523
|
+
const supportsCountedWordMotion =
|
|
2524
|
+
data === "w" ||
|
|
2525
|
+
data === "e" ||
|
|
2526
|
+
data === "b" ||
|
|
2527
|
+
data === "W" ||
|
|
2528
|
+
data === "E" ||
|
|
2529
|
+
data === "B";
|
|
2530
|
+
const supportsCountedParagraphMotion = data === "{" || data === "}";
|
|
2531
|
+
const supportsCountedNav =
|
|
2532
|
+
data === "h" || data === "j" || data === "k" || data === "l";
|
|
2533
|
+
const supportsCountedUnderscore = data === "_";
|
|
2534
|
+
|
|
2535
|
+
if (supportsCountedNav) {
|
|
2536
|
+
const count = this.takeTotalCount(1);
|
|
2537
|
+
const clamped = Math.min(count, MAX_COUNT);
|
|
2538
|
+
if ((data === "h" || data === "l") && isVisualMode(this.mode)) {
|
|
2539
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
2540
|
+
const graphemes = getLineGraphemes(line);
|
|
2541
|
+
const currentIndex = graphemes.findIndex(({ end }) => col < end);
|
|
2542
|
+
const graphemeIndex =
|
|
2543
|
+
currentIndex === -1 ? graphemes.length : currentIndex;
|
|
2544
|
+
const availableSteps =
|
|
2545
|
+
data === "h" ? graphemeIndex : graphemes.length - graphemeIndex;
|
|
2546
|
+
const steps = Math.min(clamped, availableSteps);
|
|
2547
|
+
this.moveCursorBy(data === "h" ? -steps : steps);
|
|
2548
|
+
} else if (data === "h") {
|
|
2549
|
+
this.moveCursorBy(-clamped);
|
|
2550
|
+
} else if (data === "l") {
|
|
2551
|
+
this.moveCursorBy(clamped);
|
|
2552
|
+
} else {
|
|
2553
|
+
const delta = data === "j" ? clamped : -clamped;
|
|
2554
|
+
this.moveCursorVertically(delta);
|
|
2555
|
+
}
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
if (supportsCountedParagraphMotion) {
|
|
2560
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
if (
|
|
2565
|
+
!supportsCountedStandaloneEdit &&
|
|
2566
|
+
!supportsCountedCharMotion &&
|
|
2567
|
+
!supportsCountedWordMotion &&
|
|
2568
|
+
!supportsCountedParagraphMotion &&
|
|
2569
|
+
!supportsCountedUnderscore
|
|
2570
|
+
) {
|
|
2571
|
+
this.prefixCount = "";
|
|
2572
|
+
this.operatorCount = "";
|
|
2573
|
+
}
|
|
2574
|
+
} else if (isCountStarter(data)) {
|
|
2575
|
+
this.prefixCount = data;
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
if (data === "J") {
|
|
2580
|
+
this.joinLines(true);
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
if (data === "g") {
|
|
2585
|
+
this.pendingGCount = "";
|
|
2586
|
+
this.pendingG = true;
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
if (data === ":") {
|
|
2591
|
+
this.startPendingExCommand();
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
if (data === "G") {
|
|
2596
|
+
this.moveCursorToBufferEnd();
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
if (data === "r") {
|
|
2601
|
+
this.pendingReplace = true;
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
if (data === "v" || data === "V") {
|
|
2606
|
+
this.enterVisualMode(data === "v" ? "visual" : "visual-line");
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
if (data === "d") {
|
|
2611
|
+
this.pendingOperator = "d";
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
if (data === "c") {
|
|
2616
|
+
this.pendingOperator = "c";
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (data === "y") {
|
|
2621
|
+
this.pendingOperator = "y";
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
if (data === "p") {
|
|
2626
|
+
this.putAfter();
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
if (data === "P") {
|
|
2631
|
+
this.putBefore();
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
if (data === "Y") {
|
|
2636
|
+
const count = this.takeTotalCount(1);
|
|
2637
|
+
this.yankLinewiseByDelta(count - 1);
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
2642
|
+
this.pendingMotion = data as PendingMotion;
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
if (data === ";" && this.lastCharMotion) {
|
|
2647
|
+
this.executeCharMotion(
|
|
2648
|
+
this.lastCharMotion.motion,
|
|
2649
|
+
this.lastCharMotion.char,
|
|
2650
|
+
false,
|
|
2651
|
+
);
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
if (data === "," && this.lastCharMotion) {
|
|
2655
|
+
this.executeCharMotion(
|
|
2656
|
+
reverseCharMotion(this.lastCharMotion.motion),
|
|
2657
|
+
this.lastCharMotion.char,
|
|
2658
|
+
false,
|
|
2659
|
+
);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
if (
|
|
2664
|
+
data === "u" ||
|
|
2665
|
+
data === CTRL_UNDERSCORE ||
|
|
2666
|
+
matchesKey(data, "ctrl+_")
|
|
2667
|
+
) {
|
|
2668
|
+
this.performUndo();
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (data === CTRL_R || matchesKey(data, "ctrl+r")) {
|
|
2673
|
+
this.performRedo();
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
if (data === ".") {
|
|
2678
|
+
this.repeatLastCommand();
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
if (data === "}" || data === "{") {
|
|
2683
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
if (data === "^") {
|
|
2688
|
+
this.moveCursorToFirstNonWhitespace();
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
if (data === "_") {
|
|
2693
|
+
const count = this.takeTotalCount(1);
|
|
2694
|
+
if (count > 1) {
|
|
2695
|
+
this.moveCursorVertically(count - 1);
|
|
2696
|
+
}
|
|
2697
|
+
this.moveCursorToFirstNonWhitespace();
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
if (data === "w") {
|
|
2702
|
+
const count = this.takeTotalCount(1);
|
|
2703
|
+
this.moveWord("forward", "start", count, "word");
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if (data === "b") {
|
|
2707
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
if (data === "e") {
|
|
2711
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
if (data === "W") {
|
|
2715
|
+
this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
if (data === "%") {
|
|
2719
|
+
this.moveToMatchingPairTarget();
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (data === "B") {
|
|
2723
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (data === "E") {
|
|
2727
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
2732
|
+
this.handleMappedKey(data);
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (isPrintableChunk(data)) return;
|
|
2737
|
+
super.handleInput(data);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
private openLineBelow(): void {
|
|
2741
|
+
super.handleInput(CTRL_E);
|
|
2742
|
+
super.handleInput(NEWLINE);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
private openLineAbove(): void {
|
|
2746
|
+
super.handleInput(CTRL_A);
|
|
2747
|
+
super.handleInput(NEWLINE);
|
|
2748
|
+
super.handleInput(ESC_UP);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
private handleMappedKey(key: string): void {
|
|
2752
|
+
const seq = NORMAL_KEYS[key];
|
|
2753
|
+
switch (key) {
|
|
2754
|
+
case "i":
|
|
2755
|
+
this.setMode();
|
|
2756
|
+
break;
|
|
2757
|
+
case "a":
|
|
2758
|
+
this.setMode();
|
|
2759
|
+
if (!this.isCursorAtOrPastEol()) {
|
|
2760
|
+
super.handleInput(ESC_RIGHT);
|
|
2761
|
+
}
|
|
2762
|
+
break;
|
|
2763
|
+
case "A":
|
|
2764
|
+
this.setMode();
|
|
2765
|
+
super.handleInput(CTRL_E);
|
|
2766
|
+
break;
|
|
2767
|
+
case "I":
|
|
2768
|
+
this.setMode();
|
|
2769
|
+
this.moveCursorToFirstNonWhitespace();
|
|
2770
|
+
break;
|
|
2771
|
+
case "$": {
|
|
2772
|
+
const { line } = this.getCurrentLineAndCol();
|
|
2773
|
+
if (isVisualMode(this.mode)) {
|
|
2774
|
+
this.moveCursorToCol(line.length);
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
const graphemes = getLineGraphemes(line);
|
|
2778
|
+
this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
|
|
2779
|
+
break;
|
|
2780
|
+
}
|
|
2781
|
+
case "o":
|
|
2782
|
+
this.openLineBelow();
|
|
2783
|
+
this.setMode();
|
|
2784
|
+
break;
|
|
2785
|
+
case "O":
|
|
2786
|
+
this.openLineAbove();
|
|
2787
|
+
this.setMode();
|
|
2788
|
+
break;
|
|
2789
|
+
case "D":
|
|
2790
|
+
this.takeTotalCount(1);
|
|
2791
|
+
this.cutToEndOfLine();
|
|
2792
|
+
break;
|
|
2793
|
+
case "C":
|
|
2794
|
+
this.takeTotalCount(1);
|
|
2795
|
+
this.cutToEndOfLine();
|
|
2796
|
+
this.setMode();
|
|
2797
|
+
break;
|
|
2798
|
+
case "S":
|
|
2799
|
+
this.takeTotalCount(1);
|
|
2800
|
+
this.cutCurrentLineContent();
|
|
2801
|
+
this.setMode();
|
|
2802
|
+
break;
|
|
2803
|
+
case "s":
|
|
2804
|
+
this.cutCharUnderCursor();
|
|
2805
|
+
this.setMode();
|
|
2806
|
+
break;
|
|
2807
|
+
case "x":
|
|
2808
|
+
this.cutCharUnderCursor(true);
|
|
2809
|
+
break;
|
|
2810
|
+
case "X":
|
|
2811
|
+
this.cutCharsBeforeCursor();
|
|
2812
|
+
break;
|
|
2813
|
+
case "j":
|
|
2814
|
+
this.moveCursorVertically(1);
|
|
2815
|
+
break;
|
|
2816
|
+
case "k":
|
|
2817
|
+
this.moveCursorVertically(-1);
|
|
2818
|
+
break;
|
|
2819
|
+
default:
|
|
2820
|
+
if (seq) super.handleInput(seq);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
private executeCharMotion(
|
|
2825
|
+
motion: CharMotion,
|
|
2826
|
+
targetChar: string,
|
|
2827
|
+
saveMotion: boolean = true,
|
|
2828
|
+
): void {
|
|
2829
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
2830
|
+
const col = this.getCursor().col;
|
|
2831
|
+
const count = this.takeTotalCount(1);
|
|
2832
|
+
const targetCol = findCharMotionTarget(
|
|
2833
|
+
line,
|
|
2834
|
+
col,
|
|
2835
|
+
motion,
|
|
2836
|
+
targetChar,
|
|
2837
|
+
!saveMotion,
|
|
2838
|
+
count,
|
|
2839
|
+
);
|
|
2840
|
+
|
|
2841
|
+
if (targetCol !== null && saveMotion) {
|
|
2842
|
+
this.lastCharMotion = { motion, char: targetChar };
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
if (targetCol !== null && targetCol !== col) {
|
|
2846
|
+
this.moveCursorToCol(targetCol);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
private executeParagraphMotion(direction: "forward" | "backward"): void {
|
|
2851
|
+
const lines = this.getLines();
|
|
2852
|
+
const fromLine = this.getCursor().line;
|
|
2853
|
+
const count = this.takeTotalCount(1);
|
|
2854
|
+
const targetLine = findParagraphMotionTarget(
|
|
2855
|
+
lines,
|
|
2856
|
+
fromLine,
|
|
2857
|
+
direction,
|
|
2858
|
+
count,
|
|
2859
|
+
);
|
|
2860
|
+
this.moveCursorToLineStart(targetLine);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
private tryMoveCursorByState(delta: number): boolean {
|
|
2864
|
+
if (delta === 0) return true;
|
|
2865
|
+
|
|
2866
|
+
const editor = this as unknown as {
|
|
2867
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2868
|
+
preferredVisualCol?: number;
|
|
2869
|
+
tui?: { requestRender?: () => void };
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
const state = editor.state;
|
|
2873
|
+
if (!state || !Array.isArray(state.lines)) return false;
|
|
2874
|
+
if (
|
|
2875
|
+
!Number.isInteger(state.cursorLine) ||
|
|
2876
|
+
!Number.isInteger(state.cursorCol)
|
|
2877
|
+
)
|
|
2878
|
+
return false;
|
|
2879
|
+
|
|
2880
|
+
const cursorLine = state.cursorLine as number;
|
|
2881
|
+
const cursorCol = state.cursorCol as number;
|
|
2882
|
+
const line = state.lines[cursorLine] ?? "";
|
|
2883
|
+
if (this.hasMultiCodeUnitGraphemes(line)) return false;
|
|
2884
|
+
|
|
2885
|
+
const target = cursorCol + delta;
|
|
2886
|
+
|
|
2887
|
+
if (target < 0 || target > line.length) return false;
|
|
2888
|
+
|
|
2889
|
+
state.cursorCol = target;
|
|
2890
|
+
editor.preferredVisualCol = target;
|
|
2891
|
+
editor.tui?.requestRender?.();
|
|
2892
|
+
return true;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
private moveCursorBy(delta: number): void {
|
|
2896
|
+
if (delta === 0) return;
|
|
2897
|
+
|
|
2898
|
+
if (this.tryMoveCursorByState(delta)) return;
|
|
2899
|
+
|
|
2900
|
+
const seq = delta > 0 ? ESC_RIGHT : ESC_LEFT;
|
|
2901
|
+
for (let i = 0; i < Math.abs(delta); i++) {
|
|
2902
|
+
super.handleInput(seq);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
private moveCursorVertically(delta: number): void {
|
|
2907
|
+
if (delta === 0) return;
|
|
2908
|
+
|
|
2909
|
+
const editor = this as unknown as {
|
|
2910
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2911
|
+
preferredVisualCol?: number | null;
|
|
2912
|
+
lastAction?: string | null;
|
|
2913
|
+
tui?: { requestRender?: () => void };
|
|
2914
|
+
};
|
|
2915
|
+
|
|
2916
|
+
const state = editor.state;
|
|
2917
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
2918
|
+
const seq = delta > 0 ? ESC_DOWN : ESC_UP;
|
|
2919
|
+
for (let i = 0; i < Math.abs(delta); i++) {
|
|
2920
|
+
super.handleInput(seq);
|
|
2921
|
+
}
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const currentLine = state.cursorLine ?? 0;
|
|
2926
|
+
const targetLine = Math.max(
|
|
2927
|
+
0,
|
|
2928
|
+
Math.min(currentLine + delta, state.lines.length - 1),
|
|
2929
|
+
);
|
|
2930
|
+
if (targetLine === currentLine) return;
|
|
2931
|
+
|
|
2932
|
+
const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
|
|
2933
|
+
const targetLineText = state.lines[targetLine] ?? "";
|
|
2934
|
+
editor.lastAction = null;
|
|
2935
|
+
state.cursorLine = targetLine;
|
|
2936
|
+
state.cursorCol = Math.min(preferredCol, targetLineText.length);
|
|
2937
|
+
editor.preferredVisualCol = preferredCol;
|
|
2938
|
+
editor.tui?.requestRender?.();
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
private moveCursorToCol(col: number): void {
|
|
2942
|
+
const editor = this as unknown as {
|
|
2943
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2944
|
+
preferredVisualCol?: number | null;
|
|
2945
|
+
lastAction?: string | null;
|
|
2946
|
+
tui?: { requestRender?: () => void };
|
|
2947
|
+
};
|
|
2948
|
+
|
|
2949
|
+
const state = editor.state;
|
|
2950
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
2951
|
+
|
|
2952
|
+
editor.lastAction = null;
|
|
2953
|
+
state.cursorCol = col;
|
|
2954
|
+
editor.preferredVisualCol = col;
|
|
2955
|
+
editor.tui?.requestRender?.();
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
private moveCursorToAbsoluteIndex(abs: number): void {
|
|
2959
|
+
const editor = this as unknown as {
|
|
2960
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2961
|
+
preferredVisualCol?: number | null;
|
|
2962
|
+
lastAction?: string | null;
|
|
2963
|
+
tui?: { requestRender?: () => void };
|
|
2964
|
+
};
|
|
2965
|
+
|
|
2966
|
+
const state = editor.state;
|
|
2967
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
2968
|
+
|
|
2969
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
|
|
2970
|
+
editor.lastAction = null;
|
|
2971
|
+
state.cursorLine = line;
|
|
2972
|
+
state.cursorCol = col;
|
|
2973
|
+
editor.preferredVisualCol = col;
|
|
2974
|
+
editor.tui?.requestRender?.();
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
private moveCursorToLineStart(lineIndex: number): void {
|
|
2978
|
+
const editor = this as unknown as {
|
|
2979
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2980
|
+
preferredVisualCol?: number | null;
|
|
2981
|
+
lastAction?: string | null;
|
|
2982
|
+
tui?: { requestRender?: () => void };
|
|
2983
|
+
};
|
|
2984
|
+
|
|
2985
|
+
const state = editor.state;
|
|
2986
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
2987
|
+
super.handleInput(CTRL_A);
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
|
|
2992
|
+
editor.lastAction = null;
|
|
2993
|
+
state.cursorLine = targetLine;
|
|
2994
|
+
state.cursorCol = 0;
|
|
2995
|
+
editor.preferredVisualCol = null;
|
|
2996
|
+
editor.tui?.requestRender?.();
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
private moveCursorToFirstNonWhitespace(): void {
|
|
3000
|
+
const { line } = this.getCurrentLineAndCol();
|
|
3001
|
+
const targetCol = findFirstNonWhitespaceColumn(line);
|
|
3002
|
+
this.moveCursorToCol(targetCol);
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
private moveCursorToBufferEnd(): void {
|
|
3006
|
+
const lines = this.getLines();
|
|
3007
|
+
this.moveCursorToLineStart(Math.max(0, lines.length - 1));
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
private joinLines(normalize: boolean): void {
|
|
3011
|
+
const count = this.takeTotalCount(2);
|
|
3012
|
+
const steps = Math.max(0, count - 1);
|
|
3013
|
+
if (steps === 0) return;
|
|
3014
|
+
|
|
3015
|
+
this.applySyntheticEdit(() => {
|
|
3016
|
+
const editor = this as unknown as ModalEditorInternals;
|
|
3017
|
+
const state = editor.state;
|
|
3018
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
3019
|
+
|
|
3020
|
+
const currentLine = state.cursorLine ?? 0;
|
|
3021
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
3022
|
+
|
|
3023
|
+
if (this.replayingRepeat && currentLine >= state.lines.length - 1) {
|
|
3024
|
+
state.cursorCol = 0;
|
|
3025
|
+
editor.preferredVisualCol = 0;
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
for (let i = 0; i < steps; i++) {
|
|
3030
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
3031
|
+
|
|
3032
|
+
const left = state.lines[currentLine] ?? "";
|
|
3033
|
+
const right = state.lines[currentLine + 1] ?? "";
|
|
3034
|
+
let joined: string;
|
|
3035
|
+
|
|
3036
|
+
if (normalize) {
|
|
3037
|
+
const trimmedRight = right.trimStart();
|
|
3038
|
+
const leftLastChar = left[left.length - 1];
|
|
3039
|
+
const leftEndsWithSpace =
|
|
3040
|
+
leftLastChar !== undefined && /\s/.test(leftLastChar);
|
|
3041
|
+
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
3042
|
+
joined = needsSeparator
|
|
3043
|
+
? `${left} ${trimmedRight}`
|
|
3044
|
+
: left + trimmedRight;
|
|
3045
|
+
joinPoint = left.length;
|
|
3046
|
+
} else {
|
|
3047
|
+
joined = left + right;
|
|
3048
|
+
joinPoint = left.length;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
state.lines.splice(currentLine, 2, joined);
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
state.cursorLine = currentLine;
|
|
3055
|
+
state.cursorCol = joinPoint;
|
|
3056
|
+
editor.preferredVisualCol = joinPoint;
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
private isWordChar(ch: string): boolean {
|
|
3061
|
+
return /\w/.test(ch);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
private charType(
|
|
3065
|
+
ch: string | undefined,
|
|
3066
|
+
semanticClass: WordMotionClass = "word",
|
|
3067
|
+
): "space" | "word" | "other" {
|
|
3068
|
+
if (!ch || /\s/.test(ch)) return "space";
|
|
3069
|
+
if (semanticClass === "WORD") return "word";
|
|
3070
|
+
if (this.isWordChar(ch)) return "word";
|
|
3071
|
+
return "other";
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
private resolveWordMotion(
|
|
3075
|
+
motion: string,
|
|
3076
|
+
): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
|
|
3077
|
+
if (motion === "w" || motion === "e" || motion === "b") {
|
|
3078
|
+
return { motion, semanticClass: "word" };
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
if (motion === "W" || motion === "E" || motion === "B") {
|
|
3082
|
+
const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
|
|
3083
|
+
return { motion: normalizedMotion, semanticClass: "WORD" };
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
return null;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
private getAbsoluteIndex(line: number, col: number): number {
|
|
3090
|
+
const lines = this.getLines();
|
|
3091
|
+
let idx = 0;
|
|
3092
|
+
for (let i = 0; i < line; i++) {
|
|
3093
|
+
idx += (lines[i] ?? "").length + 1;
|
|
3094
|
+
}
|
|
3095
|
+
return idx + col;
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
private getAbsoluteIndexFromCursor(): number {
|
|
3099
|
+
const cursor = this.getCursor();
|
|
3100
|
+
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
private getMatchingPairMotionTarget() {
|
|
3104
|
+
const cursor = this.getCursor();
|
|
3105
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
3106
|
+
return resolveMatchingPairMotionTarget(
|
|
3107
|
+
this.getText(),
|
|
3108
|
+
this.getAbsoluteIndexFromCursor(),
|
|
3109
|
+
lineStartAbs,
|
|
3110
|
+
lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
|
|
3111
|
+
);
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
private moveToMatchingPairTarget(): void {
|
|
3115
|
+
const target = this.getMatchingPairMotionTarget();
|
|
3116
|
+
if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
private applyPercentOp(): void {
|
|
3120
|
+
const op = this.pendingOperator;
|
|
3121
|
+
const counted = this.hasPendingCount();
|
|
3122
|
+
this.clearPendingState();
|
|
3123
|
+
if (!op || counted) {
|
|
3124
|
+
this.cancelRepeatableCommand();
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
const t = this.getMatchingPairMotionTarget();
|
|
3129
|
+
if (!t) {
|
|
3130
|
+
this.cancelRepeatableCommand();
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
if (op === "y") {
|
|
3135
|
+
this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
|
|
3140
|
+
if (op === "c") {
|
|
3141
|
+
this.setMode("insert");
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
private getDelimitedTextObjectCursorAbs(): number {
|
|
3147
|
+
const lines = this.getLines();
|
|
3148
|
+
const cursor = this.getCursor();
|
|
3149
|
+
const line = lines[cursor.line] ?? "";
|
|
3150
|
+
|
|
3151
|
+
if (line.length > 0 && cursor.col >= line.length) {
|
|
3152
|
+
return this.getAbsoluteIndex(cursor.line, line.length - 1);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
private findWordTargetInText(
|
|
3159
|
+
text: string,
|
|
3160
|
+
abs: number,
|
|
3161
|
+
direction: "forward" | "backward",
|
|
3162
|
+
target: "start" | "end",
|
|
3163
|
+
count: number = 1,
|
|
3164
|
+
semanticClass: WordMotionClass = "word",
|
|
3165
|
+
): number {
|
|
3166
|
+
const len = text.length;
|
|
3167
|
+
if (len === 0) return 0;
|
|
3168
|
+
|
|
3169
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
3170
|
+
let i = Math.max(0, Math.min(abs, len));
|
|
3171
|
+
|
|
3172
|
+
for (let step = 0; step < steps; step++) {
|
|
3173
|
+
let next = i;
|
|
3174
|
+
|
|
3175
|
+
if (direction === "forward") {
|
|
3176
|
+
if (next >= len) {
|
|
3177
|
+
next = len;
|
|
3178
|
+
} else if (target === "start") {
|
|
3179
|
+
const startType = this.charType(text[next], semanticClass);
|
|
3180
|
+
if (startType !== "space") {
|
|
3181
|
+
while (
|
|
3182
|
+
next < len &&
|
|
3183
|
+
this.charType(text[next], semanticClass) === startType
|
|
3184
|
+
)
|
|
3185
|
+
next++;
|
|
3186
|
+
}
|
|
3187
|
+
while (
|
|
3188
|
+
next < len &&
|
|
3189
|
+
this.charType(text[next], semanticClass) === "space"
|
|
3190
|
+
)
|
|
3191
|
+
next++;
|
|
3192
|
+
} else {
|
|
3193
|
+
if (next < len - 1) next++;
|
|
3194
|
+
while (
|
|
3195
|
+
next < len &&
|
|
3196
|
+
this.charType(text[next], semanticClass) === "space"
|
|
3197
|
+
)
|
|
3198
|
+
next++;
|
|
3199
|
+
if (next >= len) {
|
|
3200
|
+
next = len;
|
|
3201
|
+
} else {
|
|
3202
|
+
const t = this.charType(text[next], semanticClass);
|
|
3203
|
+
while (
|
|
3204
|
+
next < len - 1 &&
|
|
3205
|
+
this.charType(text[next + 1], semanticClass) === t
|
|
3206
|
+
)
|
|
3207
|
+
next++;
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
} else {
|
|
3211
|
+
if (next >= len) next = len - 1;
|
|
3212
|
+
if (next > 0) next--;
|
|
3213
|
+
while (next > 0 && this.charType(text[next], semanticClass) === "space")
|
|
3214
|
+
next--;
|
|
3215
|
+
const t = this.charType(text[next], semanticClass);
|
|
3216
|
+
while (next > 0 && this.charType(text[next - 1], semanticClass) === t)
|
|
3217
|
+
next--;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
if (next === i) break;
|
|
3221
|
+
i = next;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
return i;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
private tryFindWordTargetInLine(
|
|
3228
|
+
line: string,
|
|
3229
|
+
col: number,
|
|
3230
|
+
direction: WordMotionDirection,
|
|
3231
|
+
target: WordMotionTarget,
|
|
3232
|
+
allowSameColumn: boolean = false,
|
|
3233
|
+
semanticClass: WordMotionClass = "word",
|
|
3234
|
+
): number | null {
|
|
3235
|
+
if (line.length === 0) return null;
|
|
3236
|
+
if (col < 0 || col > line.length) return null;
|
|
3237
|
+
|
|
3238
|
+
if (direction === "forward") {
|
|
3239
|
+
if (col >= line.length) return null;
|
|
3240
|
+
} else {
|
|
3241
|
+
if (col <= 0) return null;
|
|
3242
|
+
if (!/\S/.test(line.slice(0, col))) return null;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
3246
|
+
line,
|
|
3247
|
+
col,
|
|
3248
|
+
direction,
|
|
3249
|
+
target,
|
|
3250
|
+
semanticClass,
|
|
3251
|
+
);
|
|
3252
|
+
if (targetCol === null) return null;
|
|
3253
|
+
|
|
3254
|
+
if (direction === "forward") {
|
|
3255
|
+
if (targetCol >= line.length) return null;
|
|
3256
|
+
if (allowSameColumn) {
|
|
3257
|
+
if (targetCol < col) return null;
|
|
3258
|
+
} else if (targetCol <= col) {
|
|
3259
|
+
return null;
|
|
3260
|
+
}
|
|
3261
|
+
return targetCol;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
if (allowSameColumn) {
|
|
3265
|
+
if (targetCol > col) return null;
|
|
3266
|
+
} else if (targetCol >= col) {
|
|
3267
|
+
return null;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
return targetCol;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
private tryFindWordTargetLineLocal(
|
|
3274
|
+
direction: WordMotionDirection,
|
|
3275
|
+
target: WordMotionTarget,
|
|
3276
|
+
semanticClass: WordMotionClass = "word",
|
|
3277
|
+
): number | null {
|
|
3278
|
+
const cursor = this.getCursor();
|
|
3279
|
+
const lineIndex = cursor.line;
|
|
3280
|
+
const col = cursor.col;
|
|
3281
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
3282
|
+
|
|
3283
|
+
const targetCol = this.tryFindWordTargetInLine(
|
|
3284
|
+
lineSnapshot,
|
|
3285
|
+
col,
|
|
3286
|
+
direction,
|
|
3287
|
+
target,
|
|
3288
|
+
false,
|
|
3289
|
+
semanticClass,
|
|
3290
|
+
);
|
|
3291
|
+
if (targetCol === null) return null;
|
|
3292
|
+
|
|
3293
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
3294
|
+
const liveCol = this.getCursor().col;
|
|
3295
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
3296
|
+
|
|
3297
|
+
return targetCol;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
private tryMoveWordLineLocal(
|
|
3301
|
+
direction: "forward" | "backward",
|
|
3302
|
+
target: "start" | "end",
|
|
3303
|
+
semanticClass: WordMotionClass = "word",
|
|
3304
|
+
): boolean {
|
|
3305
|
+
const col = this.getCursor().col;
|
|
3306
|
+
const targetCol = this.tryFindWordTargetLineLocal(
|
|
3307
|
+
direction,
|
|
3308
|
+
target,
|
|
3309
|
+
semanticClass,
|
|
3310
|
+
);
|
|
3311
|
+
if (targetCol === null || targetCol === col) return false;
|
|
3312
|
+
|
|
3313
|
+
this.moveCursorToCol(targetCol);
|
|
3314
|
+
return true;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
private tryWordMotionLineLocalRange(
|
|
3318
|
+
motion: "w" | "e" | "b",
|
|
3319
|
+
count: number = 1,
|
|
3320
|
+
semanticClass: WordMotionClass = "word",
|
|
3321
|
+
): { col: number; targetCol: number; inclusive: boolean } | null {
|
|
3322
|
+
const cursor = this.getCursor();
|
|
3323
|
+
const lineIndex = cursor.line;
|
|
3324
|
+
const col = cursor.col;
|
|
3325
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
3326
|
+
const direction: WordMotionDirection =
|
|
3327
|
+
motion === "b" ? "backward" : "forward";
|
|
3328
|
+
const target: WordMotionTarget = motion === "e" ? "end" : "start";
|
|
3329
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
3330
|
+
|
|
3331
|
+
let currentCol = col;
|
|
3332
|
+
for (let step = 0; step < steps; step++) {
|
|
3333
|
+
const nextCol = this.tryFindWordTargetInLine(
|
|
3334
|
+
lineSnapshot,
|
|
3335
|
+
currentCol,
|
|
3336
|
+
direction,
|
|
3337
|
+
target,
|
|
3338
|
+
motion === "e",
|
|
3339
|
+
semanticClass,
|
|
3340
|
+
);
|
|
3341
|
+
if (nextCol === null) return null;
|
|
3342
|
+
if (nextCol === currentCol && step < steps - 1) return null;
|
|
3343
|
+
currentCol = nextCol;
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
3347
|
+
const liveCol = this.getCursor().col;
|
|
3348
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
3349
|
+
|
|
3350
|
+
return {
|
|
3351
|
+
col,
|
|
3352
|
+
targetCol: currentCol,
|
|
3353
|
+
inclusive: motion === "e",
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
private moveWord(
|
|
3358
|
+
direction: "forward" | "backward",
|
|
3359
|
+
target: "start" | "end",
|
|
3360
|
+
count: number = 1,
|
|
3361
|
+
semanticClass: WordMotionClass = "word",
|
|
3362
|
+
): void {
|
|
3363
|
+
let remaining = Math.max(1, Math.min(MAX_COUNT, count));
|
|
3364
|
+
|
|
3365
|
+
while (remaining > 0) {
|
|
3366
|
+
if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
|
|
3367
|
+
remaining--;
|
|
3368
|
+
continue;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
const text = this.getText();
|
|
3372
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
3373
|
+
const targetAbs = this.findWordTargetInText(
|
|
3374
|
+
text,
|
|
3375
|
+
currentAbs,
|
|
3376
|
+
direction,
|
|
3377
|
+
target,
|
|
3378
|
+
remaining,
|
|
3379
|
+
semanticClass,
|
|
3380
|
+
);
|
|
3381
|
+
if (targetAbs !== currentAbs) {
|
|
3382
|
+
this.moveCursorToAbsoluteIndex(targetAbs);
|
|
3383
|
+
}
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
private shouldMirrorRegisterWrite(source: RegisterWriteSource): boolean {
|
|
3389
|
+
if (this.clipboardMirrorPolicy === "never") return false;
|
|
3390
|
+
if (this.clipboardMirrorPolicy === "yank") return source === "yank";
|
|
3391
|
+
return true;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
private writeToRegister(
|
|
3395
|
+
text: string,
|
|
3396
|
+
source: RegisterWriteSource = "mutation",
|
|
3397
|
+
): void {
|
|
3398
|
+
this.unnamedRegister = text;
|
|
3399
|
+
const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source);
|
|
3400
|
+
this.preferRegisterForPut = text !== "" && !shouldMirror;
|
|
3401
|
+
if (!shouldMirror) return;
|
|
3402
|
+
|
|
3403
|
+
this.clipboardMirror.mirror(text);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
private getCurrentLineAndCol(): { line: string; col: number } {
|
|
3407
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
3408
|
+
const col = this.getCursor().col;
|
|
3409
|
+
return { line, col };
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
private hasMultiCodeUnitGraphemes(line: string): boolean {
|
|
3413
|
+
return getLineGraphemes(line).some(
|
|
3414
|
+
(segment) => segment.end - segment.start > 1,
|
|
3415
|
+
);
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
private getGraphemeRangeAtCol(
|
|
3419
|
+
line: string,
|
|
3420
|
+
col: number,
|
|
3421
|
+
count: number,
|
|
3422
|
+
clampToLine: boolean = false,
|
|
3423
|
+
): { start: number; end: number } | null {
|
|
3424
|
+
const clampedCol = Math.max(0, Math.min(col, line.length));
|
|
3425
|
+
const segments = getLineGraphemes(line);
|
|
3426
|
+
const startIndex = segments.findIndex(
|
|
3427
|
+
(segment) => clampedCol < segment.end,
|
|
3428
|
+
);
|
|
3429
|
+
if (startIndex === -1) return null;
|
|
3430
|
+
|
|
3431
|
+
let endIndex = startIndex + Math.max(1, count) - 1;
|
|
3432
|
+
if (endIndex >= segments.length) {
|
|
3433
|
+
if (!clampToLine) return null;
|
|
3434
|
+
endIndex = segments.length - 1;
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
const startSegment = segments[startIndex];
|
|
3438
|
+
const endSegment = segments[endIndex];
|
|
3439
|
+
if (!startSegment || !endSegment) return null;
|
|
3440
|
+
|
|
3441
|
+
return {
|
|
3442
|
+
start: startSegment.start,
|
|
3443
|
+
end: endSegment.end,
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
private isCursorOnNonWhitespace(): boolean {
|
|
3448
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
3449
|
+
const ch = line[col];
|
|
3450
|
+
return ch !== undefined && !/\s/.test(ch);
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
private isCursorAtOrPastEol(): boolean {
|
|
3454
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
3455
|
+
return col >= line.length;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
private cutCharUnderCursor(normal: boolean = false): void {
|
|
3459
|
+
const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
|
|
3460
|
+
const cursor = this.getCursor();
|
|
3461
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
3462
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
|
|
3463
|
+
if (!range) return;
|
|
3464
|
+
|
|
3465
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
3466
|
+
const text = this.getText();
|
|
3467
|
+
this.writeToRegister(line.slice(range.start, range.end));
|
|
3468
|
+
this.replaceTextInBuffer(
|
|
3469
|
+
text.slice(0, lineStartAbs + range.start) +
|
|
3470
|
+
text.slice(lineStartAbs + range.end),
|
|
3471
|
+
lineStartAbs + range.start,
|
|
3472
|
+
);
|
|
3473
|
+
if (normal) {
|
|
3474
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
3475
|
+
if (line && col >= line.length) this.moveCursorBy(-1);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
// Vim `X`: delete up to {count} graphemes before the cursor on the current
|
|
3480
|
+
// line, clamping at column 0; a cursor already at column 0 is a no-op.
|
|
3481
|
+
private cutCharsBeforeCursor(): void {
|
|
3482
|
+
const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
|
|
3483
|
+
const cursor = this.getCursor();
|
|
3484
|
+
if (cursor.col <= 0) return;
|
|
3485
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
3486
|
+
const segments = getLineGraphemes(line);
|
|
3487
|
+
const before = segments.filter((segment) => segment.end <= cursor.col);
|
|
3488
|
+
if (before.length === 0) return;
|
|
3489
|
+
const take = Math.min(count, before.length);
|
|
3490
|
+
const start = before[before.length - take].start;
|
|
3491
|
+
const end = before[before.length - 1].end;
|
|
3492
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
3493
|
+
const text = this.getText();
|
|
3494
|
+
this.writeToRegister(line.slice(start, end));
|
|
3495
|
+
this.replaceTextInBuffer(
|
|
3496
|
+
text.slice(0, lineStartAbs + start) + text.slice(lineStartAbs + end),
|
|
3497
|
+
lineStartAbs + start,
|
|
3498
|
+
);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
private cutToEndOfLine(): void {
|
|
3502
|
+
const lines = this.getLines();
|
|
3503
|
+
const cursorLine = this.getCursor().line;
|
|
3504
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
3505
|
+
|
|
3506
|
+
const hasNextLine = cursorLine < lines.length - 1;
|
|
3507
|
+
const deleted =
|
|
3508
|
+
col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
|
|
3509
|
+
|
|
3510
|
+
if (deleted !== "") this.writeToRegister(deleted);
|
|
3511
|
+
super.handleInput(CTRL_K);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
private cutCurrentLineContent(): void {
|
|
3515
|
+
const lines = this.getLines();
|
|
3516
|
+
const cursorLine = this.getCursor().line;
|
|
3517
|
+
const { line } = this.getCurrentLineAndCol();
|
|
3518
|
+
|
|
3519
|
+
const hasNextLine = cursorLine < lines.length - 1;
|
|
3520
|
+
const deleted = line.length > 0 ? line : hasNextLine ? "\n" : "";
|
|
3521
|
+
|
|
3522
|
+
this.writeToRegister(deleted);
|
|
3523
|
+
super.handleInput(CTRL_A);
|
|
3524
|
+
super.handleInput(CTRL_K);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
private cutLine(): void {
|
|
3528
|
+
this.cutCurrentLineContent();
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
private getNormalizedLineRange(
|
|
3532
|
+
startLine: number,
|
|
3533
|
+
endLine: number,
|
|
3534
|
+
): { start: number; end: number } {
|
|
3535
|
+
const lines = this.getLines();
|
|
3536
|
+
const last = Math.max(0, lines.length - 1);
|
|
3537
|
+
const clampedStart = Math.max(0, Math.min(startLine, last));
|
|
3538
|
+
const clampedEnd = Math.max(0, Math.min(endLine, last));
|
|
3539
|
+
return {
|
|
3540
|
+
start: Math.min(clampedStart, clampedEnd),
|
|
3541
|
+
end: Math.max(clampedStart, clampedEnd),
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
private getLinewisePayload(startLine: number, endLine: number): string {
|
|
3546
|
+
const lines = this.getLines();
|
|
3547
|
+
const { start, end } = this.getNormalizedLineRange(startLine, endLine);
|
|
3548
|
+
return `${lines.slice(start, end + 1).join("\n")}\n`;
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
private getLineDeleteAbsoluteRange(
|
|
3552
|
+
startLine: number,
|
|
3553
|
+
endLine: number,
|
|
3554
|
+
): { startAbs: number; endAbs: number } {
|
|
3555
|
+
const lines = this.getLines();
|
|
3556
|
+
const text = this.getText();
|
|
3557
|
+
const { start, end } = this.getNormalizedLineRange(startLine, endLine);
|
|
3558
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
3559
|
+
|
|
3560
|
+
let startAbs = this.getAbsoluteIndex(start, 0);
|
|
3561
|
+
let endAbs: number;
|
|
3562
|
+
|
|
3563
|
+
if (end < lastLine) {
|
|
3564
|
+
const endLineText = lines[end] ?? "";
|
|
3565
|
+
endAbs = this.getAbsoluteIndex(end, endLineText.length) + 1;
|
|
3566
|
+
} else {
|
|
3567
|
+
endAbs = text.length;
|
|
3568
|
+
if (start > 0) {
|
|
3569
|
+
startAbs = Math.max(0, startAbs - 1);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
return { startAbs, endAbs };
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
private deleteLineRange(startLine: number, endLine: number): void {
|
|
3577
|
+
const lines = this.getLines();
|
|
3578
|
+
if (lines.length === 0) return;
|
|
3579
|
+
|
|
3580
|
+
const payload = this.getLinewisePayload(startLine, endLine);
|
|
3581
|
+
const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(
|
|
3582
|
+
startLine,
|
|
3583
|
+
endLine,
|
|
3584
|
+
);
|
|
3585
|
+
|
|
3586
|
+
this.writeToRegister(payload);
|
|
3587
|
+
|
|
3588
|
+
if (endAbs > startAbs) {
|
|
3589
|
+
const text = this.getText();
|
|
3590
|
+
const newText = text.slice(0, startAbs) + text.slice(endAbs);
|
|
3591
|
+
this.replaceTextInBuffer(newText, startAbs);
|
|
3592
|
+
|
|
3593
|
+
super.handleInput(CTRL_A);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
private yankLineRange(startLine: number, endLine: number): void {
|
|
3598
|
+
if (this.getLines().length === 0) return;
|
|
3599
|
+
this.writeToRegister(this.getLinewisePayload(startLine, endLine), "yank");
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
private deleteLinewiseByDelta(delta: number): void {
|
|
3603
|
+
const currentLine = this.getCursor().line;
|
|
3604
|
+
this.deleteLineRange(currentLine, currentLine + delta);
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
private yankLinewiseByDelta(delta: number): void {
|
|
3608
|
+
const currentLine = this.getCursor().line;
|
|
3609
|
+
this.yankLineRange(currentLine, currentLine + delta);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
private deleteToBufferEndLinewise(): void {
|
|
3613
|
+
this.deleteLineRange(this.getCursor().line, this.getLines().length - 1);
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
private yankToBufferEndLinewise(): void {
|
|
3617
|
+
this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
private deleteWithMotion(motion: string, count: number = 1): boolean {
|
|
3621
|
+
const cursor = this.getCursor();
|
|
3622
|
+
const col = cursor.col;
|
|
3623
|
+
|
|
3624
|
+
if (motion === "$") {
|
|
3625
|
+
this.cutToEndOfLine();
|
|
3626
|
+
return true;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
if (motion === "0") {
|
|
3630
|
+
this.deleteRange(col, 0, false);
|
|
3631
|
+
return true;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
if (motion === "^") {
|
|
3635
|
+
this.deleteRange(
|
|
3636
|
+
col,
|
|
3637
|
+
findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""),
|
|
3638
|
+
false,
|
|
3639
|
+
);
|
|
3640
|
+
return true;
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
3644
|
+
if (wordMotion) {
|
|
3645
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
3646
|
+
wordMotion.motion,
|
|
3647
|
+
count,
|
|
3648
|
+
wordMotion.semanticClass,
|
|
3649
|
+
);
|
|
3650
|
+
if (lineLocalRange) {
|
|
3651
|
+
this.deleteRange(
|
|
3652
|
+
lineLocalRange.col,
|
|
3653
|
+
lineLocalRange.targetCol,
|
|
3654
|
+
lineLocalRange.inclusive,
|
|
3655
|
+
);
|
|
3656
|
+
return true;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
const text = this.getText();
|
|
3660
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
3661
|
+
const targetAbs = this.findWordTargetInText(
|
|
3662
|
+
text,
|
|
3663
|
+
currentAbs,
|
|
3664
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
3665
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
3666
|
+
count,
|
|
3667
|
+
wordMotion.semanticClass,
|
|
3668
|
+
);
|
|
3669
|
+
this.deleteRangeByAbsolute(
|
|
3670
|
+
currentAbs,
|
|
3671
|
+
targetAbs,
|
|
3672
|
+
wordMotion.motion === "e",
|
|
3673
|
+
);
|
|
3674
|
+
return true;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
return false;
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
// Handles counted `d$`/`c$`. `$` with a count N descends N-1 lines before
|
|
3681
|
+
// going to end of line, so the operator spans multiple lines. Semantics
|
|
3682
|
+
// match nvim (verified against the real editor): count 1 delegates to the
|
|
3683
|
+
// existing single-line path; on the last line a count >= 2 aborts as a
|
|
3684
|
+
// no-op; `d$` (but not `c$`) becomes linewise when the cursor is at or
|
|
3685
|
+
// before the first non-blank column.
|
|
3686
|
+
private applyLineEndOperator(
|
|
3687
|
+
count: number,
|
|
3688
|
+
mode: "change" | "delete",
|
|
3689
|
+
): boolean {
|
|
3690
|
+
const clampedCount = Math.max(1, Math.min(MAX_COUNT, count));
|
|
3691
|
+
if (clampedCount <= 1) {
|
|
3692
|
+
this.cutToEndOfLine();
|
|
3693
|
+
return true;
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
const lines = this.getLines();
|
|
3697
|
+
if (lines.length === 0) return false;
|
|
3698
|
+
|
|
3699
|
+
const cursor = this.getCursor();
|
|
3700
|
+
const lastLine = lines.length - 1;
|
|
3701
|
+
if (cursor.line >= lastLine) return false;
|
|
3702
|
+
|
|
3703
|
+
const targetLine = Math.min(cursor.line + clampedCount - 1, lastLine);
|
|
3704
|
+
const text = this.getText();
|
|
3705
|
+
const startAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
3706
|
+
const targetLineText = lines[targetLine] ?? "";
|
|
3707
|
+
const targetEndAbs = this.getAbsoluteIndex(
|
|
3708
|
+
targetLine,
|
|
3709
|
+
targetLineText.length,
|
|
3710
|
+
);
|
|
3711
|
+
|
|
3712
|
+
const firstNonBlank = findFirstNonWhitespaceColumn(
|
|
3713
|
+
lines[cursor.line] ?? "",
|
|
3714
|
+
);
|
|
3715
|
+
const linewise = mode === "delete" && cursor.col <= firstNonBlank;
|
|
3716
|
+
|
|
3717
|
+
if (linewise) {
|
|
3718
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
3719
|
+
this.writeToRegister(`${text.slice(lineStartAbs, targetEndAbs)}\n`);
|
|
3720
|
+
|
|
3721
|
+
let removeStart = lineStartAbs;
|
|
3722
|
+
let removeEnd = targetEndAbs;
|
|
3723
|
+
if (targetLine < lastLine) {
|
|
3724
|
+
removeEnd = targetEndAbs + 1;
|
|
3725
|
+
} else if (cursor.line > 0) {
|
|
3726
|
+
removeStart = Math.max(0, lineStartAbs - 1);
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
const newText = text.slice(0, removeStart) + text.slice(removeEnd);
|
|
3730
|
+
this.replaceTextInBuffer(newText, Math.min(startAbs, newText.length));
|
|
3731
|
+
this.moveCursorAfterDeleteToLineEnd(cursor.line, cursor.col);
|
|
3732
|
+
return true;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
this.writeToRegister(text.slice(startAbs, targetEndAbs));
|
|
3736
|
+
const newText = text.slice(0, startAbs) + text.slice(targetEndAbs);
|
|
3737
|
+
this.replaceTextInBuffer(newText, Math.min(startAbs, newText.length));
|
|
3738
|
+
if (mode === "delete") {
|
|
3739
|
+
this.moveCursorAfterDeleteToLineEnd(cursor.line, cursor.col);
|
|
3740
|
+
}
|
|
3741
|
+
return true;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
private moveCursorAfterDeleteToLineEnd(
|
|
3745
|
+
startLine: number,
|
|
3746
|
+
startCol: number,
|
|
3747
|
+
): void {
|
|
3748
|
+
const lines = this.getLines();
|
|
3749
|
+
const lineIndex = Math.min(startLine, Math.max(0, lines.length - 1));
|
|
3750
|
+
if (lineIndex < startLine) {
|
|
3751
|
+
this.moveCursorToLineStart(lineIndex);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
const line = lines[lineIndex] ?? "";
|
|
3756
|
+
if (line.length > 0 && startCol >= line.length) {
|
|
3757
|
+
const graphemes = getLineGraphemes(line);
|
|
3758
|
+
this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
this.moveCursorToCol(Math.min(startCol, line.length));
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
private deleteWithCharMotion(
|
|
3766
|
+
motion: CharMotion,
|
|
3767
|
+
targetChar: string,
|
|
3768
|
+
): boolean {
|
|
3769
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
3770
|
+
const col = this.getCursor().col;
|
|
3771
|
+
const count = this.takeTotalCount(1);
|
|
3772
|
+
const targetCol = findCharMotionTarget(
|
|
3773
|
+
line,
|
|
3774
|
+
col,
|
|
3775
|
+
motion,
|
|
3776
|
+
targetChar,
|
|
3777
|
+
false,
|
|
3778
|
+
count,
|
|
3779
|
+
);
|
|
3780
|
+
|
|
3781
|
+
if (targetCol === null) return false;
|
|
3782
|
+
|
|
3783
|
+
this.lastCharMotion = { motion, char: targetChar };
|
|
3784
|
+
this.deleteRange(col, targetCol, true);
|
|
3785
|
+
return true;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
private handlePendingYank(data: string): void {
|
|
3789
|
+
if (this.opDigit(data)) return;
|
|
3790
|
+
|
|
3791
|
+
if (data === "%") {
|
|
3792
|
+
this.applyPercentOp();
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
if (data === "y") {
|
|
3797
|
+
const count = this.takeTotalCount(1);
|
|
3798
|
+
this.yankLinewiseByDelta(count - 1);
|
|
3799
|
+
this.pendingOperator = null;
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
if (data === "j" || data === "k") {
|
|
3804
|
+
const hasDualCount =
|
|
3805
|
+
this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
3806
|
+
const count = this.takeTotalCount(1);
|
|
3807
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
3808
|
+
this.yankLinewiseByDelta(data === "j" ? delta : -delta);
|
|
3809
|
+
this.pendingOperator = null;
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
if (data === "G") {
|
|
3814
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
3815
|
+
this.cancelPendingOperator(data);
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
this.yankToBufferEndLinewise();
|
|
3820
|
+
this.pendingOperator = null;
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
if (data === "_") {
|
|
3825
|
+
const count = this.takeTotalCount(1);
|
|
3826
|
+
this.yankLinewiseByDelta(count - 1);
|
|
3827
|
+
this.pendingOperator = null;
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
3832
|
+
this.pendingMotion = data as PendingMotion;
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
if (data === "i" || data === "a") {
|
|
3837
|
+
this.pendingTextObject = data;
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
if (this.hasPendingCount()) {
|
|
3842
|
+
this.cancelPendingOperator(data);
|
|
3843
|
+
return;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
if (this.yankWithMotion(data)) {
|
|
3847
|
+
this.pendingOperator = null;
|
|
3848
|
+
} else {
|
|
3849
|
+
this.cancelPendingOperator(data); // cancel on unrecognised motion
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
private yankWithMotion(motion: string): boolean {
|
|
3854
|
+
const cursor = this.getCursor();
|
|
3855
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
3856
|
+
const col = cursor.col;
|
|
3857
|
+
|
|
3858
|
+
if (motion === "$") {
|
|
3859
|
+
this.yankRange(col, line.length, false);
|
|
3860
|
+
return true;
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
if (motion === "0") {
|
|
3864
|
+
this.yankRange(col, 0, false);
|
|
3865
|
+
return true;
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
if (motion === "^") {
|
|
3869
|
+
this.yankRange(col, findFirstNonWhitespaceColumn(line), false);
|
|
3870
|
+
return true;
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
3874
|
+
if (wordMotion) {
|
|
3875
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
3876
|
+
wordMotion.motion,
|
|
3877
|
+
1,
|
|
3878
|
+
wordMotion.semanticClass,
|
|
3879
|
+
);
|
|
3880
|
+
if (lineLocalRange) {
|
|
3881
|
+
this.yankRange(
|
|
3882
|
+
lineLocalRange.col,
|
|
3883
|
+
lineLocalRange.targetCol,
|
|
3884
|
+
lineLocalRange.inclusive,
|
|
3885
|
+
);
|
|
3886
|
+
return true;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
const text = this.getText();
|
|
3890
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
3891
|
+
const targetAbs = this.findWordTargetInText(
|
|
3892
|
+
text,
|
|
3893
|
+
currentAbs,
|
|
3894
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
3895
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
3896
|
+
1,
|
|
3897
|
+
wordMotion.semanticClass,
|
|
3898
|
+
);
|
|
3899
|
+
this.yankRangeByAbsolute(
|
|
3900
|
+
currentAbs,
|
|
3901
|
+
targetAbs,
|
|
3902
|
+
wordMotion.motion === "e",
|
|
3903
|
+
);
|
|
3904
|
+
return true;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
return false;
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
|
|
3911
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
3912
|
+
const col = this.getCursor().col;
|
|
3913
|
+
const count = this.takeTotalCount(1);
|
|
3914
|
+
const targetCol = findCharMotionTarget(
|
|
3915
|
+
line,
|
|
3916
|
+
col,
|
|
3917
|
+
motion,
|
|
3918
|
+
targetChar,
|
|
3919
|
+
false,
|
|
3920
|
+
count,
|
|
3921
|
+
);
|
|
3922
|
+
|
|
3923
|
+
if (targetCol === null) return;
|
|
3924
|
+
|
|
3925
|
+
this.lastCharMotion = { motion, char: targetChar };
|
|
3926
|
+
this.yankRange(col, targetCol, true);
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
private yankRange(col: number, targetCol: number, inclusive: boolean): void {
|
|
3930
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
3931
|
+
const start = Math.min(col, targetCol);
|
|
3932
|
+
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
3933
|
+
let end = Math.min(rawEnd, line.length);
|
|
3934
|
+
|
|
3935
|
+
if (inclusive) {
|
|
3936
|
+
const targetRange = this.getGraphemeRangeAtCol(
|
|
3937
|
+
line,
|
|
3938
|
+
Math.max(col, targetCol),
|
|
3939
|
+
1,
|
|
3940
|
+
);
|
|
3941
|
+
end = targetRange?.end ?? end;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
if (end <= start) return;
|
|
3945
|
+
|
|
3946
|
+
this.writeToRegister(line.slice(start, end), "yank");
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
private yankRangeByAbsolute(
|
|
3950
|
+
currentAbs: number,
|
|
3951
|
+
targetAbs: number,
|
|
3952
|
+
inclusive: boolean = false,
|
|
3953
|
+
): void {
|
|
3954
|
+
const text = this.getText();
|
|
3955
|
+
const start = Math.min(currentAbs, targetAbs);
|
|
3956
|
+
const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
|
|
3957
|
+
const end = Math.min(rawEnd, text.length);
|
|
3958
|
+
if (end <= start) return;
|
|
3959
|
+
this.writeToRegister(text.slice(start, end), "yank");
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
private getCursorFromAbsoluteIndex(
|
|
3963
|
+
text: string,
|
|
3964
|
+
abs: number,
|
|
3965
|
+
): { line: number; col: number } {
|
|
3966
|
+
const lines = text.length === 0 ? [""] : text.split("\n");
|
|
3967
|
+
let remaining = Math.max(0, Math.min(abs, text.length));
|
|
3968
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
3969
|
+
const line = lines[lineIndex] ?? "";
|
|
3970
|
+
if (remaining <= line.length) return { line: lineIndex, col: remaining };
|
|
3971
|
+
remaining -= line.length + 1;
|
|
3972
|
+
}
|
|
3973
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
3974
|
+
return { line: lastLine, col: (lines[lastLine] ?? "").length };
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
private replaceTextInBuffer(text: string, cursorAbs: number): void {
|
|
3978
|
+
const editor = this as unknown as {
|
|
3979
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
3980
|
+
preferredVisualCol?: number | null;
|
|
3981
|
+
historyIndex?: number;
|
|
3982
|
+
lastAction?: string | null;
|
|
3983
|
+
onChange?: (text: string) => void;
|
|
3984
|
+
tui?: { requestRender?: () => void };
|
|
3985
|
+
pushUndoSnapshot?: () => void;
|
|
3986
|
+
autocompleteState?: unknown;
|
|
3987
|
+
updateAutocomplete?: () => void;
|
|
3988
|
+
};
|
|
3989
|
+
const state = editor.state;
|
|
3990
|
+
if (!state) return;
|
|
3991
|
+
const currentText = this.getText();
|
|
3992
|
+
if (currentText !== text) editor.pushUndoSnapshot?.();
|
|
3993
|
+
const nextLines = text.length === 0 ? [""] : text.split("\n");
|
|
3994
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
|
|
3995
|
+
editor.historyIndex = -1;
|
|
3996
|
+
editor.lastAction = null;
|
|
3997
|
+
state.lines = nextLines;
|
|
3998
|
+
state.cursorLine = line;
|
|
3999
|
+
state.cursorCol = col;
|
|
4000
|
+
editor.preferredVisualCol = null;
|
|
4001
|
+
editor.onChange?.(text);
|
|
4002
|
+
if (editor.autocompleteState) editor.updateAutocomplete?.();
|
|
4003
|
+
editor.tui?.requestRender?.();
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
private deleteRangeByAbsolute(
|
|
4007
|
+
currentAbs: number,
|
|
4008
|
+
targetAbs: number,
|
|
4009
|
+
inclusive: boolean = false,
|
|
4010
|
+
): void {
|
|
4011
|
+
const text = this.getText();
|
|
4012
|
+
const start = Math.min(currentAbs, targetAbs);
|
|
4013
|
+
const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
|
|
4014
|
+
const end = Math.min(rawEnd, text.length);
|
|
4015
|
+
|
|
4016
|
+
if (end <= start) return;
|
|
4017
|
+
|
|
4018
|
+
this.writeToRegister(text.slice(start, end));
|
|
4019
|
+
|
|
4020
|
+
this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
private getWordObjectRange(
|
|
4024
|
+
kind: TextObjectKind,
|
|
4025
|
+
count: number = 1,
|
|
4026
|
+
semanticClass: WordTextObjectClass = "word",
|
|
4027
|
+
): TextObjectRange | null {
|
|
4028
|
+
const lines = this.getLines();
|
|
4029
|
+
const cursor = this.getCursor();
|
|
4030
|
+
const line = lines[cursor.line] ?? "";
|
|
4031
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
4032
|
+
|
|
4033
|
+
return resolveWordTextObjectRange(
|
|
4034
|
+
line,
|
|
4035
|
+
lineStartAbs,
|
|
4036
|
+
cursor.col,
|
|
4037
|
+
kind,
|
|
4038
|
+
count,
|
|
4039
|
+
semanticClass,
|
|
4040
|
+
);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
4044
|
+
|
|
4045
|
+
private getPasteRegisterText(): string {
|
|
4046
|
+
// A failed or skipped mirror leaves the OS clipboard stale relative to
|
|
4047
|
+
// the register, so the register must win until a mirror lands again.
|
|
4048
|
+
if (
|
|
4049
|
+
this.preferRegisterForPut ||
|
|
4050
|
+
this.clipboardMirror.hasPendingWrite() ||
|
|
4051
|
+
(this.unnamedRegister !== "" && this.clipboardMirror.lastWriteFailed())
|
|
4052
|
+
) {
|
|
4053
|
+
return this.unnamedRegister;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
try {
|
|
4057
|
+
const clipboardText = this.clipboardReadFn();
|
|
4058
|
+
return clipboardText ?? this.unnamedRegister;
|
|
4059
|
+
} catch {
|
|
4060
|
+
return this.unnamedRegister;
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
private moveCursorToPreviousGraphemeStart(): void {
|
|
4065
|
+
const cursor = this.getCursor();
|
|
4066
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
4067
|
+
const col = Math.max(0, Math.min(cursor.col, line.length));
|
|
4068
|
+
if (col <= 0) return;
|
|
4069
|
+
|
|
4070
|
+
const range = this.getGraphemeRangeAtCol(line, col - 1, 1);
|
|
4071
|
+
if (range) this.moveCursorToCol(range.start);
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
private moveCursorToLastGraphemeOnLine(): void {
|
|
4075
|
+
const { line } = this.getCurrentLineAndCol();
|
|
4076
|
+
const graphemes = getLineGraphemes(line);
|
|
4077
|
+
this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
private putAfter(): void {
|
|
4081
|
+
const count = this.takeTotalCount(1);
|
|
4082
|
+
const text = this.getPasteRegisterText();
|
|
4083
|
+
if (!text) return;
|
|
4084
|
+
const safeCount = Math.min(
|
|
4085
|
+
count,
|
|
4086
|
+
Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
|
|
4087
|
+
);
|
|
4088
|
+
|
|
4089
|
+
if (text.endsWith("\n")) {
|
|
4090
|
+
const content = text.slice(0, -1);
|
|
4091
|
+
const targetLine = this.getCursor().line + 1;
|
|
4092
|
+
for (let i = 0; i < safeCount; i++) {
|
|
4093
|
+
super.handleInput(CTRL_E);
|
|
4094
|
+
super.handleInput(NEWLINE);
|
|
4095
|
+
for (const char of content) {
|
|
4096
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
// Vim: line-wise `p` leaves the cursor on the first non-blank of the
|
|
4100
|
+
// first inserted line (one line below the original cursor). An
|
|
4101
|
+
// all-whitespace first line lands at col 0, sharing the `^` divergence.
|
|
4102
|
+
this.moveCursorToLineStart(targetLine);
|
|
4103
|
+
this.moveCursorToFirstNonWhitespace();
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
if (!this.isCursorAtOrPastEol()) {
|
|
4108
|
+
super.handleInput(ESC_RIGHT);
|
|
4109
|
+
}
|
|
4110
|
+
for (let i = 0; i < safeCount; i++) {
|
|
4111
|
+
for (const char of text) {
|
|
4112
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
this.moveCursorToPreviousGraphemeStart();
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
private putBefore(): void {
|
|
4119
|
+
const count = this.takeTotalCount(1);
|
|
4120
|
+
const text = this.getPasteRegisterText();
|
|
4121
|
+
if (!text) return;
|
|
4122
|
+
const safeCount = Math.min(
|
|
4123
|
+
count,
|
|
4124
|
+
Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
|
|
4125
|
+
);
|
|
4126
|
+
|
|
4127
|
+
if (text.endsWith("\n")) {
|
|
4128
|
+
const content = text.slice(0, -1);
|
|
4129
|
+
const targetLine = this.getCursor().line;
|
|
4130
|
+
for (let i = 0; i < safeCount; i++) {
|
|
4131
|
+
super.handleInput(CTRL_A);
|
|
4132
|
+
super.handleInput(NEWLINE);
|
|
4133
|
+
super.handleInput(ESC_UP);
|
|
4134
|
+
for (const char of content) {
|
|
4135
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
// Vim: line-wise `P` leaves the cursor on the first non-blank of the
|
|
4139
|
+
// first inserted line (the cursor's original line, since `P` inserts
|
|
4140
|
+
// above). All-whitespace first line lands at col 0 (`^` divergence).
|
|
4141
|
+
this.moveCursorToLineStart(targetLine);
|
|
4142
|
+
this.moveCursorToFirstNonWhitespace();
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
for (let i = 0; i < safeCount; i++) {
|
|
4147
|
+
for (const char of text) {
|
|
4148
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
this.moveCursorToPreviousGraphemeStart();
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
private deleteRange(
|
|
4155
|
+
col: number,
|
|
4156
|
+
targetCol: number,
|
|
4157
|
+
inclusive: boolean,
|
|
4158
|
+
): void {
|
|
4159
|
+
const cursor = this.getCursor();
|
|
4160
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
4161
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
4162
|
+
const start = Math.min(col, targetCol);
|
|
4163
|
+
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
4164
|
+
let end = Math.min(rawEnd, line.length);
|
|
4165
|
+
|
|
4166
|
+
if (inclusive) {
|
|
4167
|
+
const targetRange = this.getGraphemeRangeAtCol(
|
|
4168
|
+
line,
|
|
4169
|
+
Math.max(col, targetCol),
|
|
4170
|
+
1,
|
|
4171
|
+
);
|
|
4172
|
+
end = targetRange?.end ?? end;
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
private getDesiredCursorShapeSequence(): CursorShapeSequence {
|
|
4179
|
+
return "insert" === this.mode && this.pendingExCommand === null
|
|
4180
|
+
? INSERT_CURSOR_SHAPE
|
|
4181
|
+
: BLOCK_CURSOR_SHAPE;
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
private syncCursorShapeForRender(lines: string[]): void {
|
|
4185
|
+
if (!this.cursorShapeRuntime) return;
|
|
4186
|
+
if (!hasPromptCursorMarker(lines)) return;
|
|
4187
|
+
|
|
4188
|
+
// Pi re-applies its `showHardwareCursor` setting on reload and on settings
|
|
4189
|
+
// changes (applyRuntimeSettings), which resets the hardware cursor pi-vim
|
|
4190
|
+
// relies on for shape control back to its default (off). When that happens
|
|
4191
|
+
// pi falls back to a software block cursor and hides the hardware cursor,
|
|
4192
|
+
// so the thin/thick shape never recovers. Re-assert ownership here so the
|
|
4193
|
+
// next positioning pass re-shows the hardware cursor and we keep shaping.
|
|
4194
|
+
if (this.cursorShapeRuntime.getShowHardwareCursor?.() === false) {
|
|
4195
|
+
this.cursorShapeRuntime.setShowHardwareCursor(true);
|
|
4196
|
+
// The hardware cursor was hidden, so the terminal's current DECSCUSR
|
|
4197
|
+
// shape may no longer match our cache; force a fresh write.
|
|
4198
|
+
this.lastCursorShapeSequence = null;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
stripSoftwareCursorWhenHardwareCursorIsUsed(lines);
|
|
4202
|
+
|
|
4203
|
+
const sequence = this.getDesiredCursorShapeSequence();
|
|
4204
|
+
if (sequence === this.lastCursorShapeSequence) return;
|
|
4205
|
+
|
|
4206
|
+
this.cursorShapeRuntime.writeCursorShape(sequence);
|
|
4207
|
+
this.lastCursorShapeSequence = sequence;
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
render(width: number): string[] {
|
|
4211
|
+
const lines = super.render(width);
|
|
4212
|
+
this.syncCursorShapeForRender(lines);
|
|
4213
|
+
if (lines.length === 0) return lines;
|
|
4214
|
+
|
|
4215
|
+
const rawLabel = fitModeLabel(this.getModeLabel(), width);
|
|
4216
|
+
const colorize = this.getModeLabelColorizer();
|
|
4217
|
+
const label = colorize ? colorize(rawLabel) : rawLabel;
|
|
4218
|
+
const last = lines.length - 1;
|
|
4219
|
+
const lastLine = lines[last];
|
|
4220
|
+
if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
|
|
4221
|
+
const contentWidth = width - visibleWidth(rawLabel);
|
|
4222
|
+
const c = this.lastLineCache;
|
|
4223
|
+
if (lastLine !== c.l || contentWidth !== c.w || label !== c.label) {
|
|
4224
|
+
c.l = lastLine;
|
|
4225
|
+
c.w = contentWidth;
|
|
4226
|
+
c.label = label;
|
|
4227
|
+
c.result = truncateToWidth(lastLine, contentWidth, "") + label;
|
|
4228
|
+
}
|
|
4229
|
+
lines[last] = c.result;
|
|
4230
|
+
} else {
|
|
4231
|
+
lines[last] = label;
|
|
4232
|
+
}
|
|
4233
|
+
return lines;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
private getModeLabelColorizer(): ((s: string) => string) | null {
|
|
4237
|
+
if (!this.labelColorizers) return null;
|
|
4238
|
+
const labelColorizers = this.labelColorizers;
|
|
4239
|
+
const wrap = this.labelTransform ?? ((s: string) => s);
|
|
4240
|
+
return (s: string) =>
|
|
4241
|
+
this.resolveSurfaceColor(labelColorizers, this.labelSync, s, wrap);
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
private getModeLabel(): string {
|
|
4245
|
+
if ("insert" === this.mode) return " INSERT ";
|
|
4246
|
+
if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
|
|
4247
|
+
|
|
4248
|
+
const prefixCount = this.prefixCount;
|
|
4249
|
+
const operatorCount = this.operatorCount;
|
|
4250
|
+
|
|
4251
|
+
if (isVisualMode(this.mode)) {
|
|
4252
|
+
const name = this.mode === "visual" ? "VISUAL" : "V-LINE";
|
|
4253
|
+
const pending = `${prefixCount}${this.pendingG ? `g${this.pendingGCount}` : ""}${this.pendingMotion ?? ""}`;
|
|
4254
|
+
return pending ? ` ${name} ${pending}_ ` : ` ${name} `;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
if (this.pendingReplace) {
|
|
4258
|
+
return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
|
|
4259
|
+
}
|
|
4260
|
+
if (this.pendingOperator && this.pendingMotion) {
|
|
4261
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
4262
|
+
}
|
|
4263
|
+
if (this.pendingOperator) {
|
|
4264
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
|
|
4265
|
+
}
|
|
4266
|
+
if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
|
|
4267
|
+
if (this.pendingG) {
|
|
4268
|
+
return this.pendingGCount
|
|
4269
|
+
? ` NORMAL g${this.pendingGCount}_ `
|
|
4270
|
+
: " NORMAL g_ ";
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
const count = `${prefixCount}${operatorCount}`;
|
|
4274
|
+
if (count) return ` NORMAL ${count}_ `;
|
|
4275
|
+
return " NORMAL ";
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
export default function (pi: ExtensionAPI) {
|
|
4280
|
+
let cursorShapeCleanup: CursorShapeCleanup | null = null;
|
|
4281
|
+
|
|
4282
|
+
pi.on("session_start", (_event, ctx) => {
|
|
4283
|
+
const piVimSettings = readPiVimSettings(ctx.cwd);
|
|
4284
|
+
const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(
|
|
4285
|
+
piVimSettings.clipboardMirror,
|
|
4286
|
+
);
|
|
4287
|
+
if (clipboardMirrorPolicy.warning && ctx.hasUI) {
|
|
4288
|
+
ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
|
|
4289
|
+
}
|
|
4290
|
+
const exCommand = resolveExCommandSettings(
|
|
4291
|
+
piVimSettings.exCommand,
|
|
4292
|
+
piVimSettings.globalExCommand,
|
|
4293
|
+
);
|
|
4294
|
+
if (exCommand.warning && ctx.hasUI) {
|
|
4295
|
+
ctx.ui.notify(exCommand.warning, "warning");
|
|
4296
|
+
}
|
|
4297
|
+
const insertEscape = resolveInsertEscapeSettings(
|
|
4298
|
+
piVimSettings.insertEscape,
|
|
4299
|
+
);
|
|
4300
|
+
if (insertEscape.warning && ctx.hasUI) {
|
|
4301
|
+
ctx.ui.notify(insertEscape.warning, "warning");
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
const t = ctx.ui.theme;
|
|
4305
|
+
const modeColors = resolveModeColors(piVimSettings.modeColors);
|
|
4306
|
+
const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
|
4307
|
+
const { borderSync, labelSync } = resolveSurfaceSyncMaps(piVimSettings);
|
|
4308
|
+
const labelColorizers = t
|
|
4309
|
+
? buildModeColorizers(t, modeColors, reverseVideo)
|
|
4310
|
+
: null;
|
|
4311
|
+
// Both surfaces get their colorizers unconditionally; the editor installs
|
|
4312
|
+
// the border trap only when a surface actually needs it (see
|
|
4313
|
+
// borderTrapNeeded), so the all-default case stays byte-identical to the
|
|
4314
|
+
// released `false` behavior with the host border untouched.
|
|
4315
|
+
const borderColorizers = t ? buildModeColorizers(t, modeColors) : null;
|
|
4316
|
+
// Enables the "thinking" neutral-default detection; unused by "mode"/"host".
|
|
4317
|
+
const offBorderColor = t ? buildOffBorderColor(t) : null;
|
|
4318
|
+
const modeChangeHandler = createModeChangeHandler(
|
|
4319
|
+
piVimSettings.modeChange,
|
|
4320
|
+
(event) => pi.events.emit("pi-vim:mode-change", event),
|
|
4321
|
+
);
|
|
4322
|
+
ctx.ui.setEditorComponent((tui, theme, kb) => {
|
|
4323
|
+
cursorShapeCleanup = enableCursorShapeSupport(tui);
|
|
4324
|
+
const editor = new ModalEditor(tui, theme, kb, {
|
|
4325
|
+
labelColorizers,
|
|
4326
|
+
borderColorizers,
|
|
4327
|
+
borderSync,
|
|
4328
|
+
labelSync,
|
|
4329
|
+
offBorderColor,
|
|
4330
|
+
labelTransform: reverseVideo,
|
|
4331
|
+
});
|
|
4332
|
+
editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
|
|
4333
|
+
editor.setQuitFn(() => ctx.shutdown());
|
|
4334
|
+
editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));
|
|
4335
|
+
editor.setModeChangeFn(modeChangeHandler);
|
|
4336
|
+
editor.setExCommandSettings(exCommand.settings);
|
|
4337
|
+
editor.setInsertEscapeSettings(insertEscape.settings);
|
|
4338
|
+
// Resolved at submit time so commands registered or reloaded mid-session
|
|
4339
|
+
// are dispatchable without restarting.
|
|
4340
|
+
editor.setCommandNamesFn(
|
|
4341
|
+
() =>
|
|
4342
|
+
new Set([
|
|
4343
|
+
...EX_BUILTIN_COMMAND_NAMES,
|
|
4344
|
+
...pi.getCommands().map((command) => command.name),
|
|
4345
|
+
]),
|
|
4346
|
+
);
|
|
4347
|
+
return editor;
|
|
4348
|
+
});
|
|
4349
|
+
});
|
|
4350
|
+
|
|
4351
|
+
pi.on("session_shutdown", (event) => {
|
|
4352
|
+
try {
|
|
4353
|
+
cursorShapeCleanup?.(event);
|
|
4354
|
+
} finally {
|
|
4355
|
+
cancelModeChangeCommands();
|
|
4356
|
+
cursorShapeCleanup = null;
|
|
4357
|
+
}
|
|
4358
|
+
});
|
|
4359
|
+
}
|