pi-vim 0.1.3 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -68
- package/index.ts +576 -190
- package/motions.ts +122 -25
- package/package.json +4 -2
- package/word-boundary-cache.ts +24 -9
package/index.ts
CHANGED
|
@@ -16,16 +16,21 @@
|
|
|
16
16
|
* - D: delete to end of line
|
|
17
17
|
* - S: substitute line (delete line content + insert mode)
|
|
18
18
|
* - s: substitute char (delete char + insert mode)
|
|
19
|
-
* - d{motion}: delete with motion (
|
|
19
|
+
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `dd`, `f/t/F/T{char}`)
|
|
20
|
+
* - c{motion}: change with same motion set as `d` (then enter insert mode)
|
|
21
|
+
* - y{motion}: yank with same motion set as `d` (no text mutation)
|
|
20
22
|
* - f{char}: jump to next {char} on line
|
|
21
23
|
* - F{char}: jump to previous {char} on line
|
|
22
24
|
* - t{char}: jump to just before next {char} on line
|
|
23
25
|
* - T{char}: jump to just after previous {char} on line
|
|
24
26
|
* - ;: repeat last f/F/t/T motion (same direction)
|
|
25
27
|
* - ,: repeat last f/F/t/T motion (reverse direction)
|
|
26
|
-
* - w:
|
|
27
|
-
* -
|
|
28
|
-
* -
|
|
28
|
+
* - w/b/e: `word` motions (keyword/punctuation aware)
|
|
29
|
+
* - W/B/E: `WORD` motions (whitespace-delimited non-space runs)
|
|
30
|
+
* - {/}: paragraph motions to previous/next paragraph start (line start col 0)
|
|
31
|
+
* - `{count}` prefixes supported for navigation, paragraph motions, and `d/c` word/WORD motions
|
|
32
|
+
* - operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
|
|
33
|
+
* - counted yank caveat: `y2w`, `2yw`, `y2W`, `2yW` cancel (linewise counts still supported)
|
|
29
34
|
* - Shift+Alt+A: go to end of line (insert mode shortcut)
|
|
30
35
|
* - Shift+Alt+I: go to start of line (insert mode shortcut)
|
|
31
36
|
* - Alt+o: open new line below (insert mode shortcut)
|
|
@@ -78,6 +83,8 @@ import {
|
|
|
78
83
|
import {
|
|
79
84
|
reverseCharMotion,
|
|
80
85
|
findCharMotionTarget,
|
|
86
|
+
findParagraphMotionTarget,
|
|
87
|
+
type WordMotionClass,
|
|
81
88
|
} from "./motions.js";
|
|
82
89
|
import {
|
|
83
90
|
WordBoundaryCache,
|
|
@@ -88,15 +95,17 @@ import {
|
|
|
88
95
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
89
96
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
90
97
|
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
|
|
98
|
+
const MAX_COUNT = 9999;
|
|
91
99
|
|
|
92
100
|
export class ModalEditor extends CustomEditor {
|
|
93
101
|
private mode: Mode = "insert";
|
|
94
102
|
private pendingMotion: PendingMotion = null;
|
|
95
103
|
private pendingTextObject: "i" | "a" | null = null;
|
|
96
104
|
private pendingOperator: PendingOperator = null;
|
|
97
|
-
private
|
|
98
|
-
private
|
|
105
|
+
private prefixCount: string = "";
|
|
106
|
+
private operatorCount: string = "";
|
|
99
107
|
private pendingG: boolean = false;
|
|
108
|
+
private pendingGCount: string = "";
|
|
100
109
|
private lastCharMotion: LastCharMotion | null = null;
|
|
101
110
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
102
111
|
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
@@ -119,9 +128,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
119
128
|
this.pendingMotion = null;
|
|
120
129
|
this.pendingTextObject = null;
|
|
121
130
|
this.pendingOperator = null;
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
131
|
+
this.prefixCount = "";
|
|
132
|
+
this.operatorCount = "";
|
|
124
133
|
this.pendingG = false;
|
|
134
|
+
this.pendingGCount = "";
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
|
|
@@ -272,8 +282,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
272
282
|
this.pendingMotion
|
|
273
283
|
|| this.pendingTextObject
|
|
274
284
|
|| this.pendingOperator
|
|
275
|
-
|| this.
|
|
285
|
+
|| this.prefixCount
|
|
286
|
+
|| this.operatorCount
|
|
276
287
|
|| this.pendingG
|
|
288
|
+
|| this.pendingGCount
|
|
277
289
|
) {
|
|
278
290
|
this.clearPendingState();
|
|
279
291
|
return;
|
|
@@ -307,21 +319,38 @@ export class ModalEditor extends CustomEditor {
|
|
|
307
319
|
return data.length === 1 && data >= "1" && data <= "9";
|
|
308
320
|
}
|
|
309
321
|
|
|
310
|
-
private
|
|
311
|
-
|
|
322
|
+
private takeTotalCount(defaultValue: number = 1): number {
|
|
323
|
+
const prefixRaw = this.prefixCount;
|
|
324
|
+
const operatorRaw = this.operatorCount;
|
|
325
|
+
this.prefixCount = "";
|
|
326
|
+
this.operatorCount = "";
|
|
312
327
|
|
|
313
|
-
|
|
314
|
-
this.pendingCount = "";
|
|
315
|
-
this.pendingCountKind = null;
|
|
328
|
+
if (!prefixRaw && !operatorRaw) return defaultValue;
|
|
316
329
|
|
|
317
|
-
|
|
318
|
-
|
|
330
|
+
const parse = (raw: string): number | null => {
|
|
331
|
+
if (!raw) return null;
|
|
332
|
+
const parsed = Number.parseInt(raw, 10);
|
|
333
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
334
|
+
return parsed;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const prefix = parse(prefixRaw);
|
|
338
|
+
const operator = parse(operatorRaw);
|
|
339
|
+
|
|
340
|
+
if (prefix === null && operator === null) return defaultValue;
|
|
341
|
+
|
|
342
|
+
const total = prefix !== null && operator !== null
|
|
343
|
+
? prefix * operator
|
|
344
|
+
: prefix ?? operator ?? defaultValue;
|
|
345
|
+
|
|
346
|
+
if (!Number.isFinite(total) || total <= 0) return defaultValue;
|
|
347
|
+
return Math.min(MAX_COUNT, total);
|
|
319
348
|
}
|
|
320
349
|
|
|
321
350
|
private cancelPendingOperator(data: string): void {
|
|
322
351
|
this.pendingOperator = null;
|
|
323
|
-
this.
|
|
324
|
-
this.
|
|
352
|
+
this.prefixCount = "";
|
|
353
|
+
this.operatorCount = "";
|
|
325
354
|
if (!this.isPrintableChunk(data)) {
|
|
326
355
|
super.handleInput(data);
|
|
327
356
|
}
|
|
@@ -358,7 +387,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
358
387
|
return;
|
|
359
388
|
}
|
|
360
389
|
|
|
361
|
-
const
|
|
390
|
+
const count = this.takeTotalCount(1);
|
|
391
|
+
const range = this.getWordObjectRange(this.pendingTextObject!, count);
|
|
362
392
|
this.pendingTextObject = null;
|
|
363
393
|
if (!range || !this.pendingOperator) {
|
|
364
394
|
this.pendingOperator = null;
|
|
@@ -390,43 +420,35 @@ export class ModalEditor extends CustomEditor {
|
|
|
390
420
|
|
|
391
421
|
private handlePendingDelete(data: string): void {
|
|
392
422
|
if (this.isDigit(data)) {
|
|
393
|
-
if (this.
|
|
423
|
+
if (this.operatorCount.length === 0) {
|
|
394
424
|
if (data !== "0") {
|
|
395
|
-
this.
|
|
396
|
-
this.pendingCountKind = "operator";
|
|
425
|
+
this.operatorCount = data;
|
|
397
426
|
return;
|
|
398
427
|
}
|
|
399
|
-
} else if (this.pendingCountKind === "operator") {
|
|
400
|
-
this.pendingCount += data;
|
|
401
|
-
return;
|
|
402
428
|
} else {
|
|
403
|
-
|
|
404
|
-
this.cancelPendingOperator(data);
|
|
429
|
+
this.operatorCount += data;
|
|
405
430
|
return;
|
|
406
431
|
}
|
|
407
432
|
}
|
|
408
433
|
|
|
409
434
|
if (data === "d") {
|
|
410
|
-
const count = this.
|
|
435
|
+
const count = this.takeTotalCount(1);
|
|
411
436
|
this.deleteLinewiseByDelta(count - 1);
|
|
412
437
|
this.pendingOperator = null;
|
|
413
438
|
return;
|
|
414
439
|
}
|
|
415
440
|
|
|
416
441
|
if (data === "j" || data === "k") {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const count = this.takePendingCount(1);
|
|
423
|
-
this.deleteLinewiseByDelta(data === "j" ? count : -count);
|
|
442
|
+
const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
443
|
+
const count = this.takeTotalCount(1);
|
|
444
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
445
|
+
this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
|
|
424
446
|
this.pendingOperator = null;
|
|
425
447
|
return;
|
|
426
448
|
}
|
|
427
449
|
|
|
428
450
|
if (data === "G") {
|
|
429
|
-
if (this.
|
|
451
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
430
452
|
this.cancelPendingOperator(data);
|
|
431
453
|
return;
|
|
432
454
|
}
|
|
@@ -436,22 +458,36 @@ export class ModalEditor extends CustomEditor {
|
|
|
436
458
|
return;
|
|
437
459
|
}
|
|
438
460
|
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
this.cancelPendingOperator(data);
|
|
461
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
462
|
+
this.pendingMotion = data as PendingMotion;
|
|
442
463
|
return;
|
|
443
464
|
}
|
|
444
465
|
|
|
445
|
-
|
|
446
|
-
|
|
466
|
+
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
467
|
+
const supportsCountedWordMotion = (
|
|
468
|
+
data === "w"
|
|
469
|
+
|| data === "e"
|
|
470
|
+
|| data === "b"
|
|
471
|
+
|| data === "W"
|
|
472
|
+
|| data === "E"
|
|
473
|
+
|| data === "B"
|
|
474
|
+
);
|
|
475
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
476
|
+
|
|
477
|
+
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
478
|
+
// Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and
|
|
479
|
+
// d{count}{w/e/b/W/E/B}/{i/a}w are out of scope.
|
|
480
|
+
this.cancelPendingOperator(data);
|
|
447
481
|
return;
|
|
448
482
|
}
|
|
449
|
-
|
|
450
|
-
|
|
483
|
+
|
|
484
|
+
if (supportsCountedTextObject) {
|
|
485
|
+
this.pendingTextObject = data;
|
|
451
486
|
return;
|
|
452
487
|
}
|
|
453
488
|
|
|
454
|
-
|
|
489
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
490
|
+
if (this.deleteWithMotion(data, motionCount)) {
|
|
455
491
|
this.pendingOperator = null;
|
|
456
492
|
return;
|
|
457
493
|
}
|
|
@@ -461,21 +497,60 @@ export class ModalEditor extends CustomEditor {
|
|
|
461
497
|
}
|
|
462
498
|
|
|
463
499
|
private handlePendingChange(data: string): void {
|
|
500
|
+
if (this.isDigit(data)) {
|
|
501
|
+
if (this.operatorCount.length === 0) {
|
|
502
|
+
if (data !== "0") {
|
|
503
|
+
this.operatorCount = data;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
this.operatorCount += data;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
464
512
|
if (data === "c") {
|
|
513
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
514
|
+
this.cancelPendingOperator(data);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
465
518
|
this.cutLine();
|
|
466
519
|
this.pendingOperator = null;
|
|
467
520
|
this.mode = "insert";
|
|
468
521
|
return;
|
|
469
522
|
}
|
|
470
|
-
if (data === "i" || data === "a") {
|
|
471
|
-
this.pendingTextObject = data;
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
523
|
if (CHAR_MOTION_KEYS.has(data)) {
|
|
475
524
|
this.pendingMotion = data as PendingMotion;
|
|
476
525
|
return;
|
|
477
526
|
}
|
|
478
|
-
|
|
527
|
+
|
|
528
|
+
const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
529
|
+
const supportsCountedWordMotion = (
|
|
530
|
+
data === "w"
|
|
531
|
+
|| data === "e"
|
|
532
|
+
|| data === "b"
|
|
533
|
+
|| data === "W"
|
|
534
|
+
|| data === "E"
|
|
535
|
+
|| data === "B"
|
|
536
|
+
);
|
|
537
|
+
const supportsCountedTextObject = data === "i" || data === "a";
|
|
538
|
+
|
|
539
|
+
if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
|
|
540
|
+
this.cancelPendingOperator(data);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (supportsCountedTextObject) {
|
|
545
|
+
this.pendingTextObject = data;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
|
|
550
|
+
const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
|
|
551
|
+
? "E"
|
|
552
|
+
: data;
|
|
553
|
+
if (this.deleteWithMotion(effectiveMotion, motionCount)) {
|
|
479
554
|
this.pendingOperator = null;
|
|
480
555
|
this.mode = "insert";
|
|
481
556
|
return;
|
|
@@ -487,35 +562,134 @@ export class ModalEditor extends CustomEditor {
|
|
|
487
562
|
|
|
488
563
|
private handleNormalMode(data: string): void {
|
|
489
564
|
if (this.pendingG) {
|
|
490
|
-
this.
|
|
491
|
-
|
|
492
|
-
this.moveCursorToBufferStart();
|
|
565
|
+
if (this.isDigit(data)) {
|
|
566
|
+
this.pendingGCount += data;
|
|
493
567
|
return;
|
|
494
568
|
}
|
|
495
|
-
|
|
569
|
+
|
|
570
|
+
this.pendingG = false;
|
|
571
|
+
const hadGCount = this.pendingGCount.length > 0;
|
|
572
|
+
this.pendingGCount = "";
|
|
573
|
+
|
|
574
|
+
if (!hadGCount) {
|
|
575
|
+
if (data === "g") {
|
|
576
|
+
this.takeTotalCount(1);
|
|
577
|
+
this.moveCursorToBufferStart();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (data === "J") {
|
|
582
|
+
this.joinLines(false);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
this.clearPendingState();
|
|
588
|
+
return;
|
|
496
589
|
}
|
|
497
590
|
|
|
498
|
-
if (this.
|
|
499
|
-
if (this.isDigit(data)
|
|
500
|
-
this.
|
|
591
|
+
if (this.prefixCount.length > 0) {
|
|
592
|
+
if (this.isDigit(data)) {
|
|
593
|
+
this.prefixCount += data;
|
|
501
594
|
return;
|
|
502
595
|
}
|
|
503
596
|
|
|
504
|
-
if (
|
|
597
|
+
if (data === "d" || data === "y") {
|
|
505
598
|
this.pendingOperator = data;
|
|
506
599
|
return;
|
|
507
600
|
}
|
|
508
601
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
602
|
+
if (data === "c") {
|
|
603
|
+
this.pendingOperator = "c";
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (data === "g") {
|
|
608
|
+
this.pendingGCount = "";
|
|
609
|
+
this.pendingG = true;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const supportsCountedStandaloneEdit = (
|
|
614
|
+
data === "x"
|
|
615
|
+
|| data === "s"
|
|
616
|
+
|| data === "S"
|
|
617
|
+
|| data === "D"
|
|
618
|
+
|| data === "C"
|
|
619
|
+
|| data === "p"
|
|
620
|
+
|| data === "P"
|
|
621
|
+
|| data === "J"
|
|
622
|
+
);
|
|
623
|
+
const supportsCountedCharMotion = (
|
|
624
|
+
CHAR_MOTION_KEYS.has(data)
|
|
625
|
+
|| data === ";"
|
|
626
|
+
|| data === ","
|
|
627
|
+
);
|
|
628
|
+
const supportsCountedWordMotion = (
|
|
629
|
+
data === "w"
|
|
630
|
+
|| data === "e"
|
|
631
|
+
|| data === "b"
|
|
632
|
+
|| data === "W"
|
|
633
|
+
|| data === "E"
|
|
634
|
+
|| data === "B"
|
|
635
|
+
);
|
|
636
|
+
const supportsCountedParagraphMotion = data === "{" || data === "}";
|
|
637
|
+
const supportsCountedNav = (
|
|
638
|
+
data === "h"
|
|
639
|
+
|| data === "j"
|
|
640
|
+
|| data === "k"
|
|
641
|
+
|| data === "l"
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
if (supportsCountedNav) {
|
|
645
|
+
const count = this.takeTotalCount(1);
|
|
646
|
+
const clamped = Math.min(count, MAX_COUNT);
|
|
647
|
+
if (data === "h") {
|
|
648
|
+
this.moveCursorBy(-clamped);
|
|
649
|
+
} else if (data === "l") {
|
|
650
|
+
this.moveCursorBy(clamped);
|
|
651
|
+
} else {
|
|
652
|
+
// j/k: clamp vertical nav to buffer size to prevent TUI stalls
|
|
653
|
+
const lines = this.getLines();
|
|
654
|
+
const cursorLine = this.getCursor().line;
|
|
655
|
+
const safeCount = data === "j"
|
|
656
|
+
? Math.min(clamped, lines.length - 1 - cursorLine)
|
|
657
|
+
: Math.min(clamped, cursorLine);
|
|
658
|
+
const seq = data === "j" ? ESC_DOWN : ESC_UP;
|
|
659
|
+
for (let i = 0; i < safeCount; i++) {
|
|
660
|
+
super.handleInput(seq);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (supportsCountedParagraphMotion) {
|
|
667
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (
|
|
672
|
+
!supportsCountedStandaloneEdit
|
|
673
|
+
&& !supportsCountedCharMotion
|
|
674
|
+
&& !supportsCountedWordMotion
|
|
675
|
+
&& !supportsCountedParagraphMotion
|
|
676
|
+
) {
|
|
677
|
+
// Unsupported prefixed forms: drop count and keep processing this key.
|
|
678
|
+
this.prefixCount = "";
|
|
679
|
+
this.operatorCount = "";
|
|
680
|
+
}
|
|
512
681
|
} else if (this.isCountStarter(data)) {
|
|
513
|
-
this.
|
|
514
|
-
|
|
682
|
+
this.prefixCount = data;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (data === "J") {
|
|
687
|
+
this.joinLines(true);
|
|
515
688
|
return;
|
|
516
689
|
}
|
|
517
690
|
|
|
518
691
|
if (data === "g") {
|
|
692
|
+
this.pendingGCount = "";
|
|
519
693
|
this.pendingG = true;
|
|
520
694
|
return;
|
|
521
695
|
}
|
|
@@ -573,9 +747,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
573
747
|
return;
|
|
574
748
|
}
|
|
575
749
|
|
|
576
|
-
if (data === "
|
|
577
|
-
|
|
578
|
-
|
|
750
|
+
if (data === "}" || data === "{") {
|
|
751
|
+
this.executeParagraphMotion(data === "}" ? "forward" : "backward");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (data === "w") {
|
|
756
|
+
const count = this.takeTotalCount(1);
|
|
757
|
+
return this.moveWord("forward", "start", count, "word");
|
|
758
|
+
}
|
|
759
|
+
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
760
|
+
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
761
|
+
if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
762
|
+
if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
763
|
+
if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
579
764
|
|
|
580
765
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
581
766
|
return this.handleMappedKey(data);
|
|
@@ -618,13 +803,16 @@ export class ModalEditor extends CustomEditor {
|
|
|
618
803
|
this.mode = "insert";
|
|
619
804
|
break;
|
|
620
805
|
case "D":
|
|
806
|
+
this.takeTotalCount(1);
|
|
621
807
|
this.cutToEndOfLine();
|
|
622
808
|
break;
|
|
623
809
|
case "C":
|
|
810
|
+
this.takeTotalCount(1);
|
|
624
811
|
this.cutToEndOfLine();
|
|
625
812
|
this.mode = "insert";
|
|
626
813
|
break;
|
|
627
814
|
case "S":
|
|
815
|
+
this.takeTotalCount(1);
|
|
628
816
|
this.cutCurrentLineContent();
|
|
629
817
|
this.mode = "insert";
|
|
630
818
|
break;
|
|
@@ -643,7 +831,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
643
831
|
private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
|
|
644
832
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
645
833
|
const col = this.getCursor().col;
|
|
646
|
-
const
|
|
834
|
+
const count = this.takeTotalCount(1);
|
|
835
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
|
|
647
836
|
|
|
648
837
|
if (targetCol !== null && saveMotion) {
|
|
649
838
|
this.lastCharMotion = { motion, char: targetChar };
|
|
@@ -654,6 +843,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
654
843
|
}
|
|
655
844
|
}
|
|
656
845
|
|
|
846
|
+
private executeParagraphMotion(direction: "forward" | "backward"): void {
|
|
847
|
+
const lines = this.getLines();
|
|
848
|
+
const fromLine = this.getCursor().line;
|
|
849
|
+
const count = this.takeTotalCount(1);
|
|
850
|
+
const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
|
|
851
|
+
this.moveCursorToLineStart(targetLine);
|
|
852
|
+
}
|
|
853
|
+
|
|
657
854
|
private tryMoveCursorByState(delta: number): boolean {
|
|
658
855
|
if (delta === 0) return true;
|
|
659
856
|
|
|
@@ -726,16 +923,79 @@ export class ModalEditor extends CustomEditor {
|
|
|
726
923
|
this.moveCursorToLineStart(Math.max(0, lines.length - 1));
|
|
727
924
|
}
|
|
728
925
|
|
|
926
|
+
private joinLines(normalize: boolean): void {
|
|
927
|
+
const count = this.takeTotalCount(2);
|
|
928
|
+
const steps = Math.max(0, count - 1);
|
|
929
|
+
if (steps === 0) return;
|
|
930
|
+
|
|
931
|
+
const editor = this as unknown as {
|
|
932
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
933
|
+
preferredVisualCol?: number;
|
|
934
|
+
tui?: { requestRender?: () => void };
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const state = editor.state;
|
|
938
|
+
if (!state || !Array.isArray(state.lines)) return;
|
|
939
|
+
|
|
940
|
+
const currentLine = state.cursorLine ?? 0;
|
|
941
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
942
|
+
|
|
943
|
+
for (let i = 0; i < steps; i++) {
|
|
944
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
945
|
+
|
|
946
|
+
const left = state.lines[currentLine]!;
|
|
947
|
+
const right = state.lines[currentLine + 1]!;
|
|
948
|
+
let joined: string;
|
|
949
|
+
|
|
950
|
+
if (normalize) {
|
|
951
|
+
const trimmedRight = right.trimStart();
|
|
952
|
+
const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
|
|
953
|
+
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
954
|
+
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
955
|
+
joinPoint = left.length;
|
|
956
|
+
} else {
|
|
957
|
+
joined = left + right;
|
|
958
|
+
joinPoint = left.length;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
state.lines.splice(currentLine, 2, joined);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
state.cursorLine = currentLine;
|
|
965
|
+
state.cursorCol = joinPoint;
|
|
966
|
+
editor.preferredVisualCol = joinPoint;
|
|
967
|
+
editor.tui?.requestRender?.();
|
|
968
|
+
}
|
|
969
|
+
|
|
729
970
|
private isWordChar(ch: string): boolean {
|
|
730
971
|
return /\w/.test(ch);
|
|
731
972
|
}
|
|
732
973
|
|
|
733
|
-
private charType(
|
|
974
|
+
private charType(
|
|
975
|
+
ch: string | undefined,
|
|
976
|
+
semanticClass: WordMotionClass = "word",
|
|
977
|
+
): "space" | "word" | "other" {
|
|
734
978
|
if (!ch || /\s/.test(ch)) return "space";
|
|
979
|
+
if (semanticClass === "WORD") return "word";
|
|
735
980
|
if (this.isWordChar(ch)) return "word";
|
|
736
981
|
return "other";
|
|
737
982
|
}
|
|
738
983
|
|
|
984
|
+
private resolveWordMotion(
|
|
985
|
+
motion: string,
|
|
986
|
+
): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
|
|
987
|
+
if (motion === "w" || motion === "e" || motion === "b") {
|
|
988
|
+
return { motion, semanticClass: "word" };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (motion === "W" || motion === "E" || motion === "B") {
|
|
992
|
+
const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
|
|
993
|
+
return { motion: normalizedMotion, semanticClass: "WORD" };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
739
999
|
private getAbsoluteIndex(line: number, col: number): number {
|
|
740
1000
|
const lines = this.getLines();
|
|
741
1001
|
let idx = 0;
|
|
@@ -755,74 +1015,81 @@ export class ModalEditor extends CustomEditor {
|
|
|
755
1015
|
abs: number,
|
|
756
1016
|
direction: "forward" | "backward",
|
|
757
1017
|
target: "start" | "end",
|
|
1018
|
+
count: number = 1,
|
|
1019
|
+
semanticClass: WordMotionClass = "word",
|
|
758
1020
|
): number {
|
|
759
1021
|
const len = text.length;
|
|
760
1022
|
if (len === 0) return 0;
|
|
761
1023
|
|
|
1024
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
762
1025
|
let i = Math.max(0, Math.min(abs, len));
|
|
763
1026
|
|
|
764
|
-
|
|
765
|
-
|
|
1027
|
+
for (let step = 0; step < steps; step++) {
|
|
1028
|
+
let next = i;
|
|
766
1029
|
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1030
|
+
if (direction === "forward") {
|
|
1031
|
+
if (next >= len) {
|
|
1032
|
+
next = len;
|
|
1033
|
+
} else if (target === "start") {
|
|
1034
|
+
const startType = this.charType(text[next], semanticClass);
|
|
1035
|
+
if (startType !== "space") {
|
|
1036
|
+
while (next < len && this.charType(text[next], semanticClass) === startType) next++;
|
|
1037
|
+
}
|
|
1038
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
1039
|
+
} else {
|
|
1040
|
+
if (next < len - 1) next++;
|
|
1041
|
+
while (next < len && this.charType(text[next], semanticClass) === "space") next++;
|
|
1042
|
+
if (next >= len) {
|
|
1043
|
+
next = len;
|
|
1044
|
+
} else {
|
|
1045
|
+
const t = this.charType(text[next], semanticClass);
|
|
1046
|
+
while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
|
|
1047
|
+
}
|
|
771
1048
|
}
|
|
772
|
-
|
|
773
|
-
|
|
1049
|
+
} else {
|
|
1050
|
+
if (next >= len) next = len - 1;
|
|
1051
|
+
if (next > 0) next--;
|
|
1052
|
+
while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
|
|
1053
|
+
const t = this.charType(text[next], semanticClass);
|
|
1054
|
+
while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
|
|
774
1055
|
}
|
|
775
1056
|
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
if (i >= len) return len;
|
|
779
|
-
const t = this.charType(text[i]);
|
|
780
|
-
while (i < len - 1 && this.charType(text[i + 1]) === t) i++;
|
|
781
|
-
return i;
|
|
1057
|
+
if (next === i) break;
|
|
1058
|
+
i = next;
|
|
782
1059
|
}
|
|
783
1060
|
|
|
784
|
-
if (i >= len) i = len - 1;
|
|
785
|
-
if (i > 0) i--;
|
|
786
|
-
while (i > 0 && this.charType(text[i]) === "space") i--;
|
|
787
|
-
const t = this.charType(text[i]);
|
|
788
|
-
while (i > 0 && this.charType(text[i - 1]) === t) i--;
|
|
789
1061
|
return i;
|
|
790
1062
|
}
|
|
791
1063
|
|
|
792
|
-
private
|
|
1064
|
+
private tryFindWordTargetInLine(
|
|
1065
|
+
line: string,
|
|
1066
|
+
col: number,
|
|
793
1067
|
direction: WordMotionDirection,
|
|
794
1068
|
target: WordMotionTarget,
|
|
795
1069
|
allowSameColumn: boolean = false,
|
|
1070
|
+
semanticClass: WordMotionClass = "word",
|
|
796
1071
|
): number | null {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const col = cursor.col;
|
|
800
|
-
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
801
|
-
|
|
802
|
-
if (lineSnapshot.length === 0) return null;
|
|
803
|
-
if (col < 0 || col > lineSnapshot.length) return null;
|
|
1072
|
+
if (line.length === 0) return null;
|
|
1073
|
+
if (col < 0 || col > line.length) return null;
|
|
804
1074
|
|
|
805
1075
|
if (direction === "forward") {
|
|
806
|
-
if (col >=
|
|
1076
|
+
if (col >= line.length) return null;
|
|
807
1077
|
} else {
|
|
808
1078
|
if (col <= 0) return null;
|
|
809
|
-
if (!/\S/.test(
|
|
1079
|
+
if (!/\S/.test(line.slice(0, col))) return null;
|
|
810
1080
|
}
|
|
811
1081
|
|
|
812
1082
|
const targetCol = this.wordBoundaryCache.tryFindTarget(
|
|
813
|
-
|
|
1083
|
+
line,
|
|
814
1084
|
col,
|
|
815
1085
|
direction,
|
|
816
1086
|
target,
|
|
1087
|
+
semanticClass,
|
|
817
1088
|
);
|
|
818
1089
|
if (targetCol === null) return null;
|
|
819
1090
|
|
|
820
|
-
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
821
|
-
const liveCol = this.getCursor().col;
|
|
822
|
-
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
823
|
-
|
|
824
1091
|
if (direction === "forward") {
|
|
825
|
-
if (targetCol >=
|
|
1092
|
+
if (targetCol >= line.length) return null;
|
|
826
1093
|
if (allowSameColumn) {
|
|
827
1094
|
if (targetCol < col) return null;
|
|
828
1095
|
} else if (targetCol <= col) {
|
|
@@ -840,12 +1107,41 @@ export class ModalEditor extends CustomEditor {
|
|
|
840
1107
|
return targetCol;
|
|
841
1108
|
}
|
|
842
1109
|
|
|
1110
|
+
private tryFindWordTargetLineLocal(
|
|
1111
|
+
direction: WordMotionDirection,
|
|
1112
|
+
target: WordMotionTarget,
|
|
1113
|
+
allowSameColumn: boolean = false,
|
|
1114
|
+
semanticClass: WordMotionClass = "word",
|
|
1115
|
+
): number | null {
|
|
1116
|
+
const cursor = this.getCursor();
|
|
1117
|
+
const lineIndex = cursor.line;
|
|
1118
|
+
const col = cursor.col;
|
|
1119
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
1120
|
+
|
|
1121
|
+
const targetCol = this.tryFindWordTargetInLine(
|
|
1122
|
+
lineSnapshot,
|
|
1123
|
+
col,
|
|
1124
|
+
direction,
|
|
1125
|
+
target,
|
|
1126
|
+
allowSameColumn,
|
|
1127
|
+
semanticClass,
|
|
1128
|
+
);
|
|
1129
|
+
if (targetCol === null) return null;
|
|
1130
|
+
|
|
1131
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
1132
|
+
const liveCol = this.getCursor().col;
|
|
1133
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
1134
|
+
|
|
1135
|
+
return targetCol;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
843
1138
|
private tryMoveWordLineLocal(
|
|
844
1139
|
direction: "forward" | "backward",
|
|
845
1140
|
target: "start" | "end",
|
|
1141
|
+
semanticClass: WordMotionClass = "word",
|
|
846
1142
|
): boolean {
|
|
847
1143
|
const col = this.getCursor().col;
|
|
848
|
-
const targetCol = this.tryFindWordTargetLineLocal(direction, target);
|
|
1144
|
+
const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
|
|
849
1145
|
if (targetCol === null || targetCol === col) return false;
|
|
850
1146
|
|
|
851
1147
|
this.moveCursorBy(targetCol - col);
|
|
@@ -854,29 +1150,71 @@ export class ModalEditor extends CustomEditor {
|
|
|
854
1150
|
|
|
855
1151
|
private tryWordMotionLineLocalRange(
|
|
856
1152
|
motion: "w" | "e" | "b",
|
|
1153
|
+
count: number = 1,
|
|
1154
|
+
semanticClass: WordMotionClass = "word",
|
|
857
1155
|
): { col: number; targetCol: number; inclusive: boolean } | null {
|
|
858
|
-
const
|
|
1156
|
+
const cursor = this.getCursor();
|
|
1157
|
+
const lineIndex = cursor.line;
|
|
1158
|
+
const col = cursor.col;
|
|
1159
|
+
const lineSnapshot = this.getLines()[lineIndex] ?? "";
|
|
859
1160
|
const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
|
|
860
1161
|
const target: WordMotionTarget = motion === "e" ? "end" : "start";
|
|
861
|
-
const
|
|
1162
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1163
|
+
|
|
1164
|
+
let currentCol = col;
|
|
1165
|
+
for (let step = 0; step < steps; step++) {
|
|
1166
|
+
const nextCol = this.tryFindWordTargetInLine(
|
|
1167
|
+
lineSnapshot,
|
|
1168
|
+
currentCol,
|
|
1169
|
+
direction,
|
|
1170
|
+
target,
|
|
1171
|
+
motion === "e",
|
|
1172
|
+
semanticClass,
|
|
1173
|
+
);
|
|
1174
|
+
if (nextCol === null) return null;
|
|
1175
|
+
if (nextCol === currentCol && step < steps - 1) return null;
|
|
1176
|
+
currentCol = nextCol;
|
|
1177
|
+
}
|
|
862
1178
|
|
|
863
|
-
|
|
1179
|
+
const liveLine = this.getLines()[lineIndex] ?? "";
|
|
1180
|
+
const liveCol = this.getCursor().col;
|
|
1181
|
+
if (liveLine !== lineSnapshot || liveCol !== col) return null;
|
|
864
1182
|
|
|
865
1183
|
return {
|
|
866
1184
|
col,
|
|
867
|
-
targetCol,
|
|
1185
|
+
targetCol: currentCol,
|
|
868
1186
|
inclusive: motion === "e",
|
|
869
1187
|
};
|
|
870
1188
|
}
|
|
871
1189
|
|
|
872
|
-
private moveWord(
|
|
873
|
-
|
|
1190
|
+
private moveWord(
|
|
1191
|
+
direction: "forward" | "backward",
|
|
1192
|
+
target: "start" | "end",
|
|
1193
|
+
count: number = 1,
|
|
1194
|
+
semanticClass: WordMotionClass = "word",
|
|
1195
|
+
): void {
|
|
1196
|
+
let remaining = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1197
|
+
|
|
1198
|
+
while (remaining > 0) {
|
|
1199
|
+
if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
|
|
1200
|
+
remaining--;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
874
1203
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1204
|
+
const text = this.getText();
|
|
1205
|
+
const currentAbs = this.getAbsoluteIndexFromCursor();
|
|
1206
|
+
const targetAbs = this.findWordTargetInText(
|
|
1207
|
+
text,
|
|
1208
|
+
currentAbs,
|
|
1209
|
+
direction,
|
|
1210
|
+
target,
|
|
1211
|
+
remaining,
|
|
1212
|
+
semanticClass,
|
|
1213
|
+
);
|
|
1214
|
+
if (targetAbs !== currentAbs) {
|
|
1215
|
+
this.moveCursorBy(targetAbs - currentAbs);
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
880
1218
|
}
|
|
881
1219
|
}
|
|
882
1220
|
|
|
@@ -892,19 +1230,25 @@ export class ModalEditor extends CustomEditor {
|
|
|
892
1230
|
return { line, col };
|
|
893
1231
|
}
|
|
894
1232
|
|
|
1233
|
+
private isCursorOnNonWhitespace(): boolean {
|
|
1234
|
+
const { line, col } = this.getCurrentLineAndCol();
|
|
1235
|
+
const ch = line[col];
|
|
1236
|
+
return ch !== undefined && !/\s/.test(ch);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
895
1239
|
private isCursorAtOrPastEol(): boolean {
|
|
896
1240
|
const { line, col } = this.getCurrentLineAndCol();
|
|
897
1241
|
return col >= line.length;
|
|
898
1242
|
}
|
|
899
1243
|
|
|
900
1244
|
private cutCharUnderCursor(): void {
|
|
1245
|
+
const count = this.takeTotalCount(1);
|
|
901
1246
|
const { line, col } = this.getCurrentLineAndCol();
|
|
902
1247
|
if (line.length === 0) return; // Don't merge empty lines with x
|
|
903
1248
|
if (col >= line.length) return; // Don't delete past end of line
|
|
904
1249
|
|
|
905
|
-
const
|
|
906
|
-
this.
|
|
907
|
-
super.handleInput(ESC_DELETE);
|
|
1250
|
+
const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1251
|
+
this.deleteRange(col, col + boundedCount, false);
|
|
908
1252
|
}
|
|
909
1253
|
|
|
910
1254
|
private cutToEndOfLine(): void {
|
|
@@ -1023,9 +1367,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1023
1367
|
this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
|
|
1024
1368
|
}
|
|
1025
1369
|
|
|
1026
|
-
private deleteWithMotion(motion: string): boolean {
|
|
1370
|
+
private deleteWithMotion(motion: string, count: number = 1): boolean {
|
|
1027
1371
|
const cursor = this.getCursor();
|
|
1028
|
-
const line = this.getLines()[cursor.line] ?? "";
|
|
1029
1372
|
const col = cursor.col;
|
|
1030
1373
|
|
|
1031
1374
|
if (motion === "$") {
|
|
@@ -1039,8 +1382,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1039
1382
|
return true;
|
|
1040
1383
|
}
|
|
1041
1384
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1385
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1386
|
+
if (wordMotion) {
|
|
1387
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1388
|
+
wordMotion.motion,
|
|
1389
|
+
count,
|
|
1390
|
+
wordMotion.semanticClass,
|
|
1391
|
+
);
|
|
1044
1392
|
if (lineLocalRange) {
|
|
1045
1393
|
this.deleteRange(
|
|
1046
1394
|
lineLocalRange.col,
|
|
@@ -1055,10 +1403,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1055
1403
|
const targetAbs = this.findWordTargetInText(
|
|
1056
1404
|
text,
|
|
1057
1405
|
currentAbs,
|
|
1058
|
-
motion === "b" ? "backward" : "forward",
|
|
1059
|
-
motion === "e" ? "end" : "start",
|
|
1406
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1407
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1408
|
+
count,
|
|
1409
|
+
wordMotion.semanticClass,
|
|
1060
1410
|
);
|
|
1061
|
-
this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1411
|
+
this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1062
1412
|
return true;
|
|
1063
1413
|
}
|
|
1064
1414
|
|
|
@@ -1068,7 +1418,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1068
1418
|
private deleteWithCharMotion(motion: CharMotion, targetChar: string): void {
|
|
1069
1419
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1070
1420
|
const col = this.getCursor().col;
|
|
1071
|
-
const
|
|
1421
|
+
const count = this.takeTotalCount(1);
|
|
1422
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
|
|
1072
1423
|
|
|
1073
1424
|
if (targetCol === null) return;
|
|
1074
1425
|
|
|
@@ -1078,43 +1429,35 @@ export class ModalEditor extends CustomEditor {
|
|
|
1078
1429
|
|
|
1079
1430
|
private handlePendingYank(data: string): void {
|
|
1080
1431
|
if (this.isDigit(data)) {
|
|
1081
|
-
if (this.
|
|
1432
|
+
if (this.operatorCount.length === 0) {
|
|
1082
1433
|
if (data !== "0") {
|
|
1083
|
-
this.
|
|
1084
|
-
this.pendingCountKind = "operator";
|
|
1434
|
+
this.operatorCount = data;
|
|
1085
1435
|
return;
|
|
1086
1436
|
}
|
|
1087
|
-
} else if (this.pendingCountKind === "operator") {
|
|
1088
|
-
this.pendingCount += data;
|
|
1089
|
-
return;
|
|
1090
1437
|
} else {
|
|
1091
|
-
|
|
1092
|
-
this.cancelPendingOperator(data);
|
|
1438
|
+
this.operatorCount += data;
|
|
1093
1439
|
return;
|
|
1094
1440
|
}
|
|
1095
1441
|
}
|
|
1096
1442
|
|
|
1097
1443
|
if (data === "y") {
|
|
1098
|
-
const count = this.
|
|
1444
|
+
const count = this.takeTotalCount(1);
|
|
1099
1445
|
this.yankLinewiseByDelta(count - 1);
|
|
1100
1446
|
this.pendingOperator = null;
|
|
1101
1447
|
return;
|
|
1102
1448
|
}
|
|
1103
1449
|
|
|
1104
1450
|
if (data === "j" || data === "k") {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const count = this.takePendingCount(1);
|
|
1111
|
-
this.yankLinewiseByDelta(data === "j" ? count : -count);
|
|
1451
|
+
const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
|
|
1452
|
+
const count = this.takeTotalCount(1);
|
|
1453
|
+
const delta = hasDualCount ? Math.max(0, count - 1) : count;
|
|
1454
|
+
this.yankLinewiseByDelta(data === "j" ? delta : -delta);
|
|
1112
1455
|
this.pendingOperator = null;
|
|
1113
1456
|
return;
|
|
1114
1457
|
}
|
|
1115
1458
|
|
|
1116
1459
|
if (data === "G") {
|
|
1117
|
-
if (this.
|
|
1460
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
1118
1461
|
this.cancelPendingOperator(data);
|
|
1119
1462
|
return;
|
|
1120
1463
|
}
|
|
@@ -1124,8 +1467,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1124
1467
|
return;
|
|
1125
1468
|
}
|
|
1126
1469
|
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
1470
|
+
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1471
|
+
this.pendingMotion = data as PendingMotion;
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
|
|
1476
|
+
// Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
|
|
1129
1477
|
this.cancelPendingOperator(data);
|
|
1130
1478
|
return;
|
|
1131
1479
|
}
|
|
@@ -1134,10 +1482,6 @@ export class ModalEditor extends CustomEditor {
|
|
|
1134
1482
|
this.pendingTextObject = data;
|
|
1135
1483
|
return;
|
|
1136
1484
|
}
|
|
1137
|
-
if (CHAR_MOTION_KEYS.has(data)) {
|
|
1138
|
-
this.pendingMotion = data as PendingMotion;
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
1485
|
|
|
1142
1486
|
if (this.yankWithMotion(data)) {
|
|
1143
1487
|
this.pendingOperator = null;
|
|
@@ -1161,8 +1505,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1161
1505
|
return true;
|
|
1162
1506
|
}
|
|
1163
1507
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1508
|
+
const wordMotion = this.resolveWordMotion(motion);
|
|
1509
|
+
if (wordMotion) {
|
|
1510
|
+
const lineLocalRange = this.tryWordMotionLineLocalRange(
|
|
1511
|
+
wordMotion.motion,
|
|
1512
|
+
1,
|
|
1513
|
+
wordMotion.semanticClass,
|
|
1514
|
+
);
|
|
1166
1515
|
if (lineLocalRange) {
|
|
1167
1516
|
this.yankRange(
|
|
1168
1517
|
lineLocalRange.col,
|
|
@@ -1177,10 +1526,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1177
1526
|
const targetAbs = this.findWordTargetInText(
|
|
1178
1527
|
text,
|
|
1179
1528
|
currentAbs,
|
|
1180
|
-
motion === "b" ? "backward" : "forward",
|
|
1181
|
-
motion === "e" ? "end" : "start",
|
|
1529
|
+
wordMotion.motion === "b" ? "backward" : "forward",
|
|
1530
|
+
wordMotion.motion === "e" ? "end" : "start",
|
|
1531
|
+
1,
|
|
1532
|
+
wordMotion.semanticClass,
|
|
1182
1533
|
);
|
|
1183
|
-
this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
|
|
1534
|
+
this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
|
|
1184
1535
|
return true;
|
|
1185
1536
|
}
|
|
1186
1537
|
|
|
@@ -1190,7 +1541,8 @@ export class ModalEditor extends CustomEditor {
|
|
|
1190
1541
|
private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
|
|
1191
1542
|
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
1192
1543
|
const col = this.getCursor().col;
|
|
1193
|
-
const
|
|
1544
|
+
const count = this.takeTotalCount(1);
|
|
1545
|
+
const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
|
|
1194
1546
|
|
|
1195
1547
|
if (targetCol === null) return;
|
|
1196
1548
|
|
|
@@ -1241,16 +1593,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
1241
1593
|
}
|
|
1242
1594
|
}
|
|
1243
1595
|
|
|
1244
|
-
private getWordObjectRange(
|
|
1596
|
+
private getWordObjectRange(
|
|
1597
|
+
kind: "i" | "a",
|
|
1598
|
+
count: number = 1,
|
|
1599
|
+
): { startAbs: number; endAbs: number } | null {
|
|
1245
1600
|
const lines = this.getLines();
|
|
1246
1601
|
const cursor = this.getCursor();
|
|
1247
1602
|
const line = lines[cursor.line] ?? "";
|
|
1248
1603
|
if (!line) return null;
|
|
1249
1604
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1605
|
+
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
1252
1606
|
const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
|
|
1253
1607
|
|
|
1608
|
+
let col = Math.min(cursor.col, Math.max(0, line.length - 1));
|
|
1609
|
+
|
|
1254
1610
|
if (!hasWordChar(col)) {
|
|
1255
1611
|
let right = col;
|
|
1256
1612
|
while (right < line.length && !hasWordChar(right)) right++;
|
|
@@ -1270,17 +1626,28 @@ export class ModalEditor extends CustomEditor {
|
|
|
1270
1626
|
let end = col + 1;
|
|
1271
1627
|
while (end < line.length && hasWordChar(end)) end++;
|
|
1272
1628
|
|
|
1629
|
+
let remaining = steps - 1;
|
|
1630
|
+
while (remaining > 0) {
|
|
1631
|
+
let nextWordStart = end;
|
|
1632
|
+
while (nextWordStart < line.length && !hasWordChar(nextWordStart)) nextWordStart++;
|
|
1633
|
+
if (nextWordStart >= line.length) break;
|
|
1634
|
+
|
|
1635
|
+
let nextWordEnd = nextWordStart + 1;
|
|
1636
|
+
while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
|
|
1637
|
+
|
|
1638
|
+
end = nextWordEnd;
|
|
1639
|
+
remaining--;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1273
1642
|
if (kind === "a") {
|
|
1274
|
-
let aroundStart = start;
|
|
1275
1643
|
let aroundEnd = end;
|
|
1276
|
-
|
|
1277
1644
|
while (aroundEnd < line.length && /\s/.test(line[aroundEnd]!)) aroundEnd++;
|
|
1278
|
-
if (aroundEnd === end) {
|
|
1279
|
-
while (aroundStart > 0 && /\s/.test(line[aroundStart - 1]!)) aroundStart--;
|
|
1280
|
-
}
|
|
1281
1645
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1646
|
+
if (aroundEnd > end) {
|
|
1647
|
+
end = aroundEnd;
|
|
1648
|
+
} else {
|
|
1649
|
+
while (start > 0 && /\s/.test(line[start - 1]!)) start--;
|
|
1650
|
+
}
|
|
1284
1651
|
}
|
|
1285
1652
|
|
|
1286
1653
|
return {
|
|
@@ -1289,23 +1656,32 @@ export class ModalEditor extends CustomEditor {
|
|
|
1289
1656
|
};
|
|
1290
1657
|
}
|
|
1291
1658
|
|
|
1659
|
+
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
1660
|
+
|
|
1292
1661
|
private putAfter(): void {
|
|
1662
|
+
const count = this.takeTotalCount(1);
|
|
1293
1663
|
const text = this.unnamedRegister;
|
|
1294
1664
|
if (!text) return;
|
|
1665
|
+
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
1295
1666
|
|
|
1296
1667
|
if (text.endsWith("\n")) {
|
|
1297
|
-
// Line-wise: insert new line below and fill it
|
|
1298
|
-
super.handleInput(CTRL_E);
|
|
1299
|
-
super.handleInput(NEWLINE);
|
|
1300
1668
|
const content = text.slice(0, -1);
|
|
1301
|
-
for (
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1669
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1670
|
+
// Line-wise: insert new line below and fill it
|
|
1671
|
+
super.handleInput(CTRL_E);
|
|
1672
|
+
super.handleInput(NEWLINE);
|
|
1673
|
+
for (const char of content) {
|
|
1674
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1675
|
+
}
|
|
1308
1676
|
}
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Character-wise: insert after cursor
|
|
1681
|
+
if (!this.isCursorAtOrPastEol()) {
|
|
1682
|
+
super.handleInput(ESC_RIGHT);
|
|
1683
|
+
}
|
|
1684
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1309
1685
|
for (const char of text) {
|
|
1310
1686
|
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1311
1687
|
}
|
|
@@ -1313,20 +1689,27 @@ export class ModalEditor extends CustomEditor {
|
|
|
1313
1689
|
}
|
|
1314
1690
|
|
|
1315
1691
|
private putBefore(): void {
|
|
1692
|
+
const count = this.takeTotalCount(1);
|
|
1316
1693
|
const text = this.unnamedRegister;
|
|
1317
1694
|
if (!text) return;
|
|
1695
|
+
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
1318
1696
|
|
|
1319
1697
|
if (text.endsWith("\n")) {
|
|
1320
|
-
// Line-wise: insert new line above and fill it
|
|
1321
|
-
super.handleInput(CTRL_A);
|
|
1322
|
-
super.handleInput(NEWLINE);
|
|
1323
|
-
super.handleInput(ESC_UP);
|
|
1324
1698
|
const content = text.slice(0, -1);
|
|
1325
|
-
for (
|
|
1326
|
-
|
|
1699
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1700
|
+
// Line-wise: insert new line above and fill it
|
|
1701
|
+
super.handleInput(CTRL_A);
|
|
1702
|
+
super.handleInput(NEWLINE);
|
|
1703
|
+
super.handleInput(ESC_UP);
|
|
1704
|
+
for (const char of content) {
|
|
1705
|
+
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1706
|
+
}
|
|
1327
1707
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Character-wise: insert before cursor (just type it)
|
|
1712
|
+
for (let i = 0; i < safeCount; i++) {
|
|
1330
1713
|
for (const char of text) {
|
|
1331
1714
|
super.handleInput(char === "\n" ? NEWLINE : char);
|
|
1332
1715
|
}
|
|
@@ -1369,20 +1752,23 @@ export class ModalEditor extends CustomEditor {
|
|
|
1369
1752
|
private getModeLabel(): string {
|
|
1370
1753
|
if (this.mode === "insert") return " INSERT ";
|
|
1371
1754
|
|
|
1372
|
-
const
|
|
1755
|
+
const prefixCount = this.prefixCount;
|
|
1756
|
+
const operatorCount = this.operatorCount;
|
|
1373
1757
|
|
|
1374
1758
|
if (this.pendingOperator && this.pendingMotion) {
|
|
1375
|
-
|
|
1376
|
-
const opCount = this.pendingCountKind === "operator" ? count : "";
|
|
1377
|
-
return ` NORMAL ${prefix}${this.pendingOperator}${opCount}${this.pendingMotion}_ `;
|
|
1759
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
|
|
1378
1760
|
}
|
|
1379
1761
|
if (this.pendingOperator) {
|
|
1380
|
-
|
|
1381
|
-
const opCount = this.pendingCountKind === "operator" ? count : "";
|
|
1382
|
-
return ` NORMAL ${prefix}${this.pendingOperator}${opCount}_ `;
|
|
1762
|
+
return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
|
|
1383
1763
|
}
|
|
1384
1764
|
if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
|
|
1385
|
-
if (this.pendingG)
|
|
1765
|
+
if (this.pendingG) {
|
|
1766
|
+
return this.pendingGCount
|
|
1767
|
+
? ` NORMAL g${this.pendingGCount}_ `
|
|
1768
|
+
: " NORMAL g_ ";
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const count = `${prefixCount}${operatorCount}`;
|
|
1386
1772
|
if (count) return ` NORMAL ${count}_ `;
|
|
1387
1773
|
return " NORMAL ";
|
|
1388
1774
|
}
|