@wwwyo/pi-vim 0.0.0-stage → 0.14.2
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/LICENSE +21 -0
- package/README.md +552 -2
- package/clipboard-mirror.ts +449 -0
- package/clipboard-policy.ts +23 -0
- package/cursor-shape.ts +138 -0
- package/index.ts +4359 -0
- package/input-keys.ts +50 -0
- package/mode-change-command.ts +120 -0
- package/mode-colors.ts +97 -0
- package/mode-label.ts +54 -0
- package/motions.ts +305 -0
- package/package.json +55 -4
- package/settings.ts +346 -0
- package/text-objects.ts +408 -0
- package/types.ts +50 -0
- package/visual.ts +68 -0
- package/word-boundary-cache.ts +196 -0
package/text-objects.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { getLineGraphemes } from "./motions.js";
|
|
2
|
+
|
|
3
|
+
export type TextObjectKind = "i" | "a";
|
|
4
|
+
|
|
5
|
+
export type TextObjectRange = {
|
|
6
|
+
startAbs: number;
|
|
7
|
+
endAbs: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type WordTextObjectClass = "word" | "WORD";
|
|
11
|
+
|
|
12
|
+
export type DelimiterSpec = {
|
|
13
|
+
type: "quote" | "bracket";
|
|
14
|
+
open: string;
|
|
15
|
+
close: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MatchingPairKind = "()" | "[]" | "{}";
|
|
19
|
+
|
|
20
|
+
export type MatchingPairMotionTarget = {
|
|
21
|
+
pair: MatchingPairKind;
|
|
22
|
+
sourceAbs: number;
|
|
23
|
+
targetAbs: number;
|
|
24
|
+
rangeAnchorAbs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function normalizeCount(count: number): number {
|
|
28
|
+
if (!Number.isFinite(count) || count < 1) return 1;
|
|
29
|
+
return Math.floor(count);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clampCursorCol(line: string, cursorCol: number): number {
|
|
33
|
+
if (line.length === 0) return 0;
|
|
34
|
+
if (!Number.isFinite(cursorCol)) return 0;
|
|
35
|
+
|
|
36
|
+
const normalized = Math.trunc(cursorCol);
|
|
37
|
+
return Math.max(0, Math.min(normalized, line.length - 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clampCursorAbs(text: string, cursorAbs: number): number {
|
|
41
|
+
if (text.length === 0) return 0;
|
|
42
|
+
if (!Number.isFinite(cursorAbs)) return 0;
|
|
43
|
+
|
|
44
|
+
const normalized = Math.trunc(cursorAbs);
|
|
45
|
+
return Math.max(0, Math.min(normalized, text.length - 1));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findLogicalLineBounds(
|
|
49
|
+
line: string,
|
|
50
|
+
cursorCol: number,
|
|
51
|
+
): { start: number; end: number } {
|
|
52
|
+
if (line.length === 0) return { start: 0, end: 0 };
|
|
53
|
+
|
|
54
|
+
const previousSearchStart =
|
|
55
|
+
line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
|
|
56
|
+
const start = line.lastIndexOf("\n", previousSearchStart) + 1;
|
|
57
|
+
const nextNewline = line.indexOf("\n", cursorCol);
|
|
58
|
+
|
|
59
|
+
return { start, end: nextNewline === -1 ? line.length : nextNewline };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Vim character classes for word text objects: 0 = blank, 1 = other
|
|
64
|
+
* (non-blank punctuation/symbol), 2 = word (keyword). `iW`/`aW` collapse
|
|
65
|
+
* every non-blank character into the single word class. Matches nvim's
|
|
66
|
+
* `cls()` split, which is what `iw`/`aw` scan over.
|
|
67
|
+
*/
|
|
68
|
+
type WordCharClass = 0 | 1 | 2;
|
|
69
|
+
|
|
70
|
+
// Unicode-aware keyword test: letters, numbers, combining marks (so grapheme
|
|
71
|
+
// clusters such as a base letter plus combining accent stay in one word), and
|
|
72
|
+
// underscore. Mirrors nvim treating accented Latin and CJK as word characters,
|
|
73
|
+
// unlike the ASCII-only `\w`.
|
|
74
|
+
const WORD_CHAR_PATTERN = /[\p{L}\p{N}\p{M}_]/u;
|
|
75
|
+
|
|
76
|
+
function isWordChar(ch: string): boolean {
|
|
77
|
+
return WORD_CHAR_PATTERN.test(ch);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pairKind(ch?: string): MatchingPairKind | null {
|
|
81
|
+
return ch === "(" || ch === ")"
|
|
82
|
+
? "()"
|
|
83
|
+
: ch === "[" || ch === "]"
|
|
84
|
+
? "[]"
|
|
85
|
+
: ch === "{" || ch === "}"
|
|
86
|
+
? "{}"
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scanSameDelimiterPairs(
|
|
91
|
+
text: string,
|
|
92
|
+
open: string,
|
|
93
|
+
close: string,
|
|
94
|
+
onPair: (openAbs: number, closeAbs: number) => number | null,
|
|
95
|
+
): number | null {
|
|
96
|
+
const stack: number[] = [];
|
|
97
|
+
for (let index = 0; index < text.length; index++) {
|
|
98
|
+
if (text[index] === open) stack.push(index);
|
|
99
|
+
else if (text[index] === close) {
|
|
100
|
+
const openAbs = stack.pop();
|
|
101
|
+
if (openAbs === undefined) continue;
|
|
102
|
+
const targetAbs = onPair(openAbs, index);
|
|
103
|
+
if (targetAbs !== null) return targetAbs;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
|
|
110
|
+
if (key === '"' || key === "'" || key === "`")
|
|
111
|
+
return { type: "quote", open: key, close: key };
|
|
112
|
+
const pair =
|
|
113
|
+
key === "(" || key === ")" || key === "b"
|
|
114
|
+
? "()"
|
|
115
|
+
: key === "[" || key === "]"
|
|
116
|
+
? "[]"
|
|
117
|
+
: key === "{" || key === "}" || key === "B"
|
|
118
|
+
? "{}"
|
|
119
|
+
: null;
|
|
120
|
+
return pair ? { type: "bracket", open: pair[0], close: pair[1] } : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isEscapedDelimiter(text: string, index: number): boolean {
|
|
124
|
+
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
125
|
+
return false;
|
|
126
|
+
|
|
127
|
+
let backslashCount = 0;
|
|
128
|
+
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
129
|
+
backslashCount++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return backslashCount % 2 === 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveQuoteObjectRange(
|
|
136
|
+
text: string,
|
|
137
|
+
cursorAbs: number,
|
|
138
|
+
kind: TextObjectKind,
|
|
139
|
+
quote: string,
|
|
140
|
+
): TextObjectRange | null {
|
|
141
|
+
const spec = normalizeDelimiterKey(quote);
|
|
142
|
+
if (spec?.type !== "quote") return null;
|
|
143
|
+
|
|
144
|
+
const cursor = clampCursorAbs(text, cursorAbs);
|
|
145
|
+
const bounds = findLogicalLineBounds(text, cursor);
|
|
146
|
+
if (bounds.start >= bounds.end) return null;
|
|
147
|
+
|
|
148
|
+
let openIndex: number | null = null;
|
|
149
|
+
let bestPair: { open: number; close: number } | null = null;
|
|
150
|
+
|
|
151
|
+
for (let index = bounds.start; index < bounds.end; index++) {
|
|
152
|
+
if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
|
|
153
|
+
|
|
154
|
+
if (openIndex === null) {
|
|
155
|
+
openIndex = index;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (openIndex <= cursor && cursor <= index) {
|
|
160
|
+
if (
|
|
161
|
+
bestPair === null ||
|
|
162
|
+
index - openIndex < bestPair.close - bestPair.open
|
|
163
|
+
) {
|
|
164
|
+
bestPair = { open: openIndex, close: index };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
openIndex = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (bestPair === null) return null;
|
|
171
|
+
|
|
172
|
+
if (kind === "i") {
|
|
173
|
+
return {
|
|
174
|
+
startAbs: bestPair.open + 1,
|
|
175
|
+
endAbs: bestPair.close,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
startAbs: bestPair.open,
|
|
181
|
+
endAbs: bestPair.close + 1,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function resolveBracketObjectRange(
|
|
186
|
+
text: string,
|
|
187
|
+
cursorAbs: number,
|
|
188
|
+
kind: TextObjectKind,
|
|
189
|
+
open: string,
|
|
190
|
+
close: string,
|
|
191
|
+
): TextObjectRange | null {
|
|
192
|
+
if (open.length !== 1 || close.length !== 1 || open === close) return null;
|
|
193
|
+
|
|
194
|
+
const cursor = clampCursorAbs(text, cursorAbs);
|
|
195
|
+
let bestPair = null as { open: number; close: number } | null;
|
|
196
|
+
|
|
197
|
+
scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
|
|
198
|
+
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
199
|
+
if (
|
|
200
|
+
bestPair === null ||
|
|
201
|
+
closeIndex - openIndex < bestPair.close - bestPair.open
|
|
202
|
+
) {
|
|
203
|
+
bestPair = { open: openIndex, close: closeIndex };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (bestPair === null) return null;
|
|
210
|
+
|
|
211
|
+
if (kind === "i") {
|
|
212
|
+
return {
|
|
213
|
+
startAbs: bestPair.open + 1,
|
|
214
|
+
endAbs: bestPair.close,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
startAbs: bestPair.open,
|
|
220
|
+
endAbs: bestPair.close + 1,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function resolveMatchingPairMotionTarget(
|
|
225
|
+
text: string,
|
|
226
|
+
cursorAbs: number,
|
|
227
|
+
currentLineStartAbs: number,
|
|
228
|
+
currentLineEndAbs: number,
|
|
229
|
+
): MatchingPairMotionTarget | null {
|
|
230
|
+
const start = currentLineStartAbs,
|
|
231
|
+
end = currentLineEndAbs;
|
|
232
|
+
if (!text.length || start >= end) return null;
|
|
233
|
+
const visibleEol = cursorAbs >= end;
|
|
234
|
+
let sourceAbs = visibleEol ? end - 1 : Math.max(cursorAbs, start);
|
|
235
|
+
const rangeAnchorAbs = visibleEol ? sourceAbs : cursorAbs;
|
|
236
|
+
let pair = pairKind(text[sourceAbs]);
|
|
237
|
+
for (
|
|
238
|
+
let index = sourceAbs + 1;
|
|
239
|
+
!visibleEol && !pair && index < end;
|
|
240
|
+
index++
|
|
241
|
+
) {
|
|
242
|
+
pair = pairKind(text[index]);
|
|
243
|
+
if (pair) sourceAbs = index;
|
|
244
|
+
}
|
|
245
|
+
if (!pair) return null;
|
|
246
|
+
const targetAbs = scanSameDelimiterPairs(
|
|
247
|
+
text,
|
|
248
|
+
pair[0],
|
|
249
|
+
pair[1],
|
|
250
|
+
(openAbs, closeAbs) =>
|
|
251
|
+
openAbs === sourceAbs
|
|
252
|
+
? closeAbs
|
|
253
|
+
: closeAbs === sourceAbs
|
|
254
|
+
? openAbs
|
|
255
|
+
: null,
|
|
256
|
+
);
|
|
257
|
+
if (targetAbs !== null) return { pair, sourceAbs, targetAbs, rangeAnchorAbs };
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function resolveDelimitedTextObjectRange(
|
|
263
|
+
text: string,
|
|
264
|
+
cursorAbs: number,
|
|
265
|
+
kind: TextObjectKind,
|
|
266
|
+
key: string,
|
|
267
|
+
): TextObjectRange | null {
|
|
268
|
+
const spec = normalizeDelimiterKey(key);
|
|
269
|
+
if (spec === null) return null;
|
|
270
|
+
|
|
271
|
+
if (spec.type === "quote") {
|
|
272
|
+
return resolveQuoteObjectRange(text, cursorAbs, kind, spec.open);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (spec.type === "bracket") {
|
|
276
|
+
return resolveBracketObjectRange(
|
|
277
|
+
text,
|
|
278
|
+
cursorAbs,
|
|
279
|
+
kind,
|
|
280
|
+
spec.open,
|
|
281
|
+
spec.close,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function resolveWordTextObjectRange(
|
|
289
|
+
line: string,
|
|
290
|
+
lineStartAbs: number,
|
|
291
|
+
cursorCol: number,
|
|
292
|
+
kind: TextObjectKind,
|
|
293
|
+
count: number = 1,
|
|
294
|
+
semanticClass: WordTextObjectClass = "word",
|
|
295
|
+
): TextObjectRange | null {
|
|
296
|
+
if (line.length === 0) return null;
|
|
297
|
+
|
|
298
|
+
const cursor = clampCursorCol(line, cursorCol);
|
|
299
|
+
const bounds = findLogicalLineBounds(line, cursor);
|
|
300
|
+
if (bounds.start >= bounds.end) return null;
|
|
301
|
+
|
|
302
|
+
// Classify by grapheme (keyed on the grapheme's first code point), not by a
|
|
303
|
+
// single UTF-16 code unit. Indexing `line[idx]` splits astral letters — a
|
|
304
|
+
// lone surrogate half fails the `\p{L}` keyword test and scores as
|
|
305
|
+
// punctuation (`a𐐀b` becomes three runs) — and detaches a base character
|
|
306
|
+
// from its trailing combining marks, so an emoji plus variation selector
|
|
307
|
+
// (`☕️`) breaks into a symbol run and a mark run. nvim keeps each grapheme in
|
|
308
|
+
// one class run; classifying the grapheme's leading code point and stamping
|
|
309
|
+
// that class across the grapheme's UTF-16 offsets reproduces that while the
|
|
310
|
+
// returned ranges stay in UTF-16 offsets.
|
|
311
|
+
const classByOffset = new Int8Array(line.length).fill(-1);
|
|
312
|
+
for (const { start: gStart, end: gEnd } of getLineGraphemes(line)) {
|
|
313
|
+
const codePoint = line.codePointAt(gStart);
|
|
314
|
+
const ch = codePoint === undefined ? "" : String.fromCodePoint(codePoint);
|
|
315
|
+
// A newline (only ever its own grapheme) stays a -1 boundary sentinel.
|
|
316
|
+
if (ch === "" || ch === "\n") continue;
|
|
317
|
+
const cls: WordCharClass = /\s/.test(ch)
|
|
318
|
+
? 0
|
|
319
|
+
: semanticClass === "WORD"
|
|
320
|
+
? 2
|
|
321
|
+
: isWordChar(ch)
|
|
322
|
+
? 2
|
|
323
|
+
: 1;
|
|
324
|
+
classByOffset.fill(cls, gStart, gEnd);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Class of the grapheme covering idx within the current logical line, or null
|
|
328
|
+
// when idx falls outside the line or lands on a newline (a boundary).
|
|
329
|
+
const classAt = (idx: number): WordCharClass | null => {
|
|
330
|
+
if (idx < bounds.start || idx >= bounds.end) return null;
|
|
331
|
+
const cls = classByOffset[idx];
|
|
332
|
+
return cls === undefined || cls < 0 ? null : (cls as WordCharClass);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Extend `end` over the maximal run of the class starting at `end`.
|
|
336
|
+
const extendRun = (end: number): number => {
|
|
337
|
+
const runClass = classAt(end);
|
|
338
|
+
if (runClass === null) return end;
|
|
339
|
+
let next = end;
|
|
340
|
+
while (next < bounds.end && classAt(next) === runClass) next++;
|
|
341
|
+
return next;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const totalCount = normalizeCount(count);
|
|
345
|
+
const cursorCol0 = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
|
|
346
|
+
const cursorClass = classAt(cursorCol0);
|
|
347
|
+
if (cursorClass === null) return null;
|
|
348
|
+
|
|
349
|
+
// The run under the cursor, whatever its class (word, punctuation, or the
|
|
350
|
+
// whitespace run itself — nvim's `iw`/`aw` select whichever the cursor is on).
|
|
351
|
+
let start = cursorCol0;
|
|
352
|
+
while (start > bounds.start && classAt(start - 1) === cursorClass) start--;
|
|
353
|
+
let end = cursorCol0 + 1;
|
|
354
|
+
while (end < bounds.end && classAt(end) === cursorClass) end++;
|
|
355
|
+
|
|
356
|
+
if (kind === "i") {
|
|
357
|
+
// `count` consecutive runs (whitespace runs are counted too), forward.
|
|
358
|
+
let remaining = totalCount - 1;
|
|
359
|
+
while (remaining > 0 && end < bounds.end) {
|
|
360
|
+
end = extendRun(end);
|
|
361
|
+
remaining--;
|
|
362
|
+
}
|
|
363
|
+
// A count that runs out of line before it is satisfied is a no-op in nvim,
|
|
364
|
+
// not a partial delete (`2diw` on the sole word `foo` preserves the
|
|
365
|
+
// register); cancel by returning null.
|
|
366
|
+
if (remaining > 0) return null;
|
|
367
|
+
return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// kind === "a"
|
|
371
|
+
if (cursorClass === 0) {
|
|
372
|
+
// On whitespace: the run plus the following word(s); if there is no
|
|
373
|
+
// following word at all, there is nothing to select (nvim beeps / no-ops).
|
|
374
|
+
if (end >= bounds.end) return null;
|
|
375
|
+
let words = totalCount;
|
|
376
|
+
while (words > 0 && end < bounds.end) {
|
|
377
|
+
while (end < bounds.end && classAt(end) === 0) end++;
|
|
378
|
+
if (end >= bounds.end) break;
|
|
379
|
+
end = extendRun(end);
|
|
380
|
+
words--;
|
|
381
|
+
}
|
|
382
|
+
// Not enough following words to satisfy the count: no-op (nvim preserves
|
|
383
|
+
// the register) rather than deleting a partial span.
|
|
384
|
+
if (words > 0) return null;
|
|
385
|
+
return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// On a word/punctuation run: `count` non-blank words with the whitespace
|
|
389
|
+
// between them, then trailing whitespace; if none, leading whitespace.
|
|
390
|
+
let words = totalCount - 1;
|
|
391
|
+
while (words > 0 && end < bounds.end) {
|
|
392
|
+
while (end < bounds.end && classAt(end) === 0) end++;
|
|
393
|
+
if (end >= bounds.end) break;
|
|
394
|
+
end = extendRun(end);
|
|
395
|
+
words--;
|
|
396
|
+
}
|
|
397
|
+
// The count reached past the last word: no-op (nvim preserves the register,
|
|
398
|
+
// e.g. `2daw` on the final word) instead of deleting whatever was collected.
|
|
399
|
+
if (words > 0) return null;
|
|
400
|
+
|
|
401
|
+
if (classAt(end) === 0) {
|
|
402
|
+
end = extendRun(end); // trailing whitespace
|
|
403
|
+
} else {
|
|
404
|
+
while (start > bounds.start && classAt(start - 1) === 0) start--;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
|
|
408
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types and constants for vim-mode extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type Mode = "normal" | "insert" | "visual" | "visual-line";
|
|
6
|
+
export type CharMotion = "f" | "F" | "t" | "T";
|
|
7
|
+
export type PendingMotion = CharMotion | null;
|
|
8
|
+
export type PendingOperator = "d" | "c" | "y" | null;
|
|
9
|
+
|
|
10
|
+
export interface LastCharMotion {
|
|
11
|
+
motion: CharMotion;
|
|
12
|
+
char: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Normal mode key mappings: key -> escape sequence (or null for mode switch)
|
|
16
|
+
export const NORMAL_KEYS: Record<string, string | null> = {
|
|
17
|
+
h: "\x1b[D", // left
|
|
18
|
+
j: "\x1b[B", // down
|
|
19
|
+
k: "\x1b[A", // up
|
|
20
|
+
l: "\x1b[C", // right
|
|
21
|
+
"0": "\x01", // line start
|
|
22
|
+
$: null, // line end
|
|
23
|
+
x: null, // delete char (custom clipboard handling)
|
|
24
|
+
X: null, // delete char before cursor (custom clipboard handling)
|
|
25
|
+
D: null, // delete to end of line (custom clipboard handling)
|
|
26
|
+
C: null, // change to end of line (delete to end + insert mode)
|
|
27
|
+
S: null, // substitute line (delete line content + insert mode)
|
|
28
|
+
s: null, // substitute char (delete char + insert mode)
|
|
29
|
+
i: null, // insert mode
|
|
30
|
+
a: null, // append (insert + right)
|
|
31
|
+
A: null, // append at end of line
|
|
32
|
+
I: null, // insert at start of line
|
|
33
|
+
o: null, // open line below
|
|
34
|
+
O: null, // open line above
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Character motion keys that wait for a target character
|
|
38
|
+
export const CHAR_MOTION_KEYS = new Set<string>(["f", "F", "t", "T"]);
|
|
39
|
+
|
|
40
|
+
// Escape sequences
|
|
41
|
+
export const ESC_LEFT = "\x1b[D";
|
|
42
|
+
export const ESC_RIGHT = "\x1b[C";
|
|
43
|
+
export const CTRL_A = "\x01"; // line start
|
|
44
|
+
export const CTRL_E = "\x05"; // line end
|
|
45
|
+
export const CTRL_K = "\x0b"; // kill to end of line
|
|
46
|
+
export const CTRL_R = "\x12"; // ctrl+r — readline redo trigger in vim layer
|
|
47
|
+
export const CTRL_UNDERSCORE = "\x1f"; // ctrl+_ — readline undo
|
|
48
|
+
export const NEWLINE = "\n"; // newline character
|
|
49
|
+
export const ESC_UP = "\x1b[A"; // cursor up
|
|
50
|
+
export const ESC_DOWN = "\x1b[B"; // cursor down
|
package/visual.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual-mode selection geometry.
|
|
3
|
+
*
|
|
4
|
+
* A visual selection is an anchor (where `v`/`V` was pressed) plus the live
|
|
5
|
+
* cursor. Both ends are inclusive, so the grapheme under the later end belongs
|
|
6
|
+
* to the selection. Character-wise selections span an absolute text range;
|
|
7
|
+
* line-wise selections span whole lines and ignore the columns.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLineGraphemes } from "./motions.js";
|
|
11
|
+
import type { Mode } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export type VisualMode = "visual" | "visual-line";
|
|
14
|
+
export type VisualPosition = { line: number; col: number };
|
|
15
|
+
|
|
16
|
+
export function isVisualMode(mode: Mode): mode is VisualMode {
|
|
17
|
+
return mode === "visual" || mode === "visual-line";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Negative when `a` precedes `b` in buffer order, positive when it follows. */
|
|
21
|
+
export function compareVisualPositions(
|
|
22
|
+
a: VisualPosition,
|
|
23
|
+
b: VisualPosition,
|
|
24
|
+
): number {
|
|
25
|
+
return a.line !== b.line ? a.line - b.line : a.col - b.col;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The two endpoints in buffer order, regardless of which one is the anchor. */
|
|
29
|
+
export function orderVisualEndpoints(
|
|
30
|
+
anchor: VisualPosition,
|
|
31
|
+
cursor: VisualPosition,
|
|
32
|
+
): { start: VisualPosition; end: VisualPosition } {
|
|
33
|
+
return compareVisualPositions(anchor, cursor) <= 0
|
|
34
|
+
? { start: anchor, end: cursor }
|
|
35
|
+
: { start: cursor, end: anchor };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whole lines covered by a line-wise selection. */
|
|
39
|
+
export function getVisualLineRange(
|
|
40
|
+
anchor: VisualPosition,
|
|
41
|
+
cursor: VisualPosition,
|
|
42
|
+
): { startLine: number; endLine: number } {
|
|
43
|
+
return {
|
|
44
|
+
startLine: Math.min(anchor.line, cursor.line),
|
|
45
|
+
endLine: Math.max(anchor.line, cursor.line),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exclusive end column for an inclusive selection end sitting at `col`.
|
|
51
|
+
* Grapheme-aware, so a selection ending on an emoji takes the whole cluster.
|
|
52
|
+
* A column at or past the end of the line yields the line length.
|
|
53
|
+
*/
|
|
54
|
+
export function getInclusiveEndColumn(line: string, col: number): number {
|
|
55
|
+
if (col >= line.length) return line.length;
|
|
56
|
+
const segment = getLineGraphemes(line).find((s) => col < s.end);
|
|
57
|
+
return segment ? segment.end : line.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Keep a stale anchor inside the buffer after the text changed underneath it. */
|
|
61
|
+
export function clampVisualPosition(
|
|
62
|
+
position: VisualPosition,
|
|
63
|
+
lines: string[],
|
|
64
|
+
): VisualPosition {
|
|
65
|
+
const line = Math.max(0, Math.min(position.line, lines.length - 1));
|
|
66
|
+
const col = Math.max(0, Math.min(position.col, (lines[line] ?? "").length));
|
|
67
|
+
return { line, col };
|
|
68
|
+
}
|