Skip to content

Architecture

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

focus_self() also takes an optional fullscreen argument (focus_self(fullscreen=cfg.fullscreen_only)), passed straight through from [wm] fullscreen_only in config (see Config Reference). Reclaiming focus alone isn't enough to keep a fullscreen tuicc fullscreen: sway/i3 both drop a container back to plain floating the instant any new window is mapped anywhere in the session — even briefly, on tuicc's own workspace, before its code gets a chance to move that window elsewhere. That drop can't be prevented ahead of time (it's a WM-level race that happens before tuicc's Python ever observes the new window) — only re-asserted after the fact, which is what focus_self(fullscreen=True) does by chaining fullscreen enable onto the same focus command.

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 holds the one ResizeState instance and decides when to call these functions, but the two levels now split who decides which key means what: browsing's key handling (confirm/delete/Escape; everything else falls through to normal Tab/arrow/F-key navigation) still lives directly in main.py, deliberately — it's not a true modal claim, since it never swallows an unrecognized key. Editing's key handling (direction keys, move/size toggle, delete, confirm/Escape, and the F-key handoffs to help/save/cycle-preset/new-preset/spawn) lives in resize_mode.handle_editing_key() instead, returning a small EditKeyResult value (still_claiming/handoff/deleted_name) rather than a bare bool — resize_mode.py still can't call back into main.py's own functions directly, so a handoff is a signal, not a call; main.py's own handle_resize_editing reads that signal and dispatches accordingly. Neither the dataclass nor any function operating on it knows anything about curses. 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.

Whole-theme presets (theme_presets.pyBUILTIN_THEME_PRESETS, preset_cycle_list(), next_preset()) sit one layer below help_mode.py, deliberately disk-free and curses-free — main.py's own do_cycle_theme_preset()/do_save_theme_preset() are the orchestration layer that combines them with config.py's on-disk user-preset reads (available_theme_preset_numbers()/load_theme_preset()/save_new_theme_preset()) and applies the result the same way a single-role edit does (reassign_theme_pairs() + config.set_theme_colors()). No "active preset" is tracked anywhere as state — next_preset() re-derives "where am I now" by comparing the live [theme] values on disk against every entry in the cycle list on each press, so a manual per-role tweak afterward never needs its own dirty-tracking to stay consistent. theme_setup.apply_background() is the other half of making a preset's background role actually visible: reassign_theme_pairs() bakes that same resolved color onto every OTHER role's own curses pair too (an explicit pair's -1 component renders as the terminal's own raw default, not whatever the window's fill is — a real, empirically-confirmed distinction from how curses's bkgd() behaves for pair-0/untouched cells specifically), and apply_background()'s own stdscr.bkgd() call covers what that can't: cells drawn with pair 0 (curses's reserved default, can't be redefined), like draw_filled_box's own default and stdscr.erase().

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.

main() itself is split into two pieces: app_setup.build_app() does the one-time work (backends, D-Bus agents, the shared StatusWorker) and returns an AppContext; everything the loop then reads or mutates frame-to-frame — which module owns raw keystrokes, the current selection, the color theme, and so on — lives on one LoopState instance (loop_state.py), passed explicitly to whichever function needs it. Every one of main.py's per-key handlers is a plain function, not a closure relying on Python's nonlocal — a deliberate structural choice, not incidental: it's what makes each of them callable (and testable) on its own.

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 Summoning tuicc 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.

StatusWorker: one background poll thread for everything that isn't the WM

Wifi/bluetooth status, audio sinks, MPRIS players, and every [[control.toggle]] entry all share one thing: checking their state means running something (a D-Bus call, a shell command) that shouldn't block the render loop, and changing their state (connect to a network, toggle night light) is a write that also shouldn't block. status_worker.py's StatusWorker is one generic background-poll-plus-action-queue worker, started once in main.py, that every one of these registers against as a Domain — a name, a zero-argument poll() returning the domain's current state, and an actions dict mapping an action name to a one-argument callable. connectivity.py was domain client #1 (wifi/bluetooth); control/media (R5) register their own domains against the exact same worker instead of each hand-rolling their own thread/lock/queue plumbing — an earlier version had a ConnectivityWorker wrapper class specific to wifi/bluetooth, found live to be a purposeless ~8-method pass-through layer once a second, unrelated domain needed the identical pattern; removed in favor of this generic version.

No-silent-failure lives here. A domain's poll() raising doesn't vanish into a bare except: pass — it's captured into that domain's own last-error string, and the cached snapshot for that round becomes None, not [], so a module can tell "genuinely nothing there" apart from "couldn't check" (ctx.status.get(name) returns the snapshot, ctx.status.get_error(name) the error, both read together — see connectivity.py's own _build_rows() for the pattern). Actions get the identical treatment, in a separate get_action_error(name), not sharing the poll error's slot — StatusWorker always re-polls a domain right after acting on it, in the same loop iteration, so a shared slot would let that immediate re-poll's own (likely successful) result silently clobber the action's own failure before any module read it. This was a real, found-live gap: a control toggle's command failing near-instantly (a misconfigured night-light command exiting for lack of a location provider, say) produced zero visible feedback before this existed — the toggle just silently stayed in its old state.

Per-domain poll interval, not one shared timer. Domain.poll_interval (default None, meaning "use StatusWorker's own shared default, 5s") lets a domain refresh faster when its state can change for reasons entirely outside tuicc's own action flow — audio's default sink can be switched by WirePlumber auto-connecting a bluetooth headset, a track can change mid-playback — both found live to feel "stuck" at the shared 5s default and set to 1s instead. A domain that was just acted on is always re-polled immediately regardless of its own interval, so confirming an action's real effect never waits out a stale timer.

ctx.status (RenderContext) and ActionContext.status (actions.py) are the same StatusWorker instance, same field name, different dataclass — see Writing a Module for the full field-by-field rundown of what a module actually calls on it (get/get_error/get_action_error/is_pending/request_action).

Not everything fits this shape. The media module's optional audio visualizer (cava) streams continuously rather than answering one question at a time — genuinely different from "ask once, get an answer," closer to StatusWorker's own thread-per-worker pattern than to a Domain's poll() model, so it isn't one: media/cava.py's CavaReader is its own small background-thread-plus-lock class, owned directly by main.py, started/stopped lazily based on whether anything's actually playing. See its own module docstring for the reasoning.

Modules and the RenderContext pattern

Modules (sidebar.py, sidebar_compact.py, preview.py, launcher.py, connectivity.py, control.py, media.py, power_menu.py, sessions.py, quick_actions.py, rwb.py, bars.py, sysmon.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 is registered and fully functional, but not placed in the default preset — reserved for later; sidebar_compact.py is used by the alternate compact preset instead of sidebar.py, and 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. rwb.py — "real world box," time/date/compact weather — is a rename of what used to be clock.py, once weather.py gave it a second thing to show.)

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

A module's own drawing area in the preview panel

NavItem carries a few optional fields a module can set on its own items to show more than plain text when that item is selected — preview_text (centered lines, the common case, read by preview.py regardless of which module produced the item), preview_footer (a separate boxed-off "how do I interact with this" hint strip), preview_urgent (colors the whole preview border to flag something needing attention, e.g. sysmon.py's diagnostics row when there are real issues). These three stay simple and generic on purpose — preview.py interprets them directly, and every module that uses them wants the exact same rendering.

A fourth field, preview_data, is different: it's opaque to preview.py — genuinely object | None, no shape preview.py knows or interprets. A module that wants something richer than centered text (bordered tables, in connectivity.py's case — its WiFi "Device"/"Connection" diagnostic panels) registers its own render function in render.py's PREVIEW_RENDERERS dict ({module_name: render_fn}, same shape as MODULES/NAV_PROVIDERS) instead of teaching preview.py a new field per module. preview.py resolves which renderer to call via navigation.module_of_item() (the owning module, derived from the item's own "modulename:id" convention) — not ctx.active_module, which is frame-state that can in principle lag behind exactly which module produced the currently-selected item — hands it the remaining content rect, and gets back how much height it used so it can keep stacking preview_text/preview_footer below it. A render function's signature: (stdscr, box, preview_data, theme) -> int.

This replaced an earlier, single-consumer preview_tables field that had preview.py itself doing the table-stacking/height-math work — a real violation of the "core never guesses a module's internal layout" rule stated at the top of this section, since only connectivity.py ever used it. The underlying design principle this was built to satisfy: a module must be fully operable and show everything functionally necessary from its own box alone, regardless of whether preview is even present in the current layout — preview_data is for genuinely optional, richer secondary detail, never the only place something you need to use the module lives. See CLAUDE/NOTES/design-decisions.md#module-self-sufficiency-vs-preview in the main repo for the full reasoning (including a live-confirmed finding that nothing today actually stops preview itself from being deleted via resize mode, the same as any other module box).

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
    statusworker["StatusWorker (background thread)"] -. cached state/actions .-> modules
    loop -. request_action .-> statusworker
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, via save_layout_to_preset() — repeated saves during one resize-mode session don't pile up a new numbered file each time. new_preset (F5) instead mints a genuinely new preset number via save_new_preset() and switches to it, forking the current layout without touching the one you started from — the way to start a fresh preset from a layout you like without hand-editing files. 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.