-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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."
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.1That'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.
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 itemsPrefix 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 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 linehandle() 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.
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).
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.
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.
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.