-
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, power_menu, 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.connectivity |
The ConnectivityWorker itself, for modules that need to request a connect/disconnect |
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, 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,
"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,
"power_menu": power_menu.nav_items,
}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, so keyboard navigation lands where the user visually expects:
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.connectivity — NOT the same object as the RenderContext also
# called ctx in draw()/nav_items(). Same name, different type; don't
# mix them up.
...
return should_exit, 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_exit, pending) tuple: should_exit=True means tuicc exits right after (like switching workspace or running a command). pending, if not None, becomes the new ctx.pending_confirm (the RenderContext one, this time) — see power_menu.py for how that's used to show a confirmation prompt instead of running 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, or a HANDLERS dict for a module whose items need genuinely different behavior depending on which kind of item was selected — both are 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.
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.