-
Notifications
You must be signed in to change notification settings - Fork 1
Writing a WM Provider
Dear wiki reader,
This is the page that matters most if you want tuicc to run on something other than what's already availible ( only sway so far :c -- but PLEASE don´t get discouraged, the project is very much ready for YOUR wm). If you get one thing right in tuicc, make it this: a provider is the only code in the entire project allowed to know your window manager exists. Everything else — layout, navigation, theming, every module — only ever speaks tuicc's generic language. Get the provider right, and all of that works on your WM for free, unmodified.
This page goes deeper than the README's version. If you just want the contract, the README has it. If you're actually about to sit down and write one, read this first.
Don't start by writing Provider methods. Start by figuring out what your WM's IPC/API actually gives you — in a Python REPL, interactively, before you commit to any code. This is genuinely how sway.py's floating-window support was built, and it's worth walking through as a model for how to approach any WM.
The starting question was simple: does tuicc's model handle floating windows? Nobody knew the answer, including what data was even available. So instead of guessing, the process was:
>>> from i3ipc import Connection
>>> conn = Connection()
>>> tree = conn.get_tree()
>>> ws = tree.find_focused().workspace()
>>> ws.floating_nodes
[]Empty — but that turned out to be because the check was on the wrong workspace, not because floating windows aren't exposed. Re-checked against the workspace that actually had floating windows on it:
>>> ws3.floating_nodes
[<i3ipc.con.Con object at 0x...>, <i3ipc.con.Con object at 0x...>]
>>> f1, f2 = ws3.floating_nodes
>>> f1.rect.x, f1.rect.y, f1.rect.width, f1.rect.height
(1019, 116, 620, 619)
>>> f1.floating
'user_on'That one exploration session answered three real questions at once: floating windows live in a separate floating_nodes list (not mixed into leaves()), their rect has the exact same shape as tiled windows, and "is this floating" is a string ('user_on'/'auto_off'/etc.), not a boolean — so the provider needed leaf.floating in ("user_on", "auto_on") to convert it.
None of that was documented anywhere convenient. It came from typing things into a REPL and looking at what came back. Do this for your WM before writing get_state() — find out what a "window" object actually contains, what a "workspace" object contains, and how to tell if something is focused, floating, or whatever else your model needs to distinguish.
from abc import ABC, abstractmethod
from tuicc.model import WMState
class Provider(ABC):
@abstractmethod
def get_state(self) -> WMState:
"""Return the current window-manager state."""
@abstractmethod
def focus_region(self, region_id: str) -> None:
"""Switch the WM's focus to the given region (e.g. workspace)."""
@abstractmethod
def focus_window(self, window_id: str) -> None:
"""Switch the WM's focus to the given window."""Three methods. get_state() is the one that does the real translation work; the two focus_* methods just need to know how to tell your WM "switch to this."
@dataclass
class Window:
id: str
app_id: str
title: str
focused: bool
rect: tuple[float, float, float, float] # x, y, w, h — normalized 0..1
floating: bool = False
@dataclass
class Region:
id: str
name: str
windows: list[Window]
focused: bool = False
active: bool = True
@dataclass
class WMState:
regions: list[Region]
focused_region_id: str | None = NoneA few things worth understanding why, not just what:
id fields are strings, always — even if your WM numbers workspaces (sway does). This isn't pedantry: some WMs (or setups) use named, non-numeric identifiers, and str is the format both can satisfy. Don't parse or assume anything about the shape of an id; just carry whatever your WM natively uses, stringified.
rect is normalized to 0..1, relative to the containing region — not screen pixels. This is the single decision that makes tuicc WM-agnostic: the layout engine, the preview module, and navigation all work in ratios, so they never need to know your screen resolution, your monitor count, or anything about physical geometry. Your provider does one division per axis:
x = (window_rect.x - region_rect.x) / region_rect.width
y = (window_rect.y - region_rect.y) / region_rect.height
w = window_rect.width / region_rect.width
h = window_rect.height / region_rect.heightfloating exists because not every window participates in a tiled layout, and lying about it (treating floating windows as regular tiled ones) produces nonsensical, overlapping rect values that make no sense in a grid. If your WM has no concept of floating windows at all, just leave every Window.floating as False — the default — and move on; the field costs you nothing to ignore.
This is the actual reference implementation, in full, because reading a complete working provider beats reading fragments:
"""Sway provider — translates sway's IPC tree into tuicc's generic model."""
from i3ipc import Connection
from tuicc.model import Window, Region, WMState
from tuicc.providers.base import Provider
def _leaf_to_window(leaf, ws_rect, floating):
x = (leaf.rect.x - ws_rect.x) / ws_rect.width
y = (leaf.rect.y - ws_rect.y) / ws_rect.height
w = leaf.rect.width / ws_rect.width
h = leaf.rect.height / ws_rect.height
return Window(
id=str(leaf.id),
app_id=leaf.app_id or leaf.window_class or "unknown",
title=leaf.name or "",
focused=leaf.focused,
rect=(x, y, w, h),
floating=floating,
)
class SwayProvider(Provider):
def __init__(self):
self.conn = Connection()
def focus_region(self, region_id: str) -> None:
self.conn.command(f"workspace {region_id}")
def focus_window(self, window_id: str) -> None:
self.conn.command(f"[con_id={window_id}] focus")
def get_state(self) -> WMState:
tree = self.conn.get_tree()
focused_leaf = tree.find_focused()
focused_ws_num = None
if focused_leaf:
ws = focused_leaf.workspace()
if ws:
focused_ws_num = ws.num
regions = []
for workspace in tree.workspaces():
windows = []
ws_rect = workspace.rect
for leaf in workspace.leaves():
windows.append(_leaf_to_window(leaf, ws_rect, floating=False))
for leaf in workspace.floating_nodes:
windows.append(_leaf_to_window(leaf, ws_rect, floating=True))
regions.append(Region(
id=str(workspace.num),
name=workspace.name,
windows=windows,
focused=(workspace.num == focused_ws_num),
))
focused_region_id = str(focused_ws_num) if focused_ws_num is not None else None
return WMState(regions=regions, focused_region_id=focused_region_id)Notice _leaf_to_window() is a plain, local helper function — not imported from anywhere else, not shared infrastructure. Providers are meant to be self-contained and readable top to bottom, usable as a copy-paste starting template. If you need a small helper, write it inside your own provider file rather than importing one from sway.py or elsewhere.
Also notice focused on a Region and focused on a Window are determined by two genuinely different mechanisms here — one from tree.find_focused(), one from comparing workspace numbers — and both derive from the same single lookup (focused_ws_num), computed once at the top. An earlier draft of this provider computed workspace focus two different, inconsistent ways in two different places; if you're translating a WM with a similarly tree-shaped IPC, watch for that trap — resolve "what's focused" once, and derive everything else from that one answer.
sway (and i3) build a workspace as a tree of nested splits — windows never overlap on a workspace, they tile to fill it exactly. If your WM works the same way, the normalization above is the whole story.
If your WM works differently — a scrolling/infinite-canvas layout (like scroll or niri) — the rect normalization is the part to think hardest about, because the workspace itself isn't bounded by the screen. A window five columns to the right of the visible area is still a real window with a real position; sway's model has no equivalent situation (everything on a workspace fits inside it, by construction), so there's no existing pattern to copy.
Two things this affects concretely:
-
Normalizing against the workspace rect stops making sense when the workspace has no fixed width.
scroll's own IPC still reports arectfor each window using absolute coordinates that can exceed the monitor's dimensions (or go negative) — normalizing against the output/monitor rect instead of the workspace rect, and expecting values outside 0..1 for off-screen windows, is a more honest representation than trying to force everything into a fixed 0..1 box. -
The preview module isn't obligated to render every window's exact
rect. tuicc's own preview module doesn't attempt pixel-perfect realism even for sway (see Architecture: Floating windows) — for an infinite-strip WM, consider whether the provider should reportrectvalues that a module then interprets loosely (e.g. "show the 3 columns around focus"), rather than trying to make raw normalized coordinates alone tell the whole story.
If you're building a provider for exactly this kind of WM, this is where the real design work is — not in following a fixed formula, but in deciding what rect values will let downstream modules make good decisions.
# src/tuicc/providers/registry.py
from tuicc.providers.your_wm import YourWMProvider
PROVIDERS = {
"sway": SwayProvider,
"yourwm": YourWMProvider, # add this line
}That's the entire integration surface. main.py, render.py, and every module remain completely unaware your provider exists — they only ever see WMState. Users select it with provider = "yourwm" under [wm] in their config (see Config Reference).
You don't need curses running to check whether your provider works — test it the same way sway.py's floating support was verified today, directly in a REPL:
>>> import sys; sys.path.insert(0, "src")
>>> from tuicc.providers.your_wm import YourWMProvider
>>> p = YourWMProvider()
>>> state = p.get_state()
>>> len(state.regions)
>>> [(w.app_id, w.floating, w.rect) for r in state.regions for w in r.windows]If the rect values look sane (roughly 0..1, or intentionally outside that range for an infinite-canvas WM) and focused/floating match what you'd expect from actually looking at your screen, you're most of the way there — long before you need to worry about how it renders.
Open an issue or a draft PR. I'd genuinely like to see this work on more than sway, and I'm specifically working on preview support for scrolling WMs myself right now — if that's your situation, it's worth checking in before duplicating effort.