-
Notifications
You must be signed in to change notification settings - Fork 1
Keybindings
This page covers how key handling actually works, not just which config keys exist — for the config syntax alone, see Config Reference.
keybinds.py's resolve_key() turns a string from your config into a curses key code, once, at startup — it's pure (no live screen needed), so you can check what a name resolves to without running tuicc at all:
>>> import sys; sys.path.insert(0, "src")
>>> from tuicc.keybinds import resolve_key
>>> resolve_key("Left")
258
>>> resolve_key("h")
104
>>> resolve_key("Ctrl+L")
12
>>> resolve_key("nonsense")
Traceback (most recent call last):
...
ValueError: Unknown key name: 'nonsense'. Expected one of [...], a single character, or "Ctrl+<letter>".Three input shapes are accepted:
- A named special (
Left,Right,Up,Down,Tab,Shift+Tab,Enter,Escape,Space). -
Any single character, resolved via Python's
ord(). Uppercase letters work as an implicit Shift for free — curses reports"A"and"a"as different codes on their own, soresolve_key("A")just works without any special-casing. -
Ctrl+<letter>— resolved as the plain ASCII control code (Ctrl+A= 1 ...Ctrl+Z= 26, i.e.ord(letter.upper()) - 64). This works reliably across terminals, unlike generalShift+<key>combos (onlyShift+Tabis special-cased, viacurses.KEY_BTAB) — curses just doesn't expose modifier state separately from the key itself for most combinations, soCtrl+is deliberately the only modifier this goes further than that with.Ctrl+with anything other than a single letter (a digit, punctuation, an arrow key) isn't attempted, for the same reliability reason.
left, right, up, down, tab, switch_module, confirm, confirm_yes, confirm_no, and insert (only relevant if [navigation].vim_mode = true) are all read from [navigation.keys] in your config — see Config Reference for the full table. confirm_yes/confirm_no answer a pending Y/N dialog; the on-screen hint (power_menu.py/quick_actions.py's draw(), via keybinds.key_label()) always reflects whichever keys you've actually bound, so rebinding these never leaves the displayed hint out of sync.
One thing is still hardcoded, separate from [navigation.keys] entirely: there's no quit key at all — Ctrl+C (an ordinary terminal interrupt, not a tuicc keybind) is currently the only way to exit without doing anything.
If you want vim-style navigation, you can remap every key covered in [navigation.keys] today; that one gap will need to stay as-is until it closes.
[navigation.keys] covers navigation — moving selection, switching modules. Global shortcuts are different: a key bound to a specific power-menu action's shortcut field (see Config Reference) runs that exact action from anywhere in the running app, regardless of what's currently selected or which module is active.
How it's built, at config-load time in config.py: every power_menu.action entry with a shortcut set gets resolved via resolve_key() and collected into Config.global_shortcuts — a {key_code: {"target_kind": ..., "item_id": ...}} dict. While building it, every new shortcut is checked against everything already claimed — every other shortcut, and every [navigation.keys] binding — and a collision raises a KeyError naming both things bound to the same key. Two bindings silently fighting over one key, with one winning invisibly, is exactly what this is built to prevent.
How it's dispatched, in main.py's loop, in this order:
-
pending_confirm— if a Y/N prompt is showing, onlyy/ndo anything; a shortcut can't fire mid-confirmation. -
global_shortcuts— checked next, before anything else. If the key matches, the corresponding action runs immediately via the sameACTION_HANDLERSlookup normal Enter-confirm uses (a syntheticNavItemis built from the storedtarget_kind/item_id, sopower_menu.py's ownhandle()needs no special-casing for "was this triggered by selection or by shortcut"). -
typing_mode— the launcher's search input. A shortcut still fires here too, sinceCtrl+<letter>codes (1–26) never overlap with the printable-character range typing mode captures. - Everything else —
confirm,switch_module,tab, arrow keys, and ambient typing to open the launcher.
This is worth understanding if you're debugging unexpected navigation behavior, or writing a module that needs to interact with it.
Tab cycles through items within the currently active module — ordered (the full navigable item list) is filtered down to just the items whose id prefix matches ctx.active_module, then advances to the next one with wraparound.
switch_module (Shift+Tab by default) switches which module is "active" — cycles through the module names present in your current layout preset, and jumps selection to the first item in the newly-active module.
Arrow keys behave differently depending on what's currently selected:
- If the selected item is a
"window"(in the preview) and the direction is left/right, tuicc first triessibling_in_same_group()(innavigation.py) — the predictable left-to-right order within the preview, not spatial search. See Architecture: Spatial vs. linear navigation for why. - If that finds nothing (you're at the leftmost window) and the direction is left, it falls back to a deterministic rule: return to the sidebar entry for the workspace actually being viewed.
- Otherwise, it falls back to
nearest_in_direction()— genuine spatial search, used for moving between well-separated modules like sidebar ↔ preview.
Whichever of these actually moves the selection also updates ctx.active_module to match — so the highlighted module border (border_selected vs border in your theme) always reflects where the selection actually is, even after an arrow-key move that crosses module boundaries.
Confirm looks up a handler for the selected item's target_kind in ACTION_HANDLERS (built from actions.py's BASE_HANDLERS plus whatever modules register — see Writing a Module) and calls it. A global shortcut goes through this exact same lookup, just triggered by a different key check earlier in the loop, with a synthetic NavItem standing in for "the thing that's actually selected right now." The handler decides whether tuicc exits immediately or shows a confirmation prompt first, in both cases.
You don't need a full session to check that a config change resolves the way you expect:
>>> import sys; sys.path.insert(0, "src")
>>> from tuicc.config import load_config
>>> cfg = load_config()
>>> cfg.keybinds
{'left': 260, 'right': 261, 'up': 259, 'down': 258, 'tab': 9, 'switch_module': 353, 'confirm': 10, 'confirm_yes': 121, 'confirm_no': 110}
>>> cfg.global_shortcuts
{12: {'target_kind': 'power_action', 'item_id': 'power_menu:0'}, 15: {'target_kind': 'power_action', 'item_id': 'power_menu:1'}}If a value here doesn't match what you put in config.toml, check for the usual culprit first: ~/.config/tuicc/config.toml might be out of date with a newer packaged default (delete it and let tuicc regenerate it) — see the note at the top of Config Reference. If load_config() itself raises a KeyError mentioning a shortcut collision, that's the collision check described above doing its job — pick a different key for one of the two things it named.