@linxiraos/pi-tui 1.1.13 → 1.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -37
- package/dist/types/components/editor.d.ts +30 -0
- package/dist/types/mouse.d.ts +5 -5
- package/dist/types/terminal.d.ts +14 -0
- package/dist/types/tui.d.ts +22 -0
- package/dist/types/vim.d.ts +94 -0
- package/package.json +3 -3
- package/src/components/editor.ts +492 -22
- package/src/mouse.ts +5 -5
- package/src/terminal.ts +42 -0
- package/src/tui.ts +106 -17
- package/src/vim.ts +760 -0
package/src/vim.ts
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { getSegmenter, moveWordLeft, moveWordRight } from "./utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal editing state machine for {@link Editor}.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately a *pure* state machine: it reads a snapshot of the buffer and returns the edits it
|
|
7
|
+
* wants applied, so motions can be unit-tested without a terminal and the editor keeps sole
|
|
8
|
+
* ownership of undo, atomic placeholder tokens, the kill ring, and `onChange`.
|
|
9
|
+
*
|
|
10
|
+
* This is a usable subset of Vim, not a reimplementation of it (see issue #3299): Normal and Visual
|
|
11
|
+
* modes, the common motions, and operators built from those motions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type VimMode = "insert" | "normal" | "visual" | "visual-line";
|
|
15
|
+
|
|
16
|
+
export type VimOperator = "d" | "y" | "c";
|
|
17
|
+
|
|
18
|
+
export interface VimPosition {
|
|
19
|
+
line: number;
|
|
20
|
+
col: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read-only view of the editor buffer that motions resolve against. */
|
|
24
|
+
export interface VimBuffer {
|
|
25
|
+
readonly lines: readonly string[];
|
|
26
|
+
readonly cursorLine: number;
|
|
27
|
+
readonly cursorCol: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An edit the editor should apply. `to` is always an *exclusive* end offset, so ranges compose the
|
|
32
|
+
* same way regardless of whether the originating motion was inclusive (`e`) or exclusive (`w`).
|
|
33
|
+
*/
|
|
34
|
+
export type VimCommand =
|
|
35
|
+
| { kind: "move"; to: VimPosition }
|
|
36
|
+
| { kind: "mode"; mode: VimMode }
|
|
37
|
+
| { kind: "yank"; from: VimPosition; to: VimPosition; linewise: boolean }
|
|
38
|
+
| { kind: "delete"; from: VimPosition; to: VimPosition; linewise: boolean; insert: boolean }
|
|
39
|
+
| { kind: "openLine"; below: boolean }
|
|
40
|
+
| { kind: "paste"; after: boolean; count: number }
|
|
41
|
+
| { kind: "undo" };
|
|
42
|
+
|
|
43
|
+
const segmenter = getSegmenter();
|
|
44
|
+
|
|
45
|
+
const cursorOf = (buf: VimBuffer): VimPosition => ({ line: buf.cursorLine, col: buf.cursorCol });
|
|
46
|
+
|
|
47
|
+
/** Start offset of the grapheme after `col`, clamped to `text.length`. */
|
|
48
|
+
export function nextGraphemeStart(text: string, col: number): number {
|
|
49
|
+
if (col >= text.length) return text.length;
|
|
50
|
+
for (const seg of segmenter.segment(text)) {
|
|
51
|
+
if (seg.index >= col) return Math.min(seg.index + seg.segment.length, text.length);
|
|
52
|
+
}
|
|
53
|
+
return text.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Start offset of the grapheme before `col`, clamped to 0. */
|
|
57
|
+
export function prevGraphemeStart(text: string, col: number): number {
|
|
58
|
+
if (col <= 0) return 0;
|
|
59
|
+
let last = 0;
|
|
60
|
+
for (const seg of segmenter.segment(text)) {
|
|
61
|
+
if (seg.index >= col) break;
|
|
62
|
+
last = seg.index;
|
|
63
|
+
}
|
|
64
|
+
return last;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Offset of the final grapheme — where a Normal-mode cursor rests on a non-empty line. */
|
|
68
|
+
export function lastGraphemeStart(text: string): number {
|
|
69
|
+
return prevGraphemeStart(text, text.length);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function firstNonBlank(text: string): number {
|
|
73
|
+
for (const seg of segmenter.segment(text)) {
|
|
74
|
+
if (seg.segment.trim() !== "") return seg.index;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Vim's `w`: `moveWordRight` stops at the end of the current word, so skip the gap that follows. */
|
|
80
|
+
function wordForward(text: string, col: number): number {
|
|
81
|
+
let next = moveWordRight(text, col);
|
|
82
|
+
while (next < text.length && /\s/.test(text.charAt(next))) next++;
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Vim's `e`: the last grapheme of the word the cursor is about to run into. */
|
|
87
|
+
function wordEnd(text: string, col: number): number {
|
|
88
|
+
let from = nextGraphemeStart(text, col);
|
|
89
|
+
while (from < text.length && /\s/.test(text.charAt(from))) from++;
|
|
90
|
+
const end = moveWordRight(text, from);
|
|
91
|
+
return Math.max(col, prevGraphemeStart(text, end));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** End-exclusive span an `iw`/`a(`-style text object resolves to. */
|
|
95
|
+
interface TextObjectRange {
|
|
96
|
+
from: VimPosition;
|
|
97
|
+
to: VimPosition;
|
|
98
|
+
linewise: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `0` whitespace, `1` keyword, `2` punctuation — the classes `w` groups by (`W` folds 2 into 1). */
|
|
102
|
+
function charClass(ch: string, big: boolean): 0 | 1 | 2 {
|
|
103
|
+
if (/\s/.test(ch)) return 0;
|
|
104
|
+
if (big || /[\p{L}\p{N}_]/u.test(ch)) return 1;
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Chunk {
|
|
109
|
+
start: number;
|
|
110
|
+
end: number;
|
|
111
|
+
space: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Split a line into maximal same-class runs — the atoms a counted `iw`/`aw` walks over. */
|
|
115
|
+
function chunkLine(text: string, big: boolean): Chunk[] {
|
|
116
|
+
const chunks: Chunk[] = [];
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < text.length) {
|
|
119
|
+
const cls = charClass(text.charAt(i), big);
|
|
120
|
+
let j = i + 1;
|
|
121
|
+
while (j < text.length && charClass(text.charAt(j), big) === cls) j++;
|
|
122
|
+
chunks.push({ start: i, end: j, space: cls === 0 });
|
|
123
|
+
i = j;
|
|
124
|
+
}
|
|
125
|
+
return chunks;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `iw`/`aw`/`iW`/`aW`, line-local like Vim's. `aw` takes the trailing whitespace run, or the
|
|
130
|
+
* leading one when the word ends the line; starting on whitespace instead takes it plus the
|
|
131
|
+
* following word.
|
|
132
|
+
*/
|
|
133
|
+
function wordObject(text: string, col: number, around: boolean, big: boolean, count: number): [number, number] | null {
|
|
134
|
+
const chunks = chunkLine(text, big);
|
|
135
|
+
if (chunks.length === 0) return null;
|
|
136
|
+
const last = chunks.length - 1;
|
|
137
|
+
let first = chunks.findIndex(chunk => col < chunk.end);
|
|
138
|
+
if (first < 0) first = last;
|
|
139
|
+
let end = first;
|
|
140
|
+
if (!around) {
|
|
141
|
+
end = Math.min(first + count - 1, last);
|
|
142
|
+
} else if (chunks[first]!.space) {
|
|
143
|
+
end = Math.min(first + 2 * count - 1, last);
|
|
144
|
+
} else {
|
|
145
|
+
for (let n = 0; n < count; n++) {
|
|
146
|
+
if (n > 0 && end < last) end++;
|
|
147
|
+
if (end < last && chunks[end + 1]!.space) end++;
|
|
148
|
+
}
|
|
149
|
+
if (!chunks[end]!.space && first > 0 && chunks[first - 1]!.space) first--;
|
|
150
|
+
}
|
|
151
|
+
return [chunks[first]!.start, chunks[end]!.end];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `i"`/`a"` and friends, line-local like Vim's: quotes pair off left to right, and the cursor
|
|
156
|
+
* selects the first pair that ends at or after it. `a` swallows the trailing whitespace, or the
|
|
157
|
+
* leading run when there is none.
|
|
158
|
+
*/
|
|
159
|
+
function quoteObject(text: string, col: number, quote: string, around: boolean): [number, number] | null {
|
|
160
|
+
const marks: number[] = [];
|
|
161
|
+
for (let i = 0; i < text.length; i++) {
|
|
162
|
+
if (text.charAt(i) === "\\") i++;
|
|
163
|
+
else if (text.charAt(i) === quote) marks.push(i);
|
|
164
|
+
}
|
|
165
|
+
for (let p = 0; p + 1 < marks.length; p += 2) {
|
|
166
|
+
const open = marks[p]!;
|
|
167
|
+
const close = marks[p + 1]!;
|
|
168
|
+
if (col > close) continue;
|
|
169
|
+
if (!around) return [open + 1, close];
|
|
170
|
+
let end = close + 1;
|
|
171
|
+
let start = open;
|
|
172
|
+
while (end < text.length && /\s/.test(text.charAt(end))) end++;
|
|
173
|
+
if (end === close + 1) while (start > 0 && /\s/.test(text.charAt(start - 1))) start--;
|
|
174
|
+
return [start, end];
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Buffer flattened to one string plus the cursor's offset in it, so scans can cross lines. */
|
|
180
|
+
function flatten(buf: VimBuffer): { text: string; cursor: number } {
|
|
181
|
+
let cursor = buf.cursorCol;
|
|
182
|
+
for (let i = 0; i < buf.cursorLine; i++) cursor += (buf.lines[i] ?? "").length + 1;
|
|
183
|
+
return { text: buf.lines.join("\n"), cursor };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function offsetToPos(lines: readonly string[], offset: number): VimPosition {
|
|
187
|
+
let remaining = offset;
|
|
188
|
+
for (let line = 0; line < lines.length; line++) {
|
|
189
|
+
const width = (lines[line] ?? "").length;
|
|
190
|
+
if (remaining <= width) return { line, col: remaining };
|
|
191
|
+
remaining -= width + 1;
|
|
192
|
+
}
|
|
193
|
+
const line = Math.max(0, lines.length - 1);
|
|
194
|
+
return { line, col: (lines[line] ?? "").length };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `i(`/`a{`… — the innermost pair enclosing the cursor, counting nesting and spanning lines. A
|
|
199
|
+
* cursor sitting on either delimiter counts as being on that pair. Charwise in both variants;
|
|
200
|
+
* Vim's linewise-ish `i{` reshaping is deliberately not reproduced.
|
|
201
|
+
*/
|
|
202
|
+
function bracketObject(buf: VimBuffer, open: string, close: string, around: boolean): TextObjectRange | null {
|
|
203
|
+
const { text, cursor } = flatten(buf);
|
|
204
|
+
let depth = 0;
|
|
205
|
+
let openAt = -1;
|
|
206
|
+
for (let i = Math.min(cursor, text.length - 1); i >= 0; i--) {
|
|
207
|
+
const ch = text.charAt(i);
|
|
208
|
+
if (ch === close && i !== cursor) depth++;
|
|
209
|
+
else if (ch === open) {
|
|
210
|
+
if (depth === 0) {
|
|
211
|
+
openAt = i;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
depth--;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (openAt < 0) return null;
|
|
218
|
+
depth = 0;
|
|
219
|
+
let closeAt = -1;
|
|
220
|
+
for (let i = openAt + 1; i < text.length; i++) {
|
|
221
|
+
const ch = text.charAt(i);
|
|
222
|
+
if (ch === open) depth++;
|
|
223
|
+
else if (ch === close) {
|
|
224
|
+
if (depth === 0) {
|
|
225
|
+
closeAt = i;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
depth--;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (closeAt < 0) return null;
|
|
232
|
+
const from = offsetToPos(buf.lines, around ? openAt : openAt + 1);
|
|
233
|
+
const to = offsetToPos(buf.lines, around ? closeAt + 1 : closeAt);
|
|
234
|
+
return { from, to, linewise: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `ip`/`ap`: the run of lines matching the cursor line's blankness. `ap` also takes the run that
|
|
239
|
+
* follows, or the one before it when the paragraph ends the buffer. Always linewise.
|
|
240
|
+
*/
|
|
241
|
+
function paragraphObject(buf: VimBuffer, around: boolean): TextObjectRange {
|
|
242
|
+
const last = buf.lines.length - 1;
|
|
243
|
+
const blank = (line: number): boolean => (buf.lines[line] ?? "").trim() === "";
|
|
244
|
+
const target = blank(buf.cursorLine);
|
|
245
|
+
let first = buf.cursorLine;
|
|
246
|
+
let end = buf.cursorLine;
|
|
247
|
+
while (first > 0 && blank(first - 1) === target) first--;
|
|
248
|
+
while (end < last && blank(end + 1) === target) end++;
|
|
249
|
+
if (around) {
|
|
250
|
+
const stop = end;
|
|
251
|
+
while (end < last && blank(end + 1) !== target) end++;
|
|
252
|
+
if (end === stop) while (first > 0 && blank(first - 1) !== target) first--;
|
|
253
|
+
}
|
|
254
|
+
return { from: { line: first, col: 0 }, to: { line: end, col: (buf.lines[end] ?? "").length }, linewise: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface Motion {
|
|
258
|
+
to: VimPosition;
|
|
259
|
+
/** Inclusive motions cover the grapheme under `to` when used with an operator. */
|
|
260
|
+
inclusive: boolean;
|
|
261
|
+
linewise: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export class VimState {
|
|
265
|
+
mode: VimMode = "normal";
|
|
266
|
+
/** Fixed end of a Visual selection; the cursor is the moving end. */
|
|
267
|
+
anchor: VimPosition | null = null;
|
|
268
|
+
|
|
269
|
+
#count = "";
|
|
270
|
+
#operator: VimOperator | null = null;
|
|
271
|
+
#pendingG = false;
|
|
272
|
+
/** `i` or `a` typed after an operator or in Visual mode — waiting for the object key. */
|
|
273
|
+
#textObject: "i" | "a" | null = null;
|
|
274
|
+
/**
|
|
275
|
+
* Vim's "desired column": `j`/`k` remember the column you started from, so descending through a
|
|
276
|
+
* short line and back out returns to it instead of collapsing permanently. `null` means the
|
|
277
|
+
* next vertical motion anchors it to the live cursor column; `Infinity` is `$`'s sticky
|
|
278
|
+
* end-of-line, which keeps `$j` on the end of each line. Every non-vertical command clears it.
|
|
279
|
+
*/
|
|
280
|
+
#desiredCol: number | null = null;
|
|
281
|
+
|
|
282
|
+
/** True while a count, operator, `g`, or text-object prefix is half-typed — Escape cancels it. */
|
|
283
|
+
get pending(): boolean {
|
|
284
|
+
return this.#count.length > 0 || this.#operator !== null || this.#pendingG || this.#textObject !== null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The half-typed command as Vim would echo it (`"2"`, `"d"`, `"2d"`, `"di"`) — empty when
|
|
289
|
+
* nothing is pending. Hosts render this next to the mode so a partially entered operator is
|
|
290
|
+
* visible instead of silently swallowing the next keystroke.
|
|
291
|
+
*/
|
|
292
|
+
get pendingText(): string {
|
|
293
|
+
return `${this.#count}${this.#operator ?? ""}${this.#pendingG ? "g" : ""}${this.#textObject ?? ""}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
get visual(): boolean {
|
|
297
|
+
return this.mode === "visual" || this.mode === "visual-line";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
reset(): void {
|
|
301
|
+
this.mode = "normal";
|
|
302
|
+
this.anchor = null;
|
|
303
|
+
this.#desiredCol = null;
|
|
304
|
+
this.#clearPending();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#clearPending(): void {
|
|
308
|
+
this.#count = "";
|
|
309
|
+
this.#operator = null;
|
|
310
|
+
this.#pendingG = false;
|
|
311
|
+
this.#textObject = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#takeCount(): number {
|
|
315
|
+
const count = this.#count.length > 0 ? Number.parseInt(this.#count, 10) : 1;
|
|
316
|
+
this.#count = "";
|
|
317
|
+
return Math.max(1, count);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Clamp a position so the Normal-mode cursor rests *on* a grapheme rather than past the last. */
|
|
321
|
+
#clampNormal(buf: VimBuffer, pos: VimPosition): VimPosition {
|
|
322
|
+
const line = Math.max(0, Math.min(pos.line, buf.lines.length - 1));
|
|
323
|
+
const text = buf.lines[line] ?? "";
|
|
324
|
+
return { line, col: Math.max(0, Math.min(pos.col, lastGraphemeStart(text))) };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Handle one key. `key` is either the literal `"escape"` or a single grapheme; the editor
|
|
329
|
+
* normalizes arrow/Home/End keys onto their Vim equivalents before calling.
|
|
330
|
+
*
|
|
331
|
+
* Returns the commands to apply, or `null` when the key is not ours — the editor then falls
|
|
332
|
+
* through to its regular handling (and, for Escape in Normal mode, to the app's interrupt).
|
|
333
|
+
*/
|
|
334
|
+
handleKey(key: string, buf: VimBuffer): VimCommand[] | null {
|
|
335
|
+
if (key === "escape") return this.#handleEscape(buf);
|
|
336
|
+
if (this.mode === "insert") return null;
|
|
337
|
+
|
|
338
|
+
// Count prefix. `0` is the line-start motion unless it extends a count already being typed.
|
|
339
|
+
if ((key >= "1" && key <= "9") || (key === "0" && this.#count.length > 0)) {
|
|
340
|
+
this.#count += key;
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (this.#pendingG) {
|
|
345
|
+
this.#pendingG = false;
|
|
346
|
+
if (key !== "g") {
|
|
347
|
+
this.#clearPending();
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
// `gg` goes to the first line; `5gg` to line 5.
|
|
351
|
+
this.#desiredCol = null;
|
|
352
|
+
const line = Math.min(this.#takeCount() - 1, buf.lines.length - 1);
|
|
353
|
+
return this.#applyMotion(buf, { to: { line, col: 0 }, inclusive: false, linewise: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (this.#textObject !== null) {
|
|
357
|
+
this.#desiredCol = null;
|
|
358
|
+
return this.#applyTextObject(key, buf);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// `i`/`a` only introduce a text object where they cannot mean "insert": after an operator,
|
|
362
|
+
// or in Visual mode. Bare `i` in Normal mode still enters Insert.
|
|
363
|
+
if ((key === "i" || key === "a") && (this.#operator !== null || this.visual)) {
|
|
364
|
+
this.#textObject = key;
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Only consecutive `j`/`k` carry the desired column; anything else re-anchors it. Counts and
|
|
369
|
+
// the `g` prefix returned above, so `2j` still continues an established column.
|
|
370
|
+
if (key !== "j" && key !== "k") this.#desiredCol = null;
|
|
371
|
+
|
|
372
|
+
const motion = this.#resolveMotion(key, buf);
|
|
373
|
+
if (motion) return this.#applyMotion(buf, motion);
|
|
374
|
+
|
|
375
|
+
return this.visual ? this.#handleVisualKey(key, buf) : this.#handleNormalKey(key, buf);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#handleEscape(buf: VimBuffer): VimCommand[] | null {
|
|
379
|
+
if (this.pending) {
|
|
380
|
+
this.#clearPending();
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
if (this.visual) {
|
|
384
|
+
this.mode = "normal";
|
|
385
|
+
this.anchor = null;
|
|
386
|
+
return [
|
|
387
|
+
{ kind: "mode", mode: "normal" },
|
|
388
|
+
{ kind: "move", to: this.#clampNormal(buf, cursorOf(buf)) },
|
|
389
|
+
];
|
|
390
|
+
}
|
|
391
|
+
if (this.mode === "insert") {
|
|
392
|
+
this.mode = "normal";
|
|
393
|
+
const text = buf.lines[buf.cursorLine] ?? "";
|
|
394
|
+
return [
|
|
395
|
+
{ kind: "mode", mode: "normal" },
|
|
396
|
+
{ kind: "move", to: { line: buf.cursorLine, col: prevGraphemeStart(text, buf.cursorCol) } },
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
// Normal mode with nothing pending: leave Escape to the app (interrupt / clear draft).
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#resolveMotion(key: string, buf: VimBuffer): Motion | null {
|
|
404
|
+
const count = this.#count.length > 0 ? Number.parseInt(this.#count, 10) : 1;
|
|
405
|
+
const line = buf.lines[buf.cursorLine] ?? "";
|
|
406
|
+
const at = (col: number): VimPosition => ({ line: buf.cursorLine, col });
|
|
407
|
+
|
|
408
|
+
switch (key === " " ? "l" : key) {
|
|
409
|
+
case "h": {
|
|
410
|
+
let col = buf.cursorCol;
|
|
411
|
+
for (let i = 0; i < count; i++) col = prevGraphemeStart(line, col);
|
|
412
|
+
return { to: at(col), inclusive: false, linewise: false };
|
|
413
|
+
}
|
|
414
|
+
case "l": {
|
|
415
|
+
let col = buf.cursorCol;
|
|
416
|
+
for (let i = 0; i < count; i++) col = nextGraphemeStart(line, col);
|
|
417
|
+
return { to: at(col), inclusive: false, linewise: false };
|
|
418
|
+
}
|
|
419
|
+
case "j":
|
|
420
|
+
case "k": {
|
|
421
|
+
const delta = key === "j" ? count : -count;
|
|
422
|
+
const target = Math.max(0, Math.min(buf.cursorLine + delta, buf.lines.length - 1));
|
|
423
|
+
// Anchor the desired column on the first vertical move, then keep reusing it. The host
|
|
424
|
+
// clamps the target to each line's length, so short lines en route never shrink it.
|
|
425
|
+
this.#desiredCol ??= buf.cursorCol;
|
|
426
|
+
return { to: { line: target, col: this.#desiredCol }, inclusive: false, linewise: true };
|
|
427
|
+
}
|
|
428
|
+
case "0":
|
|
429
|
+
return { to: at(0), inclusive: false, linewise: false };
|
|
430
|
+
case "^":
|
|
431
|
+
return { to: at(firstNonBlank(line)), inclusive: false, linewise: false };
|
|
432
|
+
case "$":
|
|
433
|
+
// Sticky end-of-line, so `$j` lands on the end of each line rather than a fixed column.
|
|
434
|
+
this.#desiredCol = Number.POSITIVE_INFINITY;
|
|
435
|
+
return { to: at(line.length), inclusive: false, linewise: false };
|
|
436
|
+
case "w": {
|
|
437
|
+
let col = buf.cursorCol;
|
|
438
|
+
// Vim's `cw` quirk: standing on a non-blank, it changes to the end of the word like
|
|
439
|
+
// `ce` rather than swallowing the whitespace that follows it.
|
|
440
|
+
if (this.#operator === "c" && !/\s/.test(line.charAt(col))) {
|
|
441
|
+
for (let i = 0; i < count; i++) col = wordEnd(line, col);
|
|
442
|
+
return { to: at(col), inclusive: true, linewise: false };
|
|
443
|
+
}
|
|
444
|
+
for (let i = 0; i < count; i++) col = wordForward(line, col);
|
|
445
|
+
return { to: at(col), inclusive: false, linewise: false };
|
|
446
|
+
}
|
|
447
|
+
case "b": {
|
|
448
|
+
let col = buf.cursorCol;
|
|
449
|
+
for (let i = 0; i < count; i++) col = moveWordLeft(line, col);
|
|
450
|
+
return { to: at(col), inclusive: false, linewise: false };
|
|
451
|
+
}
|
|
452
|
+
case "e": {
|
|
453
|
+
let col = buf.cursorCol;
|
|
454
|
+
for (let i = 0; i < count; i++) col = wordEnd(line, col);
|
|
455
|
+
return { to: at(col), inclusive: true, linewise: false };
|
|
456
|
+
}
|
|
457
|
+
case "G": {
|
|
458
|
+
const target = this.#count.length > 0 ? count - 1 : buf.lines.length - 1;
|
|
459
|
+
return {
|
|
460
|
+
to: { line: Math.max(0, Math.min(target, buf.lines.length - 1)), col: 0 },
|
|
461
|
+
inclusive: false,
|
|
462
|
+
linewise: true,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
default:
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Turn a resolved motion into either a cursor move, a selection extension, or an operator range. */
|
|
471
|
+
#applyMotion(buf: VimBuffer, motion: Motion): VimCommand[] {
|
|
472
|
+
const operator = this.#operator;
|
|
473
|
+
this.#operator = null;
|
|
474
|
+
this.#takeCount();
|
|
475
|
+
|
|
476
|
+
if (operator === null) {
|
|
477
|
+
// `$` parks on the last grapheme in Normal mode but must still be able to select the
|
|
478
|
+
// final character in Visual mode, where the cursor is allowed one past it.
|
|
479
|
+
const to = this.visual
|
|
480
|
+
? { line: motion.to.line, col: Math.min(motion.to.col, (buf.lines[motion.to.line] ?? "").length) }
|
|
481
|
+
: this.#clampNormal(buf, motion.to);
|
|
482
|
+
return [{ kind: "move", to }];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const from: VimPosition = { line: buf.cursorLine, col: buf.cursorCol };
|
|
486
|
+
let start = from;
|
|
487
|
+
let end = motion.to;
|
|
488
|
+
if (end.line < start.line || (end.line === start.line && end.col < start.col)) [start, end] = [end, start];
|
|
489
|
+
if (motion.inclusive) {
|
|
490
|
+
const text = buf.lines[end.line] ?? "";
|
|
491
|
+
end = { line: end.line, col: nextGraphemeStart(text, end.col) };
|
|
492
|
+
}
|
|
493
|
+
return this.#operate(operator, start, end, motion.linewise);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
#operate(operator: VimOperator, from: VimPosition, to: VimPosition, linewise: boolean): VimCommand[] {
|
|
497
|
+
if (operator === "y") {
|
|
498
|
+
return [
|
|
499
|
+
{ kind: "yank", from, to, linewise },
|
|
500
|
+
{ kind: "move", to: from },
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
if (operator !== "c") {
|
|
504
|
+
return [{ kind: "delete", from, to, linewise, insert: false }];
|
|
505
|
+
}
|
|
506
|
+
// `c` always lands in Insert mode. The leading move matters when the range is empty
|
|
507
|
+
// (`ci"` between bare quotes): the delete is a no-op, so nothing else would park the cursor.
|
|
508
|
+
// `cc`/`cj` clear the lines but keep them, so a linewise change stays linewise-shaped.
|
|
509
|
+
this.mode = "insert";
|
|
510
|
+
return [
|
|
511
|
+
{ kind: "move", to: from },
|
|
512
|
+
{ kind: "delete", from, to, linewise: false, insert: true },
|
|
513
|
+
{ kind: "mode", mode: "insert" },
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** `iw`, `a(`, `i"`, `ap`, … — resolved around the cursor rather than from a motion endpoint. */
|
|
518
|
+
#resolveTextObject(key: string, around: boolean, buf: VimBuffer, count: number): TextObjectRange | null {
|
|
519
|
+
const line = buf.lines[buf.cursorLine] ?? "";
|
|
520
|
+
const onLine = (range: [number, number] | null): TextObjectRange | null =>
|
|
521
|
+
range === null
|
|
522
|
+
? null
|
|
523
|
+
: {
|
|
524
|
+
from: { line: buf.cursorLine, col: range[0] },
|
|
525
|
+
to: { line: buf.cursorLine, col: range[1] },
|
|
526
|
+
linewise: false,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
switch (key) {
|
|
530
|
+
case "w":
|
|
531
|
+
case "W":
|
|
532
|
+
return onLine(wordObject(line, buf.cursorCol, around, key === "W", count));
|
|
533
|
+
case '"':
|
|
534
|
+
case "'":
|
|
535
|
+
case "`":
|
|
536
|
+
return onLine(quoteObject(line, buf.cursorCol, key, around));
|
|
537
|
+
case "(":
|
|
538
|
+
case ")":
|
|
539
|
+
case "b":
|
|
540
|
+
return bracketObject(buf, "(", ")", around);
|
|
541
|
+
case "[":
|
|
542
|
+
case "]":
|
|
543
|
+
return bracketObject(buf, "[", "]", around);
|
|
544
|
+
case "{":
|
|
545
|
+
case "}":
|
|
546
|
+
case "B":
|
|
547
|
+
return bracketObject(buf, "{", "}", around);
|
|
548
|
+
case "<":
|
|
549
|
+
case ">":
|
|
550
|
+
return bracketObject(buf, "<", ">", around);
|
|
551
|
+
case "p":
|
|
552
|
+
return paragraphObject(buf, around);
|
|
553
|
+
default:
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Consume the object key that follows a pending `i`/`a`. Under an operator the object becomes
|
|
560
|
+
* the operated range (`diw`); in Visual mode it becomes the selection instead (`viw`).
|
|
561
|
+
*/
|
|
562
|
+
#applyTextObject(key: string, buf: VimBuffer): VimCommand[] {
|
|
563
|
+
const around = this.#textObject === "a";
|
|
564
|
+
const operator = this.#operator;
|
|
565
|
+
this.#textObject = null;
|
|
566
|
+
this.#operator = null;
|
|
567
|
+
const range = this.#resolveTextObject(key, around, buf, this.#takeCount());
|
|
568
|
+
if (range === null) return [];
|
|
569
|
+
if (operator !== null) return this.#operate(operator, range.from, range.to, range.linewise);
|
|
570
|
+
|
|
571
|
+
this.anchor = range.from;
|
|
572
|
+
const commands: VimCommand[] = [];
|
|
573
|
+
// A linewise object in charwise Visual mode promotes the selection, as Vim's `vip` does.
|
|
574
|
+
if (range.linewise && this.mode === "visual") {
|
|
575
|
+
this.mode = "visual-line";
|
|
576
|
+
commands.push({ kind: "mode", mode: this.mode });
|
|
577
|
+
}
|
|
578
|
+
// The selection's moving end sits *on* the object's last grapheme, not one past it.
|
|
579
|
+
let to = range.to;
|
|
580
|
+
if (to.col > 0) to = { line: to.line, col: prevGraphemeStart(buf.lines[to.line] ?? "", to.col) };
|
|
581
|
+
else if (to.line > range.from.line)
|
|
582
|
+
to = { line: to.line - 1, col: lastGraphemeStart(buf.lines[to.line - 1] ?? "") };
|
|
583
|
+
else to = range.from;
|
|
584
|
+
commands.push({ kind: "move", to });
|
|
585
|
+
return commands;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#handleNormalKey(key: string, buf: VimBuffer): VimCommand[] | null {
|
|
589
|
+
const line = buf.lines[buf.cursorLine] ?? "";
|
|
590
|
+
const count = this.#count.length > 0 ? Number.parseInt(this.#count, 10) : 1;
|
|
591
|
+
|
|
592
|
+
switch (key) {
|
|
593
|
+
case "g":
|
|
594
|
+
this.#pendingG = true;
|
|
595
|
+
return [];
|
|
596
|
+
case "i":
|
|
597
|
+
this.#takeCount();
|
|
598
|
+
this.mode = "insert";
|
|
599
|
+
return [{ kind: "mode", mode: "insert" }];
|
|
600
|
+
case "a":
|
|
601
|
+
this.#takeCount();
|
|
602
|
+
this.mode = "insert";
|
|
603
|
+
return [
|
|
604
|
+
{ kind: "mode", mode: "insert" },
|
|
605
|
+
{ kind: "move", to: { line: buf.cursorLine, col: nextGraphemeStart(line, buf.cursorCol) } },
|
|
606
|
+
];
|
|
607
|
+
case "I":
|
|
608
|
+
this.#takeCount();
|
|
609
|
+
this.mode = "insert";
|
|
610
|
+
return [
|
|
611
|
+
{ kind: "mode", mode: "insert" },
|
|
612
|
+
{ kind: "move", to: { line: buf.cursorLine, col: firstNonBlank(line) } },
|
|
613
|
+
];
|
|
614
|
+
case "A":
|
|
615
|
+
this.#takeCount();
|
|
616
|
+
this.mode = "insert";
|
|
617
|
+
return [
|
|
618
|
+
{ kind: "mode", mode: "insert" },
|
|
619
|
+
{ kind: "move", to: { line: buf.cursorLine, col: line.length } },
|
|
620
|
+
];
|
|
621
|
+
case "o":
|
|
622
|
+
case "O":
|
|
623
|
+
this.#takeCount();
|
|
624
|
+
this.mode = "insert";
|
|
625
|
+
return [
|
|
626
|
+
{ kind: "openLine", below: key === "o" },
|
|
627
|
+
{ kind: "mode", mode: "insert" },
|
|
628
|
+
];
|
|
629
|
+
case "v":
|
|
630
|
+
case "V":
|
|
631
|
+
this.#takeCount();
|
|
632
|
+
this.mode = key === "v" ? "visual" : "visual-line";
|
|
633
|
+
this.anchor = { line: buf.cursorLine, col: buf.cursorCol };
|
|
634
|
+
return [{ kind: "mode", mode: this.mode }];
|
|
635
|
+
case "x": {
|
|
636
|
+
this.#takeCount();
|
|
637
|
+
let col = buf.cursorCol;
|
|
638
|
+
for (let i = 0; i < count && col < line.length; i++) col = nextGraphemeStart(line, col);
|
|
639
|
+
if (col === buf.cursorCol) return [];
|
|
640
|
+
return [
|
|
641
|
+
{
|
|
642
|
+
kind: "delete",
|
|
643
|
+
from: { line: buf.cursorLine, col: buf.cursorCol },
|
|
644
|
+
to: { line: buf.cursorLine, col },
|
|
645
|
+
linewise: false,
|
|
646
|
+
insert: false,
|
|
647
|
+
},
|
|
648
|
+
];
|
|
649
|
+
}
|
|
650
|
+
case "D":
|
|
651
|
+
case "C": {
|
|
652
|
+
// Like Vim, `D`/`C` take a count: `2D` deletes to the end of the next line, not just
|
|
653
|
+
// this one (`:h D` — "and [count]-1 more lines").
|
|
654
|
+
const span = this.#takeCount();
|
|
655
|
+
const last = Math.min(buf.cursorLine + span - 1, buf.lines.length - 1);
|
|
656
|
+
return this.#operate(
|
|
657
|
+
key === "C" ? "c" : "d",
|
|
658
|
+
{ line: buf.cursorLine, col: buf.cursorCol },
|
|
659
|
+
{ line: last, col: (buf.lines[last] ?? "").length },
|
|
660
|
+
false,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
case "d":
|
|
664
|
+
case "y":
|
|
665
|
+
case "c":
|
|
666
|
+
// A doubled operator (`dd`, `yy`, `cc`) is linewise over `count` lines.
|
|
667
|
+
if (this.#operator === key) {
|
|
668
|
+
const span = this.#takeCount();
|
|
669
|
+
const last = Math.min(buf.cursorLine + span - 1, buf.lines.length - 1);
|
|
670
|
+
this.#operator = null;
|
|
671
|
+
return this.#operate(
|
|
672
|
+
key,
|
|
673
|
+
{ line: buf.cursorLine, col: 0 },
|
|
674
|
+
{ line: last, col: (buf.lines[last] ?? "").length },
|
|
675
|
+
true,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
this.#operator = key;
|
|
679
|
+
return [];
|
|
680
|
+
case "p":
|
|
681
|
+
case "P":
|
|
682
|
+
return [{ kind: "paste", after: key === "p", count: this.#takeCount() }];
|
|
683
|
+
case "u":
|
|
684
|
+
this.#takeCount();
|
|
685
|
+
return [{ kind: "undo" }];
|
|
686
|
+
default:
|
|
687
|
+
// Normal mode swallows unknown printable keys rather than typing them into the buffer.
|
|
688
|
+
this.#clearPending();
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
#handleVisualKey(key: string, buf: VimBuffer): VimCommand[] | null {
|
|
694
|
+
const anchor = this.anchor ?? { line: buf.cursorLine, col: buf.cursorCol };
|
|
695
|
+
const linewise = this.mode === "visual-line";
|
|
696
|
+
|
|
697
|
+
switch (key) {
|
|
698
|
+
case "v":
|
|
699
|
+
case "V": {
|
|
700
|
+
const next = key === "v" ? "visual" : "visual-line";
|
|
701
|
+
if (this.mode === next) {
|
|
702
|
+
this.mode = "normal";
|
|
703
|
+
this.anchor = null;
|
|
704
|
+
return [
|
|
705
|
+
{ kind: "mode", mode: "normal" },
|
|
706
|
+
{ kind: "move", to: this.#clampNormal(buf, cursorOf(buf)) },
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
this.mode = next;
|
|
710
|
+
return [{ kind: "mode", mode: next }];
|
|
711
|
+
}
|
|
712
|
+
case "o": {
|
|
713
|
+
this.anchor = { line: buf.cursorLine, col: buf.cursorCol };
|
|
714
|
+
return [{ kind: "move", to: anchor }];
|
|
715
|
+
}
|
|
716
|
+
case "y":
|
|
717
|
+
case "d":
|
|
718
|
+
case "x":
|
|
719
|
+
case "c":
|
|
720
|
+
case "s": {
|
|
721
|
+
const operator: VimOperator = key === "y" ? "y" : key === "c" || key === "s" ? "c" : "d";
|
|
722
|
+
const { from, to } = visualRange(buf, anchor, linewise);
|
|
723
|
+
this.#clearPending();
|
|
724
|
+
this.anchor = null;
|
|
725
|
+
// `#operate` switches to Insert itself for `c`; everything else drops back to Normal.
|
|
726
|
+
if (operator !== "c") this.mode = "normal";
|
|
727
|
+
const commands = this.#operate(operator, from, to, linewise);
|
|
728
|
+
return operator === "c" ? commands : [...commands, { kind: "mode", mode: "normal" }];
|
|
729
|
+
}
|
|
730
|
+
default:
|
|
731
|
+
this.#clearPending();
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Normalized, end-exclusive span covered by a Visual selection.
|
|
739
|
+
*
|
|
740
|
+
* Charwise selections include the grapheme under the cursor (Vim semantics); linewise selections
|
|
741
|
+
* span whole lines, with `to.col` at the end of the last line so the caller can decide whether the
|
|
742
|
+
* trailing newline goes too.
|
|
743
|
+
*/
|
|
744
|
+
export function visualRange(
|
|
745
|
+
buf: VimBuffer,
|
|
746
|
+
anchor: VimPosition,
|
|
747
|
+
linewise: boolean,
|
|
748
|
+
): { from: VimPosition; to: VimPosition } {
|
|
749
|
+
const cursor: VimPosition = { line: buf.cursorLine, col: buf.cursorCol };
|
|
750
|
+
let from = anchor;
|
|
751
|
+
let to = cursor;
|
|
752
|
+
if (to.line < from.line || (to.line === from.line && to.col < from.col)) [from, to] = [to, from];
|
|
753
|
+
|
|
754
|
+
if (linewise) {
|
|
755
|
+
const lastLine = Math.min(to.line, buf.lines.length - 1);
|
|
756
|
+
return { from: { line: from.line, col: 0 }, to: { line: lastLine, col: (buf.lines[lastLine] ?? "").length } };
|
|
757
|
+
}
|
|
758
|
+
const text = buf.lines[to.line] ?? "";
|
|
759
|
+
return { from, to: { line: to.line, col: nextGraphemeStart(text, to.col) } };
|
|
760
|
+
}
|