Skip to content

Architecture

Lshika edited this page Jul 31, 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 — but not only as ratios anymore. Each box picks one option per dimension:

  • x: a manual ratio, or right_of = "other_box" (this box's left edge sits at that box's actual right edge)
  • y: a manual ratio, below/above (flush against another box's bottom/top edge), or bottom = true (flush against the screen's bottom edge)
  • w: a manual ratio, or cols (a fixed column count that doesn't scale)
  • h: a manual ratio, rows (a fixed row count that doesn't scale), or fill_to = "other_box" (height reaches exactly to that box's top edge — "take whatever's left")

compute_boxes() (in layout_engine.py) resolves all of this into actual terminal rows/columns for the current terminal size. Boxes that reference another box can't be computed until that box is fully resolved, so resolution happens in passes — compute whatever's ready, repeat, until either everything resolves or a pass makes no progress (a missing reference or a dependency cycle), which is a hard error rather than a silent partial layout.

This exists because a box stack that mixes a ratio-sized box with fixed-row neighbors can never sum to exactly the terminal height across more than one terminal size — the fixed portion is additive, the ratio portion is multiplicative, and they don't scale together. below/above/bottom/fill_to let you anchor the fixed-size boxes from whichever end makes sense and have the flexible box consume exactly what's left, so the same preset renders correctly at any terminal size with zero manual per-size tuning.

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

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, preview.py, launcher.py, connectivity.py, power_menu.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. (quick_actions.py and clock.py are registered and fully functional, but not placed in the current default preset — reserved for later.)

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"]
    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 a future live-resize/save-preset feature a real per-number file to write to, and keeps any of that from ever touching config.toml's hand-written sections, which Python's tomllib can't write back out anyway without losing comments and formatting.

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