Shortcut Customization UI
Build a settings page where users can record and customize their keyboard shortcuts.
Let users redefine key bindings with a settings UI. duck-vim provides KeyRecorder (and useKeyRecorder) to capture key combinations, and formatForDisplay to render them.
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
duck-vim doesn't include persistence. Store user bindings wherever makes sense for your app (localStorage, database, etc.) and re-register them on startup.
// 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
Before saving a new binding, check if it conflicts with an existing one.
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(', '))
}