anvilsign in

collin/strudel-claude

1// Modal editing.
2//
3// Not a vim emulation in any complete sense — it's the subset that matters when
4// the text you're editing is eight lines of pattern code you keep re-evaluating:
5// motions to get around, operators to restructure, undo to back out of an idea.
6// Anything reachable through a different key is deliberately absent rather than
7// half-implemented, so nothing here silently does the wrong thing.
8//
9// The whole layer is a pure reducer: (state, key) → state. That keeps the
10// grammar testable without a terminal, and it's what makes counts and pending
11// operators tractable — "d2w" is three keystrokes that must accumulate
12// somewhere, and accumulating them in state beats a stateful parser object.
13//
14// Commands are expressed as offsets into the flat text (see buffer.ts) so that
15// motions spanning lines, joins, and multi-line deletes are all one operation.
16import * as buf from './buffer.ts';
17import type { Buffer } from './buffer.ts';
18
19export type Mode = 'normal' | 'insert' | 'visual' | 'visual-line';
20
21/** What the caller may need to act on after a keystroke. */
22export interface VimEffects {
23 /** The user asked to evaluate the buffer. */
24 evaluate?: boolean;
25 /** The user asked to stop playback. */
26 hush?: boolean;
27 /** A message for the status line (errors, "3 lines yanked"). */
28 message?: string;
29}
30
31export interface VimState {
32 buffer: Buffer;
33 mode: Mode;
34 /** Keystrokes accumulated toward a command, e.g. "d2" waiting on a motion. */
35 pending: string;
36 /** The unnamed register. `linewise` decides whether `p` pastes above or after. */
37 register: { text: string; linewise: boolean };
38 /** Where visual mode started, as an offset. */
39 anchor: number | null;
40 undoStack: Buffer[];
41 redoStack: Buffer[];
42}
43
44const UNDO_LIMIT = 200;
45
46export function createVimState(text: string): VimState {
47 return {
48 buffer: buf.create(text),
49 mode: 'normal',
50 pending: '',
51 register: { text: '', linewise: false },
52 anchor: null,
53 undoStack: [],
54 redoStack: [],
55 };
56}
57
58// ---------------------------------------------------------------------------
59// Character classes
60//
61// vim's notion of a "word" is three classes, not two: word characters,
62// punctuation, and whitespace. `w` stops at the boundary between word and
63// punctuation, which is why it lands usefully inside `s("bd*2")` instead of
64// skipping the whole expression.
65// ---------------------------------------------------------------------------
66
67type CharClass = 'space' | 'word' | 'punct';
68
69function classOf(ch: string | undefined): CharClass {
70 if (ch === undefined || /\s/.test(ch)) return 'space';
71 if (/[\w]/.test(ch)) return 'word';
72 return 'punct';
73}
74
75// ---------------------------------------------------------------------------
76// Motions
77// ---------------------------------------------------------------------------
78
79/**
80 * How a motion's range is treated when an operator consumes it.
81 *
82 * - `exclusive`: the character at the destination is not included (`dw`)
83 * - `inclusive`: it is (`de`, `x`)
84 * - `linewise`: the whole lines the range touches (`dd`, `dj`)
85 */
86type MotionKind = 'exclusive' | 'inclusive' | 'linewise';
87
88interface MotionResult {
89 offset: number;
90 kind: MotionKind;
91}
92
93const lineBounds = (text: string, offset: number): { start: number; end: number } => {
94 const start = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
95 const nl = text.indexOf('\n', offset);
96 return { start, end: nl === -1 ? text.length : nl };
97};
98
99function forwardWord(text: string, from: number, bigWord: boolean): number {
100 let i = from;
101 const startClass = classOf(text[i]);
102 if (startClass !== 'space') {
103 // Skip the rest of the current run. With `W`, punctuation counts as part of
104 // the word, so only whitespace ends it.
105 while (i < text.length && classOf(text[i]) !== 'space'
106 && (bigWord || classOf(text[i]) === startClass)) i++;
107 }
108 while (i < text.length && classOf(text[i]) === 'space') i++;
109 return i;
110}
111
112function backWord(text: string, from: number, bigWord: boolean): number {
113 let i = from - 1;
114 while (i > 0 && classOf(text[i]) === 'space') i--;
115 if (i <= 0) return 0;
116 const cls = classOf(text[i]);
117 while (i > 0 && classOf(text[i - 1]) !== 'space'
118 && (bigWord || classOf(text[i - 1]) === cls)) i--;
119 return Math.max(0, i);
120}
121
122function endOfWord(text: string, from: number, bigWord: boolean): number {
123 let i = from + 1;
124 while (i < text.length && classOf(text[i]) === 'space') i++;
125 if (i >= text.length) return text.length - 1;
126 const cls = classOf(text[i]);
127 while (i + 1 < text.length && classOf(text[i + 1]) !== 'space'
128 && (bigWord || classOf(text[i + 1]) === cls)) i++;
129 return i;
130}
131
132/** Move `count` lines vertically, keeping the column the cursor is aiming for. */
133function verticalMove(state: VimState, count: number): number {
134 const { lines } = state.buffer;
135 const row = Math.max(0, Math.min(state.buffer.row + count, lines.length - 1));
136 const col = Math.min(state.buffer.desiredCol, (lines[row] ?? '').length);
137 return buf.positionToOffset(lines, { row, col });
138}
139
140/** Offset of the first non-blank character on the cursor's line. */
141function firstNonBlank(text: string, offset: number): number {
142 const { start, end } = lineBounds(text, offset);
143 let i = start;
144 while (i < end && /\s/.test(text[i] ?? '')) i++;
145 return i;
146}
147
148function paragraphMove(text: string, from: number, forward: boolean): number {
149 const lines = text.split('\n');
150 const { row } = buf.offsetToPosition(lines, from);
151 const step = forward ? 1 : -1;
152 let r = row + step;
153 while (r > 0 && r < lines.length - 1 && (lines[r] ?? '').trim() !== '') r += step;
154 return buf.positionToOffset(lines, { row: Math.max(0, Math.min(r, lines.length - 1)), col: 0 });
155}
156
157/**
158 * Resolve a motion key to a destination. Returns null when the key isn't a
159 * motion, which is how the command parser tells motions from everything else.
160 */
161function resolveMotion(
162 state: VimState,
163 key: string,
164 count: number,
165 explicitCount: boolean,
166): MotionResult | null {
167 const text = buf.toText(state.buffer);
168 const at = buf.cursorOffset(state.buffer);
169 const { start, end } = lineBounds(text, at);
170 const lines = state.buffer.lines;
171
172 switch (key) {
173 case 'h': return { offset: Math.max(start, at - count), kind: 'exclusive' };
174 case 'l': return { offset: Math.min(end, at + count), kind: 'exclusive' };
175 case 'j': return { offset: verticalMove(state, count), kind: 'linewise' };
176 case 'k': return { offset: verticalMove(state, -count), kind: 'linewise' };
177 case '0': return { offset: start, kind: 'exclusive' };
178 case '^': return { offset: firstNonBlank(text, at), kind: 'exclusive' };
179 case '$': return { offset: end, kind: 'inclusive' };
180 case 'w': case 'W': {
181 let o = at;
182 for (let i = 0; i < count; i++) o = forwardWord(text, o, key === 'W');
183 return { offset: o, kind: 'exclusive' };
184 }
185 case 'b': case 'B': {
186 let o = at;
187 for (let i = 0; i < count; i++) o = backWord(text, o, key === 'B');
188 return { offset: o, kind: 'exclusive' };
189 }
190 case 'e': case 'E': {
191 let o = at;
192 for (let i = 0; i < count; i++) o = endOfWord(text, o, key === 'E');
193 return { offset: o, kind: 'inclusive' };
194 }
195 case '{': return { offset: paragraphMove(text, at, false), kind: 'exclusive' };
196 case '}': return { offset: paragraphMove(text, at, true), kind: 'exclusive' };
197 case 'G': {
198 // Bare G goes to the last line; with a count, to that line number.
199 const row = explicitCount
200 ? Math.max(0, Math.min(count - 1, lines.length - 1))
201 : lines.length - 1;
202 return { offset: buf.positionToOffset(lines, { row, col: 0 }), kind: 'linewise' };
203 }
204 default: return null;
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Operators
210// ---------------------------------------------------------------------------
211
212/** Widen a range to whole lines, including the trailing newline if there is one. */
213function expandLinewise(text: string, from: number, to: number): { start: number; end: number } {
214 const a = lineBounds(text, Math.min(from, to));
215 const b = lineBounds(text, Math.max(from, to));
216 // Take the newline *after* the last line so `dd` removes the line rather than
217 // leaving a blank one. At end of file there is none, so take the one before.
218 if (b.end < text.length) return { start: a.start, end: b.end + 1 };
219 return { start: Math.max(0, a.start - 1), end: b.end };
220}
221
222function pushUndo(state: VimState): Pick<VimState, 'undoStack' | 'redoStack'> {
223 const undoStack = [...state.undoStack, state.buffer].slice(-UNDO_LIMIT);
224 return { undoStack, redoStack: [] };
225}
226
227function applyOperator(
228 state: VimState,
229 op: 'd' | 'c' | 'y',
230 from: number,
231 to: number,
232 kind: MotionKind,
233): VimState {
234 const text = buf.toText(state.buffer);
235 const linewise = kind === 'linewise';
236
237 let start = Math.min(from, to);
238 let end = Math.max(from, to);
239 if (linewise) {
240 ({ start, end } = expandLinewise(text, from, to));
241 } else if (kind === 'inclusive') {
242 end = Math.min(end + 1, text.length);
243 }
244
245 const taken = text.slice(start, end);
246 const register = { text: taken, linewise };
247
248 if (op === 'y') {
249 // Yank leaves the text alone and, as in vim, parks the cursor at the start
250 // of what was yanked.
251 return { ...state, register, buffer: buf.setCursorOffset(state.buffer, start), mode: 'normal', anchor: null };
252 }
253
254 // `cc`/`c`-linewise keeps the line itself so you type into an empty line
255 // rather than having the line vanish under you.
256 const replacement = op === 'c' && linewise ? '\n' : '';
257 const cursorAt = op === 'c' && linewise ? start : start;
258
259 return {
260 ...state,
261 ...pushUndo(state),
262 register,
263 buffer: buf.replaceRange(state.buffer, start, end, replacement, cursorAt),
264 mode: op === 'c' ? 'insert' : 'normal',
265 anchor: null,
266 };
267}
268
269function paste(state: VimState, before: boolean): VimState {
270 const { text: reg, linewise } = state.register;
271 if (!reg) return state;
272 const text = buf.toText(state.buffer);
273 const at = buf.cursorOffset(state.buffer);
274
275 if (linewise) {
276 const { start, end } = lineBounds(text, at);
277 const payload = reg.endsWith('\n') ? reg : `${reg}\n`;
278 const insertAt = before ? start : Math.min(end + 1, text.length);
279 // At end of file with no trailing newline there's nowhere "after" to paste,
280 // so open one.
281 const needsLeadingNewline = !before && end >= text.length;
282 const body = needsLeadingNewline ? `\n${payload.replace(/\n$/, '')}` : payload;
283 return {
284 ...state,
285 ...pushUndo(state),
286 buffer: buf.replaceRange(state.buffer, insertAt, insertAt, body, insertAt + (needsLeadingNewline ? 1 : 0)),
287 };
288 }
289
290 const insertAt = before ? at : Math.min(at + 1, text.length);
291 return {
292 ...state,
293 ...pushUndo(state),
294 buffer: buf.replaceRange(state.buffer, insertAt, insertAt, reg, insertAt + reg.length - 1),
295 };
296}
297
298function openLine(state: VimState, below: boolean): VimState {
299 const text = buf.toText(state.buffer);
300 const at = buf.cursorOffset(state.buffer);
301 const { start, end } = lineBounds(text, at);
302 // Match the current line's indentation — patterns are chains of indented
303 // continuations, and re-typing the leading spaces every time is friction.
304 const indent = /^\s*/.exec(text.slice(start, end))?.[0] ?? '';
305
306 const insertAt = below ? end : start;
307 const body = below ? `\n${indent}` : `${indent}\n`;
308 const cursorAt = below ? end + 1 + indent.length : start + indent.length;
309
310 return {
311 ...state,
312 ...pushUndo(state),
313 buffer: buf.replaceRange(state.buffer, insertAt, insertAt, body, cursorAt),
314 mode: 'insert',
315 };
316}
317
318function undo(state: VimState): VimState {
319 const previous = state.undoStack.at(-1);
320 if (!previous) return state;
321 return {
322 ...state,
323 buffer: previous,
324 undoStack: state.undoStack.slice(0, -1),
325 redoStack: [...state.redoStack, state.buffer],
326 };
327}
328
329function redo(state: VimState): VimState {
330 const next = state.redoStack.at(-1);
331 if (!next) return state;
332 return {
333 ...state,
334 buffer: next,
335 redoStack: state.redoStack.slice(0, -1),
336 undoStack: [...state.undoStack, state.buffer],
337 };
338}
339
340/**
341 * In normal mode the cursor sits *on* a character, so it may not rest past the
342 * last one — otherwise it appears to hang in space at end of line.
343 */
344function clampNormal(state: VimState): VimState {
345 if (state.mode === 'insert') return state;
346 const len = (state.buffer.lines[state.buffer.row] ?? '').length;
347 if (state.buffer.col < Math.max(len, 1)) return state;
348 const col = Math.max(0, len - 1);
349 return { ...state, buffer: { ...state.buffer, col, desiredCol: col } };
350}
351
352// ---------------------------------------------------------------------------
353// Key dispatch
354// ---------------------------------------------------------------------------
355
356/** A keystroke, normalised out of Ink's (input, key) pair. */
357export interface Key {
358 input: string;
359 ctrl: boolean;
360 escape: boolean;
361 return: boolean;
362 backspace: boolean;
363 tab: boolean;
364 leftArrow: boolean;
365 rightArrow: boolean;
366 upArrow: boolean;
367 downArrow: boolean;
368}
369
370export interface VimResult {
371 state: VimState;
372 effects: VimEffects;
373}
374
375const NONE: VimEffects = {};
376
377function insertModeKey(state: VimState, key: Key): VimResult {
378 if (key.escape) {
379 // vim steps left on leaving insert mode, so the cursor lands on the
380 // character you just typed rather than past it.
381 const at = buf.cursorOffset(state.buffer);
382 const text = buf.toText(state.buffer);
383 const { start } = lineBounds(text, at);
384 return {
385 state: clampNormal({
386 ...state,
387 mode: 'normal',
388 buffer: buf.setCursorOffset(state.buffer, Math.max(start, at - 1)),
389 }),
390 effects: NONE,
391 };
392 }
393 if (key.return) return { state: { ...state, buffer: buf.newline(state.buffer) }, effects: NONE };
394 if (key.backspace) return { state: { ...state, buffer: buf.backspace(state.buffer) }, effects: NONE };
395 if (key.tab) return { state: { ...state, buffer: buf.insert(state.buffer, ' ') }, effects: NONE };
396 if (key.leftArrow) return { state: { ...state, buffer: buf.moveLeft(state.buffer) }, effects: NONE };
397 if (key.rightArrow) return { state: { ...state, buffer: buf.moveRight(state.buffer) }, effects: NONE };
398 if (key.upArrow) return { state: { ...state, buffer: buf.moveUp(state.buffer) }, effects: NONE };
399 if (key.downArrow) return { state: { ...state, buffer: buf.moveDown(state.buffer) }, effects: NONE };
400
401 if (key.input && !key.ctrl && !/[\u0000-\u001f]/.test(key.input)) {
402 return { state: { ...state, buffer: buf.insert(state.buffer, key.input) }, effects: NONE };
403 }
404 return { state, effects: NONE };
405}
406
407/**
408 * Interpret the accumulated `pending` string as a normal- or visual-mode
409 * command.
410 *
411 * The grammar is `[count] [operator] [count] (motion | doubled-operator)`, or a
412 * standalone command. Returning `null` means "incomplete — keep accumulating",
413 * which is how `d`, `2`, and `d2` all wait for more input.
414 */
415function runPending(state: VimState): VimResult | null {
416 const seq = state.pending;
417 // Counts never start with `0` — that digit is the "column zero" motion. A
418 // `\d*` here would swallow it and leave the command waiting forever for a
419 // motion that already arrived.
420 const match = /^([1-9]\d*)?([dcy]?)([1-9]\d*)?([\s\S]*)$/.exec(seq);
421 if (!match) return { state: { ...state, pending: '' }, effects: NONE };
422
423 const [, count1 = '', op = '', count2 = '', rest = ''] = match;
424 if (rest === '') return null; // waiting on a motion or command
425
426 const explicitCount = count1 !== '' || count2 !== '';
427 const count = Math.max(1, (Number.parseInt(count1 || '1', 10)) * (Number.parseInt(count2 || '1', 10)));
428 const visual = state.mode === 'visual' || state.mode === 'visual-line';
429
430 // `gg` is the only two-key motion here; `g` alone is incomplete.
431 if (rest === 'g') return null;
432 if (rest === 'gg') {
433 const row = explicitCount ? Math.min(count - 1, state.buffer.lines.length - 1) : 0;
434 const target = buf.positionToOffset(state.buffer.lines, { row, col: 0 });
435 if (op) return { state: { ...applyOperator(state, op as 'd' | 'c' | 'y', buf.cursorOffset(state.buffer), target, 'linewise'), pending: '' }, effects: NONE };
436 return { state: clampNormal({ ...state, buffer: buf.setCursorOffset(state.buffer, target), pending: '' }), effects: NONE };
437 }
438
439 // A doubled operator (dd, cc, yy) acts on `count` whole lines.
440 if (op && rest === op) {
441 const from = buf.cursorOffset(state.buffer);
442 const to = verticalMove(state, count - 1);
443 return { state: { ...applyOperator(state, op as 'd' | 'c' | 'y', from, to, 'linewise'), pending: '' }, effects: NONE };
444 }
445
446 // `cw` is vim's one deliberate inconsistency: on a non-blank it behaves like
447 // `ce`, changing the word without swallowing the space after it. Every other
448 // operator uses `w` as written. Reproducing the quirk matters more than being
449 // principled about it — `cw` is how you rename things, and one that ate the
450 // following space would be wrong every single time.
451 const motion = (op === 'c' && (rest === 'w' || rest === 'W')
452 && classOf(buf.toText(state.buffer)[buf.cursorOffset(state.buffer)]) !== 'space')
453 ? resolveMotion(state, rest === 'w' ? 'e' : 'E', count, explicitCount)
454 : resolveMotion(state, rest, count, explicitCount);
455
456 if (motion) {
457 if (op) {
458 const next = applyOperator(state, op as 'd' | 'c' | 'y', buf.cursorOffset(state.buffer), motion.offset, motion.kind);
459 return { state: clampNormal({ ...next, pending: '' }), effects: NONE };
460 }
461 return {
462 state: clampNormal({ ...state, buffer: buf.setCursorOffset(state.buffer, motion.offset), pending: '' }),
463 effects: NONE,
464 };
465 }
466
467 // Past this point nothing takes an operator prefix, so `dZ` is simply dropped
468 // rather than doing something surprising.
469 if (op) return { state: { ...state, pending: '' }, effects: NONE };
470
471 const done = (next: VimState, effects: VimEffects = NONE): VimResult =>
472 ({ state: clampNormal({ ...next, pending: '' }), effects });
473
474 switch (rest) {
475 case 'i': return done({ ...state, mode: 'insert' });
476 case 'a': return done({ ...state, mode: 'insert', buffer: buf.moveRight(state.buffer) });
477 case 'I': return done({ ...state, mode: 'insert', buffer: buf.setCursorOffset(state.buffer, firstNonBlank(buf.toText(state.buffer), buf.cursorOffset(state.buffer))) });
478 case 'A': return done({ ...state, mode: 'insert', buffer: buf.moveLineEnd(state.buffer) });
479 case 'o': return { state: { ...openLine(state, true), pending: '' }, effects: NONE };
480 case 'O': return { state: { ...openLine(state, false), pending: '' }, effects: NONE };
481
482 case 'x': {
483 const at = buf.cursorOffset(state.buffer);
484 return done(applyOperator(state, 'd', at, Math.min(at + count - 1, buf.toText(state.buffer).length), 'inclusive'));
485 }
486 case 'D': case 'C': {
487 const at = buf.cursorOffset(state.buffer);
488 const { end } = lineBounds(buf.toText(state.buffer), at);
489 const next = applyOperator(state, rest === 'D' ? 'd' : 'c', at, end, 'exclusive');
490 return done(next);
491 }
492 case 'S': {
493 const from = buf.cursorOffset(state.buffer);
494 return done(applyOperator(state, 'c', from, verticalMove(state, count - 1), 'linewise'));
495 }
496
497 case 'p': return done(paste(state, false));
498 case 'P': return done(paste(state, true));
499 case 'u': return done(undo(state));
500
501 case 'v': return done({ ...state, mode: visual ? 'normal' : 'visual', anchor: visual ? null : buf.cursorOffset(state.buffer) });
502 case 'V': return done({ ...state, mode: visual ? 'normal' : 'visual-line', anchor: visual ? null : buf.cursorOffset(state.buffer) });
503
504 default:
505 return done(state);
506 }
507}
508
509/** Apply a visual-mode operator to the current selection. */
510function visualOperator(state: VimState, op: 'd' | 'c' | 'y'): VimResult {
511 const anchor = state.anchor ?? buf.cursorOffset(state.buffer);
512 const kind: MotionKind = state.mode === 'visual-line' ? 'linewise' : 'inclusive';
513 const next = applyOperator(state, op, anchor, buf.cursorOffset(state.buffer), kind);
514 return { state: clampNormal({ ...next, pending: '', mode: op === 'c' ? 'insert' : 'normal', anchor: null }), effects: NONE };
515}
516
517/**
518 * The reducer. One keystroke in, new state plus anything the caller must act on.
519 */
520export function handleKey(state: VimState, key: Key): VimResult {
521 // Transport controls work identically in every mode — you evaluate while
522 // typing at least as often as you evaluate from normal mode, and having to
523 // leave insert mode first would be the single most annoying thing here.
524 if (key.ctrl && key.input === 'e') return { state, effects: { evaluate: true } };
525 if (key.ctrl && key.input === 'o') return { state, effects: { hush: true } };
526 if (key.ctrl && key.input === 'r') return { state: redo(state), effects: NONE };
527
528 if (state.mode === 'insert') return insertModeKey(state, key);
529
530 // Enter evaluates, outside insert mode. Ctrl+Enter would be the obvious key
531 // for it, but a terminal sends the same byte for both unless it speaks the
532 // Kitty keyboard protocol, so there is nothing to tell them apart. Plain
533 // Enter is free instead: vim's normal-mode `<cr>` moves to the next line's
534 // first non-blank, which `j` and `^` already cover, and re-evaluating is the
535 // thing you do most in a live-coding buffer.
536 if (key.return) return { state: { ...state, pending: '' }, effects: { evaluate: true } };
537
538 if (key.escape) {
539 return { state: clampNormal({ ...state, mode: 'normal', pending: '', anchor: null }), effects: NONE };
540 }
541
542 const visual = state.mode === 'visual' || state.mode === 'visual-line';
543 if (visual && state.pending === '' && (key.input === 'd' || key.input === 'c' || key.input === 'y' || key.input === 'x')) {
544 return visualOperator(state, key.input === 'x' ? 'd' : key.input);
545 }
546
547 // Arrow keys are accepted in normal mode too — muscle memory from the rest of
548 // the terminal shouldn't be punished.
549 if (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow) {
550 const move = key.leftArrow ? buf.moveLeft
551 : key.rightArrow ? buf.moveRight
552 : key.upArrow ? buf.moveUp : buf.moveDown;
553 return { state: clampNormal({ ...state, buffer: move(state.buffer) }), effects: NONE };
554 }
555
556 if (!key.input || key.ctrl) return { state, effects: NONE };
557
558 const pending = state.pending + key.input;
559 const next = { ...state, pending };
560
561 // Digits are a count — except a `0` typed with nothing pending, which is the
562 // "go to column zero" motion. `10j` is ten lines down; `0` alone is not the
563 // start of a count that never arrives.
564 const bareZero = key.input === '0' && state.pending === '';
565 if (/^\d+$/.test(pending) && !bareZero) {
566 return { state: next, effects: NONE }; // still accumulating a count
567 }
568
569 return runPending(next) ?? { state: next, effects: NONE };
570}
571
572/**
573 * Put the cursor at a row/column — how a mouse click enters the editing state.
574 *
575 * Goes through the same normal-mode clamp as every motion, so a click past the
576 * end of a line lands on its last character rather than in the void beyond it.
577 */
578export function moveCursorTo(state: VimState, row: number, col: number): VimState {
579 const lines = state.buffer.lines;
580 const r = Math.max(0, Math.min(row, lines.length - 1));
581 const c = Math.max(0, Math.min(col, (lines[r] ?? '').length));
582 return clampNormal({
583 ...state,
584 buffer: { ...state.buffer, row: r, col: c, desiredCol: c },
585 pending: '',
586 });
587}
588
589/** The current visual selection as an offset range, or null outside visual mode. */
590export function selectionRange(state: VimState): { start: number; end: number } | null {
591 if (state.anchor === null) return null;
592 const at = buf.cursorOffset(state.buffer);
593 const text = buf.toText(state.buffer);
594 if (state.mode === 'visual-line') return expandLinewise(text, state.anchor, at);
595 if (state.mode !== 'visual') return null;
596 return { start: Math.min(state.anchor, at), end: Math.min(Math.max(state.anchor, at) + 1, text.length) };
597}