Skip to content

Architecture

Lshika edited this page Aug 2, 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.

A fifth, non-abstract method — mark_self() — exists so tuicc never lists itself as a window in its own sidebar/preview. Called once at startup, it marks tuicc's own window (via sway/i3's native mark IPC command); get_state() filters any marked window out before it's ever turned into a Window, so every module downstream just sees clean data automatically, with no per-module filtering. It's deliberately not required — a provider for a WM without an equivalent concept can leave it as the default no-op, and tuicc degrades to showing itself in its own preview rather than crashing. See Writing a WM Provider: Filtering tuicc's own window 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 (start/commit/escape/apply_direction/toggle_dimension, open_picker/choose): same "pure function over an explicit value" shape as navigation.py's resolve_selection/next_module_name, deliberately not a class-with-methods rewrite. main.py's loop holds one instance of each and decides when to call these functions (which key means what, in what order) — neither 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(). 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 three ways to move between a list of them: tab_order() (fixed sequence), nearest_in_direction() (spatial), and hotkey_map() (direct jump). sibling_in_same_group(), also in navigation.py, provides a fourth: a linear left-to-right neighbor within the same target_kind, used where spatial search breaks down (see "Spatial vs. linear navigation" 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.

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, and never touched again after that. 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 (always a new number, never overwriting an existing one — regenerating a preset via tomli_w would silently strip any comments in it), 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).

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.

Spatial vs. linear navigation

nearest_in_direction() (spatial, geometric nearest-match) works well for grid-like layouts — the sidebar, future status bars — where items have clean, non-overlapping positions. It breaks down for freely-positioned, overlapping items like floating windows: 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 is tuned.

The fix wasn't a better spatial algorithm — it was recognizing that windows within the preview module don't need spatial navigation at all. They're sorted left-to-right by position and navigated as a predictable linear list (sibling_in_same_group() in navigation.py, called from main.py), the same way Tab cycles through sidebar entries. Spatial search is only used to move between modules (sidebar ↔ preview ↔ future modules), where positions are well-separated and geometric nearest-match is the right tool. Moving left from the leftmost preview window falls back to a third, deterministic rule — return to the sidebar entry for the workspace actually being viewed — rather than letting spatial search land on an arbitrary nearby region.

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