-
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,Delete,F1–F12). -
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.
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.
left, right, up, down, tab, switch_module, confirm, confirm_yes, confirm_no, insert (only relevant if [navigation].vim_mode = true), help (see Help menu below), 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.
[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.
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. Pressmove_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 asksy/n(via yourconfirm_yes/confirm_nobindings) first. -
confirm(Enter) keeps the change and returns to normal navigation. Nothing is written to disk at this point — you canswitch_moduleand enter resize mode on a different box, repeatedly, before saving anything. -
Escapereverts 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 viaspawn_boxin this same session, there's no "before" state to revert to, soEscaperemoves it instead.
spawn_box (F6), 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), cycle_preset (F4), and help (F1) all 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, rather than losing it. 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.
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 (start/commit/escape/apply_direction/toggle_dimension, open_picker/choose), not a class with methods. main.py holds one instance of each and calls these instead of containing the key-handling logic itself — see Architecture: Editing the layout from inside tuicc.
help (F1 by default) opens a small in-app reference — no need to leave tuicc or find this wiki to remember a keybind. Digits 1–3 pick a page, Escape backs out one level (page → menu → closed):
-
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 anypower_menuCtrl+<letter>shortcuts printed inline — read straight fromconfig.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 aCtrl+<letter>name from its resolved code — reading the original string sidesteps that instead of working around it). - Resize mode — a condensed version of the Resize mode section above.
-
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),Enteredits it inline in place — type a named color,#hex, or"inherit",Enteragain 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 toconfig.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-editingconfig.toml.
Same HelpState-dataclass-plus-functions shape as resize mode, in help_mode.py — see Architecture: Editing the layout from inside 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, 'help': 265, 'resize': 266, 'save_layout': 267, 'cycle_preset': 268, 'spawn_box': 270, '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.