Command
The Registry and KeyHandler classes that form the core of duck-vim's shortcut system.
The Registry and KeyHandler classes form the core of duck-vim's shortcut system. Register commands with key bindings and let the KeyHandler dispatch them automatically.
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): RegistrationHandleregistry.register(key: string, command: Command, options?: KeyBindOptions): RegistrationHandleConflict behavior:
If the key is already registered, the conflictBehavior option controls what happens:
| Value | Behavior |
|---|---|
'warn' (default) | Logs a warning and replaces the binding |
'error' | Throws an error |
'replace' | Silently replaces |
'allow' | Silently replaces |
The default conflict behavior is 'warn', which replaces the existing binding and logs a warning. If you want to prevent accidental overwrites, use 'error' to throw instead.
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): booleanregistry.unregister(key: string): booleanhasCommand(key)
registry.hasCommand(key: string): booleanregistry.hasCommand(key: string): booleangetCommand(key)
registry.getCommand(key: string): Command | undefinedregistry.getCommand(key: string): Command | undefinedgetEntry(key)
Returns the full entry including options and fired state.
registry.getEntry(key: string): RegistryEntry | undefinedregistry.getEntry(key: string): RegistryEntry | undefinedgetOptions(key)
registry.getOptions(key: string): KeyBindOptions | undefinedregistry.getOptions(key: string): KeyBindOptions | undefinedisPrefix(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): booleanregistry.isPrefix(key: string): booleangetAllCommands()
Returns a Map<string, Command> of all registered commands.
registry.getAllCommands(): Map<string, Command>registry.getAllCommands(): Map<string, Command>Useful for building command palettes:
const commands = registry.getAllCommands()
for (const [binding, cmd] of commands) {
console.log(`${cmd.name}: ${binding}`)
}const commands = registry.getAllCommands()
for (const [binding, cmd] of commands) {
console.log(`${cmd.name}: ${binding}`)
}clear()
Removes all registered commands.
registry.clear(): voidregistry.clear(): voidKeyHandler
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.defaultOptionsare 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 documenthandler.attach(target?: HTMLElement | Document) // defaults to documentdetach(target?)
Stops listening.
handler.detach(target?: HTMLElement | Document) // defaults to documenthandler.detach(target?: HTMLElement | Document) // defaults to documentMatching 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)Use requireReset: true for actions that should only fire once per invocation (like saving). Call handle.resetFired() when the action completes to allow the binding to fire again.