Skip to content

Writing a Module

Lshika edited this page Aug 8, 2026 · 8 revisions

Writing a Module

A module is a self-contained file under src/tuicc/modules/ that owns two things: how it draws itself, and where its own focusable items are. The core (main.py, render.py) never knows a specific module exists beyond its name in a registry — this page explains the contract a module needs to follow, using the built-in modules (sidebar, preview, launcher, connectivity, control, media, power_menu, sessions, plus quick_actions and clock, both fully working but not placed in the default layout) as worked examples.

The contract: two functions

def draw(stdscr, box, ctx, module_name):
    ...

def nav_items(box, ctx, module_name) -> list[NavItem]:
    ...

That's the entire interface. No base class, no required inheritance — render.py's registries just need something callable with this shape.

box is (x, y, w, h) in absolute terminal cells — where your module's territory starts and how big it is. Everything you draw should stay inside it.

ctx is a RenderContext (see Architecture for why it exists) carrying everything shared across all modules this frame:

Field What it is
ctx.state Current WMState — regions and windows, from the provider
ctx.selected_id The NavItem.id currently selected, or None
ctx.selected_item The resolved NavItem for ctx.selected_id itself (not just its id), or None. Useful for reading data a module attached to its own item — e.g. preview.py reads ctx.selected_item.preview_text to show what a selected power_menu/quick_actions entry would actually run, in place of its normal contents, without needing to know anything about those modules.
ctx.focus_id Which region's contents should currently be shown (may differ from what sway itself reports as focused — see Architecture)
ctx.theme dict of role → curses color pair, e.g. ctx.theme.get("accent", 0)
ctx.config The full resolved Config — your module's own settings live here if you add any
ctx.pending_confirm Set if a confirmation prompt is currently pending
ctx.active_module Name of the module Shift+Tab last landed on
ctx.typing_mode True while the launcher's search input is active
ctx.search_query Current launcher search text
ctx.search_selected_index Which launcher result is highlighted
ctx.wifi_networks / ctx.bluetooth_devices Cached connectivity state, refreshed from a background thread
ctx.status The shared StatusWorker itself (status_worker.py) — one background poll thread every domain registers against: wifi/bluetooth (connectivity.py), audio sinks + MPRIS players (media.py), and one domain per [[control.toggle]] entry (control.py). ctx.status.get(domain_name) reads the cached snapshot (None means "last poll errored or hasn't run yet," not "genuinely empty" — see get_error(domain_name) alongside it), ctx.status.request_action(domain_name, action_name, arg) queues a write, ctx.status.is_pending(domain_name, key) tells you whether that write is still in flight (for a blink/pending visual state). If your module needs its own polled/actionable thing, register a new Domain in main.py against this same worker rather than rolling your own background thread.
ctx.cava None unless the media module specifically wired one up — the one field here that ISN'T unconditionally populated, since it's a CavaReader (media/cava.py) media.py alone needs, not a general-purpose worker every module could plausibly want. See that module's own docstring if you're building something with a similar "continuous stream, not a periodic poll" shape.

Take only what you need; ignore the rest. A module that doesn't care about pending_confirm never has to mention it.

module_name is your module's own registered name, passed separately from ctx because it's specific to this one call, not shared frame-wide state (see Architecture for the reasoning). Its main use: comparing against ctx.active_module to decide whether to draw yourself as "active."

A minimal complete module

Here's the smallest real module that does something — a simplified clock, showing only the current time, with no navigable items. (The real clock.py also reads time_format/date_format from config and shows a date line — this version is trimmed for teaching purposes; see modules/clock.py for the shipped one.)

"""Clock module: shows the current time. No navigable items."""

import curses
from datetime import datetime

from tuicc.navigation import NavItem
from tuicc.render_utils import draw_box_outline


def draw(stdscr, box, ctx, module_name):
    x, y, w, h = box
    theme = ctx.theme or {}

    is_active = module_name == ctx.active_module
    outer_color = theme.get("border_selected", 0) if is_active else theme.get("border", 0)
    draw_box_outline(stdscr, y, x, h, w, outer_color)

    now = datetime.now().strftime("%H:%M:%S")
    try:
        stdscr.addstr(y + 1, x + 1, now[:max(w - 2, 0)], theme.get("text", 0))
    except curses.error:
        pass


def nav_items(box, ctx, module_name) -> list[NavItem]:
    return []

Register it, alongside whatever else render.py already has:

# render.py
from tuicc.modules import sidebar, preview, launcher, connectivity, control, media, power_menu, quick_actions, clock

MODULES = {
    "sidebar": sidebar.draw,
    "preview": preview.draw,
    "quick_actions": quick_actions.draw,
    "clock": clock.draw,
    "launcher": launcher.draw,
    "connectivity": connectivity.draw,
    "control": control.draw,
    "media": media.draw,
    "power_menu": power_menu.draw,
}

NAV_PROVIDERS = {
    "sidebar": sidebar.nav_items,
    "preview": preview.nav_items,
    "quick_actions": quick_actions.nav_items,
    "clock": clock.nav_items,
    "launcher": launcher.nav_items,
    "connectivity": connectivity.nav_items,
    "control": control.nav_items,
    "media": media.nav_items,
    "power_menu": power_menu.nav_items,
}

(the real render.py also has sidebar_compact and sessions in both registries — trimmed here to keep the example focused on what's actually changing.)

Add it to a preset if you want it visible:

# presets/1.toml (or your own ~/.config/tuicc/presets/<N>.toml)
[[box]]
name = "clock"
x = 0.8
y = 0.0
w = 0.2
h = 0.1

That's the whole integration. main.py and draw_all()/collect_nav_items() never needed to change — see Architecture for why that's true by design, not luck.

Reporting focusable items

If your module has things a user should be able to select — sidebar.py's workspaces, power_menu.py's actions — report them as NavItems with positions matching what you actually drew. rect matters because tab_order() sorts items by it (columns-first or rows-first, per [navigation] tab_order) — that sorted order is exactly what Tab/Shift+Tab walk, so an inaccurate rect means Tab visits your items in a surprising order, even though there's no separate geometric/spatial navigation reading it directly:

from tuicc.navigation import NavItem

def nav_items(box, ctx, module_name) -> list[NavItem]:
    x, y, w, h = box
    items = []
    for i, thing in enumerate(your_things):
        items.append(NavItem(
            id=f"{module_name}:{i}",             # unique, prefixed by module name
            rect=(x + 1, y + 1 + i, w - 2, 1),    # match where you'll actually draw it
            focus_target=thing.some_id,           # what this item "means" to other code
            target_kind="region",                 # see below
        ))
    return items

Prefix your ids with your module's name (f"{module_name}:{i}"), the same way sidebar:1 and power_menu:2 avoid collisions today. main.py and other modules parse the module name back out of an id via item.id.split(":")[0] in a few places (module switching, Tab-within-module) — keeping this convention means your module works with that code for free.

target_kind

target_kind tells main.py what pressing confirm — or a global keyboard shortcut, if you're wiring one up (see Keybindings) — on this item should do. Built-in kinds: "region" (switch workspace) and "window" (focus a window) are handled by BASE_HANDLERS in actions.py, since any module reporting one of these wants the same underlying behavior.

If your module needs its own kind of action — like power_menu.py's "power_action" kind, which runs a shell command and can pause for a Y/N confirmation first — your module registers its own handler instead of touching main.py at all:

# your_module.py
TARGET_KIND = "your_kind"

def handle(ctx, item, cfg):
    # ctx here is an ActionContext (actions.py) — bundles ctx.provider and
    # ctx.status — NOT the same object as the RenderContext also called
    # ctx in draw()/nav_items() (that one ALSO has a .status field, same
    # name, but it's the RenderContext's, a different attribute on a
    # different type — don't mix the two up).
    ...
    return should_dismiss, pending_confirm_or_None
# render.py
from tuicc.modules import sidebar, preview, quick_actions, power_menu, your_module
from tuicc.actions import BASE_HANDLERS

ACTION_HANDLERS = dict(BASE_HANDLERS)
ACTION_HANDLERS[quick_actions.TARGET_KIND] = quick_actions.handle
ACTION_HANDLERS[power_menu.TARGET_KIND] = power_menu.handle
ACTION_HANDLERS[your_module.TARGET_KIND] = your_module.handle   # add this line

handle() returns a (should_dismiss, pending) tuple: should_dismiss=True means tuicc dismisses itself right after (like switching workspace or running a command) — via Provider.dismiss_self(), hiding tuicc through the WM without ending the process; the only real quit is Ctrl+C. pending, if not None, becomes the new ctx.pending_confirm (the RenderContext one, this time), with its own dismiss_after_confirm key carrying the same True/False meaning forward until the y/n answer actually comes in — see power_menu.py for how that's used to show a confirmation prompt instead of dismissing immediately. main.py itself never branches on target_kind — it just looks up ACTION_HANDLERS.get(item.target_kind) and calls whatever it finds.

A module isn't limited to one target_kind. connectivity.py reports two — wifi_network and bluetooth_device — and registers both handlers at once via its own dict, merged in the same way:

# connectivity.py
HANDLERS = {
    "wifi_network": handle_wifi,
    "bluetooth_device": handle_bluetooth,
}
# render.py
ACTION_HANDLERS.update(connectivity.HANDLERS)

Use one TARGET_KIND constant for a module with a single kind of action (control.py, power_menu.py, quick_actions.py), or a HANDLERS dict for a module whose items need genuinely different behavior depending on which kind of item was selected (connectivity.py's two kinds above; media.py has three — media_row, media_transport, media_output — and sessions.py has its own too) — all established, valid patterns.

If your module's items are purely informational (nothing to do on confirm — like the clock above), just return [] from nav_items() and skip target_kind entirely.

Two-level modules: browsing vs. expanded

sessions.py and media.py both need more interactive surface than one NavItem per row can hold — a session slot has separate save/load/delete actions, a media player has separate previous/play-pause/next actions. Neither exposes all of that at once. Level 1 (browsing) reports exactly one NavItem per row, the whole row selectable; pressing confirm on it (handle_row) doesn't run an action at all — it just flips a module-level "this one's expanded" flag and returns (False, None) like any other non-dismissing handler. Level 2 (expanded) is where nav_items() reports the real sub-action items, but only for whichever row is currently expanded; every other row stays collapsed to its single row-item.

This solved a real problem, not just a taste preference: an earlier version of media.py reported all of a player's transport controls (prev/play/next) as always-active NavItems positioned near the box's right edge. With tab_order = "columns_first" (sorts by x before y), those items — visually near the top of the box — sorted late in the whole app's Tab order, since their x was large even though their y was small. Collapsing to one item per row at level 1 sidesteps the whole class of problem: a row-based module's single item is always positioned the same predictable way every other module's rows are.

Module-level state (_expanded_slot/_expanded_bus_name, a plain module-global, not something threaded through RenderContext) tracks which row is expanded; a collapse() function resets it and returns whatever was expanded, so the caller (main.py) can reselect that row's own id — without this, nav_items() stops reporting the just-selected sub-action's id the instant it collapses (Escape, or active_module moving elsewhere), which trips the stale-selection recovery into jumping to the sidebar instead of landing back on the row. is_expanded() lets main.py know whether Escape should collapse the module instead of dismissing tuicc, and whether Tab/Shift+Tab's module-wrap behavior needs to treat this module specially while it's mid-expansion. See media.py's own module docstring for the full reasoning, including the dynamic-list wrinkle sessions.py's fixed 3 slots don't have (a player can appear/quit/reorder between polls — _expanded_bus_name tracks by stable D-Bus identity, not list position, and self-corrects if that player disappears while expanded).

Drawing patterns worth reusing

Item boxes vs. flat lists. sidebar.py draws each workspace slot inside its own small box, with a per-slot height computed dynamically (2 + len(region.windows) — more windows, taller slot) rather than a fixed constant. power_menu.py and quick_actions.py, by contrast, draw a plain one-row-per-item list with no per-item border at all (row = y + 1 + i) — simpler, and the right choice when items don't need visual separation from each other. Pick whichever matches what you're building; neither is more "correct."

Centered text. render_utils.py has a shared centered_x(box_x, box_w, text) helper — used by clock.py to center the time/date — rather than every module reinventing its own centering math:

from tuicc.render_utils import centered_x

text_x = box_x + 1 + centered_x(0, inner_w, some_text)

Filled boxes. render_utils.py also has draw_filled_box() alongside draw_box_outline() for when you need a solid background, not just an outline — used for floating windows in preview.py so they visually sit "on top" of whatever's beneath them.

Always wrap addstr() in try/except curses.error. Terminal edges and off-by-one sizing cause addstr() to fail in ways that are annoying but harmless to ignore — every built-in module does this, and yours should too.

Module-specific config

If your module needs its own settings, add a section to config.toml and read it in config.py the same way quick_actions/power_menu do — resolve raw TOML into whatever shape your module wants, store it on Config, and read it from ctx.config inside your module. power_menu.action is the richer example to copy from if your module needs optional fields with no forced default (shortcut, confirm_text — both use .get(), None when absent, never silently substituted with something else). See Config Reference for the pattern, and keep the same principle the rest of tuicc follows: config.py is where parsing happens, modules just consume already-resolved values.

Testing without curses

nav_items() doesn't touch curses at all, so you can sanity-check it in a plain REPL before worrying about how anything looks:

>>> import sys; sys.path.insert(0, "src")
>>> from tuicc.config import load_config
>>> from tuicc.providers.registry import build_provider
>>> from tuicc.context import RenderContext
>>> from tuicc.modules import your_module
>>> cfg = load_config()
>>> state = build_provider(cfg.provider_name).get_state()
>>> ctx = RenderContext(state=state, selected_id=None, focus_id=None, theme={}, config=cfg)
>>> your_module.nav_items((0, 0, 20, 10), ctx, "your_module")

Everything on RenderContext past config has a default (pending_confirm=None, typing_mode=False, empty lists for wifi_networks/bluetooth_devices, etc.), so a minimal call like this is enough unless your module specifically reads one of those fields — in which case just pass it explicitly.

draw() needs a real stdscr, so that part you do need to check by actually running python main.py — but confirming your nav_items() returns sensible NavItems first means you're not debugging two problems (positions and rendering) at once.

Clone this wiki locally