Skip to content

Keybindings

Lshika edited this page Aug 2, 2026 · 10 revisions

Keybindings

This page covers how key handling actually works, not just which config keys exist — for the config syntax alone, see Config Reference.

How a key name becomes a key press

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, Delete, F1F12).
  • 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, so resolve_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 general Shift+<key> combos (only Shift+Tab is special-cased, via curses.KEY_BTAB) — curses just doesn't expose modifier state separately from the key itself for most combinations, so Ctrl+ 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.

F-keys are their own namespace, added specifically for resize mode (below): a bare character collides with the launcher's ambient-typing fallback, and Ctrl+<letter> is already the convention for power-menu-style global shortcuts — F-keys collide with neither.

What's configurable, and what isn't (yet)

left, right, up, down, tab, switch_module, confirm, confirm_yes, confirm_no, insert (only relevant if [navigation].vim_mode = true), and resize mode's spawn_box/resize/save_layout/cycle_preset/move_toggle/delete_box (see Resize mode below) 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.

Global shortcuts (a separate system from [navigation.keys])

[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:

  1. pending_confirm — if a Y/N prompt is showing, only y/n do anything; a shortcut can't fire mid-confirmation.
  2. global_shortcuts — checked next, before anything else. If the key matches, the corresponding action runs immediately via the same ACTION_HANDLERS lookup normal Enter-confirm uses (a synthetic NavItem is built from the stored target_kind/item_id, so power_menu.py's own handle() needs no special-casing for "was this triggered by selection or by shortcut").
  3. typing_mode — the launcher's search input. A shortcut still fires here too, since Ctrl+<letter> codes (1–26) never overlap with the printable-character range typing mode captures.
  4. Everything else — confirm, switch_module, tab, arrow keys, and ambient typing to open the launcher.

How a keypress becomes an action

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 moduleordered (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 tries sibling_in_same_group() (in navigation.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.

Resize mode

resize (F2 by default) enters resize mode on whichever module is currently active — a completely separate input-hijack state from normal navigation, structurally the same idea as typing_mode (see main.py: a boolean flag checked early in the loop, before the normal confirm/switch_module/arrow-key dispatch, that owns every keypress until it exits). See Architecture: Editing the layout from inside tuicc for how the underlying box mutation works; this section covers the key-level behavior.

While active:

  • Arrow keys change the box's size (w/h) by one terminal cell per press. Press move_toggle (m) to switch to changing its position (x/y) instead — the on-screen hint always shows which sub-mode you're in.
  • delete_box (Delete) doesn't delete immediately — it asks y/n (via your confirm_yes/confirm_no bindings) first.
  • confirm (Enter) keeps the change and returns to normal navigation. Nothing is written to disk at this point — you can switch_module and enter resize mode on a different box, repeatedly, before saving anything.
  • Escape reverts the box to exactly how it was when you entered resize mode on it (position and size both, even if you toggled between them mid-session). On a box spawned via spawn_box in this same session, there's no "before" state to revert to, so Escape removes it instead.

spawn_box (F1), normally reachable from ordinary navigation, also works while resize mode is already active on some other box — it first does the same thing confirm would (keeps the current box's change, exits back to normal state), then opens its own picker: a numbered list of every module not currently in your layout (set(render.MODULES.keys()) - {box names already placed}). Pressing the matching digit spawns that module centered on screen and drops you straight into resize mode on it, in move sub-mode, ready to position.

save_layout (F3) and cycle_preset (F4) behave the same way — reachable from normal navigation, but also mid-resize, where they first commit the in-progress change the same way confirm does. save_layout writes your whole current layout as a new preset and switches to it (see Config Reference); cycle_preset swaps in the next preset number that exists, replacing cfg.layout entirely — if you were resizing a box that doesn't exist in the newly-loaded preset, there's simply nothing left to resize, and normal navigation just picks a new selection the way it always does when the previous selection stops being valid.

Testing keybindings without running tuicc

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, 'spawn_box': 265, 'resize': 266, 'save_layout': 267, 'cycle_preset': 268, 'move_toggle': 109, 'delete_box': 330}
>>> cfg.global_shortcuts
{12: {'target_kind': 'power_action', 'item_id': 'power_menu:0'}, 15: {'target_kind': 'power_action', 'item_id': 'power_menu:1'}, 18: {'target_kind': 'power_action', 'item_id': 'power_menu:2'}, 16: {'target_kind': 'power_action', 'item_id': 'power_menu:3'}}

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.

Clone this wiki locally