Skip to main content

Lesson 7: Key Recorder and Settings

Build a settings UI where users can record and customize their keyboard shortcuts.

What we're building


The KeyRecorder class

KeyRecorder captures a full key combination (modifiers + one non-modifier key) and outputs a canonical binding string like 'ctrl+shift+k'.

How it works

Call start() to begin listening on a target element.

The recorder tracks modifier keys as they're pressed/released.

When a non-modifier key is pressed, it builds the full binding string.

The onRecord callback fires with the string.

Call stop() to clean up.

Vanilla usage

import { KeyRecorder } from '@gentleduck/vim/recorder'
 
const recorder = new KeyRecorder({
  onRecord: (binding) => {
    console.log('Recorded:', binding)
    // e.g. 'ctrl+shift+k'
    recorder.stop()
  },
  onStart: () => console.log('Recording started'),
  onStop: () => console.log('Recording stopped'),
})
 
// Start recording
recorder.start(document.body)
 
// The user presses Ctrl+Shift+K
// Output: "Recorded: ctrl+shift+k"
import { KeyRecorder } from '@gentleduck/vim/recorder'
 
const recorder = new KeyRecorder({
  onRecord: (binding) => {
    console.log('Recorded:', binding)
    // e.g. 'ctrl+shift+k'
    recorder.stop()
  },
  onStart: () => console.log('Recording started'),
  onStop: () => console.log('Recording stopped'),
})
 
// Start recording
recorder.start(document.body)
 
// The user presses Ctrl+Shift+K
// Output: "Recorded: ctrl+shift+k"

React: useKeyRecorder

The hook wraps KeyRecorder with React state:

import { useKeyRecorder } from '@gentleduck/vim/react'
 
function RecorderDemo() {
  const { state, start, stop, reset } = useKeyRecorder()
 
  return (
    <div>
      <p>Recording: {state.isRecording ? 'Yes' : 'No'}</p>
      <p>Recorded: {state.recorded ?? 'Nothing yet'}</p>
      <button onClick={() => start()}>Start</button>
      <button onClick={() => stop()}>Stop</button>
      <button onClick={() => reset()}>Reset</button>
    </div>
  )
}
import { useKeyRecorder } from '@gentleduck/vim/react'
 
function RecorderDemo() {
  const { state, start, stop, reset } = useKeyRecorder()
 
  return (
    <div>
      <p>Recording: {state.isRecording ? 'Yes' : 'No'}</p>
      <p>Recorded: {state.recorded ?? 'Nothing yet'}</p>
      <button onClick={() => start()}>Start</button>
      <button onClick={() => stop()}>Stop</button>
      <button onClick={() => reset()}>Reset</button>
    </div>
  )
}

The state object:

{
  activeKeys: string[]     // Keys currently held
  recorded: string | null  // Last recorded binding
  isRecording: boolean     // Whether we're listening
}
{
  activeKeys: string[]     // Keys currently held
  recorded: string | null  // Last recorded binding
  isRecording: boolean     // Whether we're listening
}

Building the settings UI

Here's a complete shortcut settings component:

import { useState, useContext, useMemo } from 'react'
import { KeyContext, useKeyRecorder } from '@gentleduck/vim/react'
import { formatForDisplay } from '@gentleduck/vim/format'
import { validateKeyBind, normalizeKeyBind } from '@gentleduck/vim/parser'
 
interface ShortcutItem {
  originalBinding: string
  currentBinding: string
  name: string
}
 
function ShortcutSettings() {
  const ctx = useContext(KeyContext)
  const { state: recorderState, start, stop, reset } = useKeyRecorder()
  const [editingIndex, setEditingIndex] = useState<number | null>(null)
  const [error, setError] = useState<string | null>(null)
 
  const [shortcuts, setShortcuts] = useState<ShortcutItem[]>(() => {
    if (!ctx) return []
    return Array.from(ctx.registry.getAllCommands()).map(([binding, cmd]) => ({
      originalBinding: binding,
      currentBinding: binding,
      name: cmd.name,
    }))
  })
 
  function startEditing(index: number) {
    setEditingIndex(index)
    setError(null)
    reset()
    start()
  }
 
  function cancelEditing() {
    stop()
    setEditingIndex(null)
    setError(null)
    reset()
  }
 
  function saveBinding(index: number) {
    if (!recorderState.recorded || !ctx) return
 
    // Validate
    const validation = validateKeyBind(recorderState.recorded)
    if (!validation.valid) {
      setError(validation.errors.join(', '))
      return
    }
 
    // Check for conflicts
    const normalized = normalizeKeyBind(recorderState.recorded)
    const conflicting = shortcuts.find(
      (s, i) => i !== index && normalizeKeyBind(s.currentBinding) === normalized,
    )
    if (conflicting) {
      setError(`Conflicts with "${conflicting.name}"`)
      return
    }
 
    // Apply the change
    const item = shortcuts[index]
    if (!item) return
 
    const command = ctx.registry.getCommand(item.currentBinding)
    if (command) {
      ctx.registry.unregister(item.currentBinding)
      ctx.registry.register(recorderState.recorded, command)
    }
 
    setShortcuts((prev) =>
      prev.map((s, i) =>
        i === index ? { ...s, currentBinding: recorderState.recorded! } : s,
      ),
    )
 
    stop()
    setEditingIndex(null)
    setError(null)
    reset()
  }
 
  function resetToDefault(index: number) {
    if (!ctx) return
    const item = shortcuts[index]
    if (!item || item.currentBinding === item.originalBinding) return
 
    const command = ctx.registry.getCommand(item.currentBinding)
    if (command) {
      ctx.registry.unregister(item.currentBinding)
      ctx.registry.register(item.originalBinding, command)
    }
 
    setShortcuts((prev) =>
      prev.map((s, i) =>
        i === index ? { ...s, currentBinding: s.originalBinding } : s,
      ),
    )
  }
 
  return (
    <div className="max-w-lg mx-auto p-6">
      <h2 className="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
 
      {error && (
        <div className="bg-red-50 text-red-700 px-3 py-2 rounded mb-3 text-sm">
          {error}
        </div>
      )}
 
      <div className="space-y-1">
        {shortcuts.map((shortcut, i) => (
          <div
            key={i}
            className="flex items-center justify-between py-2 px-3 rounded hover:bg-gray-50"
          >
            <span className="text-sm">{shortcut.name}</span>
 
            <div className="flex items-center gap-2">
              {editingIndex === i ? (
                <>
                  <kbd className="px-2 py-1 bg-blue-100 border border-blue-300 rounded text-sm min-w-[80px] text-center">
                    {recorderState.recorded ?? 'Press keys...'}
                  </kbd>
                  <button
                    onClick={() => saveBinding(i)}
                    disabled={!recorderState.recorded}
                    className="text-xs text-blue-600 hover:underline"
                  >
                    Save
                  </button>
                  <button
                    onClick={cancelEditing}
                    className="text-xs text-gray-500 hover:underline"
                  >
                    Cancel
                  </button>
                </>
              ) : (
                <>
                  <button
                    onClick={() => startEditing(i)}
                    className="px-2 py-1 bg-gray-100 border rounded text-sm hover:bg-gray-200"
                  >
                    {formatForDisplay(shortcut.currentBinding)}
                  </button>
                  {shortcut.currentBinding !== shortcut.originalBinding && (
                    <button
                      onClick={() => resetToDefault(i)}
                      className="text-xs text-gray-400 hover:text-gray-600"
                    >
                      Reset
                    </button>
                  )}
                </>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}
import { useState, useContext, useMemo } from 'react'
import { KeyContext, useKeyRecorder } from '@gentleduck/vim/react'
import { formatForDisplay } from '@gentleduck/vim/format'
import { validateKeyBind, normalizeKeyBind } from '@gentleduck/vim/parser'
 
interface ShortcutItem {
  originalBinding: string
  currentBinding: string
  name: string
}
 
function ShortcutSettings() {
  const ctx = useContext(KeyContext)
  const { state: recorderState, start, stop, reset } = useKeyRecorder()
  const [editingIndex, setEditingIndex] = useState<number | null>(null)
  const [error, setError] = useState<string | null>(null)
 
  const [shortcuts, setShortcuts] = useState<ShortcutItem[]>(() => {
    if (!ctx) return []
    return Array.from(ctx.registry.getAllCommands()).map(([binding, cmd]) => ({
      originalBinding: binding,
      currentBinding: binding,
      name: cmd.name,
    }))
  })
 
  function startEditing(index: number) {
    setEditingIndex(index)
    setError(null)
    reset()
    start()
  }
 
  function cancelEditing() {
    stop()
    setEditingIndex(null)
    setError(null)
    reset()
  }
 
  function saveBinding(index: number) {
    if (!recorderState.recorded || !ctx) return
 
    // Validate
    const validation = validateKeyBind(recorderState.recorded)
    if (!validation.valid) {
      setError(validation.errors.join(', '))
      return
    }
 
    // Check for conflicts
    const normalized = normalizeKeyBind(recorderState.recorded)
    const conflicting = shortcuts.find(
      (s, i) => i !== index && normalizeKeyBind(s.currentBinding) === normalized,
    )
    if (conflicting) {
      setError(`Conflicts with "${conflicting.name}"`)
      return
    }
 
    // Apply the change
    const item = shortcuts[index]
    if (!item) return
 
    const command = ctx.registry.getCommand(item.currentBinding)
    if (command) {
      ctx.registry.unregister(item.currentBinding)
      ctx.registry.register(recorderState.recorded, command)
    }
 
    setShortcuts((prev) =>
      prev.map((s, i) =>
        i === index ? { ...s, currentBinding: recorderState.recorded! } : s,
      ),
    )
 
    stop()
    setEditingIndex(null)
    setError(null)
    reset()
  }
 
  function resetToDefault(index: number) {
    if (!ctx) return
    const item = shortcuts[index]
    if (!item || item.currentBinding === item.originalBinding) return
 
    const command = ctx.registry.getCommand(item.currentBinding)
    if (command) {
      ctx.registry.unregister(item.currentBinding)
      ctx.registry.register(item.originalBinding, command)
    }
 
    setShortcuts((prev) =>
      prev.map((s, i) =>
        i === index ? { ...s, currentBinding: s.originalBinding } : s,
      ),
    )
  }
 
  return (
    <div className="max-w-lg mx-auto p-6">
      <h2 className="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
 
      {error && (
        <div className="bg-red-50 text-red-700 px-3 py-2 rounded mb-3 text-sm">
          {error}
        </div>
      )}
 
      <div className="space-y-1">
        {shortcuts.map((shortcut, i) => (
          <div
            key={i}
            className="flex items-center justify-between py-2 px-3 rounded hover:bg-gray-50"
          >
            <span className="text-sm">{shortcut.name}</span>
 
            <div className="flex items-center gap-2">
              {editingIndex === i ? (
                <>
                  <kbd className="px-2 py-1 bg-blue-100 border border-blue-300 rounded text-sm min-w-[80px] text-center">
                    {recorderState.recorded ?? 'Press keys...'}
                  </kbd>
                  <button
                    onClick={() => saveBinding(i)}
                    disabled={!recorderState.recorded}
                    className="text-xs text-blue-600 hover:underline"
                  >
                    Save
                  </button>
                  <button
                    onClick={cancelEditing}
                    className="text-xs text-gray-500 hover:underline"
                  >
                    Cancel
                  </button>
                </>
              ) : (
                <>
                  <button
                    onClick={() => startEditing(i)}
                    className="px-2 py-1 bg-gray-100 border rounded text-sm hover:bg-gray-200"
                  >
                    {formatForDisplay(shortcut.currentBinding)}
                  </button>
                  {shortcut.currentBinding !== shortcut.originalBinding && (
                    <button
                      onClick={() => resetToDefault(i)}
                      className="text-xs text-gray-400 hover:text-gray-600"
                    >
                      Reset
                    </button>
                  )}
                </>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

Validation details

import { validateKeyBind } from '@gentleduck/vim/parser'
 
const result = validateKeyBind(recorded)
 
if (!result.valid) {
  // Show errors to the user
  showError(result.errors)
  return
}
 
if (result.warnings.length > 0) {
  // Show warnings but allow saving
  showWarning(result.warnings)
}
import { validateKeyBind } from '@gentleduck/vim/parser'
 
const result = validateKeyBind(recorded)
 
if (!result.valid) {
  // Show errors to the user
  showError(result.errors)
  return
}
 
if (result.warnings.length > 0) {
  // Show warnings but allow saving
  showWarning(result.warnings)
}

Common warnings:

  • Alt+letter combinations may not work on macOS (they produce special characters).

Common errors:

  • Empty binding string.
  • Multiple non-modifier keys.
  • Duplicate modifiers.

The KeyStateTracker

import { KeyStateTracker } from '@gentleduck/vim/recorder'
 
const tracker = new KeyStateTracker()
tracker.attach(document)
 
// In an animation loop
function gameLoop() {
  if (tracker.isKeyPressed('w')) moveUp()
  if (tracker.isKeyPressed('s')) moveDown()
  if (tracker.isKeyPressed('shift')) sprint()
 
  const snapshot = tracker.getSnapshot()
  console.log('Keys held:', snapshot.pressed)
  console.log('Has modifier:', snapshot.hasModifier)
 
  requestAnimationFrame(gameLoop)
}
import { KeyStateTracker } from '@gentleduck/vim/recorder'
 
const tracker = new KeyStateTracker()
tracker.attach(document)
 
// In an animation loop
function gameLoop() {
  if (tracker.isKeyPressed('w')) moveUp()
  if (tracker.isKeyPressed('s')) moveDown()
  if (tracker.isKeyPressed('shift')) sprint()
 
  const snapshot = tracker.getSnapshot()
  console.log('Keys held:', snapshot.pressed)
  console.log('Has modifier:', snapshot.hasModifier)
 
  requestAnimationFrame(gameLoop)
}

Exercises

  1. Build the shortcut settings component above and test it with three shortcuts.
  2. Add persistence: save customized bindings to localStorage and reload them on mount.
  3. Add an "Export" button that outputs the current bindings as JSON.