Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 48 additions & 83 deletions docs/guide/input-and-focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,89 +74,54 @@ ui.focusTrap({ id: "modal", active: state.showModal }, [
])
```

## Keybindings

### Basic Keybindings

Register global keyboard shortcuts with `app.keys()`:

```typescript
app.keys({
"ctrl+s": () => save(),
"ctrl+q": () => app.stop(),
"escape": () => closeModal(),
"f1": () => showHelp(),
});
```

### Modifier Keys

Supported modifiers: `ctrl`, `alt`, `shift`, `meta`

```typescript
app.keys({
"ctrl+s": () => save(),
"ctrl+shift+s": () => saveAs(),
"alt+f": () => openFileMenu(),
"meta+q": () => quit(), // Cmd on macOS
});
```

### Chord Sequences

Chords are key sequences pressed in succession (like Vim's `gg` or Emacs's `C-x C-s`):

```typescript
app.keys({
"g g": () => scrollToTop(), // Press g twice
"g e": () => scrollToEnd(), // Press g then e
"ctrl+x ctrl+s": () => save(), // Emacs-style
"d d": () => deleteLine(), // Vim-style
});
```

Chord timeout is 1000ms by default.

### Key Context

Key handlers receive a context object with state access:

```typescript
app.keys({
"j": (ctx) => ctx.update((s) => ({ ...s, cursor: s.cursor + 1 })),
"k": (ctx) => ctx.update((s) => ({ ...s, cursor: s.cursor - 1 })),
});
```

### Modal Keybinding Modes

For Vim-style modal editing, use `app.modes()`:

```typescript
app.modes({
normal: {
"i": () => app.setMode("insert"),
"v": () => app.setMode("visual"),
"j": (ctx) => ctx.update(moveCursorDown),
"k": (ctx) => ctx.update(moveCursorUp),
"d d": (ctx) => ctx.update(deleteLine),
":": () => openCommandLine(),
},
insert: {
"escape": () => app.setMode("normal"),
},
visual: {
"escape": () => app.setMode("normal"),
"y": (ctx) => { yank(); app.setMode("normal"); },
"d": (ctx) => { deleteSelection(); app.setMode("normal"); },
},
});

// Start in normal mode
app.setMode("normal");
```

Query current mode with `app.getMode()`.
## Keybinding System

### Key string syntax

- A binding string is one or more key parts separated by whitespace (for chords), for example `"g g"` or `"ctrl+x ctrl+s"`.
- Each key part uses `modifier+...+key`. The final segment must be a key, not a modifier.
- Parsing is case-insensitive for modifiers and named keys.
- Letter case does not imply `shift`: `"a"` and `"A"` parse the same; require `shift+...` explicitly when Shift must match.
- Supported modifiers: `shift`, `ctrl`/`control`, `alt`, `meta`/`cmd`/`command`/`win`/`super`.
- Supported named keys: `escape`/`esc`, `enter`/`return`, `tab`, `backspace`, `space`, `insert`, `delete`/`del`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `f1`-`f12`.
- Single-character keys are supported for letters, digits, and most punctuation (`+` is reserved as the modifier separator). Use `space` (not a literal space) for the Space key.
- Invalid key strings are skipped during registration; they do not throw.

### Chord matching

- Matching only considers `key` events with `action: "down"`.
- A chord prefix enters pending state and consumes the key.
- Timeout is `1000ms` from the first key in the pending chord.
- If the next key does not continue the pending chord, pending state is cancelled and that same key is retried as a fresh start.
- Any full match or full miss resets chord pending state.
- Prefix conflicts are eager: if a sequence is both a complete match and a prefix of a longer sequence, the complete match fires immediately (no wait window for the longer sequence).

### Modes: definition, activation, inheritance

- `app.keys()` registers into the built-in `default` mode.
- `app.modes()` accepts either:
- `{ modeName: { ...bindings } }`
- `{ modeName: { parent?: string, bindings: { ... } } }`
- `app.setMode(name)` activates a previously-registered mode; unknown mode names throw.
- `app.getMode()` returns the active mode name.
- Mode lookup is current mode first, then `parent` chain fallback (cycle-safe).
- Switching to a different mode resets pending chord state. Calling `setMode()` with the current mode is a no-op.

### Binding conflicts and re-registration

- Registration is additive by mode, but re-registering the same sequence replaces the previous binding for that sequence.
- For distinct sequences in one mode, higher `priority` wins (`priority` default is `0`).
- Parent modes are only consulted when the active mode does not produce a usable match (or the active-mode winner's `when(ctx)` predicate returns `false`).
- Handlers receive `ctx` with `state`, `update(...)`, and `focusedId`.

### Key event routing order

In widget mode, key routing is:

1. App keybindings (`app.keys` / `app.modes`) run first.
2. Escape bypass: if key is `Escape` and a dropdown is open or any modal layer exists, app keybindings are skipped for that event.
3. If keybindings consume the event (matched or pending chord), widget routing is skipped.
4. Otherwise widget routing runs, in this order: top dropdown key handling, layer Escape close handling, focused-widget-specific key handlers, then generic focus/press/input routing.

## Mouse Input

Expand Down
47 changes: 43 additions & 4 deletions packages/core/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,29 @@ export function createApp<S>(
return false;
}

function applyRoutedKeybindingState(
routeInputState: KeybindingManagerState<KeyContext<S>>,
routeNextState: KeybindingManagerState<KeyContext<S>>,
): void {
// If handlers did not mutate keybinding state, take the routed state directly.
if (keybindingState === routeInputState) {
keybindingState = routeNextState;
return;
}

// Preserve handler-triggered mode changes (for example app.setMode() in a binding).
if (keybindingState.currentMode !== routeInputState.currentMode) {
return;
}

// For non-mode mutations (e.g. app.keys/app.modes in a handler), keep those
// edits but still advance chord state from the routed event.
keybindingState = Object.freeze({
...keybindingState,
chordState: routeNextState.chordState,
});
}

function noteBreadcrumbEvent(kind: string): void {
breadcrumbEventTracked = false;
if (!runtimeBreadcrumbsEnabled) return;
Expand Down Expand Up @@ -715,8 +738,16 @@ export function createApp<S>(
update: app.update,
focusedId: widgetRenderer.getFocusedId(),
});
const keyResult = routeKeyEvent(keybindingState, ev, keyCtx);
keybindingState = keyResult.nextState;
const routeInputState = keybindingState;
const keyResult = routeKeyEvent(routeInputState, ev, keyCtx);
applyRoutedKeybindingState(routeInputState, keyResult.nextState);
if (keyResult.handlerError !== undefined) {
enqueueFatal(
"ZRUI_USER_CODE_THROW",
`keybinding handler threw: ${describeThrown(keyResult.handlerError)}`,
);
return;
}
if (keyResult.consumed) {
Comment on lines +742 to 751

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate keybinding handler failures before consuming input

This routing path applies keyResult.nextState and consumes the event without checking keyResult.handlerError, so handler exceptions are silently dropped in app runtime. That became user-visible in this commit because setMode() now throws for unknown modes: a typo like app.setMode("inert") inside a keybinding handler is swallowed here, the key is still treated as consumed, and no fatal/error is surfaced, making mode bugs hard to diagnose.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 220d04c.

createApp now checks keyResult.handlerError after keybinding routing (for both raw key events and synthetic key events from text input), enqueues ZRUI_USER_CODE_THROW, and stops processing the batch.

Added regression test: unknown mode switch inside keybinding handler surfaces fatal error in packages/core/src/keybindings/__tests__/keybinding.mode-switch.test.ts.

noteBreadcrumbConsumptionPath("keybindings");
continue; // Skip default widget routing
Expand All @@ -741,8 +772,16 @@ export function createApp<S>(
update: app.update,
focusedId: widgetRenderer.getFocusedId(),
});
const keyResult = routeKeyEvent(keybindingState, syntheticKeyEvent, keyCtx);
keybindingState = keyResult.nextState;
const routeInputState = keybindingState;
const keyResult = routeKeyEvent(routeInputState, syntheticKeyEvent, keyCtx);
applyRoutedKeybindingState(routeInputState, keyResult.nextState);
if (keyResult.handlerError !== undefined) {
enqueueFatal(
"ZRUI_USER_CODE_THROW",
`keybinding handler threw: ${describeThrown(keyResult.handlerError)}`,
);
return;
}
if (keyResult.consumed) {
noteBreadcrumbConsumptionPath("keybindings");
continue; // Skip default widget routing
Expand Down
Loading
Loading