docs/internal/input-keybindings-actions.md
Purpose: explain how Fresh turns a raw terminal keystroke (or mouse event) into a buffer change — the key-translation layer, the modal dispatch priority, unified keybinding resolution, the command → action → event pipeline, multi-cursor, and fuzzy/quick-open input. Everything below is IMPLEMENTED unless explicitly flagged PLANNED.
A keystroke flows through these stages:
Terminal (crossterm KeyEvent)
→ KeyTranslator.translate() — calibration fixups
→ Editor::handle_key() — modal priority + chord state
→ KeybindingResolver.resolve() — key → Action
→ Editor::handle_action(Action) — large match; side-effects or…
→ action_to_events(Action) — Action → events
→ log_and_apply_event / bulk edit — events mutate buffer + undo log
Three distinct vocabularies live here and the separation is deliberate (§4):
Action.MoveUp, Save,
InsertChar('a')). The Action enum is the rebinding currency and the
serialization unit for keymaps/macros.Event::Insert,
Event::Delete, Event::MoveCursor, Event::BulkEdit). Events are the undo
unit and the plugin-hook unit.Terminals are inconsistent about what bytes they emit for a given physical key. Fresh handles this in three layers, applied in order, before keybinding resolution.
The KeyTranslator is a map that rewrites a raw KeyEvent to a normalized one.
It is populated by the input-calibration wizard (Action::CalibrateInput) and
persisted to a calibration file. The canonical order is fixed:
"Terminal → KeyTranslator → KeybindingResolver". Rationale: keep "fix the broken
terminal" separate from "rebind the action" — calibration must not pollute the
keymap. Unmapped keys pass through untouched.
normalize_key (resolution-time canonicalization)normalize_key folds redundant modifier noise so a single binding matches every
terminal's encoding of the same key:
BackTab/Backspace + SHIFT → drop the redundant SHIFT.Char('P') → lowercase + inferred SHIFT, unless CONTROL is set.
This handles the divergence in how terminals report shifted characters:
kitty-protocol terminals send a real SHIFT with an uppercase char, non-kitty
terminals send a bare uppercase char with no modifier, and CapsLock+Ctrl sends
uppercase + CONTROL. Inferring SHIFT for the no-CONTROL case makes Shift+P /
Alt+Shift+F bindings match uniformly, while the CONTROL exception preserves
the "CapsLock+Ctrl+A still fires Ctrl+A" intent.normalize_key is applied to both the live key and every chord-state key in both
single-key and chord resolution.
terminal_key_equivalents (alias expansion)terminal_key_equivalents maps control-key combos that terminals encode
differently into aliases that are inserted alongside the primary binding at
load time:
Ctrl+/ ↔ Ctrl+7, Ctrl+Backspace ↔ Ctrl+H, Ctrl+Space ↔ Ctrl+@
(NUL), Ctrl+- ↔ Ctrl+_.Explicit bindings always win over auto-generated equivalents; a conflict only logs a warning. The keybinding priority score deprioritizes the terminal-equivalent half when picking a canonical display key.
The text-input-modifier check additionally treats Windows AltGr (reported as
Ctrl+Alt by crossterm) as text-input-capable so international layouts can type
@ [ ] { }.
PLANNED/orthogonal: the kitty keyboard protocol enhancement flags
(DISAMBIGUATE_ESCAPE_CODES, REPORT_ALTERNATE_KEYS) are enabled at the
terminal layer so capable terminals can report Shift+Up, etc.; unsupported
terminals degrade rather than getting a custom fallback.
Editor::handle_key is the central router. It establishes a strict precedence so
that overlays consume input before the buffer does. The priority is computed from
an overlay layer stack, not a hand-listed ladder — get_key_context() and
dispatch_modal_input both consult the overlay layers so the keyboard, mouse, and
terminal paths cannot drift out of sync (this is the modern replacement for an
older hardcoded order).
Order of checks in handle_key:
dispatch_terminal_input. Returns None (falls through)
if any blocking overlay is up. Plugin commands flagged terminalBypass: true
are resolved against the Normal context and dispatched before the PTY claims
the key — that is how Orchestrator: Open stays reachable from inside htop.getNextKey() plugin callback — a plugin awaiting the next key (vi
find-char, flash labels) gets it before any other dispatch.dispatch_modal_input — the heart of the modal ladder:
dispatch_modal_keyboard walks the overlay stack top-down for the four
capture-all modals — Settings → KeybindingEditor → CalibrationWizard →
Menu. These early-return.dispatch_input. A prompt that returns Ignored falls through to global
keybindings.popups_capture_keys() so editor-pane popups don't eat
keys when the file explorer is focused. Completion popups consult the
Completion keybinding context first; global popups outrank buffer popups.
Ignored falls through.Normal/CompositeBuffer context. Chord
then single-key resolution against the Mode(name) context.try_route_composite_key.handle_action(action).The net priority is Settings → Menu → Prompt → Popup → (mode) → Normal, with terminal mode and floating overlays gating ahead of all of them.
Modal components (Settings, Menu, Prompt, Popup, file browser) implement
InputHandler. The contract:
dispatch_input tries the focused child first, then
the handler itself.InputResult::Consumed (stop) or
Ignored (try parent).Ignored — this
opt-out is what lets, e.g., Ctrl+P toggle Quick Open closed while the prompt is
open. No DOM-style capture phase, by choice.Handlers cannot mutate Editor directly (they only borrow their own sub-state),
so they enqueue DeferredAction values into InputContext.deferred_actions.
After dispatch, process_deferred_actions drains the queue with full &mut Editor
access and execute_deferred_action performs the real side-effect
(CloseSettings, ExecuteMenuAction, SendTerminalKey, ExecuteAction, …).
This is the borrow-checker-friendly bridge between self-contained handlers and
editor-global mutation. DeferredAction::ExecuteAction(Action) lets a handler
fall back into the normal action pipeline.
Action is Serialize/Deserialize and
round-trips through string names via a declarative action-name-mapping macro,
which generates from_str, to_action_str, and all_action_names from one
definition so the keymap parser, keybinding editor, and macro recorder can never
drift. Keybindings and macros store Actions, not Events — Events are
position-specific and would not replay.log_and_apply_event, which appends to the EventLog
(undo chain) and then applies. Actions that aren't edits (e.g. Quit,
OpenSettings) produce no events at all (action_to_events returns None).Action::MoveUp is purely semantic; the conversion to
a concrete Event::MoveCursor resolves soft-wrap visual lines via the cached
layout. One action, many possible byte targets.handle_action is a large match. Two kinds of arms:
Save opens a prompt or writes the file;
AddCursorNextMatch calls into the multi-cursor module.apply_action_as_events, which
calls Window::action_to_events. That bridge tries visual-line movement and
page motion first, then falls back to the logical action_to_events converter.action_to_events is the pure (state, cursors, events, …) converter, decomposed
from a single large match into per-action handle_* helpers. It is where editor
semantics live: auto-close/auto-pair, smart-backspace dedent, tiered auto-indent
(tree-sitter grammar → per-language regex rules → C-style bracket scanner),
CRLF-as-one handling, and visual-column sticky movement.
The event-apply layer has three entry points:
log_and_apply_event — canonical single-event path.apply_event_to_active_buffer — apply without logging (replay). Critically,
LSP change positions and plugin-hook line info are computed before the buffer
mutates so byte offsets are pre-modification.apply_events_as_bulk_edit — batches multi-event edits under one undo
boundary, sorting by descending position so earlier edits don't shift later
ones, producing one Event::BulkEdit with old/new snapshots. Used by
replace-all, format-on-save, LSP rename, and multi-cursor typing — this is
the linear-time (not quadratic) path.The caller in apply_action_as_events picks the path: more than one event with
buffer mods → bulk edit; more than one event without mods → Event::Batch (atomic
undo for multi-cursor moves); single event → log_and_apply_event.
The command layer defines a set of static command definitions, each i18n-keyed
with an action and a context list. A Command is the localized, context-aware
wrapper the palette shows; its .action field is the Action actually executed.
The CommandRegistry merges builtin commands with plugin-registered ones, supports
first-writer-wins plugin registration, tracks usage history for recency sorting,
and exposes filter() — the palette entry point that fuzzy-matches +
context-filters + sorts. Selecting a command ultimately runs its Action through
handle_action.
The design goal: a single resolution path for builtin, keymap, user-custom,
and plugin-mode bindings — no separate ModeRegistry lookup, no mode inheritance.
This is implemented; ModeRegistry is now metadata-only (read_only,
allow_text_input, inherit_normal_bindings, plugin_name).
KeybindingResolver holds six maps plus a set:
| Field | Tier | Source |
|---|---|---|
bindings | custom | user keybindings in config |
default_bindings | keymap | active keymap (default/emacs/vscode…) |
plugin_defaults | plugin | defineMode() runtime registration |
chord_bindings / default_chord_bindings / plugin_chord_defaults | as above, multi-key | |
inheriting_modes | — | modes wanting Normal fallthrough |
KeyContext)KeyContext values: Global, Normal, Prompt, SearchPrompt, Popup, Completion, FileExplorer, Dock, Menu, Terminal, Settings, CompositeBuffer, Mode(String).
Several have layered semantics:
SearchPrompt is a narrowing of Prompt (its parent context): it owns the
match-mode toggles (case/word/regex/confirm-each) and inherits every generic
editing key from Prompt. This keeps Alt+W from flipping whole-word match while
an unrelated close-confirmation prompt is up.Completion takes precedence over Popup so accept/dismiss can be bound
independently of generic popup keys.CompositeBuffer falls through to all Normal bindings
(allows_normal_fallthrough); FileExplorer, Dock, and plugin Mode(_) fall
through only for the curated is_terminal_ui_action whitelist
(allows_ui_fallthrough) so split-nav/palette/save work while focus is on
chrome, but the panel's own keys aren't shadowed.from_when_clause parses the "when" strings (mode:git-log, searchPrompt, …)
used in keymap/plugin bindings.
resolve checks, in order:
allows_normal_fallthrough / inheriting modes,
otherwise only is_application_wide_action (Quit, Save, Esc-cancel) or — for
UI-fallthrough contexts — is_terminal_ui_action. A user binding in Normal
shadows the keymap default even when it doesn't qualify for fallthrough, so a
user can disable an application-wide default like Ctrl+Q → Quit with noop.InsertChar).A related helper resolve_in_context_only bypasses Global/Normal so prompt-specific
Alt bindings don't collide with menu mnemonics.
Chords are multi-key sequences. The chord state is a per-window list of
(KeyCode, KeyModifiers) pairs. resolve_chord builds the normalized full
sequence and searches the same tiers (custom global → keymap global → custom
context → keymap context → plugin context), returning Complete(action) /
Partial (a prefix of some binding) / NoMatch. In handle_key, Partial pushes
the key and waits, Complete clears the state and dispatches, NoMatch clears any
stale prefix. Chord resolution runs both at the mode level and the normal level.
User overrides, keymap selection, and the keybinding editor all rebuild the
config-derived tiers. reload_from_config is the safe rebuild: it reconstructs
bindings/default_bindings from config but carries over the runtime-only
plugin state (plugin_defaults, plugin_chord_defaults, inheriting_modes)
because that state lives only in the resolver, not in Config. Constructing a
fresh resolver instead would silently drop every plugin binding until restart.
Plugin modes register via load_plugin_default / load_plugin_chord_default under
a Mode(name) context, cleared per-mode with clear_plugin_defaults_for_mode.
set_mode_inherits_normal_bindings toggles membership in inheriting_modes.
Relationship to the design intent: the original unified-resolution design proposed
a metadata BufferMode without inherit_normal_bindings; the shipped
BufferMode adds that field plus the resolver's inheriting_modes set — a later
refinement that lets viewer-style modes inherit Normal motion/selection/copy
without re-declaring them. The source is authoritative.
A buffer (or the global editor) can carry a named mode. effective_mode()
returns the buffer-local mode if present else the global mode, so virtual-buffer
modes aren't hijacked by a global mode. Mode handling in handle_key:
Mode(name).allows_text_input (e.g. search-replace-list), unbound printable
chars become PluginAction("mode_text_input:<char>"); clipboard / select-all
keys are forwarded to the focused widget; Shift+arrows extend the focused
widget's selection; other unbound keys are blocked.read_only and not text-input, unbound keys are dropped;
otherwise they fall through to normal dispatch.ModeRegistry only answers is_read_only, allows_text_input,
inherits_normal_bindings, and attribution — all binding lookups go through
KeybindingResolver.
The cursor set is the Cursors model. Multi-cursor follows one rule learned the
hard way: never special-case the primary cursor; always emit one event per
cursor through the shared action→event path.
The multi-cursor module provides the add-cursor operations (invoked from
handle_action arms):
add_cursor_above / add_cursor_below — same visual column on the adjacent
line, clamped to line length, skipping the newline.add_cursor_at_next_match — VSCode Ctrl-D: with no selection, selects the word
at the cursor (AddCursorResult::WordSelected); with a selection, finds the next
non-occupied occurrence of the pattern and adds a cursor there, preserving
selection direction. Cycle detection guards against all-matches-occupied. Ctrl-D
honors an active search match rather than the surrounding word when one is
present.line_end_positions_in_selection — Sublime "split selection into lines": every
line touched by any cursor's selection contributes its end-of-line position,
deduped in document order.Once cursors exist, every edit/movement action runs for all of them inside the
action→event converter (each handle_* iterates the cursors), and the resulting
events (more than one) are applied via apply_events_as_bulk_edit for linear-time
edits or Event::Batch for non-edit moves. Block (column/rectangular) selection
lives in the action converter and is converted to per-line cursors via
convert_block_selection_to_cursors before normal multi-cursor logic runs.
RemoveSecondaryCursors collapses back to one.
The line-move logic implements MoveLineUp/Down: it merges each cursor's line
range, swaps the block with the adjacent line as a Delete+Insert pair, and remaps
every cursor/anchor position into the moved region so selections survive the move.
MouseInput::handle_mouse is the mouse counterpart to handle_key. Architecture:
ratatui is render-only, so Fresh maps screen coordinates back to components using a
cached layout / retained-mode hit test produced during render. Cached regions
include tab areas, the status-bar area, the file-explorer area, and split
separators; the long-term plan (PLANNED) is a unified HitArea + z-index and
eventually a Helix-style compositor. Fixed-row checks (e.g. a menu bar hardcoded to
row == 0) are being eliminated in favor of the cached-region approach.
Dispatch order mirrors the keyboard path:
dispatch_modal_mouse walks the same overlay stack top-down as
get_key_context, so modal capture stays in lock-step with the keyboard.try_forward_mouse_to_terminal sends SGR-encoded mouse
events to the PTY when over an alternate-screen terminal — suppressed while a
chrome drag (dock/separator/explorer resize) is in progress.Ctrl+Click on a terminal-printed path opens the file.Action::SelectWord; triple-click → Action::SelectLine;
scroll scrolls the viewport; status-bar indicator clicks map to actions
(SetLineEnding/SetEncoding/SetLanguage/ShowLspStatus).So mouse gestures, where they have an editing meaning, fold into the same
handle_action pipeline as keys; chrome interactions (resize drags, tab close,
scrollbar) are handled directly against cached layout regions.
Windows note (IMPLEMENTED): the fix was honoring wRepeatCount on coalesced
console KEY_EVENT records (mode-1003 all-motion tracking floods the buffer) in the
fresh-winterm crate.
MoveCursor events
and coalesces consecutive small moves (below a large-jump threshold) into one
jump entry; buffer switches and large jumps commit the pending movement. Fed via
track_cursor_movement, gated by in_navigation so back/forward navigation
doesn't pollute the trail. NavigateBack/Forward actions drive it.PromptHistoryPrev/Next deferred actions.route_key_event
intercepts only composite-specific keys (j/k scroll, Tab/Shift+Tab pane switch)
and returns Unhandled for everything else so normal dispatch and the
CompositeBuffer keymap handle arrows, typing, and rebindable hunk nav
(n/p/]/[).input/fuzzy/)A custom fzf-style scorer. Two strategies run in parallel and the higher score
wins: a dynamic-programming pass over interleaved query chars and a
contiguous-substring pass that rewards tight matches. Scoring rewards consecutive
matches, word boundaries (after space/_/-///.), start-of-string,
camelCase, exact and exact-basename matches, contiguous substrings, basename
prefixes, and path-segment prefixes, with gap penalties. Pattern parsing: the query
is space-separated AND terms (each must match), case-insensitive, with an ASCII
byte-level fast path and a non-allocating subsequence pre-rejection. There are
no prefix operators (', ^, !). The matcher reuses scratch buffers via an
arena-of-backpointers so the hot path is linear in the product of the input
lengths.
A unified prompt with prefix-based routing, inspired by VSCode's Ctrl+P. A
QuickOpenProvider exposes prefix(), suggestions(query, ctx), and
on_select(). The registry's get_provider_for_input sorts prefixes
longest-first, strips the matched prefix, and falls back to the empty-prefix
default. Built-in providers:
| Prefix | Provider | Result |
|---|---|---|
| (empty) | FileProvider | OpenFile { path, line, col } |
> | CommandProvider | ExecuteAction(Action) |
# | BufferProvider | ShowBuffer(id) |
: | GotoLineProvider | GotoLine(Absolute/Relative) |
on_select returns a QuickOpenResult enum that the host maps to an
action/navigation. FileProvider finds files via git ls-files (falling back to a
directory walk), loads asynchronously with partial UI updates, applies frecency
scoring (time-decay × access count), and parses a trailing :line:col. It can swap
filesystem backends for remote authorities. The corresponding actions (QuickOpen,
QuickOpenFiles, QuickOpenBuffers, CommandPalette) are all in the Action
enum; CommandPalette is kept as an alias of QuickOpen for keymap/plugin
compatibility.
PLANNED: a generic Finder<T> abstraction proposes collapsing the finder plugins
(live grep, git grep, etc.) into one shared abstraction. Not yet built; the core
Quick Open above is shipped. PLANNED: a flash/EasyMotion-style label jump is a
validated proposal that depends on getNextKey() / wildcard-binding plugin-API
additions; the input path it needs already exists (the getNextKey callback hook).
inherit_normal_bindings / inheriting_modes; the source is the source of
truth.wRepeatCount handling.This document consolidates and supersedes the input-relevant content of:
inherit_normal_bindings).Still relevant as standalone references: