Skip to content

Architecture

Lshika edited this page Aug 4, 2026 · 14 revisions

Architecture

This page goes deeper than the README's architecture summary — the reasoning behind the core design decisions, not just what they are.

The three core pieces

tuicc's core does exactly three things. Nothing else in the codebase is "core" — everything else is a module built on top.

1. WM provider layer

A Provider translates whatever your window manager reports into tuicc's generic model (Window, Region, WMState, defined in model.py). It also exposes actions back to the WM (focus_region, focus_window, move_window_to_region) through the same contract, so nothing outside the provider ever needs a WM-specific command.

The model uses normalized coordinates (0..1, relative to the containing region) instead of pixels. This one decision is why the rest of tuicc never needs to know your screen resolution, and why the layout engine, preview module, and navigation system all work identically regardless of what's actually plugged into your laptop.

Three related non-abstract methods — mark_self(), dismiss_self(), focus_self() — exist so tuicc can manage its own window without every provider needing to reimplement WM-specific mark/hide/focus commands from scratch. mark_self() (called once at startup) marks tuicc's own window so get_state() can filter it out of every Window it reports downstream — no per-module filtering needed, every module just sees clean data automatically. dismiss_self() hides that same window (sway/i3: moves it to the scratchpad) without ending the process — the persistent-process lifecycle model (see "Process lifecycle: dismiss vs. quit" below) calls this instead of exiting. focus_self() reclaims keyboard focus after a newly-spawned or session-restored window steals it, since most WMs hand a newly-mapped window focus regardless of stacking order — skipping this makes the launcher (and session restore) nearly unusable on a WM that does this.

All three are optional, degrading gracefully: a provider for a WM with no equivalent concept leaves the default no-op, and tuicc degrades (shows itself in its own preview, can't hide itself, loses focus to spawned windows) rather than crashing. mark_self() additionally takes an optional app_id (from [wm] self_app_id in config) — when your launch command gives tuicc's window a known, stable identity (e.g. kitty --app-id tuicc_scratch), marking by that WM criteria instead of "whatever's focused right now" removes a real race condition multiple back-to-back instances can otherwise hit. See Writing a WM Provider: Filtering tuicc's own window, dismissing it, and reclaiming focus for the full reasoning, including why the mark has to be per-process-unique (sway/i3 marks are globally unique across the whole tree, so a shared literal string breaks the moment a second tuicc instance runs).

2. Layout engine

Layout/ModuleBox (in layout.py) describe where each module sits: name plus x, y, w, h — all four required, all plain ratios (0.0–1.0) of the terminal's width (x, w) or height (y, h). compute_boxes() (in layout_engine.py) is a single flat loop converting each box's ratios into actual terminal rows/columns for the current size — no per-box coordination, no resolution order to worry about.

This is a deliberate simplification, not the original design. An earlier version let a box derive its position/size from another box — right_of/below/above/bottom for position, cols/rows/fill_to for a genuinely fixed (non-scaling) size — specifically so a content-driven box like power_menu could have exactly N rows on any terminal height, anchored from the bottom of the screen so a ratio-sized neighbor could fill_to it and consume exactly whatever space was left. That guarantee had real value, but it also meant compute_boxes() needed multi-pass dependency resolution (compute whatever's ready, repeat, until everything resolves or a pass makes no progress — a missing reference or a cycle was a hard error), and config.py needed to validate four separate "exactly one of these fields" groups per box.

The tradeoff stopped making sense once interactive resize mode existed (see below): if a box looks wrong on a terminal size very different from the one you set it up on, fixing it is a few keypresses away instead of needing a ratio that works everywhere out of the box. Boxes are now completely independent — resizing or repositioning one never moves or resizes another, full stop; what you configure is exactly what renders.

The whole thing is a pure function — same inputs, same output, testable without a running screen — and it's recalculated every frame, deliberately cheap rather than cached (see "Why recompute every frame" below).

Editing the layout from inside tuicc

resize_mode.py is the interactive counterpart to the layout engine. The per-box math is pure functions (enter_resize/resize_step/move_step/cancel_resize) that mutate a ModuleBox's w/h or x/y one terminal cell at a time. On top of that sits a session layer, ResizeState/SpawnPickerState — plain @dataclasses — plus functions that take one and mutate it, same "pure function over an explicit value" shape as navigation.py's resolve_selection/next_module_name, deliberately not a class-with-methods rewrite.

ResizeState is a two-level session, not the single-shot mode it used to be: enter_edit_mode()/exit_edit_mode() open and fully close the session (browsing — no box is being edited yet, Tab/Shift+Tab/arrows just pick which module is active, same as outside the session entirely); enter_box_editing()/commit_box_editing()/escape_box_editing() drill into and back out of editing one specific box (confirm keeps the change and returns to browsing — not out of the session — so you can resize/move several modules in a row before ever saving anything; Escape reverts). request_delete()/confirm_delete_yes()/confirm_delete_no() handle the delete-with-confirmation flow, reachable from either level. main.py's loop holds one ResizeState instance and decides when to call these functions (which key means what, in what order, at which level) — neither the dataclass nor any function operating on it knows anything about curses or the key-dispatch order itself. Resize mode isn't a module — it doesn't follow the draw()/nav_items() contract, since it's editing the layout itself rather than rendering within a fixed box; its status line is written directly onto the screen via render_utils.draw_status_line(), and the box currently being edited gets a highlighted outline overlay via resize_mode.draw_editing_highlight(). See Config Reference: Editing a layout from inside tuicc and Keybindings: Resize mode for the full keybinding-level behavior.

help_mode.py (the F1 help menu) is the same shape — a HelpState dataclass plus functions (enter/select_page/move_color_index/start_color_edit/type_color_key/apply_color_edit) that main.py calls into, plus a draw() that paints the whole panel in one call, replacing draw_all() for that frame rather than overlaying it. The one thing it deliberately does not do itself: applying a color edit. apply_color_edit() only validates the typed text and resolves it to a curses color number — main.py is the one that assigns it into cfg.theme, calls theme_setup.reassign_theme_pairs() (redefines the existing curses color pairs in place — every cell already drawn with that pair updates on the next refresh, no restart needed) and config.set_theme_color(), since cfg/live curses state are main.py's own, not something a mode module should reach into on its own. See Keybindings: Help menu for the page-by-page behavior.

Both modules read a config value straight from config.toml in a couple of places instead of going through Config's already-resolved version of it — config.get_raw_navigation_keys()/get_raw_power_menu_actions() for the keybinds page, get_raw_theme_values() for the colors page. This isn't an oversight: keybinds.key_label() can't recover a Ctrl+<letter> name from its resolved key code at all (there's no way back from an integer to which modifier produced it), and Config.theme only ever holds already-resolved curses color numbers, not the original "cyan"/"#7dd3fc"/[R,G,B] a user actually wrote. Reading the raw string straight from the file sidesteps both problems entirely instead of trying to work around them after the fact.

The one piece of this that reaches outside Layout/ModuleBox/cfg.theme entirely: saving (save_layout) or cycling (cycle_preset) a preset needs to update config.toml's [layout] preset = N line, and applying a color edit needs to update its [theme] <role> = ... line, so the change actually sticks on next launch — but config.toml is hand-edited and commented, and tomllib (read) paired with tomli_w (write) can't round-trip a file without silently dropping every comment in it. config.py's set_active_preset()/set_theme_color() work around this by patching just the one relevant line as text (both go through a shared _patch_config_line() helper), leaving everything else in the file byte-for-byte untouched — the first place(s) in the codebase this workaround was actually needed, rather than just designed around (see "Config as the single source of truth" below for why presets themselves are separate per-number files specifically to avoid needing this trick more often).

3. Input routing

navigation.py defines NavItem — a generic focusable thing with a position, independent of which module it came from — and two ways to move between a list of them: tab_order() (fixed sequence — next/previous item, rolling into the next/previous module once you run past either end of the current one) and hotkey_map() (direct jump). There used to be a third way, spatial (arrow-key) nearest-neighbor search across the whole layout — removed; see "Why spatial navigation was removed" below.

Global keyboard shortcuts are a separate, simpler mechanism layered on top: keybinds.py resolves a config key name (including Ctrl+<letter> combos) to a curses key code, config.py collects every action with a shortcut set into Config.global_shortcuts — checking for collisions against each other and against [navigation.keys] at load time, so two things bound to the same key fail loudly at startup instead of one silently winning — and main.py's loop checks that mapping directly, right after handling a pending confirm but before anything else, so a shortcut fires from anywhere in the running app regardless of which module is active.

Process lifecycle: dismiss vs. quit

tuicc's main loop, once started, is never expected to exit in normal use — it's a persistent process your WM shows and hides (via sway/i3's scratchpad, or the equivalent on other WMs), not something you relaunch each time. Every action handler's should_dismiss bit (see Writing a Module for the handler contract) means "call Provider.dismiss_self() and keep looping," not "terminate" — main.py's while True: has no break left in it anywhere. The only way out is an unhandled exception, which in practice means Ctrl+C (KeyboardInterrupt), caught quietly at the very bottom of main.py and preceded by a try/finally around the loop for cleanup (stopping the connectivity background worker today; a future D-Bus agent unregister has the same slot).

This is a bigger shift than a rename: tuicc used to exit the process on almost every action — focusing a workspace or window, running a confirmed power-menu command — so every "reopen tuicc" was a cold start. Now dismissing just hides the window; the process stays warm, state intact, ready for the next summon. See VISION.md (section 2) for the full design reasoning, and the main README's "Summoning tuicc" section for the WM-side setup this depends on.

Why recompute every frame instead of caching

compute_boxes(), collect_nav_items(), and the WM state itself are all recomputed on every loop iteration in main.py, not cached and invalidated on change. This was a deliberate simplicity-over-performance tradeoff: the number of items involved (a few dozen UI elements, at most) makes the actual computation cost negligible compared to the time between keypresses, and caching would add real complexity (cache invalidation logic, staleness bugs) for a performance gain nobody would ever notice. If this stops being true — hundreds of items, expensive per-item computation — that's the point where caching would earn its complexity back.

Modules and the RenderContext pattern

Modules (sidebar.py, sidebar_compact.py, preview.py, launcher.py, connectivity.py, power_menu.py, sessions.py, quick_actions.py, clock.py, all under modules/) each own two things: how they draw themselves (draw()) and where their own focusable items are (nav_items()). The core never guesses a module's internal layout, and render.py's MODULES/NAV_PROVIDERS registries mean adding a module never requires editing draw_all() or collect_nav_items() — just adding one line to each registry, which is also what lets resize mode's spawn picker (main.py, via resize_mode.open_picker()) offer any registered-but-not-yet-placed module for free. (quick_actions.py and clock.py are registered and fully functional, but not placed in the current default preset — reserved for later. sidebar_compact.py deliberately duplicates sidebar.py's target_kind="region" item shape rather than sharing code with it — see its own docstring for why a shared "compact mode" flag would have coupled the two instead of letting them vary independently.)

Every nav_items() call takes the module's resolved box, a RenderContext (context.py), and the module's own name (so a module can tell whether it's currently the active one). draw() takes the same three plus the actual curses screen to draw onto. Early versions of tuicc grew a new function parameter every time a module needed new shared data (theme, then selected_id, then focus_id, then quick_actions) — unsustainable once more than two or three modules exist. RenderContext bundles everything shared across modules (WM state, current selection, theme, the full resolved config) into one object; a module reads only the fields it cares about and ignores the rest. Data that's specific to a single call rather than shared across the whole frame — like which module is currently being asked to draw — is passed as a separate parameter instead of stuffed into the context, since the context represents "what's true this frame," not "what's true for this one call."

flowchart TB
    wm["your WM (sway, i3, ...)"] --> provider["Provider (SwayProvider / I3Provider / ...)"]
    config["config.toml + presets/"] --> cfgpy["config.py"]
    provider --> loop["main.py loop"]
    cfgpy --> loop
    loop --> ctx["RenderContext"]
    ctx --> modules["modules: sidebar, preview, launcher, connectivity, power_menu, sessions, ..."]
    modules --> terminal["terminal (curses)"]
    modules -. nav_items .-> loop
    cfgpy -. global_shortcuts .-> loop
Loading

Config as the single source of truth

Every config-driven value — layout, theme colors, keybindings, power-menu/quick-action commands — follows the same pattern: raw TOML is read in config.py, resolved into ready-to-use values (curses color numbers via theme.py, curses key codes via keybinds.py, Layout objects via the preset loader), and handed to the rest of the program as a finished Config object. Nothing downstream of config.py ever parses TOML or does resolution logic itself.

Colors and keys accept multiple input formats (named colors, hex, [R,G,B] for colors; named specials, single characters, or Ctrl+<letter> for keys) but always resolve to one canonical internal representation, so modules and main.py never branch on "what format did the user use."

Presets follow the same "always a real, editable file" principle as config.toml itself, but per-preset-number: ~/.config/tuicc/presets/<N>.toml is copied from the packaged template the first time that number is requested. This — rather than one shared layout file — keeps [layout] preset live-switchable at any time (not just on first run), gives resize mode's save_layout a real per-number file to write to, and keeps any of that from ever touching config.toml's hand-written sections directly, which Python's tomllib can't write back out without losing comments and formatting (set_active_preset() patches just the one preset = line as text instead, precisely to avoid that — see "Editing the layout from inside tuicc" above).

save_layout (F3) overwrites the active preset's file in place today, via save_layout_to_preset() — repeated saves during one resize-mode session don't pile up a new numbered file each time. save_new_preset() still exists separately, minting a genuinely new number, for anywhere that actually wants one. Regenerating a preset via tomli_w does silently strip any hand-written comments in it — a real, accepted one-time cost the first time save_layout_to_preset() touches a given preset (a freshly materialized, never-yet-saved-over preset keeps its comments until then), judged acceptable because it only ever fires on a preset you're actively reshaping via resize mode, not a hands-off reference file.

If a config file is missing required sections (e.g. after an update added a new section), the fix today is deleting ~/.config/tuicc/config.toml (or, for a single preset, ~/.config/tuicc/presets/<N>.toml) and letting it regenerate from the packaged default — there's no migration/merge logic yet. This is a known gap, not an oversight.

Floating windows: overview over realism

sway (and i3) distinguish floating windows from tiled ones. Floating windows can overlap each other and tiled windows arbitrarily, and the WM doesn't expose true stacking order over IPC — there's no reliable way to know "what's really on top" from the data available.

Rather than trying to approximate real stacking order, the preview module draws tiled windows first, then floating windows on top, always, in a distinct accent color with a filled background. The goal isn't to mirror the real screen pixel-for-pixel — it's to give a readable overview of everything that's open. A floating window rendered "underneath" something else in an attempt at realism would just be invisible and useless in an overview tool.

Why spatial navigation was removed

Arrow keys used to do genuine spatial search: nearest_in_direction() (geometric nearest-match) worked well for grid-like layouts — the sidebar, well-separated modules — but broke down for freely-positioned, overlapping items like floating windows in the preview, where a large item's overlap with your current position scales with its size, so big tiled windows always beat closer, smaller floating ones regardless of actual proximity, no matter how the scoring function was tuned. Working around that needed real special-casing: sibling_in_same_group() gave preview windows a predictable left-to-right linear order instead of spatial search, and a third deterministic rule handled moving left off the leftmost preview window (back to the sidebar entry for the workspace actually being viewed) rather than letting spatial search land on an arbitrary nearby region.

All of that is gone now — nearest_in_direction(), sibling_in_same_group(), and the leftmost-window fallback rule no longer exist anywhere in the codebase. Tab/Shift+Tab (and their duplicate arrow-key/vim-key bindings) are the only way to move now: next/previous item, rolling into the next/previous module at either boundary. This isn't a narrower fix for the overlapping-window case specifically — it sidesteps the whole class of problem, since Tab-order cycling never does geometric search in the first place, so on-screen overlap was never something it had to work around. The tradeoff: arrow keys lost their "jump to whatever's nearest on screen" feel in exchange for always being predictable, which is what actually mattered in practice — nearest-on-screen wasn't reliably nearest-in-context.

Directory structure

See the main README for the file-by-file layout — it's kept there since it needs to stay in sync with the actual repo and duplicating it here would just create two places to update.

Clone this wiki locally