Keybindings
The desktop app's keyboard actions, their default bindings and the user's overrides, a global dispatcher, and keycaps that draw a binding.
zidane-ui/keybindings holds the desktop app's keyboard actions: the catalog (every action with its default binding, label and description), a dispatcher that fires your handlers on the document, and KeyHint / FooterHint to draw a binding as keycaps. A KeybindingsHost keeps the user's overrides. The previews below run on a fixture host that rebinds two actions.
- paletteCommand KK
- sidebarCommand BB
- new sessionControl NNCommand NN
- close tabControl WWCommand WW
- terminalControl ``Command JJ
- keybindingsControl YY
Mount it#
import { createBrowserKeybindingsHost } from 'zidane-ui/adapters'
import { KeybindingsProvider, useKeybindings } from 'zidane-ui/keybindings'
const keybindings = createBrowserKeybindingsHost()
function Shortcuts() {
useKeybindings({ toggleSidebar: () => toggle(), openPalette: () => openPalette() })
return null
}
export function Root() {
return (
<KeybindingsProvider host={keybindings}>
<Shortcuts />
<App />
</KeybindingsProvider>
)
}Without a provider the defaults apply. AppHostProvider (zidane-ui/app) puts host.keybindings in a KeybindingsProvider for you.
Register commands#
An app's shortcuts come from many parts: the session on show, the side panel, the zoom. Each part registers its handlers with the key command registry, and one dispatcher fires them. AppHostProvider mounts a KeyCommandsProvider; mount one yourself without it. The desktop app and the web app register the same commands this way, so their shortcuts match.
import { useMemo } from 'react'
import { useAppHost, useConfirm } from 'zidane-ui/app'
import type { ChatSession } from 'zidane-ui/chat/headless'
import { useKeyCommands } from 'zidane-ui/keybindings'
import { usePaletteActions } from 'zidane-ui/palette'
import { createSessionActions, sessionKeyCommands } from 'zidane-ui/session/headless'
import { useWorkspaceKeyCommands } from 'zidane-ui/workspace'
function SessionKeys({ session }: { session: ChatSession | null }) {
const host = useAppHost()
const confirm = useConfirm()
const palette = usePaletteActions()
const actions = useMemo(() => createSessionActions({ session: host.session, sessions: host.chat?.sessions, confirm }), [host, confirm])
const panel = useWorkspaceKeyCommands({ scope: !!session, session: !!session })
useKeyCommands({ ...sessionKeyCommands(actions, session), ...panel, openPalette: palette.toggle })
return null
}- Registering re-renders nothing. Handlers are read at press time; the dispatcher re-attaches only when the set of bound actions or the user's overrides change.
- Parts merge in the order they mounted. A later part's handler for an action wins.
useKeyCommandActions()re-renders only when the set changes.useActionsPaletteSource()(zidane-ui/palette) lists the registered actions in the palette with their chords.createKeyCommandRegistry()is the React-free registry, for code outside React.useHelpKey(open, blocked)opens your shortcut reference on a bare?, never while the user types orblocked()says a modal owns input.
| Commands | From |
|---|---|
sessionKeyCommands(actions, session) | zidane-ui/session/headless: generate a title, compact, export, delete (asked first), favorite, copy the id |
useWorkspaceKeyCommands(options) | zidane-ui/workspace: the panel and Todos toggles, tab cycling, ⌥⌘←/→ where the focus is, close the tab in reach |
zoomKeyCommands(prefs) | zidane-ui/appearance/headless: zoom in, out, reset |
useSelectTurnKeys(options) | zidane-ui/chat/transcript: enterSelectTurnMode, then the mode's keys and pointer |
useTranscriptFindKey(options) | zidane-ui/chat/transcript: ⌘F to Files or Diffs in reach, else the conversation |
How the dispatcher matches#
- A binding matches exactly:
'o'does not firectrl+o, andctrl+odoes not fire onctrl+shift+o. - A spec may list alternatives:
ctrl+w | cmd+w. The grammar is zidane's, so akeybindings.jsonreads the same in the terminal. - Bare keys are ignored while the user types in a field or the code editor.
keyScopeOf(event)says who owns a key press:editor,input,menuorapp. An editor host opts in withdata-zg-keyscope="editor". - In the editor,
ctrlplus a letter belongs to the editor (bindingAllowedIn), except closing the tab. - The tab keys (
tabNext,tabPrev) match before the focused widget sees them. - Handlers are read at press time, so a fresh object each render costs nothing.
matchesBinding(event, spec) is the matcher, for a listener of your own. Punctuation matches by its physical key where a layout types a stand-in.
Pick a host#
| Host | From | Keeps |
|---|---|---|
createBrowserKeybindingsHost() | zidane-ui/adapters | localStorage (zg:keybindings:v1). zidane-server keeps no keybindings. |
createFixtureKeybindingsHost(options) | zidane-ui/keybindings/testing | Memory, for demos and tests |
| Your own | zidane-ui/keybindings/headless | Anything. createKeybindingOverrides(read) keeps the known actions of what read answers. |
| Member | Gives |
|---|---|
overrides, refresh() | The user's specs per action. The first useKeybindings reads them once; refresh re-reads them and every dispatcher rebinds. |
save(map) | Replace the overrides |
openInEditor(), location() | Open the file they live in, and say where it is (the desktop app's ~/.zidane/keybindings.json) |
getMergedKeybindings(host) and useMergedKeybindings() give the bindings in effect.
Parts#
| Part | Does |
|---|---|
KeyHint | A binding as keycaps (size="sm" in dense rows). Alternatives are separated by the keyHintOr label. |
FooterHint | A legend entry: the keys, then what they do |
KEYBINDING_DEFS, DEFAULT_KEYBINDINGS | The catalog and the default map |
Test your host#
import { describeKeybindingsHostContract } from 'zidane-ui/keybindings/testing/contract'
describeKeybindingsHostContract('my host', backend => makeHarness(backend), ['save'])| Entry point | Holds |
|---|---|
zidane-ui/keybindings | KeybindingsProvider, useKeybindings, useMergedKeybindings, KeyCommandsProvider, useKeyCommands, useKeyCommandRegistry, useKeyCommandActions, useHelpKey, bindKeyListeners, matchesBinding, KeyHint, FooterHint |
zidane-ui/keybindings/headless | The catalog, KeybindingsHost, the overrides model, createKeyCommandRegistry, keyScopeOf, the labels |
zidane-ui/keybindings/testing | createFixtureKeybindingsHost |
zidane-ui/keybindings/testing/contract | describeKeybindingsHostContract |