Skip to content

Writing a Module

Lshika edited this page Aug 2, 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, power_menu, 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.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."

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, 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.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, 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 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.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 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 (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.

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