pi-vim 0.1.4 → 0.1.7
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 +55 -20
- package/index.ts +236 -41
- package/motions.ts +99 -11
- package/package.json +4 -2
- package/word-boundary-cache.ts +24 -9
package/README.md
CHANGED
|
@@ -64,10 +64,30 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
|
64
64
|
| `$` | Line end |
|
|
65
65
|
| `gg` | Buffer start (line 1) |
|
|
66
66
|
| `G` | Buffer end (last line) |
|
|
67
|
-
| `w` | Next word start
|
|
68
|
-
| `b` | Previous word start
|
|
69
|
-
| `e` |
|
|
70
|
-
| `
|
|
67
|
+
| `w` | Next `word` start (keyword/punctuation aware) |
|
|
68
|
+
| `b` | Previous `word` start |
|
|
69
|
+
| `e` | `word` end (inclusive) |
|
|
70
|
+
| `W` | Next `WORD` start (whitespace-delimited token) |
|
|
71
|
+
| `B` | Previous `WORD` start |
|
|
72
|
+
| `E` | `WORD` end (inclusive) |
|
|
73
|
+
| `{count}w/b/e`| Move `{count}` `word` motions |
|
|
74
|
+
| `{count}W/B/E`| Move `{count}` `WORD` motions |
|
|
75
|
+
| `}` | Move to next paragraph start (line start col `0`) |
|
|
76
|
+
| `{` | Move to previous paragraph start (line start col `0`) |
|
|
77
|
+
| `{count}}` | Repeat `}` `{count}` times |
|
|
78
|
+
| `{count}{` | Repeat `{` `{count}` times |
|
|
79
|
+
|
|
80
|
+
`word` (`w/b/e`) splits punctuation from keyword chars. `WORD` (`W/B/E`)
|
|
81
|
+
treats any non-whitespace run as one token (`foo-bar`, `path/to`, `x.y`).
|
|
82
|
+
|
|
83
|
+
Paragraph boundary definition (this extension wave):
|
|
84
|
+
- blank line: matches `^\s*$`
|
|
85
|
+
- paragraph start: non-blank line at BOF, or non-blank line immediately after a blank line
|
|
86
|
+
|
|
87
|
+
Standalone `{` / `}` motions are navigation-only (no text/register mutation).
|
|
88
|
+
Counted forms (`{count}{`, `{count}}`) step paragraph-by-paragraph.
|
|
89
|
+
If no further paragraph boundary exists, motions clamp at BOF/EOF.
|
|
90
|
+
Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope for this wave.
|
|
71
91
|
|
|
72
92
|
---
|
|
73
93
|
|
|
@@ -101,10 +121,14 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
101
121
|
|
|
102
122
|
| Command | Deletes |
|
|
103
123
|
|-------------------|-----------------------------------------------------------|
|
|
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
|
-
| `
|
|
124
|
+
| `dw` | Forward to next `word` start (exclusive, can cross lines) |
|
|
125
|
+
| `de` | Forward to `word` end (inclusive, can cross lines) |
|
|
126
|
+
| `db` | Backward to `word` start (exclusive, can cross lines) |
|
|
127
|
+
| `dW` | Forward to next `WORD` start (exclusive, can cross lines) |
|
|
128
|
+
| `dE` | Forward to `WORD` end (inclusive, can cross lines) |
|
|
129
|
+
| `dB` | Backward to `WORD` start (exclusive, can cross lines) |
|
|
130
|
+
| `d{count}w/e/b` | Forward/backward `{count}` `word` motions |
|
|
131
|
+
| `d{count}W/E/B` | Forward/backward `{count}` `WORD` motions |
|
|
108
132
|
| `d$` | To end of line |
|
|
109
133
|
| `d0` | To start of line |
|
|
110
134
|
| `dd` | Current line (linewise) |
|
|
@@ -127,13 +151,17 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
|
127
151
|
|
|
128
152
|
| Command | Action |
|
|
129
153
|
|-----------------|------------------------------------|
|
|
130
|
-
| `cw` |
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `c
|
|
136
|
-
|
|
|
154
|
+
| `cw` | Change `word` + Insert |
|
|
155
|
+
| `ce` / `cb` | Change to `word` end / previous `word` start |
|
|
156
|
+
| `cW` | Change `WORD` + Insert (`cW` on non-space behaves like `cE`) |
|
|
157
|
+
| `cE` / `cB` | Change to `WORD` end / previous `WORD` start |
|
|
158
|
+
| `c{count}w/e/b` | Change `{count}` `word` motions + Insert |
|
|
159
|
+
| `c{count}W/E/B` | Change `{count}` `WORD` motions + Insert |
|
|
160
|
+
| `ciw` | Change inner word |
|
|
161
|
+
| `caw` | Change around word |
|
|
162
|
+
| `cc` | Delete line content + Insert |
|
|
163
|
+
| `c$` | Delete to EOL + Insert |
|
|
164
|
+
| … | All `d` motions apply |
|
|
137
165
|
|
|
138
166
|
#### Single-key edits
|
|
139
167
|
|
|
@@ -161,15 +189,22 @@ Same motion set as `d`. Writes to register, **no text mutation**.
|
|
|
161
189
|
| `y{count}j` | Current line + `{count}` lines below (linewise) |
|
|
162
190
|
| `y{count}k` | Current line + `{count}` lines above (linewise) |
|
|
163
191
|
| `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
|
|
192
|
+
| `yw` | Forward to next `word` start |
|
|
193
|
+
| `ye` | To `word` end (inclusive) |
|
|
194
|
+
| `yb` | Backward to `word` start |
|
|
195
|
+
| `yW` | Forward to next `WORD` start |
|
|
196
|
+
| `yE` | To `WORD` end (inclusive) |
|
|
197
|
+
| `yB` | Backward to `WORD` start |
|
|
167
198
|
| `y$` | To end of line |
|
|
168
199
|
| `y0` | To start of line |
|
|
169
200
|
| `yf{c}` | To and including `char` |
|
|
170
201
|
| `yiw` | Inner word |
|
|
171
202
|
| `yaw` | Around word (includes spaces) |
|
|
172
203
|
|
|
204
|
+
Counted yank caveat: counted `word`/`WORD` yank motions are intentionally not
|
|
205
|
+
implemented (`y2w`, `2yw`, `y2W`, `2yW`, etc. cancel the pending operator).
|
|
206
|
+
Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
|
|
207
|
+
|
|
173
208
|
---
|
|
174
209
|
|
|
175
210
|
### Put / Paste
|
|
@@ -213,7 +248,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
|
|
|
213
248
|
| Area | This extension | Full Vim |
|
|
214
249
|
|-----------------------|----------------------------------------|-------------------------------|
|
|
215
250
|
| `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
|
|
216
|
-
| `w` / `e` / `b`
|
|
251
|
+
| `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
|
|
217
252
|
| `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
|
|
218
253
|
| Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
|
|
219
254
|
| Redo | Not implemented | `<C-r>` |
|
|
@@ -224,7 +259,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
|
|
|
224
259
|
| Macros | Not implemented (`q`, `@`) | Supported |
|
|
225
260
|
| Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
|
|
226
261
|
| Ex commands | Not implemented (`:s`, `:g`, etc.) | Supported |
|
|
227
|
-
| Multi-line operators | Supports `d/y` with `w/e/b`, `j/k` counts
|
|
262
|
+
| 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
263
|
|
|
229
264
|
---
|
|
230
265
|
|
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
|
+
this.takeTotalCount(1);
|
|
577
|
+
this.moveCursorToBufferStart();
|
|
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,12 @@ 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
|
+
|
|
562
613
|
const supportsCountedStandaloneEdit = (
|
|
563
614
|
data === "x"
|
|
564
615
|
|| data === "s"
|
|
@@ -567,6 +618,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
567
618
|
|| data === "C"
|
|
568
619
|
|| data === "p"
|
|
569
620
|
|| data === "P"
|
|
621
|
+
|| data === "J"
|
|
570
622
|
);
|
|
571
623
|
const supportsCountedCharMotion = (
|
|
572
624
|
CHAR_MOTION_KEYS.has(data)
|
|
@@ -577,7 +629,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
577
629
|
data === "w"
|
|
578
630
|
|| data === "e"
|
|
579
631
|
|| data === "b"
|
|
632
|
+
|| data === "W"
|
|
633
|
+
|| data === "E"
|
|
634
|
+
|| data === "B"
|
|
580
635
|
);
|
|
636
|
+
const supportsCountedParagraphMotion = data === "{" || data === "}";
|
|
581
637
|
const supportsCountedNav = (
|
|
582
638
|
data === "h"
|
|
583
639
|
|| data === "j"
|
|
@@ -607,7 +663,17 @@ export class ModalEditor extends CustomEditor {
|
|
|
607
663
|
return;
|
|
608
664
|
}
|
|
609
665
|
|
|
610
|
-
if (
|
|
666
|
+
if (supportsCountedParagraphMotion) {
|
|
667
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (
|
|
672
|
+
!supportsCountedStandaloneEdit
|
|
673
|
+
&& !supportsCountedCharMotion
|
|
674
|
+
&& !supportsCountedWordMotion
|
|
675
|
+
&& !supportsCountedParagraphMotion
|
|
676
|
+
) {
|
|
611
677
|
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
612
678
|
this.prefixCount = "";
|
|
613
679
|
this.operatorCount = "";
|
|
@@ -617,7 +683,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
617
683
|
return;
|
|
618
684
|
}
|
|
619
685
|
|
|
686
|
+
if (data === "J") {
|
|
687
|
+
this.joinLines(true);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
620
691
|
if (data === "g") {
|
|
692
|
+
this.pendingGCount = "";
|
|
621
693
|
this.pendingG = true;
|
|
622
694
|
return;
|
|
623
695
|
}
|
|
@@ -675,12 +747,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
675
747
|
return;
|
|
676
748
|
}
|
|
677
749
|
|
|
750
|
+
if (data === "}" || data === "{") {
|
|
751
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
678
755
|
if (data === "w") {
|
|
679
756
|
const count = this.takeTotalCount(1);
|
|
680
|
-
return this.moveWord("forward", "start", count);
|
|
757
|
+
return this.moveWord("forward", "start", count, "word");
|
|
681
758
|
}
|
|
682
|
-
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
|
|
683
|
-
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
|
|
759
|
+
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
760
|
+
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
761
|
+
if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
762
|
+
if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
763
|
+
if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
684
764
|
|
|
685
765
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
686
766
|
return this.handleMappedKey(data);
|
|
@@ -763,6 +843,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
763
843
|
}
|
|
764
844
|
}
|
|
765
845
|
|
|
846
|
+
private executeParagraphMotion(direction: "forward" | "backward"): void {
|
|
847
|
+
const lines = this.getLines();
|
|
848
|
+
const fromLine = this.getCursor().line;
|
|
849
|
+
const count = this.takeTotalCount(1);
|
|
850
|
+
const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
|
|
851
|
+
this.moveCursorToLineStart(targetLine);
|
|
852
|
+
}
|
|
853
|
+
|
|
766
854
|
private tryMoveCursorByState(delta: number): boolean {
|
|
767
855
|
if (delta === 0) return true;
|
|
768
856
|
|
|
@@ -835,16 +923,79 @@ export class ModalEditor extends CustomEditor {
|
|
|
835
923
|
this.moveCursorToLineStart(Math.max(0, lines.length - 1));
|
|
836
924
|
}
|
|
837
925
|
|
|
926
|
+
private joinLines(normalize: boolean): void {
|
|
927
|
+
const count = this.takeTotalCount(2);
|
|
928
|
+
const steps = Math.max(0, count - 1);
|
|
929
|
+
if (steps === 0) return;
|
|
930
|
+
|
|
931
|
+
const editor = this as unknown as {
|
|
932
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
933
|
+
preferredVisualCol?: number;
|
|
934
|
+
tui?: { requestRender?: () => void };
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const state = editor.state;
|
|
938
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
939
|
+
|
|
940
|
+
const currentLine = state.cursorLine ?? 0;
|
|
941
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
942
|
+
|
|
943
|
+
for (let i = 0; i < steps; i++) {
|
|
944
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
945
|
+
|
|
946
|
+
const left = state.lines[currentLine]!;
|
|
947
|
+
const right = state.lines[currentLine + 1]!;
|
|
948
|
+
let joined: string;
|
|
949
|
+
|
|
950
|
+
if (normalize) {
|
|
951
|
+
const trimmedRight = right.trimStart();
|
|
952
|
+
const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
|
|
953
|
+
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
954
|
+
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
955
|
+
joinPoint = left.length;
|
|
956
|
+
} else {
|
|
957
|
+
joined = left + right;
|
|
958
|
+
joinPoint = left.length;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
state.lines.splice(currentLine, 2, joined);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
state.cursorLine = currentLine;
|
|
965
|
+
state.cursorCol = joinPoint;
|
|
966
|
+
editor.preferredVisualCol = joinPoint;
|
|
967
|
+
editor.tui?.requestRender?.();
|
|
968
|
+
}
|
|
969
|
+
|
|
838
970
|
private isWordChar(ch: string): boolean {
|
|
839
971
|
return /\w/.test(ch);
|
|
840
972
|
}
|
|
841
973
|
|
|
842
|
-
private charType(
|
|
974
|
+
private charType(
|
|
975
|
+
ch: string | undefined,
|
|
976
|
+
semanticClass: WordMotionClass = "word",
|
|
977
|
+
): "space" | "word" | "other" {
|
|
843
978
|
if (!ch || /\s/.test(ch)) return "space";
|
|
979
|
+
if (semanticClass === "WORD") return "word";
|
|
844
980
|
if (this.isWordChar(ch)) return "word";
|
|
845
981
|
return "other";
|
|
846
982
|
}
|
|
847
983
|
|
|
984
|
+
private resolveWordMotion(
|
|
985
|
+
motion: string,
|
|
986
|
+
): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
|
|
987
|
+
if (motion === "w" || motion === "e" || motion === "b") {
|
|
988
|
+
return { motion, semanticClass: "word" };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (motion === "W" || motion === "E" || motion === "B") {
|
|
992
|
+
const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
|
|
993
|
+
return { motion: normalizedMotion, semanticClass: "WORD" };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
848
999
|
private getAbsoluteIndex(line: number, col: number): number {
|
|
849
1000
|
const lines = this.getLines();
|
|
850
1001
|
let idx = 0;
|
|
@@ -865,6 +1016,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
865
1016
|
direction: "forward" | "backward",
|
|
866
1017
|
target: "start" | "end",
|
|
867
1018
|
count: number = 1,
|
|
1019
|
+
semanticClass: WordMotionClass = "word",
|
|
868
1020
|
): number {
|
|
869
1021
|
const len = text.length;
|
|
870
1022
|
if (len === 0) return 0;
|
|
@@ -879,27 +1031,27 @@ export class ModalEditor extends CustomEditor {
|
|
|
879
1031
|
if (next >= len) {
|
|
880
1032
|
next = len;
|
|
881
1033
|
} else if (target === "start") {
|
|
882
|
-
const startType = this.charType(text[next]);
|
|
1034
|
+
const startType = this.charType(text[next], semanticClass);
|
|
883
1035
|
if (startType !== "space") {
|
|
884
|
-
while (next < len && this.charType(text[next]) === startType) next++;
|
|
1036
|
+
while (next < len && this.charType(text[next], semanticClass) === startType) next++;
|
|
885
1037
|
}
|
|
886
|
-
while (next < len && this.charType(text[next]) === "space") next++;
|
|
1038
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
887
1039
|
} else {
|
|
888
1040
|
if (next < len - 1) next++;
|
|
889
|
-
while (next < len && this.charType(text[next]) === "space") next++;
|
|
1041
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
890
1042
|
if (next >= len) {
|
|
891
1043
|
next = len;
|
|
892
1044
|
} else {
|
|
893
|
-
const t = this.charType(text[next]);
|
|
894
|
-
while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
|
|
1045
|
+
const t = this.charType(text[next], semanticClass);
|
|
1046
|
+
while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
|
|
895
1047
|
}
|
|
896
1048
|
}
|
|
897
1049
|
} else {
|
|
898
1050
|
if (next >= len) next = len - 1;
|
|
899
1051
|
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--;
|
|
1052
|
+
while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
|
|
1053
|
+
const t = this.charType(text[next], semanticClass);
|
|
1054
|
+
while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
|
|
903
1055
|
}
|
|
904
1056
|
|
|
905
1057
|
if (next === i) break;
|
|
@@ -915,6 +1067,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
915
1067
|
direction: WordMotionDirection,
|
|
916
1068
|
target: WordMotionTarget,
|
|
917
1069
|
allowSameColumn: boolean = false,
|
|
1070
|
+
semanticClass: WordMotionClass = "word",
|
|
918
1071
|
): number | null {
|
|
919
1072
|
if (line.length === 0) return null;
|
|
920
1073
|
if (col < 0 || col > line.length) return null;
|
|
@@ -926,7 +1079,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
926
1079
|
if (!/\S/.test(line.slice(0, col))) return null;
|
|
927
1080
|
}
|
|
928
1081
|
|
|
929
|
-
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
1082
|
+
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
1083
|
+
line,
|
|
1084
|
+
col,
|
|
1085
|
+
direction,
|
|
1086
|
+
target,
|
|
1087
|
+
semanticClass,
|
|
1088
|
+
);
|
|
930
1089
|
if (targetCol === null) return null;
|
|
931
1090
|
|
|
932
1091
|
if (direction === "forward") {
|
|
@@ -952,6 +1111,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
952
1111
|
direction: WordMotionDirection,
|
|
953
1112
|
target: WordMotionTarget,
|
|
954
1113
|
allowSameColumn: boolean = false,
|
|
1114
|
+
semanticClass: WordMotionClass = "word",
|
|
955
1115
|
): number | null {
|
|
956
1116
|
const cursor = this.getCursor();
|
|
957
1117
|
const lineIndex = cursor.line;
|
|
@@ -964,6 +1124,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
964
1124
|
direction,
|
|
965
1125
|
target,
|
|
966
1126
|
allowSameColumn,
|
|
1127
|
+
semanticClass,
|
|
967
1128
|
);
|
|
968
1129
|
if (targetCol === null) return null;
|
|
969
1130
|
|
|
@@ -977,9 +1138,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
977
1138
|
private tryMoveWordLineLocal(
|
|
978
1139
|
direction: "forward" | "backward",
|
|
979
1140
|
target: "start" | "end",
|
|
1141
|
+
semanticClass: WordMotionClass = "word",
|
|
980
1142
|
): boolean {
|
|
981
1143
|
const col = this.getCursor().col;
|
|
982
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target);
|
|
1144
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
|
|
983
1145
|
if (targetCol === null || targetCol === col) return false;
|
|
984
1146
|
|
|
985
1147
|
this.moveCursorBy(targetCol - col);
|
|
@@ -989,6 +1151,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
989
1151
|
private tryWordMotionLineLocalRange(
|
|
990
1152
|
motion: "w" | "e" | "b",
|
|
991
1153
|
count: number = 1,
|
|
1154
|
+
semanticClass: WordMotionClass = "word",
|
|
992
1155
|
): { col: number; targetCol: number; inclusive: boolean } | null {
|
|
993
1156
|
const cursor = this.getCursor();
|
|
994
1157
|
const lineIndex = cursor.line;
|
|
@@ -1006,6 +1169,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1006
1169
|
direction,
|
|
1007
1170
|
target,
|
|
1008
1171
|
motion === "e",
|
|
1172
|
+
semanticClass,
|
|
1009
1173
|
);
|
|
1010
1174
|
if (nextCol === null) return null;
|
|
1011
1175
|
if (nextCol === currentCol && step < steps - 1) return null;
|
|
@@ -1027,18 +1191,26 @@ export class ModalEditor extends CustomEditor {
|
|
|
1027
1191
|
direction: "forward" | "backward",
|
|
1028
1192
|
target: "start" | "end",
|
|
1029
1193
|
count: number = 1,
|
|
1194
|
+
semanticClass: WordMotionClass = "word",
|
|
1030
1195
|
): void {
|
|
1031
1196
|
let remaining = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1032
1197
|
|
|
1033
1198
|
while (remaining > 0) {
|
|
1034
|
-
if (this.tryMoveWordLineLocal(direction, target)) {
|
|
1199
|
+
if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
|
|
1035
1200
|
remaining--;
|
|
1036
1201
|
continue;
|
|
1037
1202
|
}
|
|
1038
1203
|
|
|
1039
1204
|
const text = this.getText();
|
|
1040
1205
|
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1041
|
-
const targetAbs = this.findWordTargetInText(
|
|
1206
|
+
const targetAbs = this.findWordTargetInText(
|
|
1207
|
+
text,
|
|
1208
|
+
currentAbs,
|
|
1209
|
+
direction,
|
|
1210
|
+
target,
|
|
1211
|
+
remaining,
|
|
1212
|
+
semanticClass,
|
|
1213
|
+
);
|
|
1042
1214
|
if (targetAbs !== currentAbs) {
|
|
1043
1215
|
this.moveCursorBy(targetAbs - currentAbs);
|
|
1044
1216
|
}
|
|
@@ -1058,6 +1230,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1058
1230
|
return { line, col };
|
|
1059
1231
|
}
|
|
1060
1232
|
|
|
1233
|
+
private isCursorOnNonWhitespace(): boolean {
|
|
1234
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
1235
|
+
const ch = line[col];
|
|
1236
|
+
return ch !== undefined && !/\s/.test(ch);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1061
1239
|
private isCursorAtOrPastEol(): boolean {
|
|
1062
1240
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1063
1241
|
return col >= line.length;
|
|
@@ -1204,8 +1382,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1204
1382
|
return true;
|
|
1205
1383
|
}
|
|
1206
1384
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1385
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1386
|
+
if (wordMotion) {
|
|
1387
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1388
|
+
wordMotion.motion,
|
|
1389
|
+
count,
|
|
1390
|
+
wordMotion.semanticClass,
|
|
1391
|
+
);
|
|
1209
1392
|
if (lineLocalRange) {
|
|
1210
1393
|
this.deleteRange(
|
|
1211
1394
|
lineLocalRange.col,
|
|
@@ -1220,11 +1403,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1220
1403
|
const targetAbs = this.findWordTargetInText(
|
|
1221
1404
|
text,
|
|
1222
1405
|
currentAbs,
|
|
1223
|
-
motion === "b" ? "backward" : "forward",
|
|
1224
|
-
motion === "e" ? "end" : "start",
|
|
1406
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1407
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1225
1408
|
count,
|
|
1409
|
+
wordMotion.semanticClass,
|
|
1226
1410
|
);
|
|
1227
|
-
this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1411
|
+
this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1228
1412
|
return true;
|
|
1229
1413
|
}
|
|
1230
1414
|
|
|
@@ -1321,8 +1505,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1321
1505
|
return true;
|
|
1322
1506
|
}
|
|
1323
1507
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1508
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1509
|
+
if (wordMotion) {
|
|
1510
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1511
|
+
wordMotion.motion,
|
|
1512
|
+
1,
|
|
1513
|
+
wordMotion.semanticClass,
|
|
1514
|
+
);
|
|
1326
1515
|
if (lineLocalRange) {
|
|
1327
1516
|
this.yankRange(
|
|
1328
1517
|
lineLocalRange.col,
|
|
@@ -1337,10 +1526,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1337
1526
|
const targetAbs = this.findWordTargetInText(
|
|
1338
1527
|
text,
|
|
1339
1528
|
currentAbs,
|
|
1340
|
-
motion === "b" ? "backward" : "forward",
|
|
1341
|
-
motion === "e" ? "end" : "start",
|
|
1529
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1530
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1531
|
+
1,
|
|
1532
|
+
wordMotion.semanticClass,
|
|
1342
1533
|
);
|
|
1343
|
-
this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1534
|
+
this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1344
1535
|
return true;
|
|
1345
1536
|
}
|
|
1346
1537
|
|
|
@@ -1571,7 +1762,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
1571
1762
|
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
|
|
1572
1763
|
}
|
|
1573
1764
|
if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
|
|
1574
|
-
if (this.pendingG)
|
|
1765
|
+
if (this.pendingG) {
|
|
1766
|
+
return this.pendingGCount
|
|
1767
|
+
? ` NORMAL g${this.pendingGCount}_ `
|
|
1768
|
+
: " NORMAL g_ ";
|
|
1769
|
+
}
|
|
1575
1770
|
|
|
1576
1771
|
const count = `${prefixCount}${operatorCount}`;
|
|
1577
1772
|
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.7",
|
|
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
|
}
|