-
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, rwb, bars, sysmon, plus quick_actions, 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. (There's no standalone clock.py in the shipped codebase anymore — it grew a date line, then weather, and got renamed to rwb.py ("real world box") along the way; this trimmed-down version is a teaching device, not a snapshot of a real file. See modules/rwb.py for the shipped one if you want the real thing, config keys and all.)
"""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, sessions, rwb, bars, and sysmon in both registries — trimmed here to keep the example focused on what's actually changing. There's no real "clock" entry in either registry — see the note above the walkthrough started with.)
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).
Selecting one of your NavItems can show more than plain text in preview.py's own box. Three simple, generic fields on NavItem do most of what you'll want, and preview.py interprets all three itself — you don't register anything to use them:
-
preview_text: list[tuple[str, int]] | None— centered lines,(text, color_pair)pairs. The common case; most modules stop here. -
preview_footer: list[tuple[str, int]] | None— a separate boxed-off strip at the bottom of the preview panel, for "how do I interact with this" hints (connectivity.py's[Enter] Connect [S] Scan [Esc] Back, for example) — kept visually apart frompreview_text's own informational content on purpose. -
preview_urgent: bool— colors the whole preview border urgent-red instead of the normal selected/active color, for "this needs attention" (sysmon.py's diagnostics row, when it actually has real issues to report).
If that's not enough — you want something that isn't just stacked centered lines, e.g. a bordered table — there's a fourth field, preview_data: object | None, that works differently: it's genuinely opaque to preview.py. Nothing there inspects its shape at all. Instead, you write your own render function and register it in render.py's PREVIEW_RENDERERS dict, the same {module_name: function} shape as MODULES/NAV_PROVIDERS:
# your_module.py
def draw_preview(stdscr, box, preview_data, theme) -> int:
"""Draws whatever preview_data holds into `box` (the remaining
content rect preview.py has left after any earlier stacked areas),
and returns how many rows of it were actually used, so preview.py
can keep stacking preview_text/preview_footer below you."""
x, y, w, h = box
...
return rows_used# render.py
PREVIEW_RENDERERS = {
"your_module": your_module.draw_preview,
}preview.py finds your renderer via navigation.module_of_item() — the owning module, read straight off the selected NavItem's own "modulename:id" prefix, not ctx.active_module (which is frame-state that isn't guaranteed to always match exactly which module produced the currently-selected item). You never edit preview.py itself to add this — same "one line in a registry, core never touches" shape as MODULES/NAV_PROVIDERS/ACTION_HANDLERS.
connectivity.py's WiFi "Device"/"Connection" diagnostic tables (frequency, live security, signal, IP address...) are the one real consumer of this today — render_utils.draw_table_box() does the actual drawing, called from connectivity.draw_preview().
One real constraint worth internalizing before reaching for preview_data: whatever you put here should be genuinely optional detail, not something needed to use your module. preview is a module like any other — nothing in the layout system stops a user from resizing it small or removing it from their preset entirely (see Architecture for the full reasoning) — so your own module's box has to stay fully operable and show everything functionally necessary on its own, with or without preview around at all.
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 rwb.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.