/ src / hooks / useVimInput.ts
useVimInput.ts
  1  import React, { useCallback, useState } from 'react'
  2  import type { Key } from '../ink.js'
  3  import type { VimInputState, VimMode } from '../types/textInputTypes.js'
  4  import { Cursor } from '../utils/Cursor.js'
  5  import { lastGrapheme } from '../utils/intl.js'
  6  import {
  7    executeIndent,
  8    executeJoin,
  9    executeOpenLine,
 10    executeOperatorFind,
 11    executeOperatorMotion,
 12    executeOperatorTextObj,
 13    executeReplace,
 14    executeToggleCase,
 15    executeX,
 16    type OperatorContext,
 17  } from '../vim/operators.js'
 18  import { type TransitionContext, transition } from '../vim/transitions.js'
 19  import {
 20    createInitialPersistentState,
 21    createInitialVimState,
 22    type PersistentState,
 23    type RecordedChange,
 24    type VimState,
 25  } from '../vim/types.js'
 26  import { type UseTextInputProps, useTextInput } from './useTextInput.js'
 27  
 28  type UseVimInputProps = Omit<UseTextInputProps, 'inputFilter'> & {
 29    onModeChange?: (mode: VimMode) => void
 30    onUndo?: () => void
 31    inputFilter?: UseTextInputProps['inputFilter']
 32  }
 33  
 34  export function useVimInput(props: UseVimInputProps): VimInputState {
 35    const vimStateRef = React.useRef<VimState>(createInitialVimState())
 36    const [mode, setMode] = useState<VimMode>('INSERT')
 37  
 38    const persistentRef = React.useRef<PersistentState>(
 39      createInitialPersistentState(),
 40    )
 41  
 42    // inputFilter is applied once at the top of handleVimInput (not here) so
 43    // vim-handled paths that return without calling textInput.onInput still
 44    // run the filter — otherwise a stateful filter (e.g. lazy-space-after-
 45    // pill) stays armed across an Escape → NORMAL → INSERT round-trip.
 46    const textInput = useTextInput({ ...props, inputFilter: undefined })
 47    const { onModeChange, inputFilter } = props
 48  
 49    const switchToInsertMode = useCallback(
 50      (offset?: number): void => {
 51        if (offset !== undefined) {
 52          textInput.setOffset(offset)
 53        }
 54        vimStateRef.current = { mode: 'INSERT', insertedText: '' }
 55        setMode('INSERT')
 56        onModeChange?.('INSERT')
 57      },
 58      [textInput, onModeChange],
 59    )
 60  
 61    const switchToNormalMode = useCallback((): void => {
 62      const current = vimStateRef.current
 63      if (current.mode === 'INSERT' && current.insertedText) {
 64        persistentRef.current.lastChange = {
 65          type: 'insert',
 66          text: current.insertedText,
 67        }
 68      }
 69  
 70      // Vim behavior: move cursor left by 1 when exiting insert mode
 71      // (unless at beginning of line or at offset 0)
 72      const offset = textInput.offset
 73      if (offset > 0 && props.value[offset - 1] !== '\n') {
 74        textInput.setOffset(offset - 1)
 75      }
 76  
 77      vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
 78      setMode('NORMAL')
 79      onModeChange?.('NORMAL')
 80    }, [onModeChange, textInput, props.value])
 81  
 82    function createOperatorContext(
 83      cursor: Cursor,
 84      isReplay: boolean = false,
 85    ): OperatorContext {
 86      return {
 87        cursor,
 88        text: props.value,
 89        setText: (newText: string) => props.onChange(newText),
 90        setOffset: (offset: number) => textInput.setOffset(offset),
 91        enterInsert: (offset: number) => switchToInsertMode(offset),
 92        getRegister: () => persistentRef.current.register,
 93        setRegister: (content: string, linewise: boolean) => {
 94          persistentRef.current.register = content
 95          persistentRef.current.registerIsLinewise = linewise
 96        },
 97        getLastFind: () => persistentRef.current.lastFind,
 98        setLastFind: (type, char) => {
 99          persistentRef.current.lastFind = { type, char }
100        },
101        recordChange: isReplay
102          ? () => {}
103          : (change: RecordedChange) => {
104              persistentRef.current.lastChange = change
105            },
106      }
107    }
108  
109    function replayLastChange(): void {
110      const change = persistentRef.current.lastChange
111      if (!change) return
112  
113      const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
114      const ctx = createOperatorContext(cursor, true)
115  
116      switch (change.type) {
117        case 'insert':
118          if (change.text) {
119            const newCursor = cursor.insert(change.text)
120            props.onChange(newCursor.text)
121            textInput.setOffset(newCursor.offset)
122          }
123          break
124  
125        case 'x':
126          executeX(change.count, ctx)
127          break
128  
129        case 'replace':
130          executeReplace(change.char, change.count, ctx)
131          break
132  
133        case 'toggleCase':
134          executeToggleCase(change.count, ctx)
135          break
136  
137        case 'indent':
138          executeIndent(change.dir, change.count, ctx)
139          break
140  
141        case 'join':
142          executeJoin(change.count, ctx)
143          break
144  
145        case 'openLine':
146          executeOpenLine(change.direction, ctx)
147          break
148  
149        case 'operator':
150          executeOperatorMotion(change.op, change.motion, change.count, ctx)
151          break
152  
153        case 'operatorFind':
154          executeOperatorFind(
155            change.op,
156            change.find,
157            change.char,
158            change.count,
159            ctx,
160          )
161          break
162  
163        case 'operatorTextObj':
164          executeOperatorTextObj(
165            change.op,
166            change.scope,
167            change.objType,
168            change.count,
169            ctx,
170          )
171          break
172      }
173    }
174  
175    function handleVimInput(rawInput: string, key: Key): void {
176      const state = vimStateRef.current
177      // Run inputFilter in all modes so stateful filters disarm on any key,
178      // but only apply the transformed input in INSERT — NORMAL-mode command
179      // lookups expect single chars and a prepended space would break them.
180      const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
181      const input = state.mode === 'INSERT' ? filtered : rawInput
182      const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
183  
184      if (key.ctrl) {
185        textInput.onInput(input, key)
186        return
187      }
188  
189      // NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
190      // It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be
191      // configurable via keybindings. Vim users expect Esc to always exit INSERT mode.
192      if (key.escape && state.mode === 'INSERT') {
193        switchToNormalMode()
194        return
195      }
196  
197      // Escape in NORMAL mode cancels any pending command (replace, operator, etc.)
198      if (key.escape && state.mode === 'NORMAL') {
199        vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
200        return
201      }
202  
203      // Pass Enter to base handler regardless of mode (allows submission from NORMAL)
204      if (key.return) {
205        textInput.onInput(input, key)
206        return
207      }
208  
209      if (state.mode === 'INSERT') {
210        // Track inserted text for dot-repeat
211        if (key.backspace || key.delete) {
212          if (state.insertedText.length > 0) {
213            vimStateRef.current = {
214              mode: 'INSERT',
215              insertedText: state.insertedText.slice(
216                0,
217                -(lastGrapheme(state.insertedText).length || 1),
218              ),
219            }
220          }
221        } else {
222          vimStateRef.current = {
223            mode: 'INSERT',
224            insertedText: state.insertedText + input,
225          }
226        }
227        textInput.onInput(input, key)
228        return
229      }
230  
231      if (state.mode !== 'NORMAL') {
232        return
233      }
234  
235      // In idle state, delegate arrow keys to base handler for cursor movement
236      // and history fallback (upOrHistoryUp / downOrHistoryDown)
237      if (
238        state.command.type === 'idle' &&
239        (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
240      ) {
241        textInput.onInput(input, key)
242        return
243      }
244  
245      const ctx: TransitionContext = {
246        ...createOperatorContext(cursor, false),
247        onUndo: props.onUndo,
248        onDotRepeat: replayLastChange,
249      }
250  
251      // Backspace/Delete are only mapped in motion-expecting states. In
252      // literal-char states (replace, find, operatorFind), mapping would turn
253      // r+Backspace into "replace with h" and df+Delete into "delete to next x".
254      // Delete additionally skips count state: in vim, N<Del> removes a count
255      // digit rather than executing Nx; we don't implement digit removal but
256      // should at least not turn a cancel into a destructive Nx.
257      const expectsMotion =
258        state.command.type === 'idle' ||
259        state.command.type === 'count' ||
260        state.command.type === 'operator' ||
261        state.command.type === 'operatorCount'
262  
263      // Map arrow keys to vim motions in NORMAL mode
264      let vimInput = input
265      if (key.leftArrow) vimInput = 'h'
266      else if (key.rightArrow) vimInput = 'l'
267      else if (key.upArrow) vimInput = 'k'
268      else if (key.downArrow) vimInput = 'j'
269      else if (expectsMotion && key.backspace) vimInput = 'h'
270      else if (expectsMotion && state.command.type !== 'count' && key.delete)
271        vimInput = 'x'
272  
273      const result = transition(state.command, vimInput, ctx)
274  
275      if (result.execute) {
276        result.execute()
277      }
278  
279      // Update command state (only if execute didn't switch to INSERT)
280      if (vimStateRef.current.mode === 'NORMAL') {
281        if (result.next) {
282          vimStateRef.current = { mode: 'NORMAL', command: result.next }
283        } else if (result.execute) {
284          vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
285        }
286      }
287  
288      if (
289        input === '?' &&
290        state.mode === 'NORMAL' &&
291        state.command.type === 'idle'
292      ) {
293        props.onChange('?')
294      }
295    }
296  
297    const setModeExternal = useCallback(
298      (newMode: VimMode) => {
299        if (newMode === 'INSERT') {
300          vimStateRef.current = { mode: 'INSERT', insertedText: '' }
301        } else {
302          vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
303        }
304        setMode(newMode)
305        onModeChange?.(newMode)
306      },
307      [onModeChange],
308    )
309  
310    return {
311      ...textInput,
312      onInput: handleVimInput,
313      mode,
314      setMode: setModeExternal,
315    }
316  }