Skip to content

Keybindings

Lshika edited this page Aug 14, 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 and the help menu (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, previous, confirm, confirm_yes, confirm_no, insert (only relevant if [navigation].vim_mode = true), vim_left/vim_right/vim_up/vim_down (also only relevant if vim_mode = true — see How a keypress becomes an action below), help (see Help menu below), and resize mode's spawn_box/resize/save_layout/cycle_preset/new_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.

Ctrl+C is still the only real quit, and that's deliberate now, not a gap — tuicc is a persistent process (see Architecture: Process lifecycle), toggled into and out of view by your WM rather than relaunched each time. Every other key that used to exit tuicc — confirm on most actions, Escape at the top level — now dismisses it instead: hides the window, keeps the process running warm. Ctrl+C (an ordinary terminal interrupt, not a tuicc keybind) is the only thing that actually ends the process.

If you want vim-style navigation, vim_left/vim_right/vim_up/vim_down (default h/l/k/j) duplicate left/right/up/down once vim_mode = true — see below for exactly what they do.

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, tab/previous (plus their duplicate arrow/vim keys), left/right (plus duplicates) as an explicit module-switch, Escape (dismisses tuicc at this point, if nothing else already claimed it), 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 moves to the next item, rolling into the next module once you run past the last item in the current one — ordered (the full navigable item list, already sorted by tab_order()) is filtered to the active module's own items first; only when there's nowhere further to go within that filtered list does it fall back to the next module name (cycling through the module names present in your current layout, sorted by each box's own position — not raw declaration order in the preset file) and land on that module's first item.

previous (Shift+Tab by default) is Tab's exact mirror: previous item, rolling into the previous module's last item once you run past the first item in the current one.

Arrow keys and vim keys are pure duplicates of Tab/previous, nothing more — there's no spatial or geometric navigation left in tuicc at all (see Architecture: Why spatial navigation was removed for why it was dropped). down/vim_down behave exactly like Tab; up/vim_up behave exactly like previous. left/vim_left and right/vim_right are a coarser, distinct action: jump straight to the previous/next module's first item, skipping past however many items are left in the module you're currently in — the same thing previous used to do (back when it was switch_module) before it was repurposed into "previous item." vim_left/vim_right/vim_up/vim_down only fire when [navigation] vim_mode = true — left inert otherwise, specifically so h/j/k/l don't silently stop reaching the launcher's ambient "type anywhere to search apps" for everyone, not just vim users.

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 a Tab/arrow 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 dismisses (hides via the WM, process keeps running — see Architecture: Process lifecycle) or shows a confirmation prompt first, in both cases; some handlers (connectivity, sessions, control, media) never dismiss at all, since toggling wifi, saving a session, flipping a control toggle, or controlling media playback shouldn't hide the window you're looking at.

Resize mode

resize (F2 by default) opens a persistent, two-level session — a completely separate input-hijack state from normal navigation, structurally the same idea as typing_mode (see main.py: checked early in the loop, before the normal confirm/Tab/arrow-key dispatch, and owning every keypress until you leave it). See Architecture: Editing the layout from inside tuicc for how the underlying box mutation works; this section covers the key-level behavior.

Level 1: browsing. F2 opens here, not straight into editing a box. Tab/previous/arrows/vim-keys pick which module is active, exactly the same as normal navigation outside the session — you're just choosing which module you'll edit, nothing is being resized yet.

  • confirm (Enter) on the active module drills into level 2, editing.
  • delete_box (Delete) works here too — asks y/n first, removes the active module from your layout.
  • Escape leaves the whole session, keeping whatever changes you've already confirmed in memory (nothing's written to disk yet either way).

Level 2: editing one box, entered via confirm from browsing (or directly, standalone, via spawn_box — see below):

  • 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) asks y/n first, same as at the browsing level.
  • confirm (Enter) keeps the change and returns to browsing — not out of the session — so you can pick a different module and edit it too, repeatedly, before saving anything.
  • Escape reverts this one box to exactly how it was when you started editing it (position and size both, even if you toggled between them mid-edit), then returns to browsing. 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 (F6), normally reachable from ordinary navigation, also works from either level of an active session — it first commits whatever's in progress (same as confirm would), 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 editing it, in move sub-mode, ready to position.

save_layout (F3), cycle_preset (F4), new_preset (F5), and help (F1) all behave the same way — reachable from normal navigation, but also from either level of a resize session, where they first commit any in-progress change the same way confirm does, then end the whole session, not just the current box's edit. save_layout overwrites your currently-active preset's file in place (see Config Reference) — repeated saves across a long editing session don't pile up a new numbered preset each time; cycle_preset swaps in the next preset number that exists, replacing cfg.layout entirely; new_preset forks the current layout into a brand-new preset number instead of overwriting the active one — the only way to start a fresh preset from a layout you like without hand-editing files.

Internally, this whole mode (and the spawn picker) is driven by resize_mode.py's ResizeState/SpawnPickerState — plain dataclasses plus functions that take one and mutate it (enter_edit_mode/exit_edit_mode for the browsing level; enter_box_editing/commit_box_editing/escape_box_editing/apply_direction/toggle_dimension for the editing level; request_delete/confirm_delete_yes/confirm_delete_no for the delete-with-confirmation flow at either level; open_picker/choose for the spawn picker), not a class with methods. main.py holds one instance of each. Browsing's key handling stays directly in main.py (it never claims every key, so it isn't a true modal). Editing's key handling lives in resize_mode.handle_editing_key() instead — it returns a small EditKeyResult value rather than a bare bool, since a handoff to save_layout/cycle_preset/new_preset/help/spawn_box needs main.py's own functions, which resize_mode.py can't call directly. See Architecture: Editing the layout from inside tuicc.

Help menu

help (F1 by default) opens a small in-app reference — no need to leave tuicc or find this wiki to remember a keybind. Digits 13 pick a page, Escape backs out one level (page → menu → closed):

  1. Help — a two-question FAQ (how do you control this thing, what to do if your layout looks wrong), with your actual current [navigation.keys] bindings and any power_menu Ctrl+<letter> shortcuts printed inline — read straight from config.toml (config.get_raw_navigation_keys()/get_raw_power_menu_actions()), not resolved-then-redisplayed, for the same reliability reason described above (key_label() can't recover a Ctrl+<letter> name from its resolved code — reading the original string sidesteps that instead of working around it).
  2. Resize mode — a condensed version of the Resize mode section above.
  3. Colors — a live editor for the 8 [theme] roles (see Config Reference): arrows pick a role (each row shows its current value, same raw-string-from-config.toml approach as the keybinds page), Enter edits it inline in place — type a named color, #hex, or "inherit", Enter again to apply. A static mockup of tuicc's own UI sits alongside the list and repaints immediately when you apply a value (theme_setup.reassign_theme_pairs() redefines the existing curses color pairs in place, no restart needed), and the change is written to config.toml (config.set_theme_color()) so it survives one too. Not supported: an [R, G, B] list value — not practical to type into a single-line field, still fully available by hand-editing config.toml.

Same HelpState-dataclass-plus-functions shape as resize mode, in help_mode.py — see Architecture: Editing the layout from inside tuicc.

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, 'previous': 353, 'confirm': 10, 'confirm_yes': 121, 'confirm_no': 110, 'insert': 105, 'vim_left': 104, 'vim_right': 108, 'vim_up': 107, 'vim_down': 106, 'help': 265, 'resize': 266, 'save_layout': 267, 'cycle_preset': 268, 'spawn_box': 270, 'move_toggle': 109, 'delete_box': 330, 'new_preset': 269}
>>> 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