Skip to content

Architecture

Lshika edited this page Jul 28, 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) 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.

2. Layout engine

Layout/ModuleBox (in layout.py) describe where each module sits as ratios, not cells. compute_boxes() (in layout_engine.py) converts those ratios into actual terminal rows/columns, given the current terminal size. This is a pure function — same inputs, same output, testable without a running screen — and it's recalculated every frame, which is deliberately cheap rather than cached (see "Why recompute every frame" below).

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).

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, preview.py, quick_actions.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.

Every draw()/nav_items() call takes the same two things: the module's box, and a RenderContext (context.py). 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
    sway["sway (WM)"] --> provider["SwayProvider"]
    config["config.toml"] --> cfgpy["config.py"]
    provider --> loop["main.py loop"]
    cfgpy --> loop
    loop --> ctx["RenderContext"]
    ctx --> modules["modules: sidebar, preview, quick_actions"]
    modules --> terminal["terminal (curses)"]
    modules -. nav_items .-> loop
Loading

Config as the single source of truth

Every config-driven value — layout ratios, theme colors, keybindings, 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 or single characters for keys) but always resolve to one canonical internal representation, so modules and main.py never branch on "what format did the user use."

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