-
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 three built-in modules 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.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 (see quick_actions.py for the pattern) |
| ctx.active_module | Name of the module Shift+Tab last landed on |
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 clock, showing only the current time, with no navigable items:
"""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:
# render.py
from tuicc.modules import sidebar, preview, quick_actions, clock
MODULES = {
"sidebar": sidebar.draw,
"preview": preview.draw,
"quick_actions": quick_actions.draw,
"clock": clock.draw,
}
NAV_PROVIDERS = {
"sidebar": sidebar.nav_items,
"preview": preview.nav_items,
"quick_actions": quick_actions.nav_items,
"clock": clock.nav_items,
}
Add it to a preset:
# presets/1.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.
If your module has things a user should be able to select — sidebar.py's workspaces, quick_actions.py's commands — 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 items
Prefix your ids with your module's name (f"{module_name}:{i}"), the same way sidebar:1 and preview:23 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 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 quick_actions.py's "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(provider, item, cfg):
# do whatever pressing confirm should do
...
return should_exit, pending_confirm_or_None
# render.py
from tuicc.modules import sidebar, preview, quick_actions, your_module
from tuicc.actions import BASE_HANDLERS
ACTION_HANDLERS = dict(BASE_HANDLERS)
ACTION_HANDLERS[quick_actions.TARGET_KIND] = quick_actions.handle
ACTION_HANDLERS[your_module.TARGET_KIND] = your_module.handle # add this line
handle() 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 — see quick_actions.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.
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. sidebar.py and quick_actions.py both draw each item inside its own small box rather than a bare line of text, using a shared ITEM_HEIGHT constant and computing each item's y as y + 1 + i * ITEM_HEIGHT. If you're listing multiple selectable things, this is the established look — copy the pattern rather than inventing a new one, for visual consistency across modules.
Centered text. For text centered within a box (used for quick-action labels):
def _centered_x(box_x, box_w, text):
padding = max(box_w - len(text), 0)
return box_x + padding // 2
Filled boxes. render_utils.py 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 does — resolve raw TOML into whatever shape your module wants, store it on Config, and read it from ctx.config inside your module. 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")
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.