pi-tab-focus 1.0.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.
@@ -0,0 +1,1056 @@
1
+ export type VimPoint = {
2
+ row: number;
3
+ col: number;
4
+ };
5
+
6
+ export type VimCell = {
7
+ start: number;
8
+ end: number;
9
+ text: string;
10
+ };
11
+
12
+ export type VimTextSource = {
13
+ lineCount: number;
14
+ revision?: number;
15
+ line: (row: number) => readonly VimCell[];
16
+ };
17
+
18
+ export type VimSelectionKind = "character" | "line";
19
+
20
+ export type VimVisualSnapshot = {
21
+ head: VimPoint;
22
+ anchor?: VimPoint;
23
+ selectionKind?: VimSelectionKind;
24
+ preferredCol: number;
25
+ pending: string;
26
+ count?: number;
27
+ };
28
+
29
+ export type VimVisualCommand = "copy" | "open-link" | "ex" | "exit";
30
+
31
+ export type VimHandleResult = {
32
+ handled: boolean;
33
+ command?: VimVisualCommand;
34
+ };
35
+
36
+ type FindState = {
37
+ char: string;
38
+ direction: -1 | 1;
39
+ till: boolean;
40
+ };
41
+
42
+ type PendingState =
43
+ | { kind: "g" }
44
+ | { kind: "find"; direction: -1 | 1; till: boolean }
45
+ | { kind: "text-object"; around: boolean };
46
+
47
+ type FlatToken =
48
+ | {
49
+ kind: "cell";
50
+ point: VimPoint;
51
+ text: string;
52
+ wordClass: "word" | "punct" | "space";
53
+ bigWordClass: "word" | "space";
54
+ }
55
+ | {
56
+ kind: "newline";
57
+ wordClass: "space";
58
+ bigWordClass: "space";
59
+ };
60
+
61
+ type PointRange = {
62
+ start: VimPoint;
63
+ end: VimPoint;
64
+ };
65
+
66
+ const WORD_CHAR = /^[\p{L}\p{M}\p{N}_]+$/u;
67
+ const WHITESPACE = /^\s+$/u;
68
+ const FLAT_TOKEN_CACHE = new WeakMap<
69
+ VimTextSource,
70
+ { revision: number; tokens: FlatToken[] }
71
+ >();
72
+
73
+ export function compareVimPoints(left: VimPoint, right: VimPoint): number {
74
+ return left.row === right.row ? left.col - right.col : left.row - right.row;
75
+ }
76
+
77
+ export function firstNonWhitespaceColumn(
78
+ source: VimTextSource,
79
+ row: number,
80
+ ): number {
81
+ for (const cell of source.line(clampRow(source, row))) {
82
+ if (!isWhitespace(cell.text)) return cell.start;
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ function isWhitespace(text: string): boolean {
88
+ return WHITESPACE.test(text);
89
+ }
90
+
91
+ function classifySmall(text: string): "word" | "punct" | "space" {
92
+ if (isWhitespace(text)) return "space";
93
+ return WORD_CHAR.test(text) ? "word" : "punct";
94
+ }
95
+
96
+ function classifyBig(text: string): "word" | "space" {
97
+ return isWhitespace(text) ? "space" : "word";
98
+ }
99
+
100
+ function clampRow(source: VimTextSource, row: number): number {
101
+ if (source.lineCount <= 0) return 0;
102
+ return Math.max(0, Math.min(source.lineCount - 1, row));
103
+ }
104
+
105
+ function clonePoint(point: VimPoint): VimPoint {
106
+ return { row: point.row, col: point.col };
107
+ }
108
+
109
+ function lineCells(source: VimTextSource, row: number): readonly VimCell[] {
110
+ if (source.lineCount <= 0) return [];
111
+ return source.line(clampRow(source, row));
112
+ }
113
+
114
+ function nearestColumn(
115
+ source: VimTextSource,
116
+ row: number,
117
+ preferred: number,
118
+ ): number {
119
+ const cells = lineCells(source, row);
120
+ if (cells.length === 0) return 0;
121
+ const containing = cells.find(
122
+ (cell) => preferred >= cell.start && preferred < cell.end,
123
+ );
124
+ if (containing) return containing.start;
125
+ const last = cells[cells.length - 1];
126
+ if (last && preferred >= last.end) return last.start;
127
+ return cells.find((cell) => cell.start >= preferred)?.start ?? 0;
128
+ }
129
+
130
+ function cellIndexAt(cells: readonly VimCell[], col: number): number {
131
+ const exact = cells.findIndex((cell) => cell.start === col);
132
+ if (exact >= 0) return exact;
133
+ const containing = cells.findIndex(
134
+ (cell) => col >= cell.start && col < cell.end,
135
+ );
136
+ if (containing >= 0) return containing;
137
+ const before = cells.findLastIndex((cell) => cell.start < col);
138
+ return before >= 0 ? before : 0;
139
+ }
140
+
141
+ function flatten(source: VimTextSource): FlatToken[] {
142
+ if (source.revision !== undefined) {
143
+ const cached = FLAT_TOKEN_CACHE.get(source);
144
+ if (cached?.revision === source.revision) return cached.tokens;
145
+ }
146
+
147
+ const tokens: FlatToken[] = [];
148
+ for (let row = 0; row < source.lineCount; row++) {
149
+ for (const cell of source.line(row)) {
150
+ tokens.push({
151
+ kind: "cell",
152
+ point: { row, col: cell.start },
153
+ text: cell.text,
154
+ wordClass: classifySmall(cell.text),
155
+ bigWordClass: classifyBig(cell.text),
156
+ });
157
+ }
158
+ if (row < source.lineCount - 1) {
159
+ tokens.push({
160
+ kind: "newline",
161
+ wordClass: "space",
162
+ bigWordClass: "space",
163
+ });
164
+ }
165
+ }
166
+ if (source.revision !== undefined) {
167
+ FLAT_TOKEN_CACHE.set(source, { revision: source.revision, tokens });
168
+ }
169
+ return tokens;
170
+ }
171
+
172
+ function tokenPoint(token: FlatToken | undefined): VimPoint | undefined {
173
+ return token?.kind === "cell" ? token.point : undefined;
174
+ }
175
+
176
+ function tokenIndexAtOrAfter(tokens: FlatToken[], point: VimPoint): number {
177
+ for (let index = 0; index < tokens.length; index++) {
178
+ const token = tokens[index];
179
+ if (token.kind !== "cell") continue;
180
+ if (
181
+ token.point.row > point.row ||
182
+ (token.point.row === point.row && token.point.col >= point.col)
183
+ ) {
184
+ return index;
185
+ }
186
+ }
187
+ return Math.max(0, tokens.length - 1);
188
+ }
189
+
190
+ function tokenIndexAtOrBefore(tokens: FlatToken[], point: VimPoint): number {
191
+ for (let index = tokens.length - 1; index >= 0; index--) {
192
+ const token = tokens[index];
193
+ if (token.kind !== "cell") continue;
194
+ if (
195
+ token.point.row < point.row ||
196
+ (token.point.row === point.row && token.point.col <= point.col)
197
+ ) {
198
+ return index;
199
+ }
200
+ }
201
+ return 0;
202
+ }
203
+
204
+ function nonSpaceTokenPoint(
205
+ tokens: FlatToken[],
206
+ index: number,
207
+ direction: -1 | 1,
208
+ ): VimPoint | undefined {
209
+ for (
210
+ let cursor = index;
211
+ cursor >= 0 && cursor < tokens.length;
212
+ cursor += direction
213
+ ) {
214
+ const token = tokens[cursor];
215
+ if (token.kind === "cell" && token.wordClass !== "space") {
216
+ return token.point;
217
+ }
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ function wordClass(token: FlatToken, big: boolean): string {
223
+ return big ? token.bigWordClass : token.wordClass;
224
+ }
225
+
226
+ function wordForwardOnce(
227
+ source: VimTextSource,
228
+ point: VimPoint,
229
+ big: boolean,
230
+ ): VimPoint {
231
+ const tokens = flatten(source);
232
+ if (tokens.length === 0) return point;
233
+ let index = tokenIndexAtOrAfter(tokens, point);
234
+ const token = tokens[index];
235
+ const isCurrentCell =
236
+ token.kind === "cell" &&
237
+ token.point.row === point.row &&
238
+ token.point.col === point.col;
239
+
240
+ if (isCurrentCell && wordClass(token, big) !== "space") {
241
+ const currentClass = wordClass(token, big);
242
+ index++;
243
+ while (
244
+ index < tokens.length &&
245
+ wordClass(tokens[index], big) === currentClass
246
+ ) {
247
+ index++;
248
+ }
249
+ }
250
+
251
+ while (index < tokens.length && wordClass(tokens[index], big) === "space") {
252
+ index++;
253
+ }
254
+
255
+ return tokenPoint(tokens[index]) ?? point;
256
+ }
257
+
258
+ function wordBackwardOnce(
259
+ source: VimTextSource,
260
+ point: VimPoint,
261
+ big: boolean,
262
+ ): VimPoint {
263
+ const tokens = flatten(source);
264
+ if (tokens.length === 0) return point;
265
+ let index = tokenIndexAtOrBefore(tokens, point);
266
+ const current = tokens[index];
267
+
268
+ if (
269
+ current.kind === "cell" &&
270
+ current.point.row === point.row &&
271
+ current.point.col === point.col &&
272
+ wordClass(current, big) !== "space"
273
+ ) {
274
+ index--;
275
+ }
276
+
277
+ while (index >= 0 && wordClass(tokens[index], big) === "space") index--;
278
+ if (index < 0) return point;
279
+
280
+ const targetClass = wordClass(tokens[index], big);
281
+ while (
282
+ index > 0 &&
283
+ wordClass(tokens[index - 1], big) === targetClass
284
+ ) {
285
+ index--;
286
+ }
287
+
288
+ return tokenPoint(tokens[index]) ??
289
+ nonSpaceTokenPoint(tokens, index, 1) ??
290
+ point;
291
+ }
292
+
293
+ function wordEndOnce(
294
+ source: VimTextSource,
295
+ point: VimPoint,
296
+ big: boolean,
297
+ ): VimPoint {
298
+ const tokens = flatten(source);
299
+ if (tokens.length === 0) return point;
300
+ let index = tokenIndexAtOrAfter(tokens, point);
301
+ const token = tokens[index];
302
+ const isCurrentCell =
303
+ token.kind === "cell" &&
304
+ token.point.row === point.row &&
305
+ token.point.col === point.col;
306
+
307
+ if (wordClass(token, big) === "space") {
308
+ while (index < tokens.length && wordClass(tokens[index], big) === "space") {
309
+ index++;
310
+ }
311
+ } else if (isCurrentCell) {
312
+ const currentClass = wordClass(token, big);
313
+ let cursor = index;
314
+ while (
315
+ cursor + 1 < tokens.length &&
316
+ wordClass(tokens[cursor + 1], big) === currentClass
317
+ ) {
318
+ cursor++;
319
+ }
320
+ if (cursor > index) return tokenPoint(tokens[cursor]) ?? point;
321
+ index = cursor + 1;
322
+ while (index < tokens.length && wordClass(tokens[index], big) === "space") {
323
+ index++;
324
+ }
325
+ }
326
+
327
+ if (index >= tokens.length) return point;
328
+ const targetClass = wordClass(tokens[index], big);
329
+ while (
330
+ index + 1 < tokens.length &&
331
+ wordClass(tokens[index + 1], big) === targetClass
332
+ ) {
333
+ index++;
334
+ }
335
+ return tokenPoint(tokens[index]) ?? point;
336
+ }
337
+
338
+ function wordEndBackwardOnce(
339
+ source: VimTextSource,
340
+ point: VimPoint,
341
+ big: boolean,
342
+ ): VimPoint {
343
+ const tokens = flatten(source);
344
+ if (tokens.length === 0) return point;
345
+ let index = tokenIndexAtOrBefore(tokens, point);
346
+ const current = tokens[index];
347
+
348
+ if (
349
+ current?.kind === "cell" &&
350
+ current.point.row === point.row &&
351
+ current.point.col === point.col &&
352
+ wordClass(current, big) !== "space"
353
+ ) {
354
+ const currentClass = wordClass(current, big);
355
+ while (index >= 0 && wordClass(tokens[index], big) === currentClass) index--;
356
+ }
357
+
358
+ while (index >= 0 && wordClass(tokens[index], big) === "space") index--;
359
+ return tokenPoint(tokens[index]) ?? point;
360
+ }
361
+
362
+ function repeatMotion(
363
+ point: VimPoint,
364
+ count: number,
365
+ motion: (current: VimPoint) => VimPoint,
366
+ ): VimPoint {
367
+ let result = point;
368
+ for (let index = 0; index < count; index++) result = motion(result);
369
+ return result;
370
+ }
371
+
372
+ function lineStart(source: VimTextSource, row: number): VimPoint {
373
+ return { row: clampRow(source, row), col: 0 };
374
+ }
375
+
376
+ function lineEnd(source: VimTextSource, row: number): VimPoint {
377
+ const clamped = clampRow(source, row);
378
+ const cells = source.line(clamped);
379
+ return { row: clamped, col: cells[cells.length - 1]?.start ?? 0 };
380
+ }
381
+
382
+ function paragraphMotion(
383
+ source: VimTextSource,
384
+ point: VimPoint,
385
+ direction: -1 | 1,
386
+ count: number,
387
+ ): VimPoint {
388
+ let row = point.row;
389
+ const isBlank = (candidate: number) =>
390
+ source.line(candidate).every((cell) => isWhitespace(cell.text));
391
+
392
+ for (let iteration = 0; iteration < count; iteration++) {
393
+ row += direction;
394
+ while (row >= 0 && row < source.lineCount && !isBlank(row)) row += direction;
395
+ while (row >= 0 && row < source.lineCount && isBlank(row)) row += direction;
396
+ if (row < 0) {
397
+ row = 0;
398
+ break;
399
+ }
400
+ if (row >= source.lineCount) {
401
+ row = Math.max(0, source.lineCount - 1);
402
+ break;
403
+ }
404
+ }
405
+
406
+ return {
407
+ row,
408
+ col: firstNonWhitespaceColumn(source, row),
409
+ };
410
+ }
411
+
412
+ function findOnLine(
413
+ source: VimTextSource,
414
+ point: VimPoint,
415
+ state: FindState,
416
+ count: number,
417
+ ): VimPoint {
418
+ const cells = source.line(clampRow(source, point.row));
419
+ if (cells.length === 0) return point;
420
+ const current = cellIndexAt(cells, point.col);
421
+ let found = -1;
422
+ let remaining = count;
423
+
424
+ if (state.direction > 0) {
425
+ for (let index = current + 1; index < cells.length; index++) {
426
+ if (cells[index]?.text !== state.char) continue;
427
+ remaining--;
428
+ if (remaining === 0) {
429
+ found = index;
430
+ break;
431
+ }
432
+ }
433
+ } else {
434
+ for (let index = current - 1; index >= 0; index--) {
435
+ if (cells[index]?.text !== state.char) continue;
436
+ remaining--;
437
+ if (remaining === 0) {
438
+ found = index;
439
+ break;
440
+ }
441
+ }
442
+ }
443
+
444
+ if (found < 0) return point;
445
+ let target = found;
446
+ if (state.till) target -= state.direction;
447
+ target = Math.max(0, Math.min(cells.length - 1, target));
448
+ return { row: point.row, col: cells[target]?.start ?? point.col };
449
+ }
450
+
451
+ function matchingBracket(
452
+ source: VimTextSource,
453
+ point: VimPoint,
454
+ ): VimPoint | undefined {
455
+ const tokens = flatten(source);
456
+ const pairs: Record<string, [string, string, -1 | 1]> = {
457
+ "(": ["(", ")", 1],
458
+ ")": ["(", ")", -1],
459
+ "[": ["[", "]", 1],
460
+ "]": ["[", "]", -1],
461
+ "{": ["{", "}", 1],
462
+ "}": ["{", "}", -1],
463
+ };
464
+
465
+ let origin = -1;
466
+ for (let index = 0; index < tokens.length; index++) {
467
+ const token = tokens[index];
468
+ if (token.kind !== "cell" || token.point.row !== point.row) continue;
469
+ if (token.point.col < point.col) continue;
470
+ if (pairs[token.text]) {
471
+ origin = index;
472
+ break;
473
+ }
474
+ }
475
+ if (origin < 0) return undefined;
476
+
477
+ const originToken = tokens[origin];
478
+ if (originToken.kind !== "cell") return undefined;
479
+ const [open, close, direction] = pairs[originToken.text];
480
+ let depth = 0;
481
+ for (
482
+ let index = origin;
483
+ index >= 0 && index < tokens.length;
484
+ index += direction
485
+ ) {
486
+ const token = tokens[index];
487
+ if (token.kind !== "cell") continue;
488
+ if (direction > 0) {
489
+ if (token.text === open) depth++;
490
+ else if (token.text === close) depth--;
491
+ } else {
492
+ if (token.text === close) depth++;
493
+ else if (token.text === open) depth--;
494
+ }
495
+ if (depth === 0 && index !== origin) return token.point;
496
+ }
497
+ return undefined;
498
+ }
499
+
500
+ function wordRange(
501
+ source: VimTextSource,
502
+ point: VimPoint,
503
+ big: boolean,
504
+ around: boolean,
505
+ ): PointRange | undefined {
506
+ const tokens = flatten(source);
507
+ if (tokens.length === 0) return undefined;
508
+ let index = tokenIndexAtOrAfter(tokens, point);
509
+
510
+ if (wordClass(tokens[index], big) === "space") {
511
+ while (index < tokens.length && wordClass(tokens[index], big) === "space") {
512
+ index++;
513
+ }
514
+ }
515
+ if (index >= tokens.length) return undefined;
516
+
517
+ const targetClass = wordClass(tokens[index], big);
518
+ let start = index;
519
+ let end = index;
520
+ while (start > 0 && wordClass(tokens[start - 1], big) === targetClass) start--;
521
+ while (
522
+ end + 1 < tokens.length &&
523
+ wordClass(tokens[end + 1], big) === targetClass
524
+ ) {
525
+ end++;
526
+ }
527
+
528
+ if (around) {
529
+ let trailing = end + 1;
530
+ while (
531
+ trailing < tokens.length &&
532
+ tokens[trailing].kind === "cell" &&
533
+ wordClass(tokens[trailing], big) === "space"
534
+ ) {
535
+ end = trailing;
536
+ trailing++;
537
+ }
538
+ if (end === index || wordClass(tokens[end], big) !== "space") {
539
+ let leading = start - 1;
540
+ while (
541
+ leading >= 0 &&
542
+ tokens[leading].kind === "cell" &&
543
+ wordClass(tokens[leading], big) === "space"
544
+ ) {
545
+ start = leading;
546
+ leading--;
547
+ }
548
+ }
549
+ }
550
+
551
+ const startPoint = tokenPoint(tokens[start]);
552
+ const endPoint = tokenPoint(tokens[end]);
553
+ if (!startPoint || !endPoint) return undefined;
554
+ return { start: startPoint, end: endPoint };
555
+ }
556
+
557
+ function quoteRange(
558
+ source: VimTextSource,
559
+ point: VimPoint,
560
+ quote: string,
561
+ around: boolean,
562
+ ): PointRange | undefined {
563
+ const cells = source.line(clampRow(source, point.row));
564
+ if (cells.length === 0) return undefined;
565
+ const cursorIndex = cellIndexAt(cells, point.col);
566
+ const quoteIndices: number[] = [];
567
+ for (let index = 0; index < cells.length; index++) {
568
+ if (cells[index]?.text === quote && cells[index - 1]?.text !== "\\") {
569
+ quoteIndices.push(index);
570
+ }
571
+ }
572
+
573
+ let pair: [number, number] | undefined;
574
+ for (let index = 0; index + 1 < quoteIndices.length; index += 2) {
575
+ const open = quoteIndices[index];
576
+ const close = quoteIndices[index + 1];
577
+ if (cursorIndex >= open && cursorIndex <= close) {
578
+ pair = [open, close];
579
+ break;
580
+ }
581
+ }
582
+ if (!pair) return undefined;
583
+
584
+ const [open, close] = pair;
585
+ const startIndex = around ? open : open + 1;
586
+ const endIndex = around ? close : close - 1;
587
+ if (startIndex > endIndex) return undefined;
588
+ const start = cells[startIndex];
589
+ const end = cells[endIndex];
590
+ if (!start || !end) return undefined;
591
+ return {
592
+ start: { row: point.row, col: start.start },
593
+ end: { row: point.row, col: end.start },
594
+ };
595
+ }
596
+
597
+ function bracketRange(
598
+ source: VimTextSource,
599
+ point: VimPoint,
600
+ openChar: string,
601
+ closeChar: string,
602
+ around: boolean,
603
+ ): PointRange | undefined {
604
+ const tokens = flatten(source);
605
+ if (tokens.length === 0) return undefined;
606
+ const cursor = tokenIndexAtOrAfter(tokens, point);
607
+
608
+ for (let open = cursor; open >= 0; open--) {
609
+ const token = tokens[open];
610
+ if (token.kind !== "cell" || token.text !== openChar) continue;
611
+ let depth = 0;
612
+ for (let close = open; close < tokens.length; close++) {
613
+ const candidate = tokens[close];
614
+ if (candidate.kind !== "cell") continue;
615
+ if (candidate.text === openChar) depth++;
616
+ else if (candidate.text === closeChar) depth--;
617
+ if (depth !== 0) continue;
618
+ if (close < cursor) break;
619
+ const startIndex = around ? open : open + 1;
620
+ const endIndex = around ? close : close - 1;
621
+ const start = nonSpaceOrCellPoint(tokens, startIndex, 1, endIndex);
622
+ const end = nonSpaceOrCellPoint(tokens, endIndex, -1, startIndex);
623
+ if (!start || !end || compareVimPoints(start, end) > 0) return undefined;
624
+ return { start, end };
625
+ }
626
+ }
627
+ return undefined;
628
+ }
629
+
630
+ function nonSpaceOrCellPoint(
631
+ tokens: FlatToken[],
632
+ index: number,
633
+ direction: -1 | 1,
634
+ limit: number,
635
+ ): VimPoint | undefined {
636
+ for (
637
+ let cursor = index;
638
+ direction > 0 ? cursor <= limit : cursor >= limit;
639
+ cursor += direction
640
+ ) {
641
+ const point = tokenPoint(tokens[cursor]);
642
+ if (point) return point;
643
+ }
644
+ return undefined;
645
+ }
646
+
647
+ function textObjectRange(
648
+ source: VimTextSource,
649
+ point: VimPoint,
650
+ object: string,
651
+ around: boolean,
652
+ count: number,
653
+ ): PointRange | undefined {
654
+ if (object === "w" || object === "W") {
655
+ let range = wordRange(source, point, object === "W", around);
656
+ if (!range) return undefined;
657
+ for (let iteration = 1; iteration < count; iteration++) {
658
+ const next = wordRange(
659
+ source,
660
+ wordForwardOnce(source, range.end, object === "W"),
661
+ object === "W",
662
+ around,
663
+ );
664
+ if (!next) break;
665
+ range = { start: range.start, end: next.end };
666
+ }
667
+ return range;
668
+ }
669
+
670
+ if (object === '"' || object === "'" || object === "`") {
671
+ return quoteRange(source, point, object, around);
672
+ }
673
+
674
+ const bracketPairs: Record<string, [string, string]> = {
675
+ "(": ["(", ")"],
676
+ ")": ["(", ")"],
677
+ b: ["(", ")"],
678
+ "[": ["[", "]"],
679
+ "]": ["[", "]"],
680
+ "{": ["{", "}"],
681
+ "}": ["{", "}"],
682
+ B: ["{", "}"],
683
+ "<": ["<", ">"],
684
+ ">": ["<", ">"],
685
+ };
686
+ const pair = bracketPairs[object];
687
+ return pair
688
+ ? bracketRange(source, point, pair[0], pair[1], around)
689
+ : undefined;
690
+ }
691
+
692
+ export class VimVisualNavigation {
693
+ private head: VimPoint;
694
+ private anchor: VimPoint | undefined;
695
+ private selectionKind: VimSelectionKind | undefined;
696
+ private preferredCol: number;
697
+ private countBuffer = "";
698
+ private pending: PendingState | undefined;
699
+ private lastFind: FindState | undefined;
700
+
701
+ constructor(start: VimPoint) {
702
+ this.head = clonePoint(start);
703
+ this.preferredCol = start.col;
704
+ }
705
+
706
+ snapshot(): VimVisualSnapshot {
707
+ return {
708
+ head: clonePoint(this.head),
709
+ anchor: this.anchor ? clonePoint(this.anchor) : undefined,
710
+ selectionKind: this.selectionKind,
711
+ preferredCol: this.preferredCol,
712
+ pending: this.pendingLabel(),
713
+ count: this.countBuffer ? Number(this.countBuffer) : undefined,
714
+ };
715
+ }
716
+
717
+ isSelecting(): boolean {
718
+ return Boolean(this.selectionKind);
719
+ }
720
+
721
+ clamp(source: VimTextSource): void {
722
+ this.head.row = clampRow(source, this.head.row);
723
+ this.head.col = nearestColumn(source, this.head.row, this.head.col);
724
+ if (this.anchor) {
725
+ this.anchor.row = clampRow(source, this.anchor.row);
726
+ this.anchor.col = nearestColumn(source, this.anchor.row, this.anchor.col);
727
+ }
728
+ this.preferredCol = this.head.col;
729
+ }
730
+
731
+ startSelection(kind: VimSelectionKind): void {
732
+ if (!this.anchor || !this.selectionKind) {
733
+ this.anchor = clonePoint(this.head);
734
+ }
735
+ this.selectionKind = kind;
736
+ this.clearPending();
737
+ }
738
+
739
+ cancelSelection(): void {
740
+ this.anchor = undefined;
741
+ this.selectionKind = undefined;
742
+ this.clearPending();
743
+ }
744
+
745
+ handleKey(key: string, source: VimTextSource): VimHandleResult {
746
+ if (source.lineCount <= 0) return { handled: true };
747
+
748
+ if (key === "escape") {
749
+ this.clearPending();
750
+ if (this.isSelecting()) {
751
+ this.cancelSelection();
752
+ return { handled: true };
753
+ }
754
+ return { handled: true, command: "exit" };
755
+ }
756
+
757
+ if (this.pending?.kind === "find") {
758
+ const pending = this.pending;
759
+ this.pending = undefined;
760
+ if (key.length === 0) {
761
+ this.countBuffer = "";
762
+ return { handled: true };
763
+ }
764
+ const find: FindState = {
765
+ char: key,
766
+ direction: pending.direction,
767
+ till: pending.till,
768
+ };
769
+ const count = this.takeCount();
770
+ this.lastFind = find;
771
+ this.moveTo(findOnLine(source, this.head, find, count), source);
772
+ return { handled: true };
773
+ }
774
+
775
+ if (this.pending?.kind === "text-object") {
776
+ const pending = this.pending;
777
+ this.pending = undefined;
778
+ const range = textObjectRange(
779
+ source,
780
+ this.head,
781
+ key,
782
+ pending.around,
783
+ this.takeCount(),
784
+ );
785
+ if (range) this.selectRange(range);
786
+ return { handled: true };
787
+ }
788
+
789
+ if (this.pending?.kind === "g") {
790
+ this.pending = undefined;
791
+ if (key === "g") {
792
+ const count = this.takeCount(false);
793
+ const row = count ? Math.min(source.lineCount - 1, count - 1) : 0;
794
+ this.moveTo(
795
+ { row, col: firstNonWhitespaceColumn(source, row) },
796
+ source,
797
+ );
798
+ return { handled: true };
799
+ }
800
+ if (key === "e" || key === "E") {
801
+ const big = key === "E";
802
+ const count = this.takeCount();
803
+ this.moveTo(
804
+ repeatMotion(this.head, count, (point) =>
805
+ wordEndBackwardOnce(source, point, big),
806
+ ),
807
+ source,
808
+ );
809
+ return { handled: true };
810
+ }
811
+ this.countBuffer = "";
812
+ return { handled: true };
813
+ }
814
+
815
+ if (/^[1-9]$/u.test(key) || (key === "0" && this.countBuffer.length > 0)) {
816
+ this.countBuffer += key;
817
+ return { handled: true };
818
+ }
819
+
820
+ if (key === "v") {
821
+ if (this.isSelecting()) this.cancelSelection();
822
+ else this.startSelection("character");
823
+ return { handled: true };
824
+ }
825
+
826
+ if (key === "V") {
827
+ this.startSelection("line");
828
+ return { handled: true };
829
+ }
830
+
831
+ if (key === "y" || key === "c") {
832
+ this.clearPending();
833
+ return { handled: true, command: "copy" };
834
+ }
835
+
836
+ if (key === "enter") {
837
+ this.clearPending();
838
+ return { handled: true, command: "open-link" };
839
+ }
840
+
841
+ if (key === ":") {
842
+ this.clearPending();
843
+ return { handled: true, command: "ex" };
844
+ }
845
+
846
+ if (key === "o") {
847
+ if (this.anchor && this.selectionKind) {
848
+ const previousHead = this.head;
849
+ this.head = this.anchor;
850
+ this.anchor = previousHead;
851
+ this.preferredCol = this.head.col;
852
+ }
853
+ this.clearPending();
854
+ return { handled: true };
855
+ }
856
+
857
+ if (key === "i" || key === "a") {
858
+ this.pending = { kind: "text-object", around: key === "a" };
859
+ return { handled: true };
860
+ }
861
+
862
+ if (key === "g") {
863
+ this.pending = { kind: "g" };
864
+ return { handled: true };
865
+ }
866
+
867
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
868
+ this.pending = {
869
+ kind: "find",
870
+ direction: key === "f" || key === "t" ? 1 : -1,
871
+ till: key === "t" || key === "T",
872
+ };
873
+ return { handled: true };
874
+ }
875
+
876
+ if (key === ";" || key === ",") {
877
+ if (this.lastFind) {
878
+ const count = this.takeCount();
879
+ const find =
880
+ key === ";"
881
+ ? this.lastFind
882
+ : { ...this.lastFind, direction: (-this.lastFind.direction) as -1 | 1 };
883
+ this.moveTo(findOnLine(source, this.head, find, count), source);
884
+ } else {
885
+ this.countBuffer = "";
886
+ }
887
+ return { handled: true };
888
+ }
889
+
890
+ if (key === "G") {
891
+ const explicit = this.countBuffer ? Number(this.countBuffer) : undefined;
892
+ this.countBuffer = "";
893
+ const row = explicit
894
+ ? Math.min(source.lineCount - 1, Math.max(0, explicit - 1))
895
+ : source.lineCount - 1;
896
+ this.moveTo(
897
+ { row, col: firstNonWhitespaceColumn(source, row) },
898
+ source,
899
+ );
900
+ return { handled: true };
901
+ }
902
+
903
+ if (key === "%") {
904
+ const percent = this.countBuffer ? Number(this.countBuffer) : undefined;
905
+ this.countBuffer = "";
906
+ if (percent !== undefined) {
907
+ const clampedPercent = Math.max(1, Math.min(100, percent));
908
+ const row = Math.min(
909
+ source.lineCount - 1,
910
+ Math.max(0, Math.ceil((source.lineCount * clampedPercent) / 100) - 1),
911
+ );
912
+ this.moveTo(
913
+ { row, col: firstNonWhitespaceColumn(source, row) },
914
+ source,
915
+ );
916
+ } else {
917
+ const match = matchingBracket(source, this.head);
918
+ if (match) this.moveTo(match, source);
919
+ }
920
+ return { handled: true };
921
+ }
922
+
923
+ const count = this.takeCount();
924
+
925
+ if (key === "h" || key === "l") {
926
+ const direction: -1 | 1 = key === "h" ? -1 : 1;
927
+ const cells = lineCells(source, this.head.row);
928
+ if (cells.length > 0) {
929
+ const current = cellIndexAt(cells, this.head.col);
930
+ const target = Math.max(
931
+ 0,
932
+ Math.min(cells.length - 1, current + direction * count),
933
+ );
934
+ this.moveTo(
935
+ { row: this.head.row, col: cells[target]?.start ?? this.head.col },
936
+ source,
937
+ );
938
+ }
939
+ return { handled: true };
940
+ }
941
+
942
+ if (key === "j" || key === "k") {
943
+ const direction: -1 | 1 = key === "j" ? 1 : -1;
944
+ const row = clampRow(source, this.head.row + direction * count);
945
+ const preferred = this.preferredCol;
946
+ this.head = { row, col: nearestColumn(source, row, preferred) };
947
+ return { handled: true };
948
+ }
949
+
950
+ if (key === "0") {
951
+ this.moveTo(lineStart(source, this.head.row), source);
952
+ return { handled: true };
953
+ }
954
+
955
+ if (key === "^") {
956
+ this.moveTo(
957
+ {
958
+ row: this.head.row,
959
+ col: firstNonWhitespaceColumn(source, this.head.row),
960
+ },
961
+ source,
962
+ );
963
+ return { handled: true };
964
+ }
965
+
966
+ if (key === "$") {
967
+ this.moveTo(lineEnd(source, this.head.row), source);
968
+ return { handled: true };
969
+ }
970
+
971
+ if (key === "w" || key === "W") {
972
+ const big = key === "W";
973
+ this.moveTo(
974
+ repeatMotion(this.head, count, (point) =>
975
+ wordForwardOnce(source, point, big),
976
+ ),
977
+ source,
978
+ );
979
+ return { handled: true };
980
+ }
981
+
982
+ if (key === "b" || key === "B") {
983
+ const big = key === "B";
984
+ this.moveTo(
985
+ repeatMotion(this.head, count, (point) =>
986
+ wordBackwardOnce(source, point, big),
987
+ ),
988
+ source,
989
+ );
990
+ return { handled: true };
991
+ }
992
+
993
+ if (key === "e" || key === "E") {
994
+ const big = key === "E";
995
+ this.moveTo(
996
+ repeatMotion(this.head, count, (point) => wordEndOnce(source, point, big)),
997
+ source,
998
+ );
999
+ return { handled: true };
1000
+ }
1001
+
1002
+ if (key === "{" || key === "}") {
1003
+ this.moveTo(
1004
+ paragraphMotion(source, this.head, key === "{" ? -1 : 1, count),
1005
+ source,
1006
+ );
1007
+ return { handled: true };
1008
+ }
1009
+
1010
+ this.countBuffer = "";
1011
+ return { handled: false };
1012
+ }
1013
+
1014
+ private moveTo(point: VimPoint, source: VimTextSource): void {
1015
+ this.head = {
1016
+ row: clampRow(source, point.row),
1017
+ col: nearestColumn(source, point.row, point.col),
1018
+ };
1019
+ this.preferredCol = this.head.col;
1020
+ }
1021
+
1022
+ private selectRange(range: PointRange): void {
1023
+ this.anchor = clonePoint(range.start);
1024
+ this.head = clonePoint(range.end);
1025
+ this.selectionKind = "character";
1026
+ this.preferredCol = this.head.col;
1027
+ }
1028
+
1029
+ private takeCount(defaultToOne = true): number {
1030
+ const count = this.countBuffer ? Number(this.countBuffer) : undefined;
1031
+ this.countBuffer = "";
1032
+ return count ?? (defaultToOne ? 1 : 0);
1033
+ }
1034
+
1035
+ private clearPending(): void {
1036
+ this.countBuffer = "";
1037
+ this.pending = undefined;
1038
+ }
1039
+
1040
+ private pendingLabel(): string {
1041
+ const count = this.countBuffer;
1042
+ if (!this.pending) return count;
1043
+ if (this.pending.kind === "g") return `${count}g`;
1044
+ if (this.pending.kind === "find") {
1045
+ const key = this.pending.direction > 0
1046
+ ? this.pending.till
1047
+ ? "t"
1048
+ : "f"
1049
+ : this.pending.till
1050
+ ? "T"
1051
+ : "F";
1052
+ return `${count}${key}`;
1053
+ }
1054
+ return `${count}${this.pending.around ? "a" : "i"}`;
1055
+ }
1056
+ }