← XeFM crftwr/xefm on GitHub · craftware

Key Bindings System Implementation

Overview

XeFM maps config key tokens ("q", "Shift-Down", "Command-ENTER") to actions, matching them against PuiKit key events. It supports named keys, modifier chords, punctuation and shifted-symbol identities, and per-action selection requirements.

The normative cross-backend keyboard contract — the Event(KEY, key, char, modifiers) shape and how each backend (curses / macOS / Windows) normalizes a keypress into it — lives in PuiKit: puikit/docs/keyboard_contract.md. This document covers XeFM’s side: how a config token is parsed and matched against that contract.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                     XeFM Application (xefm/app.py)                 │
└────────────────────────┬────────────────────────────────────┘
                         │ Uses
                         ↓
┌─────────────────────────────────────────────────────────────┐
│              Public API (xefm/config.py)                      │
│  - find_action_for_event(event, has_selection)              │
│  - get_keys_for_action(action)                              │
│  - format_key_for_display(key_expr)                         │
└────────────────────────┬────────────────────────────────────┘
                         │ Delegates to
                         ↓
┌─────────────────────────────────────────────────────────────┐
│                  KeyBindings class                           │
│  - Parses tokens to (identity, modifiers, mode)             │
│  - Reduces an event to (key, char, modifiers)               │
│  - Matches the two against each other                        │
└────────────────────────┬────────────────────────────────────┘
                         │ Consumes
                         ↓
┌─────────────────────────────────────────────────────────────┐
│                PuiKit key event (puikit.event)               │
│  - key:       canonical identity string ("a", "enter")      │
│  - char:      produced glyph, or None                        │
│  - modifiers: set ⊆ {"shift","ctrl","alt","cmd"}            │
└─────────────────────────────────────────────────────────────┘

The keyboard contract (XeFM’s view)

A key event reduces to a triple: key (canonical identity), char (produced glyph or None), and modifiers (a set of shift / ctrl / alt / cmd). A parsed config token carries (identity, modifiers, mode) and matches in one of two modes:

Shifted symbols are their own identity. A shifted digit/punctuation binds to the glyph it produces — Shift-EQUAL"+", Shift-1"!" — matched in char mode with shift dropped, so it reports the same on every backend.

Bare uppercase letters do not imply shift. A bare "J" parses to key j with no modifier (identical to "j"); only "Shift-J" keeps the modifier. Alphabetical bindings are case-insensitive by design (the parser lowercases the letter).

Config token → identity map

Config token(s) Resolves to Match mode
az / AZ lowercase letter key + exact mods (Shift- adds shift)
ENTER/RETURN, ESCAPE/ESC, TAB, BACKSPACE, DELETE/DEL, INSERT, SPACE named identity (enter, escape, space, …) key + exact mods
UP / DOWN / LEFT / RIGHT / HOME / END same, lowercased key + exact mods
PAGE_UP/PAGEUP, PAGE_DOWN/PAGEDOWN pageup / pagedown key + exact mods
F1F12 f1f12 key + exact mods
named punctuation (MINUS, EQUAL, LEFT_BRACKET, SEMICOLON, SLASH, …) base glyph (-, =, [, ;, /, …) char (ignore shift/alt)
digit / punctuation literal (?, ., :, 1, …) the produced glyph char
Shift-<named punct / digit> the shifted glyph (Shift-EQUAL+, Shift-1!) char
Shift-X (letter) x + shift key + exact mods
Command-X / Alt-X x + cmd / alt key + exact mods (curses can’t deliver cmd; such chords are GUI-only)

The maps that back this table live at the top of xefm/config.py: _MODIFIER_ALIASES, _NAMED_KEYS, _PUNCT_NAMES, _SHIFT_SYMBOL, _KEY_ALIASES.

KeyBindings class

Location

xefm/config.py

Key methods

_parse_key_expression(key_expr) -> (identity, modifiers, mode)

Parses a config token to its parsed triple.

Algorithm:

  1. Single-character token: a letter(lower, frozenset(), "key"); anything else (digit / punctuation) → (char, frozenset(), "char").
  2. Otherwise split on -: the last part is the key, earlier parts are modifiers (resolved case-insensitively via _MODIFIER_ALIASES; unknown ones warn and are skipped). Then, on the key part:
    • a named key (_NAMED_KEYS) → (identity, mods, "key");
    • named punctuation (_PUNCT_NAMES) → _punct_binding (char mode);
    • a single letter(lower, mods, "key");
    • a single digit / punctuation literal_punct_binding.

_punct_binding(glyph, mods) -> (glyph, modifiers, "char")

Builds a char-mode binding, folding a Shift modifier into the produced (shifted) glyph via _SHIFT_SYMBOL and dropping shift, so the identity is the character the key actually emits.

_event_identity(event) -> (key, char, modifiers)

Reduces a PuiKit Event (event.key / event.char / event.modifiers) to the contract triple. Aliases page_up / page_downpageup / pagedown.

_matches(parsed, key, char, mods) -> bool

Applies the two match modes described in The keyboard contract above.

find_action_for_event(event, has_selection=False) -> str | None

Reduces the event, scans the reverse-lookup table for a matching parsed binding, and returns the first action whose selection requirement is satisfied.

get_keys_for_action(action) -> (key_expressions, selection_requirement)

Returns the raw config tokens and selection requirement for an action (used by the help dialog).

format_key_for_display(key_expr) -> str

Formats a token for UI display: single literals pass through; named tokens map to conventional labels via _KEY_DISPLAY (ENTEREnter, UP, PAGE_UPPgUp); modifiers abbreviate via _MOD_DISPLAY (CommandCmd, OptionOpt). E.g. "Command-Shift-X""Cmd-Shift-X".

Public API functions

Module-level wrappers in xefm/config.py delegate to the ConfigManager’s cached KeyBindings instance:

from xefm.config import find_action_for_event, get_keys_for_action, format_key_for_display

action = find_action_for_event(event, has_selection)   # -> 'quit' | None
keys, sel_req = get_keys_for_action('delete_files')     # -> (['DELETE', 'Command-Backspace'], 'required')
label = format_key_for_display('Command-Shift-X')       # -> 'Cmd-Shift-X'

Configuration formats

Simple (keys only, selection defaults to 'any'):

'action_name': ['key1', 'key2']

Extended (with selection requirement):

'action_name': {'keys': ['key1', 'key2'], 'selection': 'required'}  # or 'none' | 'any'

Selection requirements

Enforced in find_action_for_event via _check_selection_requirement, so a token can map to different actions depending on selection state.

Data structures

KeyBindings builds a reverse lookup once at init (_build_key_lookup), keyed by the parsed triple:

_key_to_actions = {
    ("q",      frozenset(), "key"):  [("quit", "any")],
    ("pageup", frozenset(), "key"):  [("page_up", "any")],       # from token "PAGE_UP"
    ("delete", frozenset(), "key"):  [("delete_files", "required")],
    ("?",      frozenset(), "char"): [("help", "any")],
    ("=",      frozenset(), "char"): [("diff_files", "any")],    # from token "EQUAL"
    ("+",      frozenset(), "char"): [("diff_directories", "any")],  # from token "Shift-EQUAL"
}

Lookup is a linear scan over this table applying _matches (the table is small); the ConfigManager caches the KeyBindings instance and rebuilds it only on reload_config().

Error handling

Parsing is defensive — an unknown modifier or key token logs a warning and is skipped rather than crashing; a missing KEY_BINDINGS config falls back to DefaultConfig.KEY_BINDINGS.

Testing

See Also