Skip to main content

Shortcut Customization UI

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


React implementation

import { useState, useContext } from 'react'
import { KeyContext, useKeyRecorder } from '@gentleduck/vim/react'
import { formatForDisplay } from '@gentleduck/vim/format'
import type { Command } from '@gentleduck/vim/command'
 
interface ShortcutEntry {
  id: string
  name: string
  binding: string
  command: Command
}
 
function ShortcutSettings() {
  const ctx = useContext(KeyContext)
  const { state, start, stop, reset } = useKeyRecorder()
  const [editingId, setEditingId] = useState<string | null>(null)
  const [shortcuts, setShortcuts] = useState<ShortcutEntry[]>(() => {
    if (!ctx) return []
    return Array.from(ctx.registry.getAllCommands()).map(([binding, cmd]) => ({
      id: binding,
      name: cmd.name,
      binding,
      command: cmd,
    }))
  })
 
  function startEditing(id: string) {
    setEditingId(id)
    reset()
    start()
  }
 
  function saveBinding(id: string) {
    if (!state.recorded || !ctx) return
 
    stop()
 
    const entry = shortcuts.find((s) => s.id === id)
    if (!entry) return
 
    // Unregister old binding
    ctx.registry.unregister(entry.binding)
 
    // Register new binding
    ctx.registry.register(state.recorded, entry.command)
 
    // Update local state
    setShortcuts((prev) =>
      prev.map((s) => (s.id === id ? { ...s, binding: state.recorded! } : s)),
    )
 
    setEditingId(null)
    reset()
  }
 
  function cancelEditing() {
    stop()
    setEditingId(null)
    reset()
  }
 
  return (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
      {shortcuts.map((shortcut) => (
        <div key={shortcut.id} className="flex items-center justify-between py-2 border-b">
          <span>{shortcut.name}</span>
 
          {editingId === shortcut.id ? (
            <div className="flex items-center gap-2">
              <kbd className="px-2 py-1 bg-blue-100 rounded text-sm">
                {state.recorded ?? 'Press a key...'}
              </kbd>
              <button onClick={() => saveBinding(shortcut.id)}>Save</button>
              <button onClick={cancelEditing}>Cancel</button>
            </div>
          ) : (
            <button
              onClick={() => startEditing(shortcut.id)}
              className="px-2 py-1 bg-gray-100 rounded text-sm hover:bg-gray-200"
            >
              {formatForDisplay(shortcut.binding)}
            </button>
          )}
        </div>
      ))}
    </div>
  )
}
import { useState, useContext } from 'react'
import { KeyContext, useKeyRecorder } from '@gentleduck/vim/react'
import { formatForDisplay } from '@gentleduck/vim/format'
import type { Command } from '@gentleduck/vim/command'
 
interface ShortcutEntry {
  id: string
  name: string
  binding: string
  command: Command
}
 
function ShortcutSettings() {
  const ctx = useContext(KeyContext)
  const { state, start, stop, reset } = useKeyRecorder()
  const [editingId, setEditingId] = useState<string | null>(null)
  const [shortcuts, setShortcuts] = useState<ShortcutEntry[]>(() => {
    if (!ctx) return []
    return Array.from(ctx.registry.getAllCommands()).map(([binding, cmd]) => ({
      id: binding,
      name: cmd.name,
      binding,
      command: cmd,
    }))
  })
 
  function startEditing(id: string) {
    setEditingId(id)
    reset()
    start()
  }
 
  function saveBinding(id: string) {
    if (!state.recorded || !ctx) return
 
    stop()
 
    const entry = shortcuts.find((s) => s.id === id)
    if (!entry) return
 
    // Unregister old binding
    ctx.registry.unregister(entry.binding)
 
    // Register new binding
    ctx.registry.register(state.recorded, entry.command)
 
    // Update local state
    setShortcuts((prev) =>
      prev.map((s) => (s.id === id ? { ...s, binding: state.recorded! } : s)),
    )
 
    setEditingId(null)
    reset()
  }
 
  function cancelEditing() {
    stop()
    setEditingId(null)
    reset()
  }
 
  return (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
      {shortcuts.map((shortcut) => (
        <div key={shortcut.id} className="flex items-center justify-between py-2 border-b">
          <span>{shortcut.name}</span>
 
          {editingId === shortcut.id ? (
            <div className="flex items-center gap-2">
              <kbd className="px-2 py-1 bg-blue-100 rounded text-sm">
                {state.recorded ?? 'Press a key...'}
              </kbd>
              <button onClick={() => saveBinding(shortcut.id)}>Save</button>
              <button onClick={cancelEditing}>Cancel</button>
            </div>
          ) : (
            <button
              onClick={() => startEditing(shortcut.id)}
              className="px-2 py-1 bg-gray-100 rounded text-sm hover:bg-gray-200"
            >
              {formatForDisplay(shortcut.binding)}
            </button>
          )}
        </div>
      ))}
    </div>
  )
}

Persisting custom bindings

// Load saved bindings
const saved = JSON.parse(localStorage.getItem('shortcuts') ?? '{}')
 
// Register with overrides
for (const [defaultBinding, command] of defaultCommands) {
  const userBinding = saved[command.name] ?? defaultBinding
  registry.register(userBinding, command)
}
 
// Save when changed
function saveBinding(commandName: string, newBinding: string) {
  const saved = JSON.parse(localStorage.getItem('shortcuts') ?? '{}')
  saved[commandName] = newBinding
  localStorage.setItem('shortcuts', JSON.stringify(saved))
}
// Load saved bindings
const saved = JSON.parse(localStorage.getItem('shortcuts') ?? '{}')
 
// Register with overrides
for (const [defaultBinding, command] of defaultCommands) {
  const userBinding = saved[command.name] ?? defaultBinding
  registry.register(userBinding, command)
}
 
// Save when changed
function saveBinding(commandName: string, newBinding: string) {
  const saved = JSON.parse(localStorage.getItem('shortcuts') ?? '{}')
  saved[commandName] = newBinding
  localStorage.setItem('shortcuts', JSON.stringify(saved))
}

Conflict detection

import { normalizeKeyBind } from '@gentleduck/vim/parser'
 
function hasConflict(newBinding: string): string | null {
  const normalized = normalizeKeyBind(newBinding)
  const existing = registry.getAllCommands()
 
  for (const [binding, cmd] of existing) {
    if (normalizeKeyBind(binding) === normalized) {
      return cmd.name // Return the conflicting command's name
    }
  }
 
  return null
}
import { normalizeKeyBind } from '@gentleduck/vim/parser'
 
function hasConflict(newBinding: string): string | null {
  const normalized = normalizeKeyBind(newBinding)
  const existing = registry.getAllCommands()
 
  for (const [binding, cmd] of existing) {
    if (normalizeKeyBind(binding) === normalized) {
      return cmd.name // Return the conflicting command's name
    }
  }
 
  return null
}

Validation

Use validateKeyBind to check user input before accepting it:

import { validateKeyBind } from '@gentleduck/vim/parser'
 
const result = validateKeyBind(state.recorded)
if (!result.valid) {
  showError(result.errors.join(', '))
  return
}
if (result.warnings.length > 0) {
  showWarning(result.warnings.join(', '))
}
import { validateKeyBind } from '@gentleduck/vim/parser'
 
const result = validateKeyBind(state.recorded)
if (!result.valid) {
  showError(result.errors.join(', '))
  return
}
if (result.warnings.length > 0) {
  showWarning(result.warnings.join(', '))
}