pi-vim 0.2.0 → 0.3.0
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 -55
- package/index.ts +342 -100
- 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
|
|
|
@@ -46,23 +34,25 @@ u # undo
|
|
|
46
34
|
```
|
|
47
35
|
|
|
48
36
|
Mode indicator (`INSERT` / `NORMAL`) appears at bottom-right.
|
|
37
|
+
Its label is theme-colored: reverse-video `borderMuted` for
|
|
38
|
+
INSERT, `borderAccent` for NORMAL.
|
|
49
39
|
|
|
50
|
-
##
|
|
40
|
+
## why pi-vim
|
|
51
41
|
|
|
52
42
|
- Fast modal editing without leaving Pi.
|
|
53
43
|
- Count-aware motions/operators (`2dw`, `3G`, `d2j`, `2}`).
|
|
54
44
|
- Strong REPL-focused defaults; safe out-of-scope boundaries documented.
|
|
55
45
|
- Clipboard/register behavior is explicit and tested.
|
|
56
46
|
|
|
57
|
-
##
|
|
47
|
+
## for you / not for you
|
|
58
48
|
|
|
59
49
|
Use pi-vim if you want fast Vim muscle-memory in Pi prompts.
|
|
60
50
|
Skip it if you need full Vim feature parity (visual mode, macros, search,
|
|
61
51
|
ex-commands, etc.).
|
|
62
52
|
|
|
63
|
-
##
|
|
53
|
+
## common recipes
|
|
64
54
|
|
|
65
|
-
|
|
|
55
|
+
| goal | keys |
|
|
66
56
|
|------|------|
|
|
67
57
|
| Jump to exact line 25 | `25gg` (or `25G`) |
|
|
68
58
|
| Delete two words | `2dw` |
|
|
@@ -76,24 +66,24 @@ ex-commands, etc.).
|
|
|
76
66
|
|
|
77
67
|
---
|
|
78
68
|
|
|
79
|
-
##
|
|
69
|
+
## full reference
|
|
80
70
|
|
|
81
|
-
###
|
|
71
|
+
### mode switching
|
|
82
72
|
|
|
83
|
-
|
|
|
73
|
+
| key | action |
|
|
84
74
|
|----------|----------------------------------------|
|
|
85
75
|
| `Esc` / `Ctrl+[` | Insert → Normal mode |
|
|
86
76
|
| `Esc` / `Ctrl+[` | Normal mode → pass to Pi (abort agent) |
|
|
87
77
|
| `i` | Normal → Insert at cursor |
|
|
88
78
|
| `a` | Normal → Insert after cursor |
|
|
89
|
-
| `I` | Normal → Insert at
|
|
79
|
+
| `I` | Normal → Insert at first non-whitespace |
|
|
90
80
|
| `A` | Normal → Insert at line end |
|
|
91
81
|
| `o` | Normal → open line below + Insert |
|
|
92
82
|
| `O` | Normal → open line above + Insert |
|
|
93
83
|
|
|
94
84
|
Insert-mode shortcuts (stay in Insert mode):
|
|
95
85
|
|
|
96
|
-
|
|
|
86
|
+
| key | action |
|
|
97
87
|
|-----------------|------------------------|
|
|
98
88
|
| `Shift+Alt+A` | Go to end of line |
|
|
99
89
|
| `Shift+Alt+I` | Go to start of line |
|
|
@@ -102,11 +92,11 @@ Insert-mode shortcuts (stay in Insert mode):
|
|
|
102
92
|
|
|
103
93
|
---
|
|
104
94
|
|
|
105
|
-
###
|
|
95
|
+
### navigation (normal mode)
|
|
106
96
|
|
|
107
97
|
A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
108
98
|
|
|
109
|
-
|
|
|
99
|
+
| key | action |
|
|
110
100
|
|---------------|-------------------------------|
|
|
111
101
|
| `h` | Left |
|
|
112
102
|
| `l` | Right |
|
|
@@ -116,6 +106,7 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
|
|
|
116
106
|
| `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
|
|
117
107
|
| `0` | Line start |
|
|
118
108
|
| `^` | First non-whitespace char of line |
|
|
109
|
+
| `_` | First non-whitespace char; with `{count}`, move down `count - 1` lines first |
|
|
119
110
|
| `$` | Line end |
|
|
120
111
|
| `gg` | Buffer start (line 1) |
|
|
121
112
|
| `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
|
|
@@ -148,11 +139,11 @@ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
|
|
|
148
139
|
|
|
149
140
|
---
|
|
150
141
|
|
|
151
|
-
###
|
|
142
|
+
### character-find motions (normal mode)
|
|
152
143
|
|
|
153
144
|
A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
|
|
154
145
|
|
|
155
|
-
|
|
|
146
|
+
| key | action |
|
|
156
147
|
|------------------|------------------------------------------------|
|
|
157
148
|
| `f{char}` | Jump forward to `char` (inclusive) |
|
|
158
149
|
| `F{char}` | Jump backward to `char` (inclusive) |
|
|
@@ -166,17 +157,17 @@ Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{cha
|
|
|
166
157
|
|
|
167
158
|
---
|
|
168
159
|
|
|
169
|
-
###
|
|
160
|
+
### edit operators (normal mode)
|
|
170
161
|
|
|
171
162
|
All operators write to the unnamed register and mirror to the system clipboard
|
|
172
163
|
(best-effort; clipboard failure never breaks editing).
|
|
173
164
|
|
|
174
|
-
####
|
|
165
|
+
#### delete `d{motion}` / `dd`
|
|
175
166
|
|
|
176
167
|
A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
|
|
177
168
|
word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
178
169
|
|
|
179
|
-
|
|
|
170
|
+
| command | deletes |
|
|
180
171
|
|-------------------|-----------------------------------------------------------|
|
|
181
172
|
| `dw` | Forward to next `word` start (exclusive, can cross lines) |
|
|
182
173
|
| `de` | Forward to `word` end (inclusive, can cross lines) |
|
|
@@ -189,6 +180,8 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
189
180
|
| `d$` | To end of line |
|
|
190
181
|
| `d0` | To start of line |
|
|
191
182
|
| `d^` | To first non-whitespace char of line |
|
|
183
|
+
| `d_` | Current line (linewise, same as `dd`) |
|
|
184
|
+
| `d{count}_` | `{count}` lines (linewise, same as `{count}dd`) |
|
|
192
185
|
| `dd` | Current line (linewise) |
|
|
193
186
|
| `{count}dd` | `{count}` lines (linewise) |
|
|
194
187
|
| `d{count}j` | Current line + `{count}` lines below (linewise) |
|
|
@@ -203,11 +196,11 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
203
196
|
| `daw` | Around word (includes surrounding spaces) |
|
|
204
197
|
| `d{count}aw` | Around `{count}` words |
|
|
205
198
|
|
|
206
|
-
####
|
|
199
|
+
#### change `c{motion}` / `cc`
|
|
207
200
|
|
|
208
201
|
Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
209
202
|
|
|
210
|
-
|
|
|
203
|
+
| command | action |
|
|
211
204
|
|-----------------|------------------------------------|
|
|
212
205
|
| `cw` | Change `word` + Insert |
|
|
213
206
|
| `ce` / `cb` | Change to `word` end / previous `word` start |
|
|
@@ -218,14 +211,16 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
|
218
211
|
| `ciw` | Change inner word |
|
|
219
212
|
| `caw` | Change around word |
|
|
220
213
|
| `cc` | Delete line content + Insert |
|
|
214
|
+
| `c_` | Change line (linewise, same as `cc`) |
|
|
215
|
+
| `c{count}_` | Change `{count}` lines (linewise) |
|
|
221
216
|
| `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
|
|
222
217
|
| … | All `d` motions apply |
|
|
223
218
|
|
|
224
|
-
####
|
|
219
|
+
#### single-key edits
|
|
225
220
|
|
|
226
221
|
A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
227
222
|
|
|
228
|
-
|
|
|
223
|
+
| key | action |
|
|
229
224
|
|--------------|---------------------------------------------------------------|
|
|
230
225
|
| `x` | Delete char under cursor (no-op at/past EOL) |
|
|
231
226
|
| `{count}x` | Delete `{count}` chars |
|
|
@@ -233,17 +228,21 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
|
233
228
|
| `S` | Delete line content + Insert mode |
|
|
234
229
|
| `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
|
|
235
230
|
| `C` | Delete cursor to EOL + Insert mode |
|
|
231
|
+
| `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
|
|
232
|
+
| `{count}r{char}` | Replace next `{count}` chars with `{char}` |
|
|
236
233
|
|
|
237
234
|
---
|
|
238
235
|
|
|
239
|
-
###
|
|
236
|
+
### yank `y{motion}` / `yy`
|
|
240
237
|
|
|
241
238
|
Same motion set as `d`. Writes to register, **no text mutation**.
|
|
242
239
|
|
|
243
|
-
|
|
|
240
|
+
| command | yanks |
|
|
244
241
|
|---------|---------------------------------|
|
|
245
242
|
| `yy` | Whole line + trailing `\n` |
|
|
243
|
+
| `Y` | Whole line + trailing `\n` (same as `yy`) |
|
|
246
244
|
| `{count}yy` | `{count}` whole lines + trailing `\n` |
|
|
245
|
+
| `{count}Y` | `{count}` whole lines + trailing `\n` (same as `{count}yy`) |
|
|
247
246
|
| `y{count}j` | Current line + `{count}` lines below (linewise) |
|
|
248
247
|
| `y{count}k` | Current line + `{count}` lines above (linewise) |
|
|
249
248
|
| `yG` | Current line to end of buffer (linewise) |
|
|
@@ -256,6 +255,8 @@ Same motion set as `d`. Writes to register, **no text mutation**.
|
|
|
256
255
|
| `y$` | To end of line |
|
|
257
256
|
| `y0` | To start of line |
|
|
258
257
|
| `y^` | To first non-whitespace char of line |
|
|
258
|
+
| `y_` | Whole line (linewise, same as `yy`) |
|
|
259
|
+
| `y{count}_` | `{count}` whole lines (linewise) |
|
|
259
260
|
| `yf{c}` | To and including `char` |
|
|
260
261
|
| `yiw` | Inner word |
|
|
261
262
|
| `yaw` | Around word (includes spaces) |
|
|
@@ -266,9 +267,9 @@ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
|
|
|
266
267
|
|
|
267
268
|
---
|
|
268
269
|
|
|
269
|
-
###
|
|
270
|
+
### put / paste
|
|
270
271
|
|
|
271
|
-
|
|
|
272
|
+
| key | action |
|
|
272
273
|
|--------------|-------------------------------------------------------------|
|
|
273
274
|
| `p` | Put after cursor (char-wise) / new line below (line-wise) |
|
|
274
275
|
| `P` | Put before cursor (char-wise) / new line above (line-wise) |
|
|
@@ -280,22 +281,23 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
280
281
|
|
|
281
282
|
---
|
|
282
283
|
|
|
283
|
-
###
|
|
284
|
+
### undo / redo
|
|
284
285
|
|
|
285
|
-
|
|
|
286
|
+
| key | action |
|
|
286
287
|
|-----|--------|
|
|
287
|
-
| `u` | Undo in normal mode |
|
|
288
|
+
| `u` | Undo one change in normal mode |
|
|
289
|
+
| `{count}u` | Undo up to `{count}` changes in normal mode; clamps at available history |
|
|
288
290
|
| `Ctrl+_` | Undo in normal mode (alias for `u`) |
|
|
289
291
|
| `<C-r>` | Redo one undone change in normal mode; safe no-op when redo history is empty |
|
|
290
292
|
| `{count}<C-r>` | Redo up to `{count}` undone changes in order; clamps at available history and consumes count state (no leak to the next command) |
|
|
291
293
|
|
|
292
294
|
---
|
|
293
295
|
|
|
294
|
-
##
|
|
296
|
+
## register and clipboard policy
|
|
295
297
|
|
|
296
298
|
- One unnamed register (like Vim's `""` register).
|
|
297
299
|
- Every `d`, `c`, `x`, `s`, `S`, `D`, `C`, `y` operator form
|
|
298
|
-
(including `dd`, `{count}dd`, `d{count}j/k`, `dG`, `yy`, `{count}yy`,
|
|
300
|
+
(including `dd`/`d_`, `{count}dd`, `d{count}j/k`, `dG`, `yy`/`y_`, `{count}yy`,
|
|
299
301
|
`y{count}j/k`, `yG`) writes to the register and mirrors to the OS clipboard
|
|
300
302
|
(via `copyToClipboard`, best-effort).
|
|
301
303
|
- `p` / `P` read from the unnamed register only (not the OS clipboard).
|
|
@@ -303,9 +305,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
303
305
|
|
|
304
306
|
---
|
|
305
307
|
|
|
306
|
-
##
|
|
308
|
+
## known differences from full Vim
|
|
307
309
|
|
|
308
|
-
|
|
|
310
|
+
| area | this extension | full Vim |
|
|
309
311
|
|-----------------------|----------------------------------------|-------------------------------|
|
|
310
312
|
| `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
|
|
311
313
|
| `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
|
|
@@ -314,7 +316,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
314
316
|
| 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
317
|
| Visual mode | Not implemented | `v`, `V`, `<C-v>` |
|
|
316
318
|
| 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 |
|
|
319
|
+
| 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
320
|
| Named registers | Not implemented (`"a`, etc.) | Supported |
|
|
319
321
|
| Macros | Not implemented (`q`, `@`) | Supported |
|
|
320
322
|
| Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
|
|
@@ -323,7 +325,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
|
|
|
323
325
|
|
|
324
326
|
---
|
|
325
327
|
|
|
326
|
-
##
|
|
328
|
+
## out of scope
|
|
327
329
|
|
|
328
330
|
These are **explicitly deferred** and not planned for this feature:
|
|
329
331
|
|
|
@@ -334,6 +336,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
334
336
|
- Ex command surface (`:s`, `:g`, `:r`, …)
|
|
335
337
|
- Search mode (`/`, `?`, `n`, `N`)
|
|
336
338
|
- Repeat (`.`)
|
|
339
|
+
- Replace mode (`R`) — only single-char `r{char}` is supported
|
|
337
340
|
- Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
|
|
338
341
|
- No insert-mode `<C-r>` feature expansion beyond current underlying-editor behavior.
|
|
339
342
|
- No cross-session redo persistence.
|
|
@@ -343,7 +346,7 @@ These are **explicitly deferred** and not planned for this feature:
|
|
|
343
346
|
|
|
344
347
|
---
|
|
345
348
|
|
|
346
|
-
##
|
|
349
|
+
## architecture notes
|
|
347
350
|
|
|
348
351
|
- `index.ts` — `ModalEditor` subclass of `CustomEditor`; all key handling.
|
|
349
352
|
- `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;
|
|
@@ -134,6 +136,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
134
136
|
private readonly redoStack: EditorSnapshot[] = [];
|
|
135
137
|
private currentTransition: TransitionState = "none";
|
|
136
138
|
private onChangeHooked: boolean = false;
|
|
139
|
+
private readonly labelColorizers: { insert: (s: string) => string; normal: (s: string) => string } | null;
|
|
137
140
|
|
|
138
141
|
// Unnamed register
|
|
139
142
|
private unnamedRegister: string = "";
|
|
@@ -141,6 +144,16 @@ export class ModalEditor extends CustomEditor {
|
|
|
141
144
|
try { copyToClipboard(text); } catch { /* best effort */ }
|
|
142
145
|
};
|
|
143
146
|
|
|
147
|
+
constructor(
|
|
148
|
+
tui: any,
|
|
149
|
+
theme: any,
|
|
150
|
+
kb: any,
|
|
151
|
+
labelColorizers?: { insert: (s: string) => string; normal: (s: string) => string } | null,
|
|
152
|
+
) {
|
|
153
|
+
super(tui, theme, kb);
|
|
154
|
+
this.labelColorizers = labelColorizers ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
// Test seams
|
|
145
158
|
setClipboardFn(fn: (text: string) => void): void { this.clipboardFn = fn; }
|
|
146
159
|
getRegister(): string { return this.unnamedRegister; }
|
|
@@ -218,16 +231,22 @@ export class ModalEditor extends CustomEditor {
|
|
|
218
231
|
}
|
|
219
232
|
}
|
|
220
233
|
|
|
221
|
-
private performUndo(): void {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
private performUndo(count: number = this.takeTotalCount(1)): void {
|
|
235
|
+
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
236
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
237
|
+
let changed = false;
|
|
238
|
+
this.withTransition("undo", () => {
|
|
239
|
+
const beforeUndo = this.captureSnapshot();
|
|
240
|
+
super.handleInput(CTRL_UNDERSCORE);
|
|
241
|
+
const afterUndo = this.captureSnapshot();
|
|
242
|
+
|
|
243
|
+
if (this.snapshotChanged(beforeUndo, afterUndo)) {
|
|
244
|
+
this.redoStack.push(beforeUndo);
|
|
245
|
+
changed = true;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (!changed) break;
|
|
249
|
+
}
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
private performRedo(count: number = this.takeTotalCount(1)): void {
|
|
@@ -333,6 +352,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
333
352
|
this.operatorCount = "";
|
|
334
353
|
this.pendingG = false;
|
|
335
354
|
this.pendingGCount = "";
|
|
355
|
+
this.pendingReplace = false;
|
|
336
356
|
}
|
|
337
357
|
|
|
338
358
|
private isEscapeLikeInput(data: string): boolean {
|
|
@@ -441,6 +461,32 @@ export class ModalEditor extends CustomEditor {
|
|
|
441
461
|
return;
|
|
442
462
|
}
|
|
443
463
|
|
|
464
|
+
if (this.pendingReplace) {
|
|
465
|
+
this.pendingReplace = false;
|
|
466
|
+
if (!this.isPrintableInput(data)) {
|
|
467
|
+
this.prefixCount = "";
|
|
468
|
+
this.operatorCount = "";
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const count = this.takeTotalCount(1);
|
|
473
|
+
const cursor = this.getCursor();
|
|
474
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
475
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
|
|
476
|
+
if (!range) return;
|
|
477
|
+
|
|
478
|
+
const before = line.slice(0, range.start);
|
|
479
|
+
const after = line.slice(range.end);
|
|
480
|
+
const replacement = data.repeat(count);
|
|
481
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
482
|
+
const text = this.getText();
|
|
483
|
+
const newText = text.slice(0, lineStartAbs) + before + replacement + after
|
|
484
|
+
+ text.slice(lineStartAbs + line.length);
|
|
485
|
+
const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
|
|
486
|
+
this.replaceTextInBuffer(newText, newCursorAbs);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
444
490
|
if (this.pendingTextObject) {
|
|
445
491
|
return this.handlePendingTextObject(data);
|
|
446
492
|
}
|
|
@@ -491,6 +537,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
491
537
|
|| this.operatorCount
|
|
492
538
|
|| this.pendingG
|
|
493
539
|
|| this.pendingGCount
|
|
540
|
+
|| this.pendingReplace
|
|
494
541
|
) {
|
|
495
542
|
this.clearPendingState();
|
|
496
543
|
return;
|
|
@@ -513,7 +560,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
513
560
|
}
|
|
514
561
|
|
|
515
562
|
private isPrintableInput(data: string): boolean {
|
|
516
|
-
return this.isPrintableChunk(data) &&
|
|
563
|
+
return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
|
|
517
564
|
}
|
|
518
565
|
|
|
519
566
|
private isDigit(data: string): boolean {
|
|
@@ -663,6 +710,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
663
710
|
return;
|
|
664
711
|
}
|
|
665
712
|
|
|
713
|
+
if (data === "_") {
|
|
714
|
+
const count = this.takeTotalCount(1);
|
|
715
|
+
this.deleteLinewiseByDelta(count - 1);
|
|
716
|
+
this.pendingOperator = null;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
666
720
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
667
721
|
this.pendingMotion = data as PendingMotion;
|
|
668
722
|
return;
|
|
@@ -725,6 +779,28 @@ export class ModalEditor extends CustomEditor {
|
|
|
725
779
|
this.mode = "insert";
|
|
726
780
|
return;
|
|
727
781
|
}
|
|
782
|
+
|
|
783
|
+
if (data === "_") {
|
|
784
|
+
const count = this.takeTotalCount(1);
|
|
785
|
+
if (count <= 1) {
|
|
786
|
+
this.cutLine();
|
|
787
|
+
} else {
|
|
788
|
+
const currentLine = this.getCursor().line;
|
|
789
|
+
const lines = this.getLines();
|
|
790
|
+
const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
|
|
791
|
+
this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
|
|
792
|
+
const before = lines.slice(0, currentLine);
|
|
793
|
+
const after = lines.slice(clampedEnd + 1);
|
|
794
|
+
const newLines = [...before, "", ...after];
|
|
795
|
+
const newText = newLines.join("\n");
|
|
796
|
+
const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
|
|
797
|
+
this.replaceTextInBuffer(newText, cursorAbs);
|
|
798
|
+
}
|
|
799
|
+
this.pendingOperator = null;
|
|
800
|
+
this.mode = "insert";
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
728
804
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
729
805
|
this.pendingMotion = data as PendingMotion;
|
|
730
806
|
return;
|
|
@@ -823,13 +899,18 @@ export class ModalEditor extends CustomEditor {
|
|
|
823
899
|
|
|
824
900
|
const supportsCountedStandaloneEdit = (
|
|
825
901
|
data === "x"
|
|
902
|
+
|| data === "r"
|
|
826
903
|
|| data === "s"
|
|
827
904
|
|| data === "S"
|
|
828
905
|
|| data === "D"
|
|
829
906
|
|| data === "C"
|
|
830
907
|
|| data === "p"
|
|
831
908
|
|| data === "P"
|
|
909
|
+
|| data === "Y"
|
|
832
910
|
|| data === "J"
|
|
911
|
+
|| data === "u"
|
|
912
|
+
|| data === CTRL_UNDERSCORE
|
|
913
|
+
|| matchesKey(data, "ctrl+_")
|
|
833
914
|
|| data === CTRL_R
|
|
834
915
|
|| matchesKey(data, "ctrl+r")
|
|
835
916
|
);
|
|
@@ -853,6 +934,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
853
934
|
|| data === "k"
|
|
854
935
|
|| data === "l"
|
|
855
936
|
);
|
|
937
|
+
const supportsCountedUnderscore = data === "_";
|
|
856
938
|
|
|
857
939
|
if (supportsCountedNav) {
|
|
858
940
|
const count = this.takeTotalCount(1);
|
|
@@ -862,16 +944,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
862
944
|
} else if (data === "l") {
|
|
863
945
|
this.moveCursorBy(clamped);
|
|
864
946
|
} 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
|
-
}
|
|
947
|
+
const delta = data === "j" ? clamped : -clamped;
|
|
948
|
+
this.moveCursorVertically(delta);
|
|
875
949
|
}
|
|
876
950
|
return;
|
|
877
951
|
}
|
|
@@ -886,6 +960,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
886
960
|
&& !supportsCountedCharMotion
|
|
887
961
|
&& !supportsCountedWordMotion
|
|
888
962
|
&& !supportsCountedParagraphMotion
|
|
963
|
+
&& !supportsCountedUnderscore
|
|
889
964
|
) {
|
|
890
965
|
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
891
966
|
this.prefixCount = "";
|
|
@@ -912,6 +987,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
912
987
|
return;
|
|
913
988
|
}
|
|
914
989
|
|
|
990
|
+
if (data === "r") {
|
|
991
|
+
this.pendingReplace = true;
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
915
995
|
if (data === "d") {
|
|
916
996
|
this.pendingOperator = "d";
|
|
917
997
|
return;
|
|
@@ -937,6 +1017,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
937
1017
|
return;
|
|
938
1018
|
}
|
|
939
1019
|
|
|
1020
|
+
if (data === "Y") {
|
|
1021
|
+
const count = this.takeTotalCount(1);
|
|
1022
|
+
this.yankLinewiseByDelta(count - 1);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
940
1026
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
941
1027
|
this.pendingMotion = data as PendingMotion;
|
|
942
1028
|
return;
|
|
@@ -975,6 +1061,15 @@ export class ModalEditor extends CustomEditor {
|
|
|
975
1061
|
return;
|
|
976
1062
|
}
|
|
977
1063
|
|
|
1064
|
+
if (data === "_") {
|
|
1065
|
+
const count = this.takeTotalCount(1);
|
|
1066
|
+
if (count > 1) {
|
|
1067
|
+
this.moveCursorVertically(count - 1);
|
|
1068
|
+
}
|
|
1069
|
+
this.moveCursorToFirstNonWhitespace();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
978
1073
|
if (data === "w") {
|
|
979
1074
|
const count = this.takeTotalCount(1);
|
|
980
1075
|
return this.moveWord("forward", "start", count, "word");
|
|
@@ -1023,7 +1118,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1023
1118
|
break;
|
|
1024
1119
|
case "I":
|
|
1025
1120
|
this.mode = "insert";
|
|
1026
|
-
|
|
1121
|
+
this.moveCursorToFirstNonWhitespace();
|
|
1027
1122
|
break;
|
|
1028
1123
|
case "o":
|
|
1029
1124
|
this.openLineBelow();
|
|
@@ -1054,6 +1149,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1054
1149
|
case "x":
|
|
1055
1150
|
this.cutCharUnderCursor();
|
|
1056
1151
|
break;
|
|
1152
|
+
case "j":
|
|
1153
|
+
this.moveCursorVertically(1);
|
|
1154
|
+
break;
|
|
1155
|
+
case "k":
|
|
1156
|
+
this.moveCursorVertically(-1);
|
|
1157
|
+
break;
|
|
1057
1158
|
default:
|
|
1058
1159
|
if (seq) super.handleInput(seq);
|
|
1059
1160
|
}
|
|
@@ -1070,7 +1171,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1070
1171
|
}
|
|
1071
1172
|
|
|
1072
1173
|
if (targetCol !== null && targetCol !== col) {
|
|
1073
|
-
this.
|
|
1174
|
+
this.moveCursorToCol(targetCol);
|
|
1074
1175
|
}
|
|
1075
1176
|
}
|
|
1076
1177
|
|
|
@@ -1098,10 +1199,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1098
1199
|
const cursorLine = state.cursorLine as number;
|
|
1099
1200
|
const cursorCol = state.cursorCol as number;
|
|
1100
1201
|
const line = state.lines[cursorLine] ?? "";
|
|
1202
|
+
if (this.hasMultiCodeUnitGraphemes(line)) return false;
|
|
1203
|
+
|
|
1101
1204
|
const target = cursorCol + delta;
|
|
1102
1205
|
|
|
1103
|
-
// Only short-circuit line-local movement
|
|
1104
|
-
//
|
|
1206
|
+
// Only short-circuit line-local movement when each grapheme is one code
|
|
1207
|
+
// unit; otherwise let the base editor keep cursor boundaries valid.
|
|
1105
1208
|
if (target < 0 || target > line.length) return false;
|
|
1106
1209
|
|
|
1107
1210
|
state.cursorCol = target;
|
|
@@ -1121,38 +1224,100 @@ export class ModalEditor extends CustomEditor {
|
|
|
1121
1224
|
}
|
|
1122
1225
|
}
|
|
1123
1226
|
|
|
1124
|
-
private
|
|
1125
|
-
|
|
1126
|
-
if (lines.length === 0) {
|
|
1127
|
-
super.handleInput(CTRL_A);
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1227
|
+
private moveCursorVertically(delta: number): void {
|
|
1228
|
+
if (delta === 0) return;
|
|
1130
1229
|
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1230
|
+
const editor = this as unknown as {
|
|
1231
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1232
|
+
preferredVisualCol?: number | null;
|
|
1233
|
+
lastAction?: string | null;
|
|
1234
|
+
tui?: { requestRender?: () => void };
|
|
1235
|
+
};
|
|
1134
1236
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
} else if (delta < 0) {
|
|
1237
|
+
const state = editor.state;
|
|
1238
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1239
|
+
const seq = delta > 0 ? ESC_DOWN : ESC_UP;
|
|
1140
1240
|
for (let i = 0; i < Math.abs(delta); i++) {
|
|
1141
|
-
super.handleInput(
|
|
1241
|
+
super.handleInput(seq);
|
|
1142
1242
|
}
|
|
1243
|
+
return;
|
|
1143
1244
|
}
|
|
1144
1245
|
|
|
1145
|
-
|
|
1246
|
+
const currentLine = state.cursorLine ?? 0;
|
|
1247
|
+
const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
|
|
1248
|
+
if (targetLine === currentLine) return;
|
|
1249
|
+
|
|
1250
|
+
const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
|
|
1251
|
+
const targetLineText = state.lines[targetLine] ?? "";
|
|
1252
|
+
editor.lastAction = null;
|
|
1253
|
+
state.cursorLine = targetLine;
|
|
1254
|
+
state.cursorCol = Math.min(preferredCol, targetLineText.length);
|
|
1255
|
+
editor.preferredVisualCol = preferredCol;
|
|
1256
|
+
editor.tui?.requestRender?.();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private moveCursorToCol(col: number): void {
|
|
1260
|
+
const editor = this as unknown as {
|
|
1261
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1262
|
+
preferredVisualCol?: number | null;
|
|
1263
|
+
lastAction?: string | null;
|
|
1264
|
+
tui?: { requestRender?: () => void };
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const state = editor.state;
|
|
1268
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1269
|
+
|
|
1270
|
+
editor.lastAction = null;
|
|
1271
|
+
state.cursorCol = col;
|
|
1272
|
+
editor.preferredVisualCol = col;
|
|
1273
|
+
editor.tui?.requestRender?.();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private moveCursorToAbsoluteIndex(abs: number): void {
|
|
1277
|
+
const editor = this as unknown as {
|
|
1278
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1279
|
+
preferredVisualCol?: number | null;
|
|
1280
|
+
lastAction?: string | null;
|
|
1281
|
+
tui?: { requestRender?: () => void };
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
const state = editor.state;
|
|
1285
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
1286
|
+
|
|
1287
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
|
|
1288
|
+
editor.lastAction = null;
|
|
1289
|
+
state.cursorLine = line;
|
|
1290
|
+
state.cursorCol = col;
|
|
1291
|
+
editor.preferredVisualCol = col;
|
|
1292
|
+
editor.tui?.requestRender?.();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private moveCursorToLineStart(lineIndex: number): void {
|
|
1296
|
+
const editor = this as unknown as {
|
|
1297
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
1298
|
+
preferredVisualCol?: number | null;
|
|
1299
|
+
lastAction?: string | null;
|
|
1300
|
+
tui?: { requestRender?: () => void };
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const state = editor.state;
|
|
1304
|
+
if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
|
|
1305
|
+
super.handleInput(CTRL_A);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
|
|
1310
|
+
editor.lastAction = null;
|
|
1311
|
+
state.cursorLine = targetLine;
|
|
1312
|
+
state.cursorCol = 0;
|
|
1313
|
+
editor.preferredVisualCol = null;
|
|
1314
|
+
editor.tui?.requestRender?.();
|
|
1146
1315
|
}
|
|
1147
1316
|
|
|
1148
1317
|
private moveCursorToFirstNonWhitespace(): void {
|
|
1149
1318
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1150
1319
|
const targetCol = findFirstNonWhitespaceColumn(line);
|
|
1151
|
-
this.
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
private moveCursorToBufferStart(): void {
|
|
1155
|
-
this.moveCursorToLineStart(0);
|
|
1320
|
+
this.moveCursorToCol(targetCol);
|
|
1156
1321
|
}
|
|
1157
1322
|
|
|
1158
1323
|
private moveCursorToBufferEnd(): void {
|
|
@@ -1343,7 +1508,6 @@ export class ModalEditor extends CustomEditor {
|
|
|
1343
1508
|
private tryFindWordTargetLineLocal(
|
|
1344
1509
|
direction: WordMotionDirection,
|
|
1345
1510
|
target: WordMotionTarget,
|
|
1346
|
-
allowSameColumn: boolean = false,
|
|
1347
1511
|
semanticClass: WordMotionClass = "word",
|
|
1348
1512
|
): number | null {
|
|
1349
1513
|
const cursor = this.getCursor();
|
|
@@ -1356,7 +1520,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1356
1520
|
col,
|
|
1357
1521
|
direction,
|
|
1358
1522
|
target,
|
|
1359
|
-
|
|
1523
|
+
false,
|
|
1360
1524
|
semanticClass,
|
|
1361
1525
|
);
|
|
1362
1526
|
if (targetCol === null) return null;
|
|
@@ -1374,10 +1538,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
1374
1538
|
semanticClass: WordMotionClass = "word",
|
|
1375
1539
|
): boolean {
|
|
1376
1540
|
const col = this.getCursor().col;
|
|
1377
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target,
|
|
1541
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
|
|
1378
1542
|
if (targetCol === null || targetCol === col) return false;
|
|
1379
1543
|
|
|
1380
|
-
this.
|
|
1544
|
+
this.moveCursorToCol(targetCol);
|
|
1381
1545
|
return true;
|
|
1382
1546
|
}
|
|
1383
1547
|
|
|
@@ -1445,7 +1609,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1445
1609
|
semanticClass,
|
|
1446
1610
|
);
|
|
1447
1611
|
if (targetAbs !== currentAbs) {
|
|
1448
|
-
this.
|
|
1612
|
+
this.moveCursorToAbsoluteIndex(targetAbs);
|
|
1449
1613
|
}
|
|
1450
1614
|
return;
|
|
1451
1615
|
}
|
|
@@ -1463,6 +1627,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1463
1627
|
return { line, col };
|
|
1464
1628
|
}
|
|
1465
1629
|
|
|
1630
|
+
private hasMultiCodeUnitGraphemes(line: string): boolean {
|
|
1631
|
+
return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
private getGraphemeRangeAtCol(
|
|
1635
|
+
line: string,
|
|
1636
|
+
col: number,
|
|
1637
|
+
count: number,
|
|
1638
|
+
clampToLine: boolean = false,
|
|
1639
|
+
): { start: number; end: number } | null {
|
|
1640
|
+
const clampedCol = Math.max(0, Math.min(col, line.length));
|
|
1641
|
+
const segments = getLineGraphemes(line);
|
|
1642
|
+
const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
|
|
1643
|
+
if (startIndex === -1) return null;
|
|
1644
|
+
|
|
1645
|
+
let endIndex = startIndex + Math.max(1, count) - 1;
|
|
1646
|
+
if (endIndex >= segments.length) {
|
|
1647
|
+
if (!clampToLine) return null;
|
|
1648
|
+
endIndex = segments.length - 1;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
return {
|
|
1652
|
+
start: segments[startIndex]!.start,
|
|
1653
|
+
end: segments[endIndex]!.end,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1466
1657
|
private isCursorOnNonWhitespace(): boolean {
|
|
1467
1658
|
const { line, col } = this.getCurrentLineAndCol();
|
|
1468
1659
|
const ch = line[col];
|
|
@@ -1475,13 +1666,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1475
1666
|
}
|
|
1476
1667
|
|
|
1477
1668
|
private cutCharUnderCursor(): void {
|
|
1478
|
-
const count = this.takeTotalCount(1);
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1669
|
+
const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
|
|
1670
|
+
const cursor = this.getCursor();
|
|
1671
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
1672
|
+
const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
|
|
1673
|
+
if (!range) return;
|
|
1482
1674
|
|
|
1483
|
-
const
|
|
1484
|
-
this.
|
|
1675
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1676
|
+
const text = this.getText();
|
|
1677
|
+
this.writeToRegister(line.slice(range.start, range.end));
|
|
1678
|
+
this.replaceTextInBuffer(
|
|
1679
|
+
text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
|
|
1680
|
+
lineStartAbs + range.start,
|
|
1681
|
+
);
|
|
1485
1682
|
}
|
|
1486
1683
|
|
|
1487
1684
|
private cutToEndOfLine(): void {
|
|
@@ -1562,19 +1759,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1562
1759
|
this.writeToRegister(payload);
|
|
1563
1760
|
|
|
1564
1761
|
if (endAbs > startAbs) {
|
|
1565
|
-
const
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
this.moveCursorBy(startAbs - cursorAbs);
|
|
1569
|
-
}
|
|
1762
|
+
const text = this.getText();
|
|
1763
|
+
const newText = text.slice(0, startAbs) + text.slice(endAbs);
|
|
1764
|
+
this.replaceTextInBuffer(newText, startAbs);
|
|
1570
1765
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
super.handleInput(ESC_DELETE);
|
|
1574
|
-
}
|
|
1766
|
+
// Ensure cursor is at column 0 of the landing line
|
|
1767
|
+
super.handleInput(CTRL_A);
|
|
1575
1768
|
}
|
|
1576
|
-
|
|
1577
|
-
super.handleInput(CTRL_A);
|
|
1578
1769
|
}
|
|
1579
1770
|
|
|
1580
1771
|
private yankLineRange(startLine: number, endLine: number): void {
|
|
@@ -1637,7 +1828,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1637
1828
|
}
|
|
1638
1829
|
|
|
1639
1830
|
const text = this.getText();
|
|
1640
|
-
const currentAbs = this.
|
|
1831
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1641
1832
|
const targetAbs = this.findWordTargetInText(
|
|
1642
1833
|
text,
|
|
1643
1834
|
currentAbs,
|
|
@@ -1705,6 +1896,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1705
1896
|
return;
|
|
1706
1897
|
}
|
|
1707
1898
|
|
|
1899
|
+
if (data === "_") {
|
|
1900
|
+
const count = this.takeTotalCount(1);
|
|
1901
|
+
this.yankLinewiseByDelta(count - 1);
|
|
1902
|
+
this.pendingOperator = null;
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1708
1906
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1709
1907
|
this.pendingMotion = data as PendingMotion;
|
|
1710
1908
|
return;
|
|
@@ -1765,7 +1963,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1765
1963
|
}
|
|
1766
1964
|
|
|
1767
1965
|
const text = this.getText();
|
|
1768
|
-
const currentAbs = this.
|
|
1966
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1769
1967
|
const targetAbs = this.findWordTargetInText(
|
|
1770
1968
|
text,
|
|
1771
1969
|
currentAbs,
|
|
@@ -1797,7 +1995,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1797
1995
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1798
1996
|
const start = Math.min(col, targetCol);
|
|
1799
1997
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1800
|
-
|
|
1998
|
+
let end = Math.min(rawEnd, line.length);
|
|
1999
|
+
|
|
2000
|
+
if (inclusive) {
|
|
2001
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
2002
|
+
end = targetRange?.end ?? end;
|
|
2003
|
+
}
|
|
1801
2004
|
|
|
1802
2005
|
if (end <= start) return;
|
|
1803
2006
|
|
|
@@ -1814,6 +2017,47 @@ export class ModalEditor extends CustomEditor {
|
|
|
1814
2017
|
this.writeToRegister(text.slice(start, end));
|
|
1815
2018
|
}
|
|
1816
2019
|
|
|
2020
|
+
private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
|
|
2021
|
+
const lines = text.length === 0 ? [""] : text.split("\n");
|
|
2022
|
+
let remaining = Math.max(0, Math.min(abs, text.length));
|
|
2023
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
2024
|
+
const line = lines[lineIndex] ?? "";
|
|
2025
|
+
if (remaining <= line.length) return { line: lineIndex, col: remaining };
|
|
2026
|
+
remaining -= line.length + 1;
|
|
2027
|
+
}
|
|
2028
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
2029
|
+
return { line: lastLine, col: (lines[lastLine] ?? "").length };
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
private replaceTextInBuffer(text: string, cursorAbs: number): void {
|
|
2033
|
+
const editor = this as unknown as {
|
|
2034
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
2035
|
+
preferredVisualCol?: number | null;
|
|
2036
|
+
historyIndex?: number;
|
|
2037
|
+
lastAction?: string | null;
|
|
2038
|
+
onChange?: (text: string) => void;
|
|
2039
|
+
tui?: { requestRender?: () => void };
|
|
2040
|
+
pushUndoSnapshot?: () => void;
|
|
2041
|
+
autocompleteState?: unknown;
|
|
2042
|
+
updateAutocomplete?: () => void;
|
|
2043
|
+
};
|
|
2044
|
+
const state = editor.state;
|
|
2045
|
+
if (!state) return;
|
|
2046
|
+
const currentText = this.getText();
|
|
2047
|
+
if (currentText !== text) editor.pushUndoSnapshot?.();
|
|
2048
|
+
const nextLines = text.length === 0 ? [""] : text.split("\n");
|
|
2049
|
+
const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
|
|
2050
|
+
editor.historyIndex = -1;
|
|
2051
|
+
editor.lastAction = null;
|
|
2052
|
+
state.lines = nextLines;
|
|
2053
|
+
state.cursorLine = line;
|
|
2054
|
+
state.cursorCol = col;
|
|
2055
|
+
editor.preferredVisualCol = null;
|
|
2056
|
+
editor.onChange?.(text);
|
|
2057
|
+
if (editor.autocompleteState) editor.updateAutocomplete?.();
|
|
2058
|
+
editor.tui?.requestRender?.();
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1817
2061
|
private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
|
|
1818
2062
|
const text = this.getText();
|
|
1819
2063
|
const start = Math.min(currentAbs, targetAbs);
|
|
@@ -1824,16 +2068,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1824
2068
|
|
|
1825
2069
|
this.writeToRegister(text.slice(start, end));
|
|
1826
2070
|
|
|
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
|
-
}
|
|
2071
|
+
this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
|
|
1837
2072
|
}
|
|
1838
2073
|
|
|
1839
2074
|
private getWordObjectRange(
|
|
@@ -1960,34 +2195,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1960
2195
|
}
|
|
1961
2196
|
|
|
1962
2197
|
private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
|
|
1963
|
-
const
|
|
1964
|
-
|
|
2198
|
+
const cursor = this.getCursor();
|
|
2199
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
2200
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
1965
2201
|
const start = Math.min(col, targetCol);
|
|
1966
2202
|
const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
|
|
1967
|
-
|
|
2203
|
+
let end = Math.min(rawEnd, line.length);
|
|
1968
2204
|
|
|
1969
|
-
if (
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
if (start !== col) {
|
|
1974
|
-
this.moveCursorBy(start - col);
|
|
2205
|
+
if (inclusive) {
|
|
2206
|
+
const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
|
|
2207
|
+
end = targetRange?.end ?? end;
|
|
1975
2208
|
}
|
|
1976
2209
|
|
|
1977
|
-
|
|
1978
|
-
for (let i = 0; i < count; i++) {
|
|
1979
|
-
super.handleInput(ESC_DELETE);
|
|
1980
|
-
}
|
|
2210
|
+
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
1981
2211
|
}
|
|
1982
2212
|
|
|
1983
2213
|
render(width: number): string[] {
|
|
1984
2214
|
const lines = super.render(width);
|
|
1985
2215
|
if (lines.length === 0) return lines;
|
|
1986
2216
|
|
|
1987
|
-
const
|
|
2217
|
+
const rawLabel = this.getModeLabel();
|
|
2218
|
+
const colorize = this.labelColorizers
|
|
2219
|
+
? (this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal)
|
|
2220
|
+
: null;
|
|
2221
|
+
const label = colorize ? colorize(rawLabel) : rawLabel;
|
|
1988
2222
|
const last = lines.length - 1;
|
|
1989
|
-
if (visibleWidth(lines[last]!) >=
|
|
1990
|
-
lines[last] = truncateToWidth(lines[last]!, width -
|
|
2223
|
+
if (visibleWidth(lines[last]!) >= visibleWidth(rawLabel)) {
|
|
2224
|
+
lines[last] = truncateToWidth(lines[last]!, width - visibleWidth(rawLabel), "") + label;
|
|
1991
2225
|
}
|
|
1992
2226
|
return lines;
|
|
1993
2227
|
}
|
|
@@ -1998,6 +2232,9 @@ export class ModalEditor extends CustomEditor {
|
|
|
1998
2232
|
const prefixCount = this.prefixCount;
|
|
1999
2233
|
const operatorCount = this.operatorCount;
|
|
2000
2234
|
|
|
2235
|
+
if (this.pendingReplace) {
|
|
2236
|
+
return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
|
|
2237
|
+
}
|
|
2001
2238
|
if (this.pendingOperator && this.pendingMotion) {
|
|
2002
2239
|
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
2003
2240
|
}
|
|
@@ -2019,6 +2256,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
2019
2256
|
|
|
2020
2257
|
export default function (pi: ExtensionAPI) {
|
|
2021
2258
|
pi.on("session_start", (_event, ctx) => {
|
|
2022
|
-
ctx.ui.
|
|
2259
|
+
const t = ctx.ui.theme;
|
|
2260
|
+
const colorizers = t ? {
|
|
2261
|
+
insert: (s: string) => t.fg("borderMuted", `\x1b[7m${s}\x1b[27m`),
|
|
2262
|
+
normal: (s: string) => t.fg("borderAccent", `\x1b[7m${s}\x1b[27m`),
|
|
2263
|
+
} : null;
|
|
2264
|
+
ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb, colorizers));
|
|
2023
2265
|
});
|
|
2024
2266
|
}
|
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
|