Skip to main content

Core Concepts

Understand key descriptors, sequences, prefixes, the Mod key, and how duck-vim processes keyboard input.

Key descriptors

A key descriptor is a string that represents a single key press, optionally combined with modifiers. The format is:

[modifier+]*key

Modifiers appear in alphabetical order: alt, ctrl, meta, shift. The key is always lowercase.

Examples:

InputCanonical form
Ctrl+Kctrl+k
Shift+Alt+Palt+shift+p
Mod+S (on Mac)meta+s
Mod+S (on Windows)ctrl+s
Spacespace
Escapeesc

Key aliases

Some keys have multiple names. duck-vim normalizes them:

AliasCanonical name
(space character)space
escapeesc
controlctrl
cmd, commandmeta
opt, optionalt

You can use any alias when registering bindings. They all resolve to the same thing.


The Mod key

PlatformMod resolves to
macOSmeta (Cmd)
Windowsctrl (Ctrl)
Linuxctrl (Ctrl)

Platform detection happens automatically via navigator.userAgent, with a linux fallback for SSR environments.

import { parseKeyBind } from '@gentleduck/vim/parser'
 
// On Mac:
parseKeyBind('Mod+S') // { key: 's', meta: true, ctrl: false, ... }
 
// On Windows:
parseKeyBind('Mod+S') // { key: 's', meta: false, ctrl: true, ... }
import { parseKeyBind } from '@gentleduck/vim/parser'
 
// On Mac:
parseKeyBind('Mod+S') // { key: 's', meta: true, ctrl: false, ... }
 
// On Windows:
parseKeyBind('Mod+S') // { key: 's', meta: false, ctrl: true, ... }

You can override the platform explicitly:

parseKeyBind('Mod+S', 'mac')   // Always resolves to meta+s
parseKeyBind('Mod+S', 'linux') // Always resolves to ctrl+s
parseKeyBind('Mod+S', 'mac')   // Always resolves to meta+s
parseKeyBind('Mod+S', 'linux') // Always resolves to ctrl+s

Sequences

A sequence is two or more key presses in order, like Vim's g then d. duck-vim supports sequences in two ways:

When you register g+d in the Registry, the KeyHandler treats g and d as two separate key presses that must happen within the timeout window.

registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })
registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })

Internally, g is recognized as a prefix. When you press g, the handler waits for the next key. If d arrives before the timeout, the command fires. If the timeout expires, the sequence resets.


Prefixes

When you register g+d, the registry automatically marks g as a prefix. The KeyHandler uses this to decide whether to wait for more keys or discard the input.

Loading diagram...


Timeout

The timeout controls how long duck-vim waits between keys in a sequence. The default is 600ms.

  • Too short (< 300ms): Users won't have time to press the second key.
  • Too long (> 1000ms): The UI feels laggy because g is "swallowed" for too long.
  • 600ms is a good default that matches Vim's behavior.

Configure it in the KeyHandler constructor or the React provider:

// Vanilla
const handler = new KeyHandler(registry, 800) // 800ms timeout
 
// React
<KeyProvider timeoutMs={800}>
// Vanilla
const handler = new KeyHandler(registry, 800) // 800ms timeout
 
// React
<KeyProvider timeoutMs={800}>

Input element handling

Set ignoreInputs: true to skip execution when the event target is a text input, textarea, select, or contenteditable element:

registry.register('g+d', command, { ignoreInputs: true })
registry.register('g+d', command, { ignoreInputs: true })

Button-type inputs (<input type="button">, <input type="submit">, <input type="reset">) are NOT considered text inputs, so bindings still fire when those are focused.


Event options

Each binding supports these options:

OptionDefaultDescription
enabledtrueToggle the binding on/off without unregistering
preventDefaultfalseCall event.preventDefault() on match
stopPropagationfalseCall event.stopPropagation() on match
ignoreInputsfalseSkip when focus is in a text input
requireResetfalseFire only once until the handle's resetFired() is called
conflictBehavior'warn'What happens when a binding is already registered: 'warn', 'error', 'replace', or 'allow'

How matching works

Build a key descriptor from the KeyboardEvent (modifiers + key, normalized)

Append it to the current sequence buffer

Check if the full buffer matches a registered command — if yes, execute and reset

Check if the full buffer is a prefix of any command — if yes, start the timeout and wait

If neither, reset the buffer and retry with just the current key (handles cases like pressing g, then x where x is a standalone binding)