Skip to main content

Command

The Registry and KeyHandler classes that form the core of duck-vim's shortcut system.

import { Registry, KeyHandler } from '@gentleduck/vim/command'
import type { Command, KeyBindOptions, RegistrationHandle, RegistryEntry } from '@gentleduck/vim/command'
import { Registry, KeyHandler } from '@gentleduck/vim/command'
import type { Command, KeyBindOptions, RegistrationHandle, RegistryEntry } from '@gentleduck/vim/command'

Types

Command

interface Command {
  name: string
  description?: string
  execute: <T>(args?: T) => void | Promise<void>
}
interface Command {
  name: string
  description?: string
  execute: <T>(args?: T) => void | Promise<void>
}

The name field is used for debugging, command palette display, and logging. The execute function is called when the binding matches.

KeyBindOptions

Per-binding options that control behavior when matched.

interface KeyBindOptions {
  enabled?: boolean              // Default: true
  preventDefault?: boolean       // Default: false
  stopPropagation?: boolean      // Default: false
  ignoreInputs?: boolean         // Default: false
  eventType?: 'keydown' | 'keyup' // Default: 'keydown'
  requireReset?: boolean         // Default: false
  conflictBehavior?: 'warn' | 'error' | 'replace' | 'allow' // Default: 'warn'
}
interface KeyBindOptions {
  enabled?: boolean              // Default: true
  preventDefault?: boolean       // Default: false
  stopPropagation?: boolean      // Default: false
  ignoreInputs?: boolean         // Default: false
  eventType?: 'keydown' | 'keyup' // Default: 'keydown'
  requireReset?: boolean         // Default: false
  conflictBehavior?: 'warn' | 'error' | 'replace' | 'allow' // Default: 'warn'
}

RegistrationHandle

Returned from registry.register(). Use this to manage the binding's lifecycle.

interface RegistrationHandle {
  unregister: () => void
  setEnabled: (enabled: boolean) => void
  isEnabled: () => boolean
  resetFired: () => void
}
interface RegistrationHandle {
  unregister: () => void
  setEnabled: (enabled: boolean) => void
  isEnabled: () => boolean
  resetFired: () => void
}

RegistryEntry

Internal storage entry. Access via registry.getEntry().

interface RegistryEntry {
  command: Command
  options: KeyBindOptions
  fired: boolean
}
interface RegistryEntry {
  command: Command
  options: KeyBindOptions
  fired: boolean
}

Registry

The registry holds all key binding to command mappings. It also tracks sequence prefixes.

constructor(debug?)

new Registry(debug?: boolean)
new Registry(debug?: boolean)

Pass true to enable debug logging to the console.

register(key, command, options?)

Registers a command for a key sequence. Returns a RegistrationHandle.

registry.register(key: string, command: Command, options?: KeyBindOptions): RegistrationHandle
registry.register(key: string, command: Command, options?: KeyBindOptions): RegistrationHandle

Conflict behavior:

If the key is already registered, the conflictBehavior option controls what happens:

ValueBehavior
'warn' (default)Logs a warning and replaces the binding
'error'Throws an error
'replace'Silently replaces
'allow'Silently replaces

Example:

const handle = registry.register('ctrl+k', {
  name: 'Open Palette',
  execute: () => openPalette(),
}, { preventDefault: true })
 
// Later: disable without unregistering
handle.setEnabled(false)
 
// Later: remove completely
handle.unregister()
const handle = registry.register('ctrl+k', {
  name: 'Open Palette',
  execute: () => openPalette(),
}, { preventDefault: true })
 
// Later: disable without unregistering
handle.setEnabled(false)
 
// Later: remove completely
handle.unregister()

unregister(key)

Removes the command at the given key. Returns true if something was removed.

registry.unregister(key: string): boolean
registry.unregister(key: string): boolean

hasCommand(key)

registry.hasCommand(key: string): boolean
registry.hasCommand(key: string): boolean

getCommand(key)

registry.getCommand(key: string): Command | undefined
registry.getCommand(key: string): Command | undefined

getEntry(key)

Returns the full entry including options and fired state.

registry.getEntry(key: string): RegistryEntry | undefined
registry.getEntry(key: string): RegistryEntry | undefined

getOptions(key)

registry.getOptions(key: string): KeyBindOptions | undefined
registry.getOptions(key: string): KeyBindOptions | undefined

isPrefix(key)

Returns true if the key is a prefix of any registered sequence. For example, if g+d is registered, isPrefix('g') returns true.

registry.isPrefix(key: string): boolean
registry.isPrefix(key: string): boolean

getAllCommands()

Returns a Map<string, Command> of all registered commands.

registry.getAllCommands(): Map<string, Command>
registry.getAllCommands(): Map<string, Command>

clear()

Removes all registered commands.

registry.clear(): void
registry.clear(): void

KeyHandler

Listens for keyboard events and dispatches commands from the registry.

constructor(registry, timeoutMs?, defaultOptions?)

new KeyHandler(registry: Registry, timeoutMs?: number, defaultOptions?: Partial<KeyBindOptions>)
new KeyHandler(registry: Registry, timeoutMs?: number, defaultOptions?: Partial<KeyBindOptions>)
  • timeoutMs (default: 600) controls how long the handler waits between keys in a sequence.
  • defaultOptions are merged with each binding's options (binding options take precedence).

attach(target?)

Starts listening for keydown events on the target.

handler.attach(target?: HTMLElement | Document) // defaults to document
handler.attach(target?: HTMLElement | Document) // defaults to document

detach(target?)

Stops listening.

handler.detach(target?: HTMLElement | Document) // defaults to document
handler.detach(target?: HTMLElement | Document) // defaults to document

Matching flow

When a key is pressed, the KeyHandler follows this process:

Builds a key descriptor from the event.

Appends it to the internal sequence buffer.

Checks for a full match in the registry. If found, executes the command and resets.

Checks for a prefix match. If found, starts the timeout timer.

If no match and no prefix, resets the buffer and retries with just the current key.

If still no match, resets completely.

Loading diagram...


Complete example

import { Registry, KeyHandler } from '@gentleduck/vim/command'
 
const registry = new Registry(process.env.NODE_ENV === 'development')
const handler = new KeyHandler(registry, 600, {
  ignoreInputs: true,  // default for all bindings
})
 
// Single-key shortcut
registry.register('ctrl+k', {
  name: 'Command Palette',
  execute: () => togglePalette(),
}, { preventDefault: true })
 
// Multi-key sequence
registry.register('g+d', {
  name: 'Go to Dashboard',
  execute: () => navigate('/dashboard'),
})
 
// One-shot binding (fires once until resetFired is called)
const saveHandle = registry.register('ctrl+s', {
  name: 'Save',
  execute: () => save(),
}, { preventDefault: true, requireReset: true })
 
// After save completes, allow it to fire again
async function save() {
  await saveDocument()
  saveHandle.resetFired()
}
 
handler.attach(document)
import { Registry, KeyHandler } from '@gentleduck/vim/command'
 
const registry = new Registry(process.env.NODE_ENV === 'development')
const handler = new KeyHandler(registry, 600, {
  ignoreInputs: true,  // default for all bindings
})
 
// Single-key shortcut
registry.register('ctrl+k', {
  name: 'Command Palette',
  execute: () => togglePalette(),
}, { preventDefault: true })
 
// Multi-key sequence
registry.register('g+d', {
  name: 'Go to Dashboard',
  execute: () => navigate('/dashboard'),
})
 
// One-shot binding (fires once until resetFired is called)
const saveHandle = registry.register('ctrl+s', {
  name: 'Save',
  execute: () => save(),
}, { preventDefault: true, requireReset: true })
 
// After save completes, allow it to fire again
async function save() {
  await saveDocument()
  saveHandle.resetFired()
}
 
handler.attach(document)