pi-vim 0.1.4 → 0.1.8
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 +58 -21
- package/index.ts +242 -41
- package/motions.ts +99 -11
- package/package.json +4 -2
- package/word-boundary-cache.ts +24 -9
package/README.md
CHANGED
|
@@ -63,11 +63,33 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
|
63
63
|
| `0` | Line start |
|
|
64
64
|
| `$` | Line end |
|
|
65
65
|
| `gg` | Buffer start (line 1) |
|
|
66
|
+
| `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
|
|
66
67
|
| `G` | Buffer end (last line) |
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
68
|
+
| `{count}G` | Go to line `{count}` (1-indexed, clamped) |
|
|
69
|
+
| `w` | Next `word` start (keyword/punctuation aware) |
|
|
70
|
+
| `b` | Previous `word` start |
|
|
71
|
+
| `e` | `word` end (inclusive) |
|
|
72
|
+
| `W` | Next `WORD` start (whitespace-delimited token) |
|
|
73
|
+
| `B` | Previous `WORD` start |
|
|
74
|
+
| `E` | `WORD` end (inclusive) |
|
|
75
|
+
| `{count}w/b/e`| Move `{count}` `word` motions |
|
|
76
|
+
| `{count}W/B/E`| Move `{count}` `WORD` motions |
|
|
77
|
+
| `}` | Move to next paragraph start (line start col `0`) |
|
|
78
|
+
| `{` | Move to previous paragraph start (line start col `0`) |
|
|
79
|
+
| `{count}}` | Repeat `}` `{count}` times |
|
|
80
|
+
| `{count}{` | Repeat `{` `{count}` times |
|
|
81
|
+
|
|
82
|
+
`word` (`w/b/e`) splits punctuation from keyword chars. `WORD` (`W/B/E`)
|
|
83
|
+
treats any non-whitespace run as one token (`foo-bar`, `path/to`, `x.y`).
|
|
84
|
+
|
|
85
|
+
Paragraph boundary definition (this extension wave):
|
|
86
|
+
- blank line: matches `^\s*$`
|
|
87
|
+
- paragraph start: non-blank line at BOF, or non-blank line immediately after a blank line
|
|
88
|
+
|
|
89
|
+
Standalone `{` / `}` motions are navigation-only (no text/register mutation).
|
|
90
|
+
Counted forms (`{count}{`, `{count}}`) step paragraph-by-paragraph.
|
|
91
|
+
If no further paragraph boundary exists, motions clamp at BOF/EOF.
|
|
92
|
+
Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope for this wave.
|
|
71
93
|
|
|
72
94
|
---
|
|
73
95
|
|
|
@@ -101,10 +123,14 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
101
123
|
|
|
102
124
|
| Command | Deletes |
|
|
103
125
|
|-------------------|-----------------------------------------------------------|
|
|
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
|
-
| `
|
|
126
|
+
| `dw` | Forward to next `word` start (exclusive, can cross lines) |
|
|
127
|
+
| `de` | Forward to `word` end (inclusive, can cross lines) |
|
|
128
|
+
| `db` | Backward to `word` start (exclusive, can cross lines) |
|
|
129
|
+
| `dW` | Forward to next `WORD` start (exclusive, can cross lines) |
|
|
130
|
+
| `dE` | Forward to `WORD` end (inclusive, can cross lines) |
|
|
131
|
+
| `dB` | Backward to `WORD` start (exclusive, can cross lines) |
|
|
132
|
+
| `d{count}w/e/b` | Forward/backward `{count}` `word` motions |
|
|
133
|
+
| `d{count}W/E/B` | Forward/backward `{count}` `WORD` motions |
|
|
108
134
|
| `d$` | To end of line |
|
|
109
135
|
| `d0` | To start of line |
|
|
110
136
|
| `dd` | Current line (linewise) |
|
|
@@ -127,13 +153,17 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
|
127
153
|
|
|
128
154
|
| Command | Action |
|
|
129
155
|
|-----------------|------------------------------------|
|
|
130
|
-
| `cw` |
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `c
|
|
136
|
-
|
|
|
156
|
+
| `cw` | Change `word` + Insert |
|
|
157
|
+
| `ce` / `cb` | Change to `word` end / previous `word` start |
|
|
158
|
+
| `cW` | Change `WORD` + Insert (`cW` on non-space behaves like `cE`) |
|
|
159
|
+
| `cE` / `cB` | Change to `WORD` end / previous `WORD` start |
|
|
160
|
+
| `c{count}w/e/b` | Change `{count}` `word` motions + Insert |
|
|
161
|
+
| `c{count}W/E/B` | Change `{count}` `WORD` motions + Insert |
|
|
162
|
+
| `ciw` | Change inner word |
|
|
163
|
+
| `caw` | Change around word |
|
|
164
|
+
| `cc` | Delete line content + Insert |
|
|
165
|
+
| `c$` | Delete to EOL + Insert |
|
|
166
|
+
| … | All `d` motions apply |
|
|
137
167
|
|
|
138
168
|
#### Single-key edits
|
|
139
169
|
|
|
@@ -161,15 +191,22 @@ Same motion set as `d`. Writes to register, **no text mutation**.
|
|
|
161
191
|
| `y{count}j` | Current line + `{count}` lines below (linewise) |
|
|
162
192
|
| `y{count}k` | Current line + `{count}` lines above (linewise) |
|
|
163
193
|
| `yG` | Current line to end of buffer (linewise) |
|
|
164
|
-
| `yw` | Forward to next word start
|
|
165
|
-
| `ye` | To word end (inclusive)
|
|
166
|
-
| `yb` | Backward to word start
|
|
194
|
+
| `yw` | Forward to next `word` start |
|
|
195
|
+
| `ye` | To `word` end (inclusive) |
|
|
196
|
+
| `yb` | Backward to `word` start |
|
|
197
|
+
| `yW` | Forward to next `WORD` start |
|
|
198
|
+
| `yE` | To `WORD` end (inclusive) |
|
|
199
|
+
| `yB` | Backward to `WORD` start |
|
|
167
200
|
| `y$` | To end of line |
|
|
168
201
|
| `y0` | To start of line |
|
|
169
202
|
| `yf{c}` | To and including `char` |
|
|
170
203
|
| `yiw` | Inner word |
|
|
171
204
|
| `yaw` | Around word (includes spaces) |
|
|
172
205
|
|
|
206
|
+
Counted yank caveat: counted `word`/`WORD` yank motions are intentionally not
|
|
207
|
+
implemented (`y2w`, `2yw`, `y2W`, `2yW`, etc. cancel the pending operator).
|
|
208
|
+
Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
|
|
209
|
+
|
|
173
210
|
---
|
|
174
211
|
|
|
175
212
|
### Put / Paste
|
|
@@ -213,7 +250,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
|
|
|
213
250
|
| Area | This extension | Full Vim |
|
|
214
251
|
|-----------------------|----------------------------------------|-------------------------------|
|
|
215
252
|
| `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
|
|
216
|
-
| `w` / `e` / `b`
|
|
253
|
+
| `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
|
|
217
254
|
| `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
|
|
218
255
|
| Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
|
|
219
256
|
| Redo | Not implemented | `<C-r>` |
|
|
@@ -224,7 +261,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
|
|
|
224
261
|
| Macros | Not implemented (`q`, `@`) | Supported |
|
|
225
262
|
| Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
|
|
226
263
|
| Ex commands | Not implemented (`:s`, `:g`, etc.) | Supported |
|
|
227
|
-
| Multi-line operators | Supports `d/y` with `w/e/b`, `j/k` counts
|
|
264
|
+
| Multi-line operators | Supports `d/c/y` with `w/e/b` and `W/E/B`, plus `j/k` counts and `G`; not full Vim motion matrix | Rich cross-line semantics |
|
|
228
265
|
|
|
229
266
|
---
|
|
230
267
|
|
|
@@ -239,7 +276,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
239
276
|
- Ex command surface (`:s`, `:g`, `:r`, …)
|
|
240
277
|
- Search mode (`/`, `?`, `n`, `N`)
|
|
241
278
|
- Repeat (`.`)
|
|
242
|
-
- Extended count prefix beyond currently supported motions (e.g.
|
|
279
|
+
- Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
|
|
243
280
|
- Redo (`<C-r>`) — no native redo primitive in the underlying readline editor;
|
|
244
281
|
deferred until a suitable hook is available.
|
|
245
282
|
- Window / tab / buffer management
|
package/index.ts
CHANGED
|
@@ -16,16 +16,21 @@
|
|
|
16
16
|
* - D: delete to end of line
|
|
17
17
|
* - S: substitute line (delete line content + insert mode)
|
|
18
18
|
* - s: substitute char (delete char + insert mode)
|
|
19
|
-
* - d{motion}: delete with motion (
|
|
19
|
+
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `dd`, `f/t/F/T{char}`)
|
|
20
|
+
* - c{motion}: change with same motion set as `d` (then enter insert mode)
|
|
21
|
+
* - y{motion}: yank with same motion set as `d` (no text mutation)
|
|
20
22
|
* - f{char}: jump to next {char} on line
|
|
21
23
|
* - F{char}: jump to previous {char} on line
|
|
22
24
|
* - t{char}: jump to just before next {char} on line
|
|
23
25
|
* - T{char}: jump to just after previous {char} on line
|
|
24
26
|
* - ;: repeat last f/F/t/T motion (same direction)
|
|
25
27
|
* - ,: repeat last f/F/t/T motion (reverse direction)
|
|
26
|
-
* - w:
|
|
27
|
-
* -
|
|
28
|
-
* -
|
|
28
|
+
* - w/b/e: `word` motions (keyword/punctuation aware)
|
|
29
|
+
* - W/B/E: `WORD` motions (whitespace-delimited non-space runs)
|
|
30
|
+
* - {/}: paragraph motions to previous/next paragraph start (line start col 0)
|
|
31
|
+
* - `{count}` prefixes supported for navigation, paragraph motions, and `d/c` word/WORD motions
|
|
32
|
+
* - operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
|
|
33
|
+
* - counted yank caveat: `y2w`, `2yw`, `y2W`, `2yW` cancel (linewise counts still supported)
|
|
29
34
|
* - Shift+Alt+A: go to end of line (insert mode shortcut)
|
|
30
35
|
* - Shift+Alt+I: go to start of line (insert mode shortcut)
|
|
31
36
|
* - Alt+o: open new line below (insert mode shortcut)
|
|
@@ -78,6 +83,8 @@ import {
|
|
|
78
83
|
import {
|
|
79
84
|
reverseCharMotion,
|
|
80
85
|
findCharMotionTarget,
|
|
86
|
+
findParagraphMotionTarget,
|
|
87
|
+
type WordMotionClass,
|
|
81
88
|
} from "./motions.js";
|
|
82
89
|
import {
|
|
83
90
|
WordBoundaryCache,
|
|
@@ -98,6 +105,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
98
105
|
private prefixCount: string = "";
|
|
99
106
|
private operatorCount: string = "";
|
|
100
107
|
private pendingG: boolean = false;
|
|
108
|
+
private pendingGCount: string = "";
|
|
101
109
|
private lastCharMotion: LastCharMotion | null = null;
|
|
102
110
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
103
111
|
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
@@ -123,6 +131,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
123
131
|
this.prefixCount = "";
|
|
124
132
|
this.operatorCount = "";
|
|
125
133
|
this.pendingG = false;
|
|
134
|
+
this.pendingGCount = "";
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
|
|
@@ -276,6 +285,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
276
285
|
|| this.prefixCount
|
|
277
286
|
|| this.operatorCount
|
|
278
287
|
|| this.pendingG
|
|
288
|
+
|| this.pendingGCount
|
|
279
289
|
) {
|
|
280
290
|
this.clearPendingState();
|
|
281
291
|
return;
|
|
@@ -454,12 +464,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
454
464
|
}
|
|
455
465
|
|
|
456
466
|
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
457
|
-
const supportsCountedWordMotion =
|
|
467
|
+
const supportsCountedWordMotion = (
|
|
468
|
+
data === "w"
|
|
469
|
+
|| data === "e"
|
|
470
|
+
|| data === "b"
|
|
471
|
+
|| data === "W"
|
|
472
|
+
|| data === "E"
|
|
473
|
+
|| data === "B"
|
|
474
|
+
);
|
|
458
475
|
const supportsCountedTextObject = data === "i" || data === "a";
|
|
459
476
|
|
|
460
477
|
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
461
478
|
// 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.
|
|
479
|
+
// d{count}{w/e/b/W/E/B}/{i/a}w are out of scope.
|
|
463
480
|
this.cancelPendingOperator(data);
|
|
464
481
|
return;
|
|
465
482
|
}
|
|
@@ -509,7 +526,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
509
526
|
}
|
|
510
527
|
|
|
511
528
|
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
512
|
-
const supportsCountedWordMotion =
|
|
529
|
+
const supportsCountedWordMotion = (
|
|
530
|
+
data === "w"
|
|
531
|
+
|| data === "e"
|
|
532
|
+
|| data === "b"
|
|
533
|
+
|| data === "W"
|
|
534
|
+
|| data === "E"
|
|
535
|
+
|| data === "B"
|
|
536
|
+
);
|
|
513
537
|
const supportsCountedTextObject = data === "i" || data === "a";
|
|
514
538
|
|
|
515
539
|
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
@@ -523,7 +547,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
523
547
|
}
|
|
524
548
|
|
|
525
549
|
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
526
|
-
|
|
550
|
+
const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
|
|
551
|
+
? "E"
|
|
552
|
+
: data;
|
|
553
|
+
if (this.deleteWithMotion(effectiveMotion, motionCount)) {
|
|
527
554
|
this.pendingOperator = null;
|
|
528
555
|
this.mode = "insert";
|
|
529
556
|
return;
|
|
@@ -535,12 +562,30 @@ export class ModalEditor extends CustomEditor {
|
|
|
535
562
|
|
|
536
563
|
private handleNormalMode(data: string): void {
|
|
537
564
|
if (this.pendingG) {
|
|
538
|
-
this.
|
|
539
|
-
|
|
540
|
-
this.moveCursorToBufferStart();
|
|
565
|
+
if (this.isDigit(data)) {
|
|
566
|
+
this.pendingGCount += data;
|
|
541
567
|
return;
|
|
542
568
|
}
|
|
543
|
-
|
|
569
|
+
|
|
570
|
+
this.pendingG = false;
|
|
571
|
+
const hadGCount = this.pendingGCount.length > 0;
|
|
572
|
+
this.pendingGCount = "";
|
|
573
|
+
|
|
574
|
+
if (!hadGCount) {
|
|
575
|
+
if (data === "g") {
|
|
576
|
+
const count = this.takeTotalCount(1);
|
|
577
|
+
this.moveCursorToLineStart(count - 1);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (data === "J") {
|
|
582
|
+
this.joinLines(false);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
this.clearPendingState();
|
|
588
|
+
return;
|
|
544
589
|
}
|
|
545
590
|
|
|
546
591
|
if (this.prefixCount.length > 0) {
|
|
@@ -559,6 +604,18 @@ export class ModalEditor extends CustomEditor {
|
|
|
559
604
|
return;
|
|
560
605
|
}
|
|
561
606
|
|
|
607
|
+
if (data === "g") {
|
|
608
|
+
this.pendingGCount = "";
|
|
609
|
+
this.pendingG = true;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (data === "G") {
|
|
614
|
+
const count = this.takeTotalCount(1);
|
|
615
|
+
this.moveCursorToLineStart(count - 1);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
562
619
|
const supportsCountedStandaloneEdit = (
|
|
563
620
|
data === "x"
|
|
564
621
|
|| data === "s"
|
|
@@ -567,6 +624,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
567
624
|
|| data === "C"
|
|
568
625
|
|| data === "p"
|
|
569
626
|
|| data === "P"
|
|
627
|
+
|| data === "J"
|
|
570
628
|
);
|
|
571
629
|
const supportsCountedCharMotion = (
|
|
572
630
|
CHAR_MOTION_KEYS.has(data)
|
|
@@ -577,7 +635,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
577
635
|
data === "w"
|
|
578
636
|
|| data === "e"
|
|
579
637
|
|| data === "b"
|
|
638
|
+
|| data === "W"
|
|
639
|
+
|| data === "E"
|
|
640
|
+
|| data === "B"
|
|
580
641
|
);
|
|
642
|
+
const supportsCountedParagraphMotion = data === "{" || data === "}";
|
|
581
643
|
const supportsCountedNav = (
|
|
582
644
|
data === "h"
|
|
583
645
|
|| data === "j"
|
|
@@ -607,7 +669,17 @@ export class ModalEditor extends CustomEditor {
|
|
|
607
669
|
return;
|
|
608
670
|
}
|
|
609
671
|
|
|
610
|
-
if (
|
|
672
|
+
if (supportsCountedParagraphMotion) {
|
|
673
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (
|
|
678
|
+
!supportsCountedStandaloneEdit
|
|
679
|
+
&& !supportsCountedCharMotion
|
|
680
|
+
&& !supportsCountedWordMotion
|
|
681
|
+
&& !supportsCountedParagraphMotion
|
|
682
|
+
) {
|
|
611
683
|
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
612
684
|
this.prefixCount = "";
|
|
613
685
|
this.operatorCount = "";
|
|
@@ -617,7 +689,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
617
689
|
return;
|
|
618
690
|
}
|
|
619
691
|
|
|
692
|
+
if (data === "J") {
|
|
693
|
+
this.joinLines(true);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
620
697
|
if (data === "g") {
|
|
698
|
+
this.pendingGCount = "";
|
|
621
699
|
this.pendingG = true;
|
|
622
700
|
return;
|
|
623
701
|
}
|
|
@@ -675,12 +753,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
675
753
|
return;
|
|
676
754
|
}
|
|
677
755
|
|
|
756
|
+
if (data === "}" || data === "{") {
|
|
757
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
678
761
|
if (data === "w") {
|
|
679
762
|
const count = this.takeTotalCount(1);
|
|
680
|
-
return this.moveWord("forward", "start", count);
|
|
763
|
+
return this.moveWord("forward", "start", count, "word");
|
|
681
764
|
}
|
|
682
|
-
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
|
|
683
|
-
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
|
|
765
|
+
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
766
|
+
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
767
|
+
if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
768
|
+
if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
769
|
+
if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
684
770
|
|
|
685
771
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
686
772
|
return this.handleMappedKey(data);
|
|
@@ -763,6 +849,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
763
849
|
}
|
|
764
850
|
}
|
|
765
851
|
|
|
852
|
+
private executeParagraphMotion(direction: "forward" | "backward"): void {
|
|
853
|
+
const lines = this.getLines();
|
|
854
|
+
const fromLine = this.getCursor().line;
|
|
855
|
+
const count = this.takeTotalCount(1);
|
|
856
|
+
const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
|
|
857
|
+
this.moveCursorToLineStart(targetLine);
|
|
858
|
+
}
|
|
859
|
+
|
|
766
860
|
private tryMoveCursorByState(delta: number): boolean {
|
|
767
861
|
if (delta === 0) return true;
|
|
768
862
|
|
|
@@ -835,16 +929,79 @@ export class ModalEditor extends CustomEditor {
|
|
|
835
929
|
this.moveCursorToLineStart(Math.max(0, lines.length - 1));
|
|
836
930
|
}
|
|
837
931
|
|
|
932
|
+
private joinLines(normalize: boolean): void {
|
|
933
|
+
const count = this.takeTotalCount(2);
|
|
934
|
+
const steps = Math.max(0, count - 1);
|
|
935
|
+
if (steps === 0) return;
|
|
936
|
+
|
|
937
|
+
const editor = this as unknown as {
|
|
938
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
939
|
+
preferredVisualCol?: number;
|
|
940
|
+
tui?: { requestRender?: () => void };
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const state = editor.state;
|
|
944
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
945
|
+
|
|
946
|
+
const currentLine = state.cursorLine ?? 0;
|
|
947
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
948
|
+
|
|
949
|
+
for (let i = 0; i < steps; i++) {
|
|
950
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
951
|
+
|
|
952
|
+
const left = state.lines[currentLine]!;
|
|
953
|
+
const right = state.lines[currentLine + 1]!;
|
|
954
|
+
let joined: string;
|
|
955
|
+
|
|
956
|
+
if (normalize) {
|
|
957
|
+
const trimmedRight = right.trimStart();
|
|
958
|
+
const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
|
|
959
|
+
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
960
|
+
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
961
|
+
joinPoint = left.length;
|
|
962
|
+
} else {
|
|
963
|
+
joined = left + right;
|
|
964
|
+
joinPoint = left.length;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
state.lines.splice(currentLine, 2, joined);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
state.cursorLine = currentLine;
|
|
971
|
+
state.cursorCol = joinPoint;
|
|
972
|
+
editor.preferredVisualCol = joinPoint;
|
|
973
|
+
editor.tui?.requestRender?.();
|
|
974
|
+
}
|
|
975
|
+
|
|
838
976
|
private isWordChar(ch: string): boolean {
|
|
839
977
|
return /\w/.test(ch);
|
|
840
978
|
}
|
|
841
979
|
|
|
842
|
-
private charType(
|
|
980
|
+
private charType(
|
|
981
|
+
ch: string | undefined,
|
|
982
|
+
semanticClass: WordMotionClass = "word",
|
|
983
|
+
): "space" | "word" | "other" {
|
|
843
984
|
if (!ch || /\s/.test(ch)) return "space";
|
|
985
|
+
if (semanticClass === "WORD") return "word";
|
|
844
986
|
if (this.isWordChar(ch)) return "word";
|
|
845
987
|
return "other";
|
|
846
988
|
}
|
|
847
989
|
|
|
990
|
+
private resolveWordMotion(
|
|
991
|
+
motion: string,
|
|
992
|
+
): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
|
|
993
|
+
if (motion === "w" || motion === "e" || motion === "b") {
|
|
994
|
+
return { motion, semanticClass: "word" };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (motion === "W" || motion === "E" || motion === "B") {
|
|
998
|
+
const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
|
|
999
|
+
return { motion: normalizedMotion, semanticClass: "WORD" };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
848
1005
|
private getAbsoluteIndex(line: number, col: number): number {
|
|
849
1006
|
const lines = this.getLines();
|
|
850
1007
|
let idx = 0;
|
|
@@ -865,6 +1022,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
865
1022
|
direction: "forward" | "backward",
|
|
866
1023
|
target: "start" | "end",
|
|
867
1024
|
count: number = 1,
|
|
1025
|
+
semanticClass: WordMotionClass = "word",
|
|
868
1026
|
): number {
|
|
869
1027
|
const len = text.length;
|
|
870
1028
|
if (len === 0) return 0;
|
|
@@ -879,27 +1037,27 @@ export class ModalEditor extends CustomEditor {
|
|
|
879
1037
|
if (next >= len) {
|
|
880
1038
|
next = len;
|
|
881
1039
|
} else if (target === "start") {
|
|
882
|
-
const startType = this.charType(text[next]);
|
|
1040
|
+
const startType = this.charType(text[next], semanticClass);
|
|
883
1041
|
if (startType !== "space") {
|
|
884
|
-
while (next < len && this.charType(text[next]) === startType) next++;
|
|
1042
|
+
while (next < len && this.charType(text[next], semanticClass) === startType) next++;
|
|
885
1043
|
}
|
|
886
|
-
while (next < len && this.charType(text[next]) === "space") next++;
|
|
1044
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
887
1045
|
} else {
|
|
888
1046
|
if (next < len - 1) next++;
|
|
889
|
-
while (next < len && this.charType(text[next]) === "space") next++;
|
|
1047
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
890
1048
|
if (next >= len) {
|
|
891
1049
|
next = len;
|
|
892
1050
|
} else {
|
|
893
|
-
const t = this.charType(text[next]);
|
|
894
|
-
while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
|
|
1051
|
+
const t = this.charType(text[next], semanticClass);
|
|
1052
|
+
while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
|
|
895
1053
|
}
|
|
896
1054
|
}
|
|
897
1055
|
} else {
|
|
898
1056
|
if (next >= len) next = len - 1;
|
|
899
1057
|
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--;
|
|
1058
|
+
while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
|
|
1059
|
+
const t = this.charType(text[next], semanticClass);
|
|
1060
|
+
while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
|
|
903
1061
|
}
|
|
904
1062
|
|
|
905
1063
|
if (next === i) break;
|
|
@@ -915,6 +1073,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
915
1073
|
direction: WordMotionDirection,
|
|
916
1074
|
target: WordMotionTarget,
|
|
917
1075
|
allowSameColumn: boolean = false,
|
|
1076
|
+
semanticClass: WordMotionClass = "word",
|
|
918
1077
|
): number | null {
|
|
919
1078
|
if (line.length === 0) return null;
|
|
920
1079
|
if (col < 0 || col > line.length) return null;
|
|
@@ -926,7 +1085,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
926
1085
|
if (!/\S/.test(line.slice(0, col))) return null;
|
|
927
1086
|
}
|
|
928
1087
|
|
|
929
|
-
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
1088
|
+
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
1089
|
+
line,
|
|
1090
|
+
col,
|
|
1091
|
+
direction,
|
|
1092
|
+
target,
|
|
1093
|
+
semanticClass,
|
|
1094
|
+
);
|
|
930
1095
|
if (targetCol === null) return null;
|
|
931
1096
|
|
|
932
1097
|
if (direction === "forward") {
|
|
@@ -952,6 +1117,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
952
1117
|
direction: WordMotionDirection,
|
|
953
1118
|
target: WordMotionTarget,
|
|
954
1119
|
allowSameColumn: boolean = false,
|
|
1120
|
+
semanticClass: WordMotionClass = "word",
|
|
955
1121
|
): number | null {
|
|
956
1122
|
const cursor = this.getCursor();
|
|
957
1123
|
const lineIndex = cursor.line;
|
|
@@ -964,6 +1130,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
964
1130
|
direction,
|
|
965
1131
|
target,
|
|
966
1132
|
allowSameColumn,
|
|
1133
|
+
semanticClass,
|
|
967
1134
|
);
|
|
968
1135
|
if (targetCol === null) return null;
|
|
969
1136
|
|
|
@@ -977,9 +1144,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
977
1144
|
private tryMoveWordLineLocal(
|
|
978
1145
|
direction: "forward" | "backward",
|
|
979
1146
|
target: "start" | "end",
|
|
1147
|
+
semanticClass: WordMotionClass = "word",
|
|
980
1148
|
): boolean {
|
|
981
1149
|
const col = this.getCursor().col;
|
|
982
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target);
|
|
1150
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
|
|
983
1151
|
if (targetCol === null || targetCol === col) return false;
|
|
984
1152
|
|
|
985
1153
|
this.moveCursorBy(targetCol - col);
|
|
@@ -989,6 +1157,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
989
1157
|
private tryWordMotionLineLocalRange(
|
|
990
1158
|
motion: "w" | "e" | "b",
|
|
991
1159
|
count: number = 1,
|
|
1160
|
+
semanticClass: WordMotionClass = "word",
|
|
992
1161
|
): { col: number; targetCol: number; inclusive: boolean } | null {
|
|
993
1162
|
const cursor = this.getCursor();
|
|
994
1163
|
const lineIndex = cursor.line;
|
|
@@ -1006,6 +1175,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1006
1175
|
direction,
|
|
1007
1176
|
target,
|
|
1008
1177
|
motion === "e",
|
|
1178
|
+
semanticClass,
|
|
1009
1179
|
);
|
|
1010
1180
|
if (nextCol === null) return null;
|
|
1011
1181
|
if (nextCol === currentCol && step < steps - 1) return null;
|
|
@@ -1027,18 +1197,26 @@ export class ModalEditor extends CustomEditor {
|
|
|
1027
1197
|
direction: "forward" | "backward",
|
|
1028
1198
|
target: "start" | "end",
|
|
1029
1199
|
count: number = 1,
|
|
1200
|
+
semanticClass: WordMotionClass = "word",
|
|
1030
1201
|
): void {
|
|
1031
1202
|
let remaining = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1032
1203
|
|
|
1033
1204
|
while (remaining > 0) {
|
|
1034
|
-
if (this.tryMoveWordLineLocal(direction, target)) {
|
|
1205
|
+
if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
|
|
1035
1206
|
remaining--;
|
|
1036
1207
|
continue;
|
|
1037
1208
|
}
|
|
1038
1209
|
|
|
1039
1210
|
const text = this.getText();
|
|
1040
1211
|
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1041
|
-
const targetAbs = this.findWordTargetInText(
|
|
1212
|
+
const targetAbs = this.findWordTargetInText(
|
|
1213
|
+
text,
|
|
1214
|
+
currentAbs,
|
|
1215
|
+
direction,
|
|
1216
|
+
target,
|
|
1217
|
+
remaining,
|
|
1218
|
+
semanticClass,
|
|
1219
|
+
);
|
|
1042
1220
|
if (targetAbs !== currentAbs) {
|
|
1043
1221
|
this.moveCursorBy(targetAbs - currentAbs);
|
|
1044
1222
|
}
|
|
@@ -1058,6 +1236,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1058
1236
|
return { line, col };
|
|
1059
1237
|
}
|
|
1060
1238
|
|
|
1239
|
+
private isCursorOnNonWhitespace(): boolean {
|
|
1240
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
1241
|
+
const ch = line[col];
|
|
1242
|
+
return ch !== undefined && !/\s/.test(ch);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1061
1245
|
private isCursorAtOrPastEol(): boolean {
|
|
1062
1246
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1063
1247
|
return col >= line.length;
|
|
@@ -1204,8 +1388,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1204
1388
|
return true;
|
|
1205
1389
|
}
|
|
1206
1390
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1391
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1392
|
+
if (wordMotion) {
|
|
1393
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1394
|
+
wordMotion.motion,
|
|
1395
|
+
count,
|
|
1396
|
+
wordMotion.semanticClass,
|
|
1397
|
+
);
|
|
1209
1398
|
if (lineLocalRange) {
|
|
1210
1399
|
this.deleteRange(
|
|
1211
1400
|
lineLocalRange.col,
|
|
@@ -1220,11 +1409,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1220
1409
|
const targetAbs = this.findWordTargetInText(
|
|
1221
1410
|
text,
|
|
1222
1411
|
currentAbs,
|
|
1223
|
-
motion === "b" ? "backward" : "forward",
|
|
1224
|
-
motion === "e" ? "end" : "start",
|
|
1412
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1413
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1225
1414
|
count,
|
|
1415
|
+
wordMotion.semanticClass,
|
|
1226
1416
|
);
|
|
1227
|
-
this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1417
|
+
this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1228
1418
|
return true;
|
|
1229
1419
|
}
|
|
1230
1420
|
|
|
@@ -1321,8 +1511,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1321
1511
|
return true;
|
|
1322
1512
|
}
|
|
1323
1513
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1514
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1515
|
+
if (wordMotion) {
|
|
1516
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1517
|
+
wordMotion.motion,
|
|
1518
|
+
1,
|
|
1519
|
+
wordMotion.semanticClass,
|
|
1520
|
+
);
|
|
1326
1521
|
if (lineLocalRange) {
|
|
1327
1522
|
this.yankRange(
|
|
1328
1523
|
lineLocalRange.col,
|
|
@@ -1337,10 +1532,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1337
1532
|
const targetAbs = this.findWordTargetInText(
|
|
1338
1533
|
text,
|
|
1339
1534
|
currentAbs,
|
|
1340
|
-
motion === "b" ? "backward" : "forward",
|
|
1341
|
-
motion === "e" ? "end" : "start",
|
|
1535
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1536
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1537
|
+
1,
|
|
1538
|
+
wordMotion.semanticClass,
|
|
1342
1539
|
);
|
|
1343
|
-
this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1540
|
+
this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1344
1541
|
return true;
|
|
1345
1542
|
}
|
|
1346
1543
|
|
|
@@ -1571,7 +1768,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
1571
1768
|
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
|
|
1572
1769
|
}
|
|
1573
1770
|
if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
|
|
1574
|
-
if (this.pendingG)
|
|
1771
|
+
if (this.pendingG) {
|
|
1772
|
+
return this.pendingGCount
|
|
1773
|
+
? ` NORMAL g${this.pendingGCount}_ `
|
|
1774
|
+
: " NORMAL g_ ";
|
|
1775
|
+
}
|
|
1575
1776
|
|
|
1576
1777
|
const count = `${prefixCount}${operatorCount}`;
|
|
1577
1778
|
if (count) return ` NORMAL ${count}_ `;
|
package/motions.ts
CHANGED
|
@@ -5,18 +5,105 @@
|
|
|
5
5
|
import type { CharMotion } from "./types.js";
|
|
6
6
|
|
|
7
7
|
// Character types for word boundary detection
|
|
8
|
+
export type WordMotionClass = "word" | "WORD";
|
|
9
|
+
|
|
8
10
|
enum CharType {
|
|
9
11
|
Space = 0,
|
|
10
|
-
Keyword = 1, // alphanumeric + underscore
|
|
12
|
+
Keyword = 1, // alphanumeric + underscore (or all non-space in WORD mode)
|
|
11
13
|
Other = 2, // punctuation/symbols
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
function getCharType(
|
|
16
|
+
function getCharType(
|
|
17
|
+
c: string | undefined,
|
|
18
|
+
semanticClass: WordMotionClass = "word",
|
|
19
|
+
): CharType {
|
|
15
20
|
if (!c || /\s/.test(c)) return CharType.Space;
|
|
21
|
+
if (semanticClass === "WORD") return CharType.Keyword;
|
|
16
22
|
if (/\w/.test(c)) return CharType.Keyword;
|
|
17
23
|
return CharType.Other;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
function clampLineIndex(lines: readonly string[], lineIndex: number): number {
|
|
27
|
+
if (lines.length === 0) return 0;
|
|
28
|
+
if (!Number.isFinite(lineIndex)) return 0;
|
|
29
|
+
const normalized = Math.trunc(lineIndex);
|
|
30
|
+
return Math.max(0, Math.min(normalized, lines.length - 1));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when line matches ^\s*$.
|
|
35
|
+
*/
|
|
36
|
+
export function isBlankLine(line: string | undefined): boolean {
|
|
37
|
+
if (line === undefined) return true;
|
|
38
|
+
return /^\s*$/.test(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Paragraph start: non-blank line at BOF or after a blank line.
|
|
43
|
+
*/
|
|
44
|
+
export function isParagraphStart(lines: readonly string[], lineIndex: number): boolean {
|
|
45
|
+
if (!Number.isInteger(lineIndex)) return false;
|
|
46
|
+
if (lineIndex < 0 || lineIndex >= lines.length) return false;
|
|
47
|
+
if (isBlankLine(lines[lineIndex])) return false;
|
|
48
|
+
if (lineIndex === 0) return true;
|
|
49
|
+
return isBlankLine(lines[lineIndex - 1]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One step of } motion from current line index.
|
|
54
|
+
*/
|
|
55
|
+
export function findNextParagraphStart(lines: readonly string[], fromLine: number): number {
|
|
56
|
+
if (lines.length === 0) return 0;
|
|
57
|
+
|
|
58
|
+
const start = clampLineIndex(lines, fromLine) + 1;
|
|
59
|
+
for (let i = start; i < lines.length; i++) {
|
|
60
|
+
if (isParagraphStart(lines, i)) return i;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return lines.length - 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One step of { motion from current line index.
|
|
68
|
+
*/
|
|
69
|
+
export function findPrevParagraphStart(lines: readonly string[], fromLine: number): number {
|
|
70
|
+
if (lines.length === 0) return 0;
|
|
71
|
+
|
|
72
|
+
const start = clampLineIndex(lines, fromLine) - 1;
|
|
73
|
+
for (let i = start; i >= 0; i--) {
|
|
74
|
+
if (isParagraphStart(lines, i)) return i;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Paragraph motion target for counted { / } semantics.
|
|
82
|
+
*/
|
|
83
|
+
export function findParagraphMotionTarget(
|
|
84
|
+
lines: readonly string[],
|
|
85
|
+
fromLine: number,
|
|
86
|
+
direction: "forward" | "backward",
|
|
87
|
+
count: number = 1,
|
|
88
|
+
): number {
|
|
89
|
+
if (lines.length === 0) return 0;
|
|
90
|
+
|
|
91
|
+
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
92
|
+
let currentLine = clampLineIndex(lines, fromLine);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < steps; i++) {
|
|
95
|
+
const nextLine =
|
|
96
|
+
direction === "forward"
|
|
97
|
+
? findNextParagraphStart(lines, currentLine)
|
|
98
|
+
: findPrevParagraphStart(lines, currentLine);
|
|
99
|
+
|
|
100
|
+
if (nextLine === currentLine) break;
|
|
101
|
+
currentLine = nextLine;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return currentLine;
|
|
105
|
+
}
|
|
106
|
+
|
|
20
107
|
/**
|
|
21
108
|
* Reverse a character motion direction (f ↔ F, t ↔ T).
|
|
22
109
|
*/
|
|
@@ -80,6 +167,7 @@ export function findWordMotionTarget(
|
|
|
80
167
|
col: number,
|
|
81
168
|
direction: "forward" | "backward",
|
|
82
169
|
target: "start" | "end",
|
|
170
|
+
semanticClass: WordMotionClass = "word",
|
|
83
171
|
): number {
|
|
84
172
|
const len = line.length;
|
|
85
173
|
if (len === 0) return 0;
|
|
@@ -91,15 +179,15 @@ export function findWordMotionTarget(
|
|
|
91
179
|
|
|
92
180
|
if (target === "start") {
|
|
93
181
|
// w: move to start of next word
|
|
94
|
-
const startType = getCharType(line[i]);
|
|
182
|
+
const startType = getCharType(line[i], semanticClass);
|
|
95
183
|
|
|
96
184
|
// Skip current word/punct block
|
|
97
185
|
if (startType !== CharType.Space) {
|
|
98
|
-
while (i < len && getCharType(line[i]) === startType) i++;
|
|
186
|
+
while (i < len && getCharType(line[i], semanticClass) === startType) i++;
|
|
99
187
|
}
|
|
100
188
|
|
|
101
189
|
// Skip whitespace
|
|
102
|
-
while (i < len && getCharType(line[i]) === CharType.Space) i++;
|
|
190
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
|
|
103
191
|
|
|
104
192
|
return i;
|
|
105
193
|
}
|
|
@@ -108,13 +196,13 @@ export function findWordMotionTarget(
|
|
|
108
196
|
if (i < len - 1) i++;
|
|
109
197
|
|
|
110
198
|
// Skip whitespace forward
|
|
111
|
-
while (i < len && getCharType(line[i]) === CharType.Space) i++;
|
|
199
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
|
|
112
200
|
|
|
113
201
|
// Now at start of next word (or end of line). Find end.
|
|
114
202
|
if (i >= len) return len;
|
|
115
203
|
|
|
116
|
-
const type = getCharType(line[i]);
|
|
117
|
-
while (i < len - 1 && getCharType(line[i + 1]) === type) i++;
|
|
204
|
+
const type = getCharType(line[i], semanticClass);
|
|
205
|
+
while (i < len - 1 && getCharType(line[i + 1], semanticClass) === type) i++;
|
|
118
206
|
|
|
119
207
|
return i;
|
|
120
208
|
}
|
|
@@ -124,11 +212,11 @@ export function findWordMotionTarget(
|
|
|
124
212
|
if (i > 0) i--;
|
|
125
213
|
|
|
126
214
|
// Skip whitespace backward
|
|
127
|
-
while (i > 0 && getCharType(line[i]) === CharType.Space) i--;
|
|
215
|
+
while (i > 0 && getCharType(line[i], semanticClass) === CharType.Space) i--;
|
|
128
216
|
|
|
129
217
|
// Now at end of prev word (or start of line). Find start.
|
|
130
|
-
const type = getCharType(line[i]);
|
|
131
|
-
while (i > 0 && getCharType(line[i - 1]) === type) i--;
|
|
218
|
+
const type = getCharType(line[i], semanticClass);
|
|
219
|
+
while (i > 0 && getCharType(line[i - 1], semanticClass) === type) i--;
|
|
132
220
|
|
|
133
221
|
return i;
|
|
134
222
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vim",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Vim-style modal editing for Pi's TUI editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "echo 'nothing to build'",
|
|
25
25
|
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
26
|
-
"check": "npm run test"
|
|
26
|
+
"check": "npm run test",
|
|
27
|
+
"pack:check": "node --import tsx/esm script/pack-check.ts",
|
|
28
|
+
"prepublishOnly": "npm run pack:check && npm test"
|
|
27
29
|
},
|
|
28
30
|
"pi": {
|
|
29
31
|
"extensions": [
|
package/word-boundary-cache.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Line-local cache for Vim word motion boundaries.
|
|
3
3
|
*
|
|
4
|
-
* Keyed by exact line content to avoid stale boundary reuse.
|
|
4
|
+
* Keyed by semantic class + exact line content to avoid stale boundary reuse.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { WordMotionClass } from "./motions.js";
|
|
8
|
+
|
|
7
9
|
export type WordMotionDirection = "forward" | "backward";
|
|
8
10
|
export type WordMotionTarget = "start" | "end";
|
|
9
11
|
|
|
@@ -22,13 +24,20 @@ export interface WordBoundaryData {
|
|
|
22
24
|
readonly prevNonSpaceAtOrBefore: Int32Array;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
function getCharType(
|
|
27
|
+
function getCharType(
|
|
28
|
+
ch: string | undefined,
|
|
29
|
+
semanticClass: WordMotionClass = "word",
|
|
30
|
+
): CharType {
|
|
26
31
|
if (!ch || /\s/.test(ch)) return CharType.Space;
|
|
32
|
+
if (semanticClass === "WORD") return CharType.Word;
|
|
27
33
|
if (/\w/.test(ch)) return CharType.Word;
|
|
28
34
|
return CharType.Other;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
function buildWordBoundaryData(
|
|
37
|
+
function buildWordBoundaryData(
|
|
38
|
+
line: string,
|
|
39
|
+
semanticClass: WordMotionClass = "word",
|
|
40
|
+
): WordBoundaryData {
|
|
32
41
|
const len = line.length;
|
|
33
42
|
const charTypes = new Uint8Array(len);
|
|
34
43
|
const runStartByIndex = new Int32Array(len);
|
|
@@ -40,7 +49,7 @@ function buildWordBoundaryData(line: string): WordBoundaryData {
|
|
|
40
49
|
prevNonSpaceAtOrBefore.fill(-1);
|
|
41
50
|
|
|
42
51
|
for (let i = 0; i < len; i++) {
|
|
43
|
-
charTypes[i] = getCharType(line[i]);
|
|
52
|
+
charTypes[i] = getCharType(line[i], semanticClass);
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
for (let runStart = 0; runStart < len;) {
|
|
@@ -149,11 +158,16 @@ export class WordBoundaryCache {
|
|
|
149
158
|
: DEFAULT_MAX_CACHE_ENTRIES;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
private makeCacheKey(line: string, semanticClass: WordMotionClass): string {
|
|
162
|
+
return `${semanticClass}\u0000${line}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get(line: string, semanticClass: WordMotionClass = "word"): WordBoundaryData {
|
|
166
|
+
const key = this.makeCacheKey(line, semanticClass);
|
|
167
|
+
const cached = this.entries.get(key);
|
|
154
168
|
if (cached) return cached;
|
|
155
169
|
|
|
156
|
-
const built = buildWordBoundaryData(line);
|
|
170
|
+
const built = buildWordBoundaryData(line, semanticClass);
|
|
157
171
|
|
|
158
172
|
if (this.entries.size >= this.maxEntries) {
|
|
159
173
|
const oldestKey = this.entries.keys().next().value;
|
|
@@ -162,7 +176,7 @@ export class WordBoundaryCache {
|
|
|
162
176
|
}
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
this.entries.set(
|
|
179
|
+
this.entries.set(key, built);
|
|
166
180
|
return built;
|
|
167
181
|
}
|
|
168
182
|
|
|
@@ -171,10 +185,11 @@ export class WordBoundaryCache {
|
|
|
171
185
|
col: number,
|
|
172
186
|
direction: WordMotionDirection,
|
|
173
187
|
target: WordMotionTarget,
|
|
188
|
+
semanticClass: WordMotionClass = "word",
|
|
174
189
|
): number | null {
|
|
175
190
|
if (!Number.isInteger(col) || col < 0) return null;
|
|
176
191
|
|
|
177
|
-
const boundaries = this.get(line);
|
|
192
|
+
const boundaries = this.get(line, semanticClass);
|
|
178
193
|
return findTargetInLine(boundaries, col, direction, target);
|
|
179
194
|
}
|
|
180
195
|
}
|