| 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. |
| 16 | import * as buf from './buffer.ts'; |
| 17 | import type { Buffer } from './buffer.ts'; |
| 18 | |
| 19 | export type Mode = 'normal' | 'insert' | 'visual' | 'visual-line'; |
| 20 | |
| 21 | /** What the caller may need to act on after a keystroke. */ |
| 22 | export 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 | |
| 31 | export 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 | |
| 44 | const UNDO_LIMIT = 200; |
| 45 | |
| 46 | export 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 | |
| 67 | type CharClass = 'space' | 'word' | 'punct'; |
| 68 | |
| 69 | function 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 | */ |
| 86 | type MotionKind = 'exclusive' | 'inclusive' | 'linewise'; |
| 87 | |
| 88 | interface MotionResult { |
| 89 | offset: number; |
| 90 | kind: MotionKind; |
| 91 | } |
| 92 | |
| 93 | const 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 | |
| 99 | function 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 | |
| 112 | function 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 | |
| 122 | function 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. */ |
| 133 | function 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. */ |
| 141 | function 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 | |
| 148 | function 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 | */ |
| 161 | function 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. */ |
| 213 | function 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 | |
| 222 | function pushUndo(state: VimState): Pick<VimState, 'undoStack' | 'redoStack'> { |
| 223 | const undoStack = [...state.undoStack, state.buffer].slice(-UNDO_LIMIT); |
| 224 | return { undoStack, redoStack: [] }; |
| 225 | } |
| 226 | |
| 227 | function 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 | |
| 269 | function 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 | |
| 298 | function 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 | |
| 318 | function 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 | |
| 329 | function 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 | */ |
| 344 | function 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. */ |
| 357 | export 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 | |
| 370 | export interface VimResult { |
| 371 | state: VimState; |
| 372 | effects: VimEffects; |
| 373 | } |
| 374 | |
| 375 | const NONE: VimEffects = {}; |
| 376 | |
| 377 | function 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 | */ |
| 415 | function 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. */ |
| 510 | function 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 | */ |
| 520 | export 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 | */ |
| 578 | export 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. */ |
| 590 | export 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 | } |