pi-vim 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -62
- package/index.ts +366 -175
- package/motions.ts +23 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,34 +50,42 @@ Insert-mode shortcuts (stay in Insert mode):
|
|
|
50
50
|
|
|
51
51
|
### Navigation (Normal mode)
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
|
56
|
-
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
|
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
|
|
|
65
|
-
| `
|
|
53
|
+
A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
54
|
+
|
|
55
|
+
| Key | Action |
|
|
56
|
+
|---------------|-------------------------------|
|
|
57
|
+
| `h` | Left |
|
|
58
|
+
| `l` | Right |
|
|
59
|
+
| `j` | Down |
|
|
60
|
+
| `k` | Up |
|
|
61
|
+
| `{count}h/l` | Move left/right `{count}` cols |
|
|
62
|
+
| `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
|
|
63
|
+
| `0` | Line start |
|
|
64
|
+
| `$` | Line end |
|
|
65
|
+
| `gg` | Buffer start (line 1) |
|
|
66
|
+
| `G` | Buffer end (last line) |
|
|
67
|
+
| `w` | Next word start |
|
|
68
|
+
| `b` | Previous word start |
|
|
69
|
+
| `e` | Word end (inclusive) |
|
|
70
|
+
| `{count}w/b/e`| Move `{count}` words |
|
|
66
71
|
|
|
67
72
|
---
|
|
68
73
|
|
|
69
74
|
### Character-find motions (Normal mode)
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|------------|------------------------------------------------|
|
|
73
|
-
| `f{char}` | Jump forward to `char` (inclusive) |
|
|
74
|
-
| `F{char}` | Jump backward to `char` (inclusive) |
|
|
75
|
-
| `t{char}` | Jump forward to one before `char` (exclusive) |
|
|
76
|
-
| `T{char}` | Jump backward to one after `char` (exclusive) |
|
|
77
|
-
| `;` | Repeat last `f/F/t/T` motion |
|
|
78
|
-
| `,` | Repeat last motion in reverse direction |
|
|
76
|
+
A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
|
|
79
77
|
|
|
80
|
-
|
|
78
|
+
| Key | Action |
|
|
79
|
+
|------------------|------------------------------------------------|
|
|
80
|
+
| `f{char}` | Jump forward to `char` (inclusive) |
|
|
81
|
+
| `F{char}` | Jump backward to `char` (inclusive) |
|
|
82
|
+
| `t{char}` | Jump forward to one before `char` (exclusive) |
|
|
83
|
+
| `T{char}` | Jump backward to one after `char` (exclusive) |
|
|
84
|
+
| `{count}f{char}` | Jump to Nth occurrence of `char` forward |
|
|
85
|
+
| `;` | Repeat last `f/F/t/T` motion |
|
|
86
|
+
| `,` | Repeat last motion in reverse direction |
|
|
87
|
+
|
|
88
|
+
Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{char}`, etc.
|
|
81
89
|
|
|
82
90
|
---
|
|
83
91
|
|
|
@@ -88,47 +96,57 @@ All operators write to the unnamed register and mirror to the system clipboard
|
|
|
88
96
|
|
|
89
97
|
#### Delete `d{motion}` / `dd`
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
|
95
|
-
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `{count}
|
|
100
|
-
| `d
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
99
|
+
A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
|
|
100
|
+
word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
101
|
+
|
|
102
|
+
| Command | Deletes |
|
|
103
|
+
|-------------------|-----------------------------------------------------------|
|
|
104
|
+
| `dw` | Forward to next word start (exclusive, can cross lines) |
|
|
105
|
+
| `de` | Forward to word end (inclusive, can cross lines) |
|
|
106
|
+
| `db` | Backward to word start (exclusive, can cross lines) |
|
|
107
|
+
| `d{count}w/e/b` | Forward/backward `{count}` words |
|
|
108
|
+
| `d$` | To end of line |
|
|
109
|
+
| `d0` | To start of line |
|
|
110
|
+
| `dd` | Current line (linewise) |
|
|
111
|
+
| `{count}dd` | `{count}` lines (linewise) |
|
|
112
|
+
| `d{count}j` | Current line + `{count}` lines below (linewise) |
|
|
113
|
+
| `d{count}k` | Current line + `{count}` lines above (linewise) |
|
|
114
|
+
| `dG` | Current line to end of buffer (linewise) |
|
|
115
|
+
| `df{char}` | To and including `char` |
|
|
116
|
+
| `d{count}f{char}` | To and including Nth `char` |
|
|
117
|
+
| `dt{char}` | Up to (not including) `char` |
|
|
118
|
+
| `dF{char}` | Backward to and including `char` |
|
|
119
|
+
| `dT{char}` | Backward to one after `char` |
|
|
120
|
+
| `diw` | Inner word |
|
|
121
|
+
| `daw` | Around word (includes surrounding spaces) |
|
|
122
|
+
| `d{count}aw` | Around `{count}` words |
|
|
109
123
|
|
|
110
124
|
#### Change `c{motion}` / `cc`
|
|
111
125
|
|
|
112
|
-
Same motion set as `d`. Deletes text then enters Insert mode.
|
|
126
|
+
Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
113
127
|
|
|
114
|
-
| Command
|
|
115
|
-
|
|
116
|
-
| `cw`
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
|
|
|
128
|
+
| Command | Action |
|
|
129
|
+
|-----------------|------------------------------------|
|
|
130
|
+
| `cw` | Delete word + Insert |
|
|
131
|
+
| `c{count}w/e/b` | Delete `{count}` words + Insert |
|
|
132
|
+
| `ciw` | Change inner word |
|
|
133
|
+
| `caw` | Change around word |
|
|
134
|
+
| `cc` | Delete line content + Insert |
|
|
135
|
+
| `c$` | Delete to EOL + Insert |
|
|
136
|
+
| … | All `d` motions apply |
|
|
122
137
|
|
|
123
138
|
#### Single-key edits
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
|
128
|
-
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
140
|
+
A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
141
|
+
|
|
142
|
+
| Key | Action |
|
|
143
|
+
|--------------|---------------------------------------------------------------|
|
|
144
|
+
| `x` | Delete char under cursor (no-op at/past EOL) |
|
|
145
|
+
| `{count}x` | Delete `{count}` chars |
|
|
146
|
+
| `s` | Delete char under cursor + Insert mode |
|
|
147
|
+
| `S` | Delete line content + Insert mode |
|
|
148
|
+
| `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
|
|
149
|
+
| `C` | Delete cursor to EOL + Insert mode |
|
|
132
150
|
|
|
133
151
|
---
|
|
134
152
|
|
|
@@ -156,10 +174,12 @@ Same motion set as `d`. Writes to register, **no text mutation**.
|
|
|
156
174
|
|
|
157
175
|
### Put / Paste
|
|
158
176
|
|
|
159
|
-
| Key
|
|
160
|
-
|
|
161
|
-
| `p`
|
|
162
|
-
| `P`
|
|
177
|
+
| Key | Action |
|
|
178
|
+
|--------------|-------------------------------------------------------------|
|
|
179
|
+
| `p` | Put after cursor (char-wise) / new line below (line-wise) |
|
|
180
|
+
| `P` | Put before cursor (char-wise) / new line above (line-wise) |
|
|
181
|
+
| `{count}p` | Put `{count}` times after cursor |
|
|
182
|
+
| `{count}P` | Put `{count}` times before cursor |
|
|
163
183
|
|
|
164
184
|
Put reads from the **unnamed register** (not OS clipboard).
|
|
165
185
|
Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
@@ -199,7 +219,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
|
|
|
199
219
|
| Redo | Not implemented | `<C-r>` |
|
|
200
220
|
| Visual mode | Not implemented | `v`, `V`, `<C-v>` |
|
|
201
221
|
| Text objects | Supports `iw`/`aw` only | Full text-object set |
|
|
202
|
-
| Count prefix |
|
|
222
|
+
| Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
|
|
203
223
|
| Named registers | Not implemented (`"a`, etc.) | Supported |
|
|
204
224
|
| Macros | Not implemented (`q`, `@`) | Supported |
|
|
205
225
|
| Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
|
|
@@ -219,7 +239,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
219
239
|
- Ex command surface (`:s`, `:g`, `:r`, …)
|
|
220
240
|
- Search mode (`/`, `?`, `n`, `N`)
|
|
221
241
|
- Repeat (`.`)
|
|
222
|
-
-
|
|
242
|
+
- Extended count prefix beyond currently supported motions (e.g. counted `gg`, `:`, global operator counts)
|
|
223
243
|
- Redo (`<C-r>`) — no native redo primitive in the underlying readline editor;
|
|
224
244
|
deferred until a suitable hook is available.
|
|
225
245
|
- Window / tab / buffer management
|
package/index.ts
CHANGED
|
@@ -88,14 +88,15 @@ import {
|
|
|
88
88
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
89
89
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
90
90
|
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
|
|
91
|
+
const MAX_COUNT = 9999;
|
|
91
92
|
|
|
92
93
|
export class ModalEditor extends CustomEditor {
|
|
93
94
|
private mode: Mode = "insert";
|
|
94
95
|
private pendingMotion: PendingMotion = null;
|
|
95
96
|
private pendingTextObject: "i" | "a" | null = null;
|
|
96
97
|
private pendingOperator: PendingOperator = null;
|
|
97
|
-
private
|
|
98
|
-
private
|
|
98
|
+
private prefixCount: string = "";
|
|
99
|
+
private operatorCount: string = "";
|
|
99
100
|
private pendingG: boolean = false;
|
|
100
101
|
private lastCharMotion: LastCharMotion | null = null;
|
|
101
102
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
@@ -119,8 +120,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
119
120
|
this.pendingMotion = null;
|
|
120
121
|
this.pendingTextObject = null;
|
|
121
122
|
this.pendingOperator = null;
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
123
|
+
this.prefixCount = "";
|
|
124
|
+
this.operatorCount = "";
|
|
124
125
|
this.pendingG = false;
|
|
125
126
|
}
|
|
126
127
|
|
|
@@ -272,7 +273,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
272
273
|
this.pendingMotion
|
|
273
274
|
|| this.pendingTextObject
|
|
274
275
|
|| this.pendingOperator
|
|
275
|
-
|| this.
|
|
276
|
+
|| this.prefixCount
|
|
277
|
+
|| this.operatorCount
|
|
276
278
|
|| this.pendingG
|
|
277
279
|
) {
|
|
278
280
|
this.clearPendingState();
|
|
@@ -307,21 +309,38 @@ export class ModalEditor extends CustomEditor {
|
|
|
307
309
|
return data.length === 1 && data >= "1" && data <= "9";
|
|
308
310
|
}
|
|
309
311
|
|
|
310
|
-
private
|
|
311
|
-
|
|
312
|
+
private takeTotalCount(defaultValue: number = 1): number {
|
|
313
|
+
const prefixRaw = this.prefixCount;
|
|
314
|
+
const operatorRaw = this.operatorCount;
|
|
315
|
+
this.prefixCount = "";
|
|
316
|
+
this.operatorCount = "";
|
|
312
317
|
|
|
313
|
-
|
|
314
|
-
this.pendingCount = "";
|
|
315
|
-
this.pendingCountKind = null;
|
|
318
|
+
if (!prefixRaw && !operatorRaw) return defaultValue;
|
|
316
319
|
|
|
317
|
-
|
|
318
|
-
|
|
320
|
+
const parse = (raw: string): number | null => {
|
|
321
|
+
if (!raw) return null;
|
|
322
|
+
const parsed = Number.parseInt(raw, 10);
|
|
323
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
324
|
+
return parsed;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const prefix = parse(prefixRaw);
|
|
328
|
+
const operator = parse(operatorRaw);
|
|
329
|
+
|
|
330
|
+
if (prefix === null && operator === null) return defaultValue;
|
|
331
|
+
|
|
332
|
+
const total = prefix !== null && operator !== null
|
|
333
|
+
? prefix * operator
|
|
334
|
+
: prefix ?? operator ?? defaultValue;
|
|
335
|
+
|
|
336
|
+
if (!Number.isFinite(total) || total <= 0) return defaultValue;
|
|
337
|
+
return Math.min(MAX_COUNT, total);
|
|
319
338
|
}
|
|
320
339
|
|
|
321
340
|
private cancelPendingOperator(data: string): void {
|
|
322
341
|
this.pendingOperator = null;
|
|
323
|
-
this.
|
|
324
|
-
this.
|
|
342
|
+
this.prefixCount = "";
|
|
343
|
+
this.operatorCount = "";
|
|
325
344
|
if (!this.isPrintableChunk(data)) {
|
|
326
345
|
super.handleInput(data);
|
|
327
346
|
}
|
|
@@ -358,7 +377,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
358
377
|
return;
|
|
359
378
|
}
|
|
360
379
|
|
|
361
|
-
const
|
|
380
|
+
const count = this.takeTotalCount(1);
|
|
381
|
+
const range = this.getWordObjectRange(this.pendingTextObject!, count);
|
|
362
382
|
this.pendingTextObject = null;
|
|
363
383
|
if (!range || !this.pendingOperator) {
|
|
364
384
|
this.pendingOperator = null;
|
|
@@ -390,43 +410,35 @@ export class ModalEditor extends CustomEditor {
|
|
|
390
410
|
|
|
391
411
|
private handlePendingDelete(data: string): void {
|
|
392
412
|
if (this.isDigit(data)) {
|
|
393
|
-
if (this.
|
|
413
|
+
if (this.operatorCount.length === 0) {
|
|
394
414
|
if (data !== "0") {
|
|
395
|
-
this.
|
|
396
|
-
this.pendingCountKind = "operator";
|
|
415
|
+
this.operatorCount = data;
|
|
397
416
|
return;
|
|
398
417
|
}
|
|
399
|
-
} else if (this.pendingCountKind === "operator") {
|
|
400
|
-
this.pendingCount += data;
|
|
401
|
-
return;
|
|
402
418
|
} else {
|
|
403
|
-
|
|
404
|
-
this.cancelPendingOperator(data);
|
|
419
|
+
this.operatorCount += data;
|
|
405
420
|
return;
|
|
406
421
|
}
|
|
407
422
|
}
|
|
408
423
|
|
|
409
424
|
if (data === "d") {
|
|
410
|
-
const count = this.
|
|
425
|
+
const count = this.takeTotalCount(1);
|
|
411
426
|
this.deleteLinewiseByDelta(count - 1);
|
|
412
427
|
this.pendingOperator = null;
|
|
413
428
|
return;
|
|
414
429
|
}
|
|
415
430
|
|
|
416
431
|
if (data === "j" || data === "k") {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const count = this.takePendingCount(1);
|
|
423
|
-
this.deleteLinewiseByDelta(data === "j" ? count : -count);
|
|
432
|
+
const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
433
|
+
const count = this.takeTotalCount(1);
|
|
434
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
435
|
+
this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
|
|
424
436
|
this.pendingOperator = null;
|
|
425
437
|
return;
|
|
426
438
|
}
|
|
427
439
|
|
|
428
440
|
if (data === "G") {
|
|
429
|
-
if (this.
|
|
441
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
430
442
|
this.cancelPendingOperator(data);
|
|
431
443
|
return;
|
|
432
444
|
}
|
|
@@ -436,22 +448,29 @@ export class ModalEditor extends CustomEditor {
|
|
|
436
448
|
return;
|
|
437
449
|
}
|
|
438
450
|
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
this.cancelPendingOperator(data);
|
|
451
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
452
|
+
this.pendingMotion = data as PendingMotion;
|
|
442
453
|
return;
|
|
443
454
|
}
|
|
444
455
|
|
|
445
|
-
|
|
446
|
-
|
|
456
|
+
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
457
|
+
const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
|
|
458
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
459
|
+
|
|
460
|
+
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
461
|
+
// Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and
|
|
462
|
+
// d{count}{w/e/b}/{i/a}w are out of scope.
|
|
463
|
+
this.cancelPendingOperator(data);
|
|
447
464
|
return;
|
|
448
465
|
}
|
|
449
|
-
|
|
450
|
-
|
|
466
|
+
|
|
467
|
+
if (supportsCountedTextObject) {
|
|
468
|
+
this.pendingTextObject = data;
|
|
451
469
|
return;
|
|
452
470
|
}
|
|
453
471
|
|
|
454
|
-
|
|
472
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
473
|
+
if (this.deleteWithMotion(data, motionCount)) {
|
|
455
474
|
this.pendingOperator = null;
|
|
456
475
|
return;
|
|
457
476
|
}
|
|
@@ -461,21 +480,50 @@ export class ModalEditor extends CustomEditor {
|
|
|
461
480
|
}
|
|
462
481
|
|
|
463
482
|
private handlePendingChange(data: string): void {
|
|
483
|
+
if (this.isDigit(data)) {
|
|
484
|
+
if (this.operatorCount.length === 0) {
|
|
485
|
+
if (data !== "0") {
|
|
486
|
+
this.operatorCount = data;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
this.operatorCount += data;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
464
495
|
if (data === "c") {
|
|
496
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
497
|
+
this.cancelPendingOperator(data);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
465
501
|
this.cutLine();
|
|
466
502
|
this.pendingOperator = null;
|
|
467
503
|
this.mode = "insert";
|
|
468
504
|
return;
|
|
469
505
|
}
|
|
470
|
-
if (data === "i" || data === "a") {
|
|
471
|
-
this.pendingTextObject = data;
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
506
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
475
507
|
this.pendingMotion = data as PendingMotion;
|
|
476
508
|
return;
|
|
477
509
|
}
|
|
478
|
-
|
|
510
|
+
|
|
511
|
+
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
512
|
+
const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
|
|
513
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
514
|
+
|
|
515
|
+
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
516
|
+
this.cancelPendingOperator(data);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (supportsCountedTextObject) {
|
|
521
|
+
this.pendingTextObject = data;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
526
|
+
if (this.deleteWithMotion(data, motionCount)) {
|
|
479
527
|
this.pendingOperator = null;
|
|
480
528
|
this.mode = "insert";
|
|
481
529
|
return;
|
|
@@ -495,23 +543,77 @@ export class ModalEditor extends CustomEditor {
|
|
|
495
543
|
// Unsupported g-prefix command: discard prefix and keep processing input.
|
|
496
544
|
}
|
|
497
545
|
|
|
498
|
-
if (this.
|
|
499
|
-
if (this.isDigit(data)
|
|
500
|
-
this.
|
|
546
|
+
if (this.prefixCount.length > 0) {
|
|
547
|
+
if (this.isDigit(data)) {
|
|
548
|
+
this.prefixCount += data;
|
|
501
549
|
return;
|
|
502
550
|
}
|
|
503
551
|
|
|
504
|
-
if (
|
|
552
|
+
if (data === "d" || data === "y") {
|
|
505
553
|
this.pendingOperator = data;
|
|
506
554
|
return;
|
|
507
555
|
}
|
|
508
556
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
557
|
+
if (data === "c") {
|
|
558
|
+
this.pendingOperator = "c";
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const supportsCountedStandaloneEdit = (
|
|
563
|
+
data === "x"
|
|
564
|
+
|| data === "s"
|
|
565
|
+
|| data === "S"
|
|
566
|
+
|| data === "D"
|
|
567
|
+
|| data === "C"
|
|
568
|
+
|| data === "p"
|
|
569
|
+
|| data === "P"
|
|
570
|
+
);
|
|
571
|
+
const supportsCountedCharMotion = (
|
|
572
|
+
CHAR_MOTION_KEYS.has(data)
|
|
573
|
+
|| data === ";"
|
|
574
|
+
|| data === ","
|
|
575
|
+
);
|
|
576
|
+
const supportsCountedWordMotion = (
|
|
577
|
+
data === "w"
|
|
578
|
+
|| data === "e"
|
|
579
|
+
|| data === "b"
|
|
580
|
+
);
|
|
581
|
+
const supportsCountedNav = (
|
|
582
|
+
data === "h"
|
|
583
|
+
|| data === "j"
|
|
584
|
+
|| data === "k"
|
|
585
|
+
|| data === "l"
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
if (supportsCountedNav) {
|
|
589
|
+
const count = this.takeTotalCount(1);
|
|
590
|
+
const clamped = Math.min(count, MAX_COUNT);
|
|
591
|
+
if (data === "h") {
|
|
592
|
+
this.moveCursorBy(-clamped);
|
|
593
|
+
} else if (data === "l") {
|
|
594
|
+
this.moveCursorBy(clamped);
|
|
595
|
+
} else {
|
|
596
|
+
// j/k: clamp vertical nav to buffer size to prevent TUI stalls
|
|
597
|
+
const lines = this.getLines();
|
|
598
|
+
const cursorLine = this.getCursor().line;
|
|
599
|
+
const safeCount = data === "j"
|
|
600
|
+
? Math.min(clamped, lines.length - 1 - cursorLine)
|
|
601
|
+
: Math.min(clamped, cursorLine);
|
|
602
|
+
const seq = data === "j" ? ESC_DOWN : ESC_UP;
|
|
603
|
+
for (let i = 0; i < safeCount; i++) {
|
|
604
|
+
super.handleInput(seq);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!supportsCountedStandaloneEdit && !supportsCountedCharMotion && !supportsCountedWordMotion) {
|
|
611
|
+
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
612
|
+
this.prefixCount = "";
|
|
613
|
+
this.operatorCount = "";
|
|
614
|
+
}
|
|
512
615
|
} else if (this.isCountStarter(data)) {
|
|
513
|
-
this.
|
|
514
|
-
this.pendingCountKind = "prefix";
|
|
616
|
+
this.prefixCount = data;
|
|
515
617
|
return;
|
|
516
618
|
}
|
|
517
619
|
|
|
@@ -573,9 +675,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
573
675
|
return;
|
|
574
676
|
}
|
|
575
677
|
|
|
576
|
-
if (data === "w")
|
|
577
|
-
|
|
578
|
-
|
|
678
|
+
if (data === "w") {
|
|
679
|
+
const count = this.takeTotalCount(1);
|
|
680
|
+
return this.moveWord("forward", "start", count);
|
|
681
|
+
}
|
|
682
|
+
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
|
|
683
|
+
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
|
|
579
684
|
|
|
580
685
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
581
686
|
return this.handleMappedKey(data);
|
|
@@ -618,13 +723,16 @@ export class ModalEditor extends CustomEditor {
|
|
|
618
723
|
this.mode = "insert";
|
|
619
724
|
break;
|
|
620
725
|
case "D":
|
|
726
|
+
this.takeTotalCount(1);
|
|
621
727
|
this.cutToEndOfLine();
|
|
622
728
|
break;
|
|
623
729
|
case "C":
|
|
730
|
+
this.takeTotalCount(1);
|
|
624
731
|
this.cutToEndOfLine();
|
|
625
732
|
this.mode = "insert";
|
|
626
733
|
break;
|
|
627
734
|
case "S":
|
|
735
|
+
this.takeTotalCount(1);
|
|
628
736
|
this.cutCurrentLineContent();
|
|
629
737
|
this.mode = "insert";
|
|
630
738
|
break;
|
|
@@ -643,7 +751,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
643
751
|
private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
|
|
644
752
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
645
753
|
const col = this.getCursor().col;
|
|
646
|
-
const
|
|
754
|
+
const count = this.takeTotalCount(1);
|
|
755
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
|
|
647
756
|
|
|
648
757
|
if (targetCol !== null && saveMotion) {
|
|
649
758
|
this.lastCharMotion = { motion, char: targetChar };
|
|
@@ -755,74 +864,73 @@ export class ModalEditor extends CustomEditor {
|
|
|
755
864
|
abs: number,
|
|
756
865
|
direction: "forward" | "backward",
|
|
757
866
|
target: "start" | "end",
|
|
867
|
+
count: number = 1,
|
|
758
868
|
): number {
|
|
759
869
|
const len = text.length;
|
|
760
870
|
if (len === 0) return 0;
|
|
761
871
|
|
|
872
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
762
873
|
let i = Math.max(0, Math.min(abs, len));
|
|
763
874
|
|
|
764
|
-
|
|
765
|
-
|
|
875
|
+
for (let step = 0; step < steps; step++) {
|
|
876
|
+
let next = i;
|
|
766
877
|
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
878
|
+
if (direction === "forward") {
|
|
879
|
+
if (next >= len) {
|
|
880
|
+
next = len;
|
|
881
|
+
} else if (target === "start") {
|
|
882
|
+
const startType = this.charType(text[next]);
|
|
883
|
+
if (startType !== "space") {
|
|
884
|
+
while (next < len && this.charType(text[next]) === startType) next++;
|
|
885
|
+
}
|
|
886
|
+
while (next < len && this.charType(text[next]) === "space") next++;
|
|
887
|
+
} else {
|
|
888
|
+
if (next < len - 1) next++;
|
|
889
|
+
while (next < len && this.charType(text[next]) === "space") next++;
|
|
890
|
+
if (next >= len) {
|
|
891
|
+
next = len;
|
|
892
|
+
} else {
|
|
893
|
+
const t = this.charType(text[next]);
|
|
894
|
+
while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
|
|
895
|
+
}
|
|
771
896
|
}
|
|
772
|
-
|
|
773
|
-
|
|
897
|
+
} else {
|
|
898
|
+
if (next >= len) next = len - 1;
|
|
899
|
+
if (next > 0) next--;
|
|
900
|
+
while (next > 0 && this.charType(text[next]) === "space") next--;
|
|
901
|
+
const t = this.charType(text[next]);
|
|
902
|
+
while (next > 0 && this.charType(text[next - 1]) === t) next--;
|
|
774
903
|
}
|
|
775
904
|
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
if (i >= len) return len;
|
|
779
|
-
const t = this.charType(text[i]);
|
|
780
|
-
while (i < len - 1 && this.charType(text[i + 1]) === t) i++;
|
|
781
|
-
return i;
|
|
905
|
+
if (next === i) break;
|
|
906
|
+
i = next;
|
|
782
907
|
}
|
|
783
908
|
|
|
784
|
-
if (i >= len) i = len - 1;
|
|
785
|
-
if (i > 0) i--;
|
|
786
|
-
while (i > 0 && this.charType(text[i]) === "space") i--;
|
|
787
|
-
const t = this.charType(text[i]);
|
|
788
|
-
while (i > 0 && this.charType(text[i - 1]) === t) i--;
|
|
789
909
|
return i;
|
|
790
910
|
}
|
|
791
911
|
|
|
792
|
-
private
|
|
912
|
+
private tryFindWordTargetInLine(
|
|
913
|
+
line: string,
|
|
914
|
+
col: number,
|
|
793
915
|
direction: WordMotionDirection,
|
|
794
916
|
target: WordMotionTarget,
|
|
795
917
|
allowSameColumn: boolean = false,
|
|
796
918
|
): number | null {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const col = cursor.col;
|
|
800
|
-
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
801
|
-
|
|
802
|
-
if (lineSnapshot.length === 0) return null;
|
|
803
|
-
if (col < 0 || col > lineSnapshot.length) return null;
|
|
919
|
+
if (line.length === 0) return null;
|
|
920
|
+
if (col < 0 || col > line.length) return null;
|
|
804
921
|
|
|
805
922
|
if (direction === "forward") {
|
|
806
|
-
if (col >=
|
|
923
|
+
if (col >= line.length) return null;
|
|
807
924
|
} else {
|
|
808
925
|
if (col <= 0) return null;
|
|
809
|
-
if (!/\S/.test(
|
|
926
|
+
if (!/\S/.test(line.slice(0, col))) return null;
|
|
810
927
|
}
|
|
811
928
|
|
|
812
|
-
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
813
|
-
lineSnapshot,
|
|
814
|
-
col,
|
|
815
|
-
direction,
|
|
816
|
-
target,
|
|
817
|
-
);
|
|
929
|
+
const targetCol = this.wordBoundaryCache.tryFindTarget(line, col, direction, target);
|
|
818
930
|
if (targetCol === null) return null;
|
|
819
931
|
|
|
820
|
-
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
821
|
-
const liveCol = this.getCursor().col;
|
|
822
|
-
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
823
|
-
|
|
824
932
|
if (direction === "forward") {
|
|
825
|
-
if (targetCol >=
|
|
933
|
+
if (targetCol >= line.length) return null;
|
|
826
934
|
if (allowSameColumn) {
|
|
827
935
|
if (targetCol < col) return null;
|
|
828
936
|
} else if (targetCol <= col) {
|
|
@@ -840,6 +948,32 @@ export class ModalEditor extends CustomEditor {
|
|
|
840
948
|
return targetCol;
|
|
841
949
|
}
|
|
842
950
|
|
|
951
|
+
private tryFindWordTargetLineLocal(
|
|
952
|
+
direction: WordMotionDirection,
|
|
953
|
+
target: WordMotionTarget,
|
|
954
|
+
allowSameColumn: boolean = false,
|
|
955
|
+
): number | null {
|
|
956
|
+
const cursor = this.getCursor();
|
|
957
|
+
const lineIndex = cursor.line;
|
|
958
|
+
const col = cursor.col;
|
|
959
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
960
|
+
|
|
961
|
+
const targetCol = this.tryFindWordTargetInLine(
|
|
962
|
+
lineSnapshot,
|
|
963
|
+
col,
|
|
964
|
+
direction,
|
|
965
|
+
target,
|
|
966
|
+
allowSameColumn,
|
|
967
|
+
);
|
|
968
|
+
if (targetCol === null) return null;
|
|
969
|
+
|
|
970
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
971
|
+
const liveCol = this.getCursor().col;
|
|
972
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
973
|
+
|
|
974
|
+
return targetCol;
|
|
975
|
+
}
|
|
976
|
+
|
|
843
977
|
private tryMoveWordLineLocal(
|
|
844
978
|
direction: "forward" | "backward",
|
|
845
979
|
target: "start" | "end",
|
|
@@ -854,29 +988,61 @@ export class ModalEditor extends CustomEditor {
|
|
|
854
988
|
|
|
855
989
|
private tryWordMotionLineLocalRange(
|
|
856
990
|
motion: "w" | "e" | "b",
|
|
991
|
+
count: number = 1,
|
|
857
992
|
): { col: number; targetCol: number; inclusive: boolean } | null {
|
|
858
|
-
const
|
|
993
|
+
const cursor = this.getCursor();
|
|
994
|
+
const lineIndex = cursor.line;
|
|
995
|
+
const col = cursor.col;
|
|
996
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
859
997
|
const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
|
|
860
998
|
const target: WordMotionTarget = motion === "e" ? "end" : "start";
|
|
861
|
-
const
|
|
999
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1000
|
+
|
|
1001
|
+
let currentCol = col;
|
|
1002
|
+
for (let step = 0; step < steps; step++) {
|
|
1003
|
+
const nextCol = this.tryFindWordTargetInLine(
|
|
1004
|
+
lineSnapshot,
|
|
1005
|
+
currentCol,
|
|
1006
|
+
direction,
|
|
1007
|
+
target,
|
|
1008
|
+
motion === "e",
|
|
1009
|
+
);
|
|
1010
|
+
if (nextCol === null) return null;
|
|
1011
|
+
if (nextCol === currentCol && step < steps - 1) return null;
|
|
1012
|
+
currentCol = nextCol;
|
|
1013
|
+
}
|
|
862
1014
|
|
|
863
|
-
|
|
1015
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
1016
|
+
const liveCol = this.getCursor().col;
|
|
1017
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
864
1018
|
|
|
865
1019
|
return {
|
|
866
1020
|
col,
|
|
867
|
-
targetCol,
|
|
1021
|
+
targetCol: currentCol,
|
|
868
1022
|
inclusive: motion === "e",
|
|
869
1023
|
};
|
|
870
1024
|
}
|
|
871
1025
|
|
|
872
|
-
private moveWord(
|
|
873
|
-
|
|
1026
|
+
private moveWord(
|
|
1027
|
+
direction: "forward" | "backward",
|
|
1028
|
+
target: "start" | "end",
|
|
1029
|
+
count: number = 1,
|
|
1030
|
+
): void {
|
|
1031
|
+
let remaining = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1032
|
+
|
|
1033
|
+
while (remaining > 0) {
|
|
1034
|
+
if (this.tryMoveWordLineLocal(direction, target)) {
|
|
1035
|
+
remaining--;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
874
1038
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1039
|
+
const text = this.getText();
|
|
1040
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1041
|
+
const targetAbs = this.findWordTargetInText(text, currentAbs, direction, target, remaining);
|
|
1042
|
+
if (targetAbs !== currentAbs) {
|
|
1043
|
+
this.moveCursorBy(targetAbs - currentAbs);
|
|
1044
|
+
}
|
|
1045
|
+
return;
|
|
880
1046
|
}
|
|
881
1047
|
}
|
|
882
1048
|
|
|
@@ -898,13 +1064,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
898
1064
|
}
|
|
899
1065
|
|
|
900
1066
|
private cutCharUnderCursor(): void {
|
|
1067
|
+
const count = this.takeTotalCount(1);
|
|
901
1068
|
const { line, col } = this.getCurrentLineAndCol();
|
|
902
1069
|
if (line.length === 0) return; // Don't merge empty lines with x
|
|
903
1070
|
if (col >= line.length) return; // Don't delete past end of line
|
|
904
1071
|
|
|
905
|
-
const
|
|
906
|
-
this.
|
|
907
|
-
super.handleInput(ESC_DELETE);
|
|
1072
|
+
const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1073
|
+
this.deleteRange(col, col + boundedCount, false);
|
|
908
1074
|
}
|
|
909
1075
|
|
|
910
1076
|
private cutToEndOfLine(): void {
|
|
@@ -1023,9 +1189,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1023
1189
|
this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
|
|
1024
1190
|
}
|
|
1025
1191
|
|
|
1026
|
-
private deleteWithMotion(motion: string): boolean {
|
|
1192
|
+
private deleteWithMotion(motion: string, count: number = 1): boolean {
|
|
1027
1193
|
const cursor = this.getCursor();
|
|
1028
|
-
const line = this.getLines()[cursor.line] ?? "";
|
|
1029
1194
|
const col = cursor.col;
|
|
1030
1195
|
|
|
1031
1196
|
if (motion === "$") {
|
|
@@ -1040,7 +1205,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1040
1205
|
}
|
|
1041
1206
|
|
|
1042
1207
|
if (motion === "w" || motion === "e" || motion === "b") {
|
|
1043
|
-
const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
|
|
1208
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(motion, count);
|
|
1044
1209
|
if (lineLocalRange) {
|
|
1045
1210
|
this.deleteRange(
|
|
1046
1211
|
lineLocalRange.col,
|
|
@@ -1057,6 +1222,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1057
1222
|
currentAbs,
|
|
1058
1223
|
motion === "b" ? "backward" : "forward",
|
|
1059
1224
|
motion === "e" ? "end" : "start",
|
|
1225
|
+
count,
|
|
1060
1226
|
);
|
|
1061
1227
|
this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1062
1228
|
return true;
|
|
@@ -1068,7 +1234,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1068
1234
|
private deleteWithCharMotion(motion: CharMotion, targetChar: string): void {
|
|
1069
1235
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1070
1236
|
const col = this.getCursor().col;
|
|
1071
|
-
const
|
|
1237
|
+
const count = this.takeTotalCount(1);
|
|
1238
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
|
|
1072
1239
|
|
|
1073
1240
|
if (targetCol === null) return;
|
|
1074
1241
|
|
|
@@ -1078,43 +1245,35 @@ export class ModalEditor extends CustomEditor {
|
|
|
1078
1245
|
|
|
1079
1246
|
private handlePendingYank(data: string): void {
|
|
1080
1247
|
if (this.isDigit(data)) {
|
|
1081
|
-
if (this.
|
|
1248
|
+
if (this.operatorCount.length === 0) {
|
|
1082
1249
|
if (data !== "0") {
|
|
1083
|
-
this.
|
|
1084
|
-
this.pendingCountKind = "operator";
|
|
1250
|
+
this.operatorCount = data;
|
|
1085
1251
|
return;
|
|
1086
1252
|
}
|
|
1087
|
-
} else if (this.pendingCountKind === "operator") {
|
|
1088
|
-
this.pendingCount += data;
|
|
1089
|
-
return;
|
|
1090
1253
|
} else {
|
|
1091
|
-
|
|
1092
|
-
this.cancelPendingOperator(data);
|
|
1254
|
+
this.operatorCount += data;
|
|
1093
1255
|
return;
|
|
1094
1256
|
}
|
|
1095
1257
|
}
|
|
1096
1258
|
|
|
1097
1259
|
if (data === "y") {
|
|
1098
|
-
const count = this.
|
|
1260
|
+
const count = this.takeTotalCount(1);
|
|
1099
1261
|
this.yankLinewiseByDelta(count - 1);
|
|
1100
1262
|
this.pendingOperator = null;
|
|
1101
1263
|
return;
|
|
1102
1264
|
}
|
|
1103
1265
|
|
|
1104
1266
|
if (data === "j" || data === "k") {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const count = this.takePendingCount(1);
|
|
1111
|
-
this.yankLinewiseByDelta(data === "j" ? count : -count);
|
|
1267
|
+
const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
1268
|
+
const count = this.takeTotalCount(1);
|
|
1269
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
1270
|
+
this.yankLinewiseByDelta(data === "j" ? delta : -delta);
|
|
1112
1271
|
this.pendingOperator = null;
|
|
1113
1272
|
return;
|
|
1114
1273
|
}
|
|
1115
1274
|
|
|
1116
1275
|
if (data === "G") {
|
|
1117
|
-
if (this.
|
|
1276
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
1118
1277
|
this.cancelPendingOperator(data);
|
|
1119
1278
|
return;
|
|
1120
1279
|
}
|
|
@@ -1124,8 +1283,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1124
1283
|
return;
|
|
1125
1284
|
}
|
|
1126
1285
|
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
1286
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1287
|
+
this.pendingMotion = data as PendingMotion;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
1292
|
+
// Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
|
|
1129
1293
|
this.cancelPendingOperator(data);
|
|
1130
1294
|
return;
|
|
1131
1295
|
}
|
|
@@ -1134,10 +1298,6 @@ export class ModalEditor extends CustomEditor {
|
|
|
1134
1298
|
this.pendingTextObject = data;
|
|
1135
1299
|
return;
|
|
1136
1300
|
}
|
|
1137
|
-
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1138
|
-
this.pendingMotion = data as PendingMotion;
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
1301
|
|
|
1142
1302
|
if (this.yankWithMotion(data)) {
|
|
1143
1303
|
this.pendingOperator = null;
|
|
@@ -1190,7 +1350,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1190
1350
|
private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
|
|
1191
1351
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1192
1352
|
const col = this.getCursor().col;
|
|
1193
|
-
const
|
|
1353
|
+
const count = this.takeTotalCount(1);
|
|
1354
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
|
|
1194
1355
|
|
|
1195
1356
|
if (targetCol === null) return;
|
|
1196
1357
|
|
|
@@ -1241,16 +1402,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
1241
1402
|
}
|
|
1242
1403
|
}
|
|
1243
1404
|
|
|
1244
|
-
private getWordObjectRange(
|
|
1405
|
+
private getWordObjectRange(
|
|
1406
|
+
kind: "i" | "a",
|
|
1407
|
+
count: number = 1,
|
|
1408
|
+
): { startAbs: number; endAbs: number } | null {
|
|
1245
1409
|
const lines = this.getLines();
|
|
1246
1410
|
const cursor = this.getCursor();
|
|
1247
1411
|
const line = lines[cursor.line] ?? "";
|
|
1248
1412
|
if (!line) return null;
|
|
1249
1413
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1414
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1252
1415
|
const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
|
|
1253
1416
|
|
|
1417
|
+
let col = Math.min(cursor.col, Math.max(0, line.length - 1));
|
|
1418
|
+
|
|
1254
1419
|
if (!hasWordChar(col)) {
|
|
1255
1420
|
let right = col;
|
|
1256
1421
|
while (right < line.length && !hasWordChar(right)) right++;
|
|
@@ -1270,17 +1435,28 @@ export class ModalEditor extends CustomEditor {
|
|
|
1270
1435
|
let end = col + 1;
|
|
1271
1436
|
while (end < line.length && hasWordChar(end)) end++;
|
|
1272
1437
|
|
|
1438
|
+
let remaining = steps - 1;
|
|
1439
|
+
while (remaining > 0) {
|
|
1440
|
+
let nextWordStart = end;
|
|
1441
|
+
while (nextWordStart < line.length && !hasWordChar(nextWordStart)) nextWordStart++;
|
|
1442
|
+
if (nextWordStart >= line.length) break;
|
|
1443
|
+
|
|
1444
|
+
let nextWordEnd = nextWordStart + 1;
|
|
1445
|
+
while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
|
|
1446
|
+
|
|
1447
|
+
end = nextWordEnd;
|
|
1448
|
+
remaining--;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1273
1451
|
if (kind === "a") {
|
|
1274
|
-
let aroundStart = start;
|
|
1275
1452
|
let aroundEnd = end;
|
|
1276
|
-
|
|
1277
1453
|
while (aroundEnd < line.length && /\s/.test(line[aroundEnd]!)) aroundEnd++;
|
|
1278
|
-
if (aroundEnd === end) {
|
|
1279
|
-
while (aroundStart > 0 && /\s/.test(line[aroundStart - 1]!)) aroundStart--;
|
|
1280
|
-
}
|
|
1281
1454
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1455
|
+
if (aroundEnd > end) {
|
|
1456
|
+
end = aroundEnd;
|
|
1457
|
+
} else {
|
|
1458
|
+
while (start > 0 && /\s/.test(line[start - 1]!)) start--;
|
|
1459
|
+
}
|
|
1284
1460
|
}
|
|
1285
1461
|
|
|
1286
1462
|
return {
|
|
@@ -1289,23 +1465,32 @@ export class ModalEditor extends CustomEditor {
|
|
|
1289
1465
|
};
|
|
1290
1466
|
}
|
|
1291
1467
|
|
|
1468
|
+
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
1469
|
+
|
|
1292
1470
|
private putAfter(): void {
|
|
1471
|
+
const count = this.takeTotalCount(1);
|
|
1293
1472
|
const text = this.unnamedRegister;
|
|
1294
1473
|
if (!text) return;
|
|
1474
|
+
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
1295
1475
|
|
|
1296
1476
|
if (text.endsWith("\n")) {
|
|
1297
|
-
// Line-wise: insert new line below and fill it
|
|
1298
|
-
super.handleInput(CTRL_E);
|
|
1299
|
-
super.handleInput(NEWLINE);
|
|
1300
1477
|
const content = text.slice(0, -1);
|
|
1301
|
-
for (
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1478
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1479
|
+
// Line-wise: insert new line below and fill it
|
|
1480
|
+
super.handleInput(CTRL_E);
|
|
1481
|
+
super.handleInput(NEWLINE);
|
|
1482
|
+
for (const char of content) {
|
|
1483
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1484
|
+
}
|
|
1308
1485
|
}
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Character-wise: insert after cursor
|
|
1490
|
+
if (!this.isCursorAtOrPastEol()) {
|
|
1491
|
+
super.handleInput(ESC_RIGHT);
|
|
1492
|
+
}
|
|
1493
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1309
1494
|
for (const char of text) {
|
|
1310
1495
|
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1311
1496
|
}
|
|
@@ -1313,20 +1498,27 @@ export class ModalEditor extends CustomEditor {
|
|
|
1313
1498
|
}
|
|
1314
1499
|
|
|
1315
1500
|
private putBefore(): void {
|
|
1501
|
+
const count = this.takeTotalCount(1);
|
|
1316
1502
|
const text = this.unnamedRegister;
|
|
1317
1503
|
if (!text) return;
|
|
1504
|
+
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
1318
1505
|
|
|
1319
1506
|
if (text.endsWith("\n")) {
|
|
1320
|
-
// Line-wise: insert new line above and fill it
|
|
1321
|
-
super.handleInput(CTRL_A);
|
|
1322
|
-
super.handleInput(NEWLINE);
|
|
1323
|
-
super.handleInput(ESC_UP);
|
|
1324
1507
|
const content = text.slice(0, -1);
|
|
1325
|
-
for (
|
|
1326
|
-
|
|
1508
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1509
|
+
// Line-wise: insert new line above and fill it
|
|
1510
|
+
super.handleInput(CTRL_A);
|
|
1511
|
+
super.handleInput(NEWLINE);
|
|
1512
|
+
super.handleInput(ESC_UP);
|
|
1513
|
+
for (const char of content) {
|
|
1514
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1515
|
+
}
|
|
1327
1516
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Character-wise: insert before cursor (just type it)
|
|
1521
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1330
1522
|
for (const char of text) {
|
|
1331
1523
|
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1332
1524
|
}
|
|
@@ -1369,20 +1561,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1369
1561
|
private getModeLabel(): string {
|
|
1370
1562
|
if (this.mode === "insert") return " INSERT ";
|
|
1371
1563
|
|
|
1372
|
-
const
|
|
1564
|
+
const prefixCount = this.prefixCount;
|
|
1565
|
+
const operatorCount = this.operatorCount;
|
|
1373
1566
|
|
|
1374
1567
|
if (this.pendingOperator && this.pendingMotion) {
|
|
1375
|
-
|
|
1376
|
-
const opCount = this.pendingCountKind === "operator" ? count : "";
|
|
1377
|
-
return ` NORMAL ${prefix}${this.pendingOperator}${opCount}${this.pendingMotion}_ `;
|
|
1568
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
1378
1569
|
}
|
|
1379
1570
|
if (this.pendingOperator) {
|
|
1380
|
-
|
|
1381
|
-
const opCount = this.pendingCountKind === "operator" ? count : "";
|
|
1382
|
-
return ` NORMAL ${prefix}${this.pendingOperator}${opCount}_ `;
|
|
1571
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
|
|
1383
1572
|
}
|
|
1384
1573
|
if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
|
|
1385
1574
|
if (this.pendingG) return " NORMAL g_ ";
|
|
1575
|
+
|
|
1576
|
+
const count = `${prefixCount}${operatorCount}`;
|
|
1386
1577
|
if (count) return ` NORMAL ${count}_ `;
|
|
1387
1578
|
return " NORMAL ";
|
|
1388
1579
|
}
|
package/motions.ts
CHANGED
|
@@ -40,26 +40,35 @@ export function findCharMotionTarget(
|
|
|
40
40
|
motion: CharMotion,
|
|
41
41
|
targetChar: string,
|
|
42
42
|
isRepeat: boolean = false,
|
|
43
|
+
count: number = 1,
|
|
43
44
|
): number | null {
|
|
44
45
|
const isForward = motion === "f" || motion === "t";
|
|
45
46
|
const isTill = motion === "t" || motion === "T";
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
48
|
+
|
|
49
|
+
let currentPos = col;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < steps; i++) {
|
|
52
|
+
const isFirst = i === 0;
|
|
53
|
+
const isFinal = i === steps - 1;
|
|
54
|
+
const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
|
|
55
|
+
|
|
56
|
+
if (isForward) {
|
|
57
|
+
const searchStart = currentPos + 1 + tillRepeatOffset;
|
|
58
|
+
const idx = line.indexOf(targetChar, searchStart);
|
|
59
|
+
if (idx === -1) return null;
|
|
60
|
+
if (isFinal) return isTill ? idx - 1 : idx;
|
|
61
|
+
currentPos = idx;
|
|
62
|
+
continue;
|
|
55
63
|
}
|
|
56
|
-
|
|
57
|
-
const searchStart =
|
|
64
|
+
|
|
65
|
+
const searchStart = currentPos - 1 - tillRepeatOffset;
|
|
58
66
|
const idx = line.lastIndexOf(targetChar, searchStart);
|
|
59
|
-
if (idx
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
if (idx === -1) return null;
|
|
68
|
+
if (isFinal) return isTill ? idx + 1 : idx;
|
|
69
|
+
currentPos = idx;
|
|
62
70
|
}
|
|
71
|
+
|
|
63
72
|
return null;
|
|
64
73
|
}
|
|
65
74
|
|