pi-vim 0.2.0 → 0.2.1
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 +51 -53
- package/index.ts +291 -85
- package/motions.ts +55 -13
- package/package.json +1 -1
- package/types.ts +0 -1
package/README.md
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
# pi-vim
|
|
1
|
+
# pi-vim
|
|
2
2
|
|
|
3
|
-
Modal vim-like editing for Pi's
|
|
4
|
-
Focus: the high-frequency 90% command surface, not full Vim.
|
|
3
|
+
Modal vim-like editing for Pi's input prompt. Covers the high-frequency 90% command surface.
|
|
5
4
|
|
|
6
|
-
##
|
|
5
|
+
## why
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
- Solution: modal editing (`INSERT`/`NORMAL`) with Vim-style motions,
|
|
10
|
-
operators, counts, and repeatable workflows.
|
|
11
|
-
- Install: `pi install npm:pi-vim`
|
|
7
|
+
You love Pi, you love Vim, you'll love pi-vim.
|
|
12
8
|
|
|
13
|
-
##
|
|
9
|
+
## install
|
|
14
10
|
|
|
15
11
|
```bash
|
|
16
12
|
pi install npm:pi-vim
|
|
@@ -18,19 +14,11 @@ pi install npm:pi-vim
|
|
|
18
14
|
|
|
19
15
|
Restart Pi after install.
|
|
20
16
|
|
|
21
|
-
|
|
17
|
+
## stats
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Or add to `.pi/settings.json`:
|
|
28
|
-
|
|
29
|
-
```json
|
|
30
|
-
{
|
|
31
|
-
"extensions": ["./pi-extensions/pi-vim/index.ts"]
|
|
32
|
-
}
|
|
33
|
-
```
|
|
19
|
+
- **112 commands**: motions, operators, counts, text objects, undo/redo
|
|
20
|
+
- **sub-µs word motions** via precomputed boundary cache (~4ms startup, ~150KB memory)
|
|
21
|
+
- **0 dependencies**
|
|
34
22
|
|
|
35
23
|
## 30-second quickstart
|
|
36
24
|
|
|
@@ -47,22 +35,22 @@ u # undo
|
|
|
47
35
|
|
|
48
36
|
Mode indicator (`INSERT` / `NORMAL`) appears at bottom-right.
|
|
49
37
|
|
|
50
|
-
##
|
|
38
|
+
## why pi-vim
|
|
51
39
|
|
|
52
40
|
- Fast modal editing without leaving Pi.
|
|
53
41
|
- Count-aware motions/operators (`2dw`, `3G`, `d2j`, `2}`).
|
|
54
42
|
- Strong REPL-focused defaults; safe out-of-scope boundaries documented.
|
|
55
43
|
- Clipboard/register behavior is explicit and tested.
|
|
56
44
|
|
|
57
|
-
##
|
|
45
|
+
## for you / not for you
|
|
58
46
|
|
|
59
47
|
Use pi-vim if you want fast Vim muscle-memory in Pi prompts.
|
|
60
48
|
Skip it if you need full Vim feature parity (visual mode, macros, search,
|
|
61
49
|
ex-commands, etc.).
|
|
62
50
|
|
|
63
|
-
##
|
|
51
|
+
## common recipes
|
|
64
52
|
|
|
65
|
-
|
|
|
53
|
+
| goal | keys |
|
|
66
54
|
|------|------|
|
|
67
55
|
| Jump to exact line 25 | `25gg` (or `25G`) |
|
|
68
56
|
| Delete two words | `2dw` |
|
|
@@ -76,11 +64,11 @@ ex-commands, etc.).
|
|
|
76
64
|
|
|
77
65
|
---
|
|
78
66
|
|
|
79
|
-
##
|
|
67
|
+
## full reference
|
|
80
68
|
|
|
81
|
-
###
|
|
69
|
+
### mode switching
|
|
82
70
|
|
|
83
|
-
|
|
|
71
|
+
| key | action |
|
|
84
72
|
|----------|----------------------------------------|
|
|
85
73
|
| `Esc` / `Ctrl+[` | Insert → Normal mode |
|
|
86
74
|
| `Esc` / `Ctrl+[` | Normal mode → pass to Pi (abort agent) |
|
|
@@ -93,7 +81,7 @@ ex-commands, etc.).
|
|
|
93
81
|
|
|
94
82
|
Insert-mode shortcuts (stay in Insert mode):
|
|
95
83
|
|
|
96
|
-
|
|
|
84
|
+
| key | action |
|
|
97
85
|
|-----------------|------------------------|
|
|
98
86
|
| `Shift+Alt+A` | Go to end of line |
|
|
99
87
|
| `Shift+Alt+I` | Go to start of line |
|
|
@@ -102,11 +90,11 @@ Insert-mode shortcuts (stay in Insert mode):
|
|
|
102
90
|
|
|
103
91
|
---
|
|
104
92
|
|
|
105
|
-
###
|
|
93
|
+
### navigation (normal mode)
|
|
106
94
|
|
|
107
95
|
A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
108
96
|
|
|
109
|
-
|
|
|
97
|
+
| key | action |
|
|
110
98
|
|---------------|-------------------------------|
|
|
111
99
|
| `h` | Left |
|
|
112
100
|
| `l` | Right |
|
|
@@ -116,6 +104,7 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
|
116
104
|
| `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
|
|
117
105
|
| `0` | Line start |
|
|
118
106
|
| `^` | First non-whitespace char of line |
|
|
107
|
+
| `_` | First non-whitespace char; with `{count}`, move down `count - 1` lines first |
|
|
119
108
|
| `$` | Line end |
|
|
120
109
|
| `gg` | Buffer start (line 1) |
|
|
121
110
|
| `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
|
|
@@ -148,11 +137,11 @@ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
|
|
|
148
137
|
|
|
149
138
|
---
|
|
150
139
|
|
|
151
|
-
###
|
|
140
|
+
### character-find motions (normal mode)
|
|
152
141
|
|
|
153
142
|
A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
|
|
154
143
|
|
|
155
|
-
|
|
|
144
|
+
| key | action |
|
|
156
145
|
|------------------|------------------------------------------------|
|
|
157
146
|
| `f{char}` | Jump forward to `char` (inclusive) |
|
|
158
147
|
| `F{char}` | Jump backward to `char` (inclusive) |
|
|
@@ -166,17 +155,17 @@ Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{cha
|
|
|
166
155
|
|
|
167
156
|
---
|
|
168
157
|
|
|
169
|
-
###
|
|
158
|
+
### edit operators (normal mode)
|
|
170
159
|
|
|
171
160
|
All operators write to the unnamed register and mirror to the system clipboard
|
|
172
161
|
(best-effort; clipboard failure never breaks editing).
|
|
173
162
|
|
|
174
|
-
####
|
|
163
|
+
#### delete `d{motion}` / `dd`
|
|
175
164
|
|
|
176
165
|
A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
|
|
177
166
|
word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
178
167
|
|
|
179
|
-
|
|
|
168
|
+
| command | deletes |
|
|
180
169
|
|-------------------|-----------------------------------------------------------|
|
|
181
170
|
| `dw` | Forward to next `word` start (exclusive, can cross lines) |
|
|
182
171
|
| `de` | Forward to `word` end (inclusive, can cross lines) |
|
|
@@ -189,6 +178,8 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
189
178
|
| `d$` | To end of line |
|
|
190
179
|
| `d0` | To start of line |
|
|
191
180
|
| `d^` | To first non-whitespace char of line |
|
|
181
|
+
| `d_` | Current line (linewise, same as `dd`) |
|
|
182
|
+
| `d{count}_` | `{count}` lines (linewise, same as `{count}dd`) |
|
|
192
183
|
| `dd` | Current line (linewise) |
|
|
193
184
|
| `{count}dd` | `{count}` lines (linewise) |
|
|
194
185
|
| `d{count}j` | Current line + `{count}` lines below (linewise) |
|
|
@@ -203,11 +194,11 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
203
194
|
| `daw` | Around word (includes surrounding spaces) |
|
|
204
195
|
| `d{count}aw` | Around `{count}` words |
|
|
205
196
|
|
|
206
|
-
####
|
|
197
|
+
#### change `c{motion}` / `cc`
|
|
207
198
|
|
|
208
199
|
Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
209
200
|
|
|
210
|
-
|
|
|
201
|
+
| command | action |
|
|
211
202
|
|-----------------|------------------------------------|
|
|
212
203
|
| `cw` | Change `word` + Insert |
|
|
213
204
|
| `ce` / `cb` | Change to `word` end / previous `word` start |
|
|
@@ -218,14 +209,16 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
|
218
209
|
| `ciw` | Change inner word |
|
|
219
210
|
| `caw` | Change around word |
|
|
220
211
|
| `cc` | Delete line content + Insert |
|
|
212
|
+
| `c_` | Change line (linewise, same as `cc`) |
|
|
213
|
+
| `c{count}_` | Change `{count}` lines (linewise) |
|
|
221
214
|
| `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
|
|
222
215
|
| … | All `d` motions apply |
|
|
223
216
|
|
|
224
|
-
####
|
|
217
|
+
#### single-key edits
|
|
225
218
|
|
|
226
219
|
A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
227
220
|
|
|
228
|
-
|
|
|
221
|
+
| key | action |
|
|
229
222
|
|--------------|---------------------------------------------------------------|
|
|
230
223
|
| `x` | Delete char under cursor (no-op at/past EOL) |
|
|
231
224
|
| `{count}x` | Delete `{count}` chars |
|
|
@@ -233,14 +226,16 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
|
233
226
|
| `S` | Delete line content + Insert mode |
|
|
234
227
|
| `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
|
|
235
228
|
| `C` | Delete cursor to EOL + Insert mode |
|
|
229
|
+
| `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
|
|
230
|
+
| `{count}r{char}` | Replace next `{count}` chars with `{char}` |
|
|
236
231
|
|
|
237
232
|
---
|
|
238
233
|
|
|
239
|
-
###
|
|
234
|
+
### yank `y{motion}` / `yy`
|
|
240
235
|
|
|
241
236
|
Same motion set as `d`. Writes to register, **no text mutation**.
|
|
242
237
|
|
|
243
|
-
|
|
|
238
|
+
| command | yanks |
|
|
244
239
|
|---------|---------------------------------|
|
|
245
240
|
| `yy` | Whole line + trailing `\n` |
|
|
246
241
|
| `{count}yy` | `{count}` whole lines + trailing `\n` |
|
|
@@ -256,6 +251,8 @@ Same motion set as `d`. Writes to register, **no text mutation**.
|
|
|
256
251
|
| `y$` | To end of line |
|
|
257
252
|
| `y0` | To start of line |
|
|
258
253
|
| `y^` | To first non-whitespace char of line |
|
|
254
|
+
| `y_` | Whole line (linewise, same as `yy`) |
|
|
255
|
+
| `y{count}_` | `{count}` whole lines (linewise) |
|
|
259
256
|
| `yf{c}` | To and including `char` |
|
|
260
257
|
| `yiw` | Inner word |
|
|
261
258
|
| `yaw` | Around word (includes spaces) |
|
|
@@ -266,9 +263,9 @@ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
|
|
|
266
263
|
|
|
267
264
|
---
|
|
268
265
|
|
|
269
|
-
###
|
|
266
|
+
### put / paste
|
|
270
267
|
|
|
271
|
-
|
|
|
268
|
+
| key | action |
|
|
272
269
|
|--------------|-------------------------------------------------------------|
|
|
273
270
|
| `p` | Put after cursor (char-wise) / new line below (line-wise) |
|
|
274
271
|
| `P` | Put before cursor (char-wise) / new line above (line-wise) |
|
|
@@ -280,9 +277,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
280
277
|
|
|
281
278
|
---
|
|
282
279
|
|
|
283
|
-
###
|
|
280
|
+
### undo / redo
|
|
284
281
|
|
|
285
|
-
|
|
|
282
|
+
| key | action |
|
|
286
283
|
|-----|--------|
|
|
287
284
|
| `u` | Undo in normal mode |
|
|
288
285
|
| `Ctrl+_` | Undo in normal mode (alias for `u`) |
|
|
@@ -291,11 +288,11 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
291
288
|
|
|
292
289
|
---
|
|
293
290
|
|
|
294
|
-
##
|
|
291
|
+
## register and clipboard policy
|
|
295
292
|
|
|
296
293
|
- One unnamed register (like Vim's `""` register).
|
|
297
294
|
- Every `d`, `c`, `x`, `s`, `S`, `D`, `C`, `y` operator form
|
|
298
|
-
(including `dd`, `{count}dd`, `d{count}j/k`, `dG`, `yy`, `{count}yy`,
|
|
295
|
+
(including `dd`/`d_`, `{count}dd`, `d{count}j/k`, `dG`, `yy`/`y_`, `{count}yy`,
|
|
299
296
|
`y{count}j/k`, `yG`) writes to the register and mirrors to the OS clipboard
|
|
300
297
|
(via `copyToClipboard`, best-effort).
|
|
301
298
|
- `p` / `P` read from the unnamed register only (not the OS clipboard).
|
|
@@ -303,9 +300,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
303
300
|
|
|
304
301
|
---
|
|
305
302
|
|
|
306
|
-
##
|
|
303
|
+
## known differences from full Vim
|
|
307
304
|
|
|
308
|
-
|
|
|
305
|
+
| area | this extension | full Vim |
|
|
309
306
|
|-----------------------|----------------------------------------|-------------------------------|
|
|
310
307
|
| `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
|
|
311
308
|
| `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
|
|
@@ -314,7 +311,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
314
311
|
| Redo | Normal-mode `<C-r>` supported (safe no-op when empty; counted redo is stepwise, clamps to available history, and preserves single-step undo granularity) | `<C-r>` |
|
|
315
312
|
| Visual mode | Not implemented | `v`, `V`, `<C-v>` |
|
|
316
313
|
| Text objects | Supports `iw`/`aw` only | Full text-object set |
|
|
317
|
-
| Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
|
|
314
|
+
| Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `r`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
|
|
318
315
|
| Named registers | Not implemented (`"a`, etc.) | Supported |
|
|
319
316
|
| Macros | Not implemented (`q`, `@`) | Supported |
|
|
320
317
|
| Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
|
|
@@ -323,7 +320,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
323
320
|
|
|
324
321
|
---
|
|
325
322
|
|
|
326
|
-
##
|
|
323
|
+
## out of scope
|
|
327
324
|
|
|
328
325
|
These are **explicitly deferred** and not planned for this feature:
|
|
329
326
|
|
|
@@ -334,6 +331,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
334
331
|
- Ex command surface (`:s`, `:g`, `:r`, …)
|
|
335
332
|
- Search mode (`/`, `?`, `n`, `N`)
|
|
336
333
|
- Repeat (`.`)
|
|
334
|
+
- Replace mode (`R`) — only single-char `r{char}` is supported
|
|
337
335
|
- Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
|
|
338
336
|
- No insert-mode `<C-r>` feature expansion beyond current underlying-editor behavior.
|
|
339
337
|
- No cross-session redo persistence.
|
|
@@ -343,7 +341,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
343
341
|
|
|
344
342
|
---
|
|
345
343
|
|
|
346
|
-
##
|
|
344
|
+
## architecture notes
|
|
347
345
|
|
|
348
346
|
- `index.ts` — `ModalEditor` subclass of `CustomEditor`; all key handling.
|
|
349
347
|
- `motions.ts` — pure motion calculation helpers (`findWordMotionTarget`,
|
package/index.ts
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* - hjkl: navigation in normal mode
|
|
14
14
|
* - 0/$: line start/end
|
|
15
15
|
* - ^: first non-whitespace char of line
|
|
16
|
+
* - _: first non-whitespace (with count: down count-1 lines first); linewise with d/c/y
|
|
16
17
|
* - x: delete char under cursor
|
|
17
18
|
* - D: delete to end of line
|
|
18
19
|
* - S: substitute line (delete line content + insert mode)
|
|
19
20
|
* - s: substitute char (delete char + insert mode)
|
|
20
|
-
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`, `f/t/F/T{char}`)
|
|
21
|
+
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`/`d_`, `f/t/F/T{char}`)
|
|
21
22
|
* - c{motion}: change with same motion set as `d` (then enter insert mode)
|
|
22
23
|
* - y{motion}: yank with same motion set as `d` (no text mutation)
|
|
23
24
|
* - f{char}: jump to next {char} on line
|
|
@@ -72,7 +73,6 @@ import {
|
|
|
72
73
|
CHAR_MOTION_KEYS,
|
|
73
74
|
ESC_LEFT,
|
|
74
75
|
ESC_RIGHT,
|
|
75
|
-
ESC_DELETE,
|
|
76
76
|
ESC_UP,
|
|
77
77
|
CTRL_A,
|
|
78
78
|
CTRL_E,
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
findCharMotionTarget,
|
|
88
88
|
findParagraphMotionTarget,
|
|
89
89
|
findFirstNonWhitespaceColumn,
|
|
90
|
+
getLineGraphemes,
|
|
90
91
|
type WordMotionClass,
|
|
91
92
|
} from "./motions.js";
|
|
92
93
|
import {
|
|
@@ -127,6 +128,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
127
128
|
private operatorCount: string = "";
|
|
128
129
|
private pendingG: boolean = false;
|
|
129
130
|
private pendingGCount: string = "";
|
|
131
|
+
private pendingReplace: boolean = false;
|
|
130
132
|
private lastCharMotion: LastCharMotion | null = null;
|
|
131
133
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
132
134
|
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
@@ -333,6 +335,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
333
335
|
this.operatorCount = "";
|
|
334
336
|
this.pendingG = false;
|
|
335
337
|
this.pendingGCount = "";
|
|
338
|
+
this.pendingReplace = false;
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
private isEscapeLikeInput(data: string): boolean {
|
|
@@ -441,6 +444,32 @@ export class ModalEditor extends CustomEditor {
|
|
|
441
444
|
return;
|
|
442
445
|
}
|
|
443
446
|
|
|
447
|
+
if (this.pendingReplace) {
|
|
448
|
+
this.pendingReplace = false;
|
|
449
|
+
if (!this.isPrintableInput(data)) {
|
|
450
|
+
this.prefixCount = "";
|
|
451
|
+
this.operatorCount = "";
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const count = this.takeTotalCount(1);
|
|
456
|
+
const cursor = this.getCursor();
|
|
457
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
458
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
|
|
459
|
+
if (!range) return;
|
|
460
|
+
|
|
461
|
+
const before = line.slice(0, range.start);
|
|
462
|
+
const after = line.slice(range.end);
|
|
463
|
+
const replacement = data.repeat(count);
|
|
464
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
465
|
+
const text = this.getText();
|
|
466
|
+
const newText = text.slice(0, lineStartAbs) + before + replacement + after
|
|
467
|
+
+ text.slice(lineStartAbs + line.length);
|
|
468
|
+
const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
|
|
469
|
+
this.replaceTextInBuffer(newText, newCursorAbs);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
444
473
|
if (this.pendingTextObject) {
|
|
445
474
|
return this.handlePendingTextObject(data);
|
|
446
475
|
}
|
|
@@ -491,6 +520,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
491
520
|
|| this.operatorCount
|
|
492
521
|
|| this.pendingG
|
|
493
522
|
|| this.pendingGCount
|
|
523
|
+
|| this.pendingReplace
|
|
494
524
|
) {
|
|
495
525
|
this.clearPendingState();
|
|
496
526
|
return;
|
|
@@ -513,7 +543,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
513
543
|
}
|
|
514
544
|
|
|
515
545
|
private isPrintableInput(data: string): boolean {
|
|
516
|
-
return this.isPrintableChunk(data) &&
|
|
546
|
+
return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
|
|
517
547
|
}
|
|
518
548
|
|
|
519
549
|
private isDigit(data: string): boolean {
|
|
@@ -663,6 +693,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
663
693
|
return;
|
|
664
694
|
}
|
|
665
695
|
|
|
696
|
+
if (data === "_") {
|
|
697
|
+
const count = this.takeTotalCount(1);
|
|
698
|
+
this.deleteLinewiseByDelta(count - 1);
|
|
699
|
+
this.pendingOperator = null;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
666
703
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
667
704
|
this.pendingMotion = data as PendingMotion;
|
|
668
705
|
return;
|
|
@@ -725,6 +762,28 @@ export class ModalEditor extends CustomEditor {
|
|
|
725
762
|
this.mode = "insert";
|
|
726
763
|
return;
|
|
727
764
|
}
|
|
765
|
+
|
|
766
|
+
if (data === "_") {
|
|
767
|
+
const count = this.takeTotalCount(1);
|
|
768
|
+
if (count <= 1) {
|
|
769
|
+
this.cutLine();
|
|
770
|
+
} else {
|
|
771
|
+
const currentLine = this.getCursor().line;
|
|
772
|
+
const lines = this.getLines();
|
|
773
|
+
const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
|
|
774
|
+
this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
|
|
775
|
+
const before = lines.slice(0, currentLine);
|
|
776
|
+
const after = lines.slice(clampedEnd + 1);
|
|
777
|
+
const newLines = [...before, "", ...after];
|
|
778
|
+
const newText = newLines.join("\n");
|
|
779
|
+
const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
|
|
780
|
+
this.replaceTextInBuffer(newText, cursorAbs);
|
|
781
|
+
}
|
|
782
|
+
this.pendingOperator = null;
|
|
783
|
+
this.mode = "insert";
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
728
787
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
729
788
|
this.pendingMotion = data as PendingMotion;
|
|
730
789
|
return;
|
|
@@ -823,6 +882,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
823
882
|
|
|
824
883
|
const supportsCountedStandaloneEdit = (
|
|
825
884
|
data === "x"
|
|
885
|
+
|| data === "r"
|
|
826
886
|
|| data === "s"
|
|
827
887
|
|| data === "S"
|
|
828
888
|
|| data === "D"
|
|
@@ -853,6 +913,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
853
913
|
|| data === "k"
|
|
854
914
|
|| data === "l"
|
|
855
915
|
);
|
|
916
|
+
const supportsCountedUnderscore = data === "_";
|
|
856
917
|
|
|
857
918
|
if (supportsCountedNav) {
|
|
858
919
|
const count = this.takeTotalCount(1);
|
|
@@ -862,16 +923,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
862
923
|
} else if (data === "l") {
|
|
863
924
|
this.moveCursorBy(clamped);
|
|
864
925
|
} else {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const cursorLine = this.getCursor().line;
|
|
868
|
-
const safeCount = data === "j"
|
|
869
|
-
? Math.min(clamped, lines.length - 1 - cursorLine)
|
|
870
|
-
: Math.min(clamped, cursorLine);
|
|
871
|
-
const seq = data === "j" ? ESC_DOWN : ESC_UP;
|
|
872
|
-
for (let i = 0; i < safeCount; i++) {
|
|
873
|
-
super.handleInput(seq);
|
|
874
|
-
}
|
|
926
|
+
const delta = data === "j" ? clamped : -clamped;
|
|
927
|
+
this.moveCursorVertically(delta);
|
|
875
928
|
}
|
|
876
929
|
return;
|
|
877
930
|
}
|
|
@@ -886,6 +939,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
886
939
|
&& !supportsCountedCharMotion
|
|
887
940
|
&& !supportsCountedWordMotion
|
|
888
941
|
&& !supportsCountedParagraphMotion
|
|
942
|
+
&& !supportsCountedUnderscore
|
|
889
943
|
) {
|
|
890
944
|
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
891
945
|
this.prefixCount = "";
|
|
@@ -912,6 +966,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
912
966
|
return;
|
|
913
967
|
}
|
|
914
968
|
|
|
969
|
+
if (data === "r") {
|
|
970
|
+
this.pendingReplace = true;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
915
974
|
if (data === "d") {
|
|
916
975
|
this.pendingOperator = "d";
|
|
917
976
|
return;
|
|
@@ -975,6 +1034,15 @@ export class ModalEditor extends CustomEditor {
|
|
|
975
1034
|
return;
|
|
976
1035
|
}
|
|
977
1036
|
|
|
1037
|
+
if (data === "_") {
|
|
1038
|
+
const count = this.takeTotalCount(1);
|
|
1039
|
+
if (count > 1) {
|
|
1040
|
+
this.moveCursorVertically(count - 1);
|
|
1041
|
+
}
|
|
1042
|
+
this.moveCursorToFirstNonWhitespace();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
978
1046
|
if (data === "w") {
|
|
979
1047
|
const count = this.takeTotalCount(1);
|
|
980
1048
|
return this.moveWord("forward", "start", count, "word");
|
|
@@ -1054,6 +1122,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1054
1122
|
case "x":
|
|
1055
1123
|
this.cutCharUnderCursor();
|
|
1056
1124
|
break;
|
|
1125
|
+
case "j":
|
|
1126
|
+
this.moveCursorVertically(1);
|
|
1127
|
+
break;
|
|
1128
|
+
case "k":
|
|
1129
|
+
this.moveCursorVertically(-1);
|
|
1130
|
+
break;
|
|
1057
1131
|
default:
|
|
1058
1132
|
if (seq) super.handleInput(seq);
|
|
1059
1133
|
}
|
|
@@ -1070,7 +1144,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1070
1144
|
}
|
|
1071
1145
|
|
|
1072
1146
|
if (targetCol !== null && targetCol !== col) {
|
|
1073
|
-
this.
|
|
1147
|
+
this.moveCursorToCol(targetCol);
|
|
1074
1148
|
}
|
|
1075
1149
|
}
|
|
1076
1150
|
|
|
@@ -1098,10 +1172,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1098
1172
|
const cursorLine = state.cursorLine as number;
|
|
1099
1173
|
const cursorCol = state.cursorCol as number;
|
|
1100
1174
|
const line = state.lines[cursorLine] ?? "";
|
|
1175
|
+
if (this.hasMultiCodeUnitGraphemes(line)) return false;
|
|
1176
|
+
|
|
1101
1177
|
const target = cursorCol + delta;
|
|
1102
1178
|
|
|
1103
|
-
// Only short-circuit line-local movement
|
|
1104
|
-
//
|
|
1179
|
+
// Only short-circuit line-local movement when each grapheme is one code
|
|
1180
|
+
// unit; otherwise let the base editor keep cursor boundaries valid.
|
|
1105
1181
|
if (target < 0 || target > line.length) return false;
|
|
1106
1182
|
|
|
1107
1183
|
state.cursorCol = target;
|
|
@@ -1121,38 +1197,100 @@ export class ModalEditor extends CustomEditor {
|
|
|
1121
1197
|
}
|
|
1122
1198
|
}
|
|
1123
1199
|
|
|
1124
|
-
private
|
|
1125
|
-
|
|
1126
|
-
if (lines.length === 0) {
|
|
1127
|
-
super.handleInput(CTRL_A);
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1200
|
+
private moveCursorVertically(delta: number): void {
|
|
1201
|
+
if (delta === 0) return;
|
|
1130
1202
|
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1203
|
+
const editor = this as unknown as {
|
|
1204
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1205
|
+
preferredVisualCol?: number | null;
|
|
1206
|
+
lastAction?: string | null;
|
|
1207
|
+
tui?: { requestRender?: () => void };
|
|
1208
|
+
};
|
|
1134
1209
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
} else if (delta < 0) {
|
|
1210
|
+
const state = editor.state;
|
|
1211
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1212
|
+
const seq = delta > 0 ? ESC_DOWN : ESC_UP;
|
|
1140
1213
|
for (let i = 0; i < Math.abs(delta); i++) {
|
|
1141
|
-
super.handleInput(
|
|
1214
|
+
super.handleInput(seq);
|
|
1142
1215
|
}
|
|
1216
|
+
return;
|
|
1143
1217
|
}
|
|
1144
1218
|
|
|
1145
|
-
|
|
1219
|
+
const currentLine = state.cursorLine ?? 0;
|
|
1220
|
+
const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
|
|
1221
|
+
if (targetLine === currentLine) return;
|
|
1222
|
+
|
|
1223
|
+
const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
|
|
1224
|
+
const targetLineText = state.lines[targetLine] ?? "";
|
|
1225
|
+
editor.lastAction = null;
|
|
1226
|
+
state.cursorLine = targetLine;
|
|
1227
|
+
state.cursorCol = Math.min(preferredCol, targetLineText.length);
|
|
1228
|
+
editor.preferredVisualCol = preferredCol;
|
|
1229
|
+
editor.tui?.requestRender?.();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private moveCursorToCol(col: number): void {
|
|
1233
|
+
const editor = this as unknown as {
|
|
1234
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1235
|
+
preferredVisualCol?: number | null;
|
|
1236
|
+
lastAction?: string | null;
|
|
1237
|
+
tui?: { requestRender?: () => void };
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
const state = editor.state;
|
|
1241
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1242
|
+
|
|
1243
|
+
editor.lastAction = null;
|
|
1244
|
+
state.cursorCol = col;
|
|
1245
|
+
editor.preferredVisualCol = col;
|
|
1246
|
+
editor.tui?.requestRender?.();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private moveCursorToAbsoluteIndex(abs: number): void {
|
|
1250
|
+
const editor = this as unknown as {
|
|
1251
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1252
|
+
preferredVisualCol?: number | null;
|
|
1253
|
+
lastAction?: string | null;
|
|
1254
|
+
tui?: { requestRender?: () => void };
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
const state = editor.state;
|
|
1258
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1259
|
+
|
|
1260
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
|
|
1261
|
+
editor.lastAction = null;
|
|
1262
|
+
state.cursorLine = line;
|
|
1263
|
+
state.cursorCol = col;
|
|
1264
|
+
editor.preferredVisualCol = col;
|
|
1265
|
+
editor.tui?.requestRender?.();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
private moveCursorToLineStart(lineIndex: number): void {
|
|
1269
|
+
const editor = this as unknown as {
|
|
1270
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1271
|
+
preferredVisualCol?: number | null;
|
|
1272
|
+
lastAction?: string | null;
|
|
1273
|
+
tui?: { requestRender?: () => void };
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
const state = editor.state;
|
|
1277
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1278
|
+
super.handleInput(CTRL_A);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
|
|
1283
|
+
editor.lastAction = null;
|
|
1284
|
+
state.cursorLine = targetLine;
|
|
1285
|
+
state.cursorCol = 0;
|
|
1286
|
+
editor.preferredVisualCol = null;
|
|
1287
|
+
editor.tui?.requestRender?.();
|
|
1146
1288
|
}
|
|
1147
1289
|
|
|
1148
1290
|
private moveCursorToFirstNonWhitespace(): void {
|
|
1149
1291
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1150
1292
|
const targetCol = findFirstNonWhitespaceColumn(line);
|
|
1151
|
-
this.
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
private moveCursorToBufferStart(): void {
|
|
1155
|
-
this.moveCursorToLineStart(0);
|
|
1293
|
+
this.moveCursorToCol(targetCol);
|
|
1156
1294
|
}
|
|
1157
1295
|
|
|
1158
1296
|
private moveCursorToBufferEnd(): void {
|
|
@@ -1343,7 +1481,6 @@ export class ModalEditor extends CustomEditor {
|
|
|
1343
1481
|
private tryFindWordTargetLineLocal(
|
|
1344
1482
|
direction: WordMotionDirection,
|
|
1345
1483
|
target: WordMotionTarget,
|
|
1346
|
-
allowSameColumn: boolean = false,
|
|
1347
1484
|
semanticClass: WordMotionClass = "word",
|
|
1348
1485
|
): number | null {
|
|
1349
1486
|
const cursor = this.getCursor();
|
|
@@ -1356,7 +1493,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1356
1493
|
col,
|
|
1357
1494
|
direction,
|
|
1358
1495
|
target,
|
|
1359
|
-
|
|
1496
|
+
false,
|
|
1360
1497
|
semanticClass,
|
|
1361
1498
|
);
|
|
1362
1499
|
if (targetCol === null) return null;
|
|
@@ -1374,10 +1511,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
1374
1511
|
semanticClass: WordMotionClass = "word",
|
|
1375
1512
|
): boolean {
|
|
1376
1513
|
const col = this.getCursor().col;
|
|
1377
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target,
|
|
1514
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
|
|
1378
1515
|
if (targetCol === null || targetCol === col) return false;
|
|
1379
1516
|
|
|
1380
|
-
this.
|
|
1517
|
+
this.moveCursorToCol(targetCol);
|
|
1381
1518
|
return true;
|
|
1382
1519
|
}
|
|
1383
1520
|
|
|
@@ -1445,7 +1582,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1445
1582
|
semanticClass,
|
|
1446
1583
|
);
|
|
1447
1584
|
if (targetAbs !== currentAbs) {
|
|
1448
|
-
this.
|
|
1585
|
+
this.moveCursorToAbsoluteIndex(targetAbs);
|
|
1449
1586
|
}
|
|
1450
1587
|
return;
|
|
1451
1588
|
}
|
|
@@ -1463,6 +1600,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1463
1600
|
return { line, col };
|
|
1464
1601
|
}
|
|
1465
1602
|
|
|
1603
|
+
private hasMultiCodeUnitGraphemes(line: string): boolean {
|
|
1604
|
+
return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
private getGraphemeRangeAtCol(
|
|
1608
|
+
line: string,
|
|
1609
|
+
col: number,
|
|
1610
|
+
count: number,
|
|
1611
|
+
clampToLine: boolean = false,
|
|
1612
|
+
): { start: number; end: number } | null {
|
|
1613
|
+
const clampedCol = Math.max(0, Math.min(col, line.length));
|
|
1614
|
+
const segments = getLineGraphemes(line);
|
|
1615
|
+
const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
|
|
1616
|
+
if (startIndex === -1) return null;
|
|
1617
|
+
|
|
1618
|
+
let endIndex = startIndex + Math.max(1, count) - 1;
|
|
1619
|
+
if (endIndex >= segments.length) {
|
|
1620
|
+
if (!clampToLine) return null;
|
|
1621
|
+
endIndex = segments.length - 1;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return {
|
|
1625
|
+
start: segments[startIndex]!.start,
|
|
1626
|
+
end: segments[endIndex]!.end,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1466
1630
|
private isCursorOnNonWhitespace(): boolean {
|
|
1467
1631
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1468
1632
|
const ch = line[col];
|
|
@@ -1475,13 +1639,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1475
1639
|
}
|
|
1476
1640
|
|
|
1477
1641
|
private cutCharUnderCursor(): void {
|
|
1478
|
-
const count = this.takeTotalCount(1);
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1642
|
+
const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
|
|
1643
|
+
const cursor = this.getCursor();
|
|
1644
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
1645
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
|
|
1646
|
+
if (!range) return;
|
|
1482
1647
|
|
|
1483
|
-
const
|
|
1484
|
-
this.
|
|
1648
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1649
|
+
const text = this.getText();
|
|
1650
|
+
this.writeToRegister(line.slice(range.start, range.end));
|
|
1651
|
+
this.replaceTextInBuffer(
|
|
1652
|
+
text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
|
|
1653
|
+
lineStartAbs + range.start,
|
|
1654
|
+
);
|
|
1485
1655
|
}
|
|
1486
1656
|
|
|
1487
1657
|
private cutToEndOfLine(): void {
|
|
@@ -1562,19 +1732,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1562
1732
|
this.writeToRegister(payload);
|
|
1563
1733
|
|
|
1564
1734
|
if (endAbs > startAbs) {
|
|
1565
|
-
const
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
this.moveCursorBy(startAbs - cursorAbs);
|
|
1569
|
-
}
|
|
1735
|
+
const text = this.getText();
|
|
1736
|
+
const newText = text.slice(0, startAbs) + text.slice(endAbs);
|
|
1737
|
+
this.replaceTextInBuffer(newText, startAbs);
|
|
1570
1738
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
super.handleInput(ESC_DELETE);
|
|
1574
|
-
}
|
|
1739
|
+
// Ensure cursor is at column 0 of the landing line
|
|
1740
|
+
super.handleInput(CTRL_A);
|
|
1575
1741
|
}
|
|
1576
|
-
|
|
1577
|
-
super.handleInput(CTRL_A);
|
|
1578
1742
|
}
|
|
1579
1743
|
|
|
1580
1744
|
private yankLineRange(startLine: number, endLine: number): void {
|
|
@@ -1637,7 +1801,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1637
1801
|
}
|
|
1638
1802
|
|
|
1639
1803
|
const text = this.getText();
|
|
1640
|
-
const currentAbs = this.
|
|
1804
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1641
1805
|
const targetAbs = this.findWordTargetInText(
|
|
1642
1806
|
text,
|
|
1643
1807
|
currentAbs,
|
|
@@ -1705,6 +1869,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1705
1869
|
return;
|
|
1706
1870
|
}
|
|
1707
1871
|
|
|
1872
|
+
if (data === "_") {
|
|
1873
|
+
const count = this.takeTotalCount(1);
|
|
1874
|
+
this.yankLinewiseByDelta(count - 1);
|
|
1875
|
+
this.pendingOperator = null;
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1708
1879
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1709
1880
|
this.pendingMotion = data as PendingMotion;
|
|
1710
1881
|
return;
|
|
@@ -1765,7 +1936,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1765
1936
|
}
|
|
1766
1937
|
|
|
1767
1938
|
const text = this.getText();
|
|
1768
|
-
const currentAbs = this.
|
|
1939
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1769
1940
|
const targetAbs = this.findWordTargetInText(
|
|
1770
1941
|
text,
|
|
1771
1942
|
currentAbs,
|
|
@@ -1797,7 +1968,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1797
1968
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1798
1969
|
const start = Math.min(col, targetCol);
|
|
1799
1970
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1800
|
-
|
|
1971
|
+
let end = Math.min(rawEnd, line.length);
|
|
1972
|
+
|
|
1973
|
+
if (inclusive) {
|
|
1974
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
1975
|
+
end = targetRange?.end ?? end;
|
|
1976
|
+
}
|
|
1801
1977
|
|
|
1802
1978
|
if (end <= start) return;
|
|
1803
1979
|
|
|
@@ -1814,6 +1990,47 @@ export class ModalEditor extends CustomEditor {
|
|
|
1814
1990
|
this.writeToRegister(text.slice(start, end));
|
|
1815
1991
|
}
|
|
1816
1992
|
|
|
1993
|
+
private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
|
|
1994
|
+
const lines = text.length === 0 ? [""] : text.split("\n");
|
|
1995
|
+
let remaining = Math.max(0, Math.min(abs, text.length));
|
|
1996
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
1997
|
+
const line = lines[lineIndex] ?? "";
|
|
1998
|
+
if (remaining <= line.length) return { line: lineIndex, col: remaining };
|
|
1999
|
+
remaining -= line.length + 1;
|
|
2000
|
+
}
|
|
2001
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
2002
|
+
return { line: lastLine, col: (lines[lastLine] ?? "").length };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
private replaceTextInBuffer(text: string, cursorAbs: number): void {
|
|
2006
|
+
const editor = this as unknown as {
|
|
2007
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2008
|
+
preferredVisualCol?: number | null;
|
|
2009
|
+
historyIndex?: number;
|
|
2010
|
+
lastAction?: string | null;
|
|
2011
|
+
onChange?: (text: string) => void;
|
|
2012
|
+
tui?: { requestRender?: () => void };
|
|
2013
|
+
pushUndoSnapshot?: () => void;
|
|
2014
|
+
autocompleteState?: unknown;
|
|
2015
|
+
updateAutocomplete?: () => void;
|
|
2016
|
+
};
|
|
2017
|
+
const state = editor.state;
|
|
2018
|
+
if (!state) return;
|
|
2019
|
+
const currentText = this.getText();
|
|
2020
|
+
if (currentText !== text) editor.pushUndoSnapshot?.();
|
|
2021
|
+
const nextLines = text.length === 0 ? [""] : text.split("\n");
|
|
2022
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
|
|
2023
|
+
editor.historyIndex = -1;
|
|
2024
|
+
editor.lastAction = null;
|
|
2025
|
+
state.lines = nextLines;
|
|
2026
|
+
state.cursorLine = line;
|
|
2027
|
+
state.cursorCol = col;
|
|
2028
|
+
editor.preferredVisualCol = null;
|
|
2029
|
+
editor.onChange?.(text);
|
|
2030
|
+
if (editor.autocompleteState) editor.updateAutocomplete?.();
|
|
2031
|
+
editor.tui?.requestRender?.();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1817
2034
|
private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
|
|
1818
2035
|
const text = this.getText();
|
|
1819
2036
|
const start = Math.min(currentAbs, targetAbs);
|
|
@@ -1824,16 +2041,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1824
2041
|
|
|
1825
2042
|
this.writeToRegister(text.slice(start, end));
|
|
1826
2043
|
|
|
1827
|
-
|
|
1828
|
-
const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
1829
|
-
if (cursorAbs !== start) {
|
|
1830
|
-
this.moveCursorBy(start - cursorAbs);
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
const count = end - start;
|
|
1834
|
-
for (let i = 0; i < count; i++) {
|
|
1835
|
-
super.handleInput(ESC_DELETE);
|
|
1836
|
-
}
|
|
2044
|
+
this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
|
|
1837
2045
|
}
|
|
1838
2046
|
|
|
1839
2047
|
private getWordObjectRange(
|
|
@@ -1960,24 +2168,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1960
2168
|
}
|
|
1961
2169
|
|
|
1962
2170
|
private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
|
|
1963
|
-
const
|
|
1964
|
-
|
|
2171
|
+
const cursor = this.getCursor();
|
|
2172
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
2173
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1965
2174
|
const start = Math.min(col, targetCol);
|
|
1966
2175
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
if (end <= start) return;
|
|
1970
|
-
|
|
1971
|
-
this.writeToRegister(line.slice(start, end));
|
|
2176
|
+
let end = Math.min(rawEnd, line.length);
|
|
1972
2177
|
|
|
1973
|
-
if (
|
|
1974
|
-
this.
|
|
2178
|
+
if (inclusive) {
|
|
2179
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
2180
|
+
end = targetRange?.end ?? end;
|
|
1975
2181
|
}
|
|
1976
2182
|
|
|
1977
|
-
|
|
1978
|
-
for (let i = 0; i < count; i++) {
|
|
1979
|
-
super.handleInput(ESC_DELETE);
|
|
1980
|
-
}
|
|
2183
|
+
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
1981
2184
|
}
|
|
1982
2185
|
|
|
1983
2186
|
render(width: number): string[] {
|
|
@@ -1998,6 +2201,9 @@ export class ModalEditor extends CustomEditor {
|
|
|
1998
2201
|
const prefixCount = this.prefixCount;
|
|
1999
2202
|
const operatorCount = this.operatorCount;
|
|
2000
2203
|
|
|
2204
|
+
if (this.pendingReplace) {
|
|
2205
|
+
return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
|
|
2206
|
+
}
|
|
2001
2207
|
if (this.pendingOperator && this.pendingMotion) {
|
|
2002
2208
|
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
2003
2209
|
}
|
package/motions.ts
CHANGED
|
@@ -125,6 +125,27 @@ export function reverseCharMotion(motion: CharMotion): CharMotion {
|
|
|
125
125
|
return reverseMap[motion];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
const GRAPHEME_SEGMENTER = typeof Intl !== "undefined"
|
|
129
|
+
&& typeof Intl.Segmenter === "function"
|
|
130
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
131
|
+
: null;
|
|
132
|
+
|
|
133
|
+
export function getLineGraphemes(line: string): Array<{ start: number; end: number }> {
|
|
134
|
+
const segments: Array<{ start: number; end: number }> = [];
|
|
135
|
+
if (GRAPHEME_SEGMENTER) {
|
|
136
|
+
for (const part of GRAPHEME_SEGMENTER.segment(line)) {
|
|
137
|
+
segments.push({ start: part.index, end: part.index + part.segment.length });
|
|
138
|
+
}
|
|
139
|
+
return segments;
|
|
140
|
+
}
|
|
141
|
+
let start = 0;
|
|
142
|
+
for (const text of Array.from(line)) {
|
|
143
|
+
segments.push({ start, end: start + text.length });
|
|
144
|
+
start += text.length;
|
|
145
|
+
}
|
|
146
|
+
return segments;
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
/**
|
|
129
150
|
* Find target column for a character motion (f/F/t/T).
|
|
130
151
|
* @returns target column or null if not found
|
|
@@ -141,7 +162,9 @@ export function findCharMotionTarget(
|
|
|
141
162
|
const isTill = motion === "t" || motion === "T";
|
|
142
163
|
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
143
164
|
|
|
144
|
-
|
|
165
|
+
const graphemes = getLineGraphemes(line);
|
|
166
|
+
let currentIndex = graphemes.findIndex(g => col < g.end);
|
|
167
|
+
if (currentIndex === -1) currentIndex = graphemes.length;
|
|
145
168
|
|
|
146
169
|
for (let i = 0; i < steps; i++) {
|
|
147
170
|
const isFirst = i === 0;
|
|
@@ -149,19 +172,38 @@ export function findCharMotionTarget(
|
|
|
149
172
|
const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
|
|
150
173
|
|
|
151
174
|
if (isForward) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
let nextIndex = currentIndex + 1 + tillRepeatOffset;
|
|
176
|
+
let found = -1;
|
|
177
|
+
for (let j = nextIndex; j < graphemes.length; j++) {
|
|
178
|
+
const g = graphemes[j]!;
|
|
179
|
+
// Use startsWith to allow matching base chars if targetChar lacks combining marks,
|
|
180
|
+
// or just exact match since targetChar is typically a full grapheme.
|
|
181
|
+
if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
|
|
182
|
+
found = j;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (found === -1) return null;
|
|
187
|
+
if (isFinal) return isTill ? graphemes[found - 1]!.start : graphemes[found]!.start;
|
|
188
|
+
currentIndex = found;
|
|
189
|
+
} else {
|
|
190
|
+
let nextIndex = currentIndex - 1 - tillRepeatOffset;
|
|
191
|
+
let found = -1;
|
|
192
|
+
for (let j = nextIndex; j >= 0; j--) {
|
|
193
|
+
const g = graphemes[j]!;
|
|
194
|
+
if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
|
|
195
|
+
found = j;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (found === -1) return null;
|
|
200
|
+
if (isFinal) {
|
|
201
|
+
if (!isTill) return graphemes[found]!.start;
|
|
202
|
+
const afterTarget = graphemes[found + 1];
|
|
203
|
+
return afterTarget ? afterTarget.start : line.length;
|
|
204
|
+
}
|
|
205
|
+
currentIndex = found;
|
|
158
206
|
}
|
|
159
|
-
|
|
160
|
-
const searchStart = currentPos - 1 - tillRepeatOffset;
|
|
161
|
-
const idx = line.lastIndexOf(targetChar, searchStart);
|
|
162
|
-
if (idx === -1) return null;
|
|
163
|
-
if (isFinal) return isTill ? idx + 1 : idx;
|
|
164
|
-
currentPos = idx;
|
|
165
207
|
}
|
|
166
208
|
|
|
167
209
|
return null;
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -39,7 +39,6 @@ export const CHAR_MOTION_KEYS = new Set<string>(["f", "F", "t", "T"]);
|
|
|
39
39
|
// Escape sequences
|
|
40
40
|
export const ESC_LEFT = "\x1b[D";
|
|
41
41
|
export const ESC_RIGHT = "\x1b[C";
|
|
42
|
-
export const ESC_DELETE = "\x1b[3~";
|
|
43
42
|
export const CTRL_A = "\x01"; // line start
|
|
44
43
|
export const CTRL_E = "\x05"; // line end
|
|
45
44
|
export const CTRL_K = "\x0b"; // kill to end of line
|