Skip to content

Writing a WM Provider

Lshika edited this page Aug 4, 2026 · 11 revisions

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 available (sway and i3 so far — but PLEASE don't get discouraged if yours isn't one of those two, 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 about to sit down and write one, reading this might help!

Before you write any code: explore

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.

Don't assume a sibling WM behaves like the one you already know. When the i3 provider was built, the natural assumption was "i3ipc is i3ipc, this should look like sway." It mostly did — but not entirely. Exploring i3's actual tree turned up three real surprises: i3 wraps every floating window in an extra floating_con container that sway flattens away, i3 has no app_id at all (it's an X11 WM, not Wayland — window_class is the equivalent), and, easiest to miss, i3's workspace.leaves() includes floating windows instead of excluding them like sway's does, which silently produced duplicate windows until it was caught by comparing the actual IDs returned by both. None of these were guessable from reading sway's provider alone. Explore each WM on its own terms, even ones that claim compatibility with something you've already integrated.

The contract

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."""

    @abstractmethod
    def move_window_to_region(self, window_id: str, region_id: str) -> None:
        """Move the given window to the given region, without changing
        which region is currently visible."""

    @abstractmethod
    def close_window(self, window_id: str) -> None:
        """Close the given window. Required, not optional like the
        methods below — every WM worth supporting can close a window,
        there's no meaningful degraded case to fall back to."""

    def mark_self(self, app_id: str | None = None) -> None:
        """Mark tuicc's own window (called once at startup) so get_state()
        can filter it out of what it reports — otherwise tuicc would list
        itself as a window in its own sidebar/preview. NOT abstract — see
        "Filtering tuicc's own window" below for why, and what a provider
        for a WM without an equivalent concept should do instead. app_id,
        if given (from `[wm] self_app_id`), lets an implementation mark by
        WM criteria instead of "whatever's currently focused" — see below."""

    def dismiss_self(self) -> None:
        """Hide tuicc's own window without ending the process — the
        WM-side half of tuicc's dismiss-vs-quit lifecycle model (see
        [Architecture: Process lifecycle](Architecture#process-lifecycle-dismiss-vs-quit)).
        NOT abstract — a WM with no hide/scratchpad-equivalent concept
        just can't dismiss this way."""

    def focus_self(self) -> None:
        """Reclaim keyboard focus for tuicc's own window, called right
        after a spawned or restored window is moved into place — most
        WMs focus a newly-mapped window regardless of stacking order, so
        without this every launcher spawn silently steals input away
        from tuicc. NOT abstract, but leaving it as a no-op is a much
        more severe gap than the other optional methods here."""

Five required methods, three optional ones shown above (two more, resolve_pid/set_floating_geometry, exist for session-restore use cases not covered on this page — see providers/base.py's own docstrings if you need them). get_state() is the one that does the real translation work; focus_region/focus_window/move_window_to_region/close_window just need to know how to tell your WM "switch to this," "move that there," or "close that" — move_window_to_region exists specifically for the launcher: it spawns an app normally, waits for the new window to appear, then moves it onto whichever workspace the sidebar currently has selected, without switching your visible focus away from what you were doing. mark_self()/dismiss_self()/focus_self() are covered in their own sections below, after the two reference providers — easier to explain once you've seen how parse_tree() actually works.

The model you're translating into

@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 = None

A few things worth understanding why, not just what:

id fields are strings, always — even if your WM numbers workspaces (sway and i3 both do). 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.height

floating 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.

Two complete, real examples: sway.py and i3.py

Reading one complete working provider beats reading fragments. Reading two — for WMs that are related but not identical — is even more useful, because the diff between them tells you exactly which parts of a provider are WM-specific and which are boilerplate you can copy freely.

sway.py

"""Sway provider — translates sway's IPC tree into tuicc's generic model."""

import os

from i3ipc import Connection

from tuicc.model import Window, Region, WMState
from tuicc.providers.base import Provider


MARK_PREFIX = "_tuicc_self_"


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,
    )


def _is_tuicc_self(leaf) -> bool:
    return any(m.startswith(MARK_PREFIX) for m in leaf.marks)


def parse_tree(tree) -> WMState:
    """Convert an i3ipc tree into tuicc's generic WMState.

    Pure function: no IPC, no side effects. Takes an i3ipc Con node so it
    can be tested against recorded fixtures without a running compositor.
    """
    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():
            if _is_tuicc_self(leaf):
                continue
            windows.append(_leaf_to_window(leaf, ws_rect, floating=False))

        for leaf in workspace.floating_nodes:
            if _is_tuicc_self(leaf):
                continue
            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)


class SwayProvider(Provider):
    def __init__(self, conn=None):
        self.conn = conn or 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 move_window_to_region(self, window_id: str, region_id: str) -> None:
        self.conn.command(f"[con_id={window_id}] move container to workspace number {region_id}")

    def close_window(self, window_id: str) -> None:
        self.conn.command(f"[con_id={window_id}] kill")

    def mark_self(self, app_id: str | None = None) -> None:
        mark = f"{MARK_PREFIX}{os.getpid()}"
        if app_id:
            # Marks by WM criteria — no focus-timing assumption at all,
            # so this is immune to the race the fallback below has.
            self.conn.command(f'[app_id="{app_id}"] mark --add {mark}')
            return
        # Fallback only, when no self_app_id is configured — see
        # "Filtering tuicc's own window" below for the known limitation
        # this path (and only this path) still has.
        self.conn.command(f"mark --add {mark}")

    def dismiss_self(self) -> None:
        self.conn.command(f"[con_mark={MARK_PREFIX}{os.getpid()}] move scratchpad")

    def focus_self(self) -> None:
        self.conn.command(f"[con_mark={MARK_PREFIX}{os.getpid()}] focus")

    def get_state(self) -> WMState:
        return parse_tree(self.conn.get_tree())

i3.py

Same shape, same Provider contract, same parse_tree/class split — but look closely at what's different:

"""i3 provider — translates i3's IPC tree into tuicc's generic model."""

import os

from i3ipc import Connection

from tuicc.model import Window, Region, WMState
from tuicc.providers.base import Provider


MARK_PREFIX = "_tuicc_self_"


def _unwrap_floating(node):
    """i3 wraps every floating window in a floating_con container that
    carries no window properties of its own — the real window is its
    single child. Sway flattens this away; i3 does not, so we undo it
    here to keep the rest of the parsing code identical to sway's.
    """
    if node.type == "floating_con" and node.nodes:
        return node.nodes[0]
    return node


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.window_class or leaf.app_id or "unknown",
        title=leaf.name or "",
        focused=leaf.focused,
        rect=(x, y, w, h),
        floating=floating,
    )


def _is_tuicc_self(leaf) -> bool:
    return any(m.startswith(MARK_PREFIX) for m in leaf.marks)


def parse_tree(tree) -> WMState:
    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

        # i3's workspace.leaves() includes floating windows as well as
        # tiled ones, unlike sway's. Resolve floating windows first so we
        # can skip them when walking leaves(), avoiding double-counting.
        floating_leaves = [_unwrap_floating(n) for n in workspace.floating_nodes]
        floating_ids = {leaf.id for leaf in floating_leaves}

        for leaf in workspace.leaves():
            if leaf.id in floating_ids:
                continue
            if _is_tuicc_self(leaf):
                continue
            windows.append(_leaf_to_window(leaf, ws_rect, floating=False))

        for leaf in floating_leaves:
            if _is_tuicc_self(leaf):
                continue
            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)


class I3Provider(Provider):
    def __init__(self, conn=None):
        self.conn = conn or Connection()

    def focus_region(self, region_id: str) -> None:
        self.conn.command(f"workspace number {region_id}")

    def focus_window(self, window_id: str) -> None:
        self.conn.command(f"[con_id={window_id}] focus")

    def move_window_to_region(self, window_id: str, region_id: str) -> None:
        self.conn.command(f"[con_id={window_id}] move container to workspace number {region_id}")

    def close_window(self, window_id: str) -> None:
        self.conn.command(f"[con_id={window_id}] kill")

    def mark_self(self, app_id: str | None = None) -> None:
        mark = f"{MARK_PREFIX}{os.getpid()}"
        if app_id:
            # Marks by WM criteria — no focus-timing assumption at all,
            # so this is immune to the race the fallback below has. i3
            # is X11-only, no "app_id" concept — its criteria keyword is
            # "class" (see _leaf_to_window mapping window_class into the
            # generic model's app_id field for the same reason).
            self.conn.command(f'[class="{app_id}"] mark --add {mark}')
            return
        # Fallback only, when no self_app_id is configured — see
        # "Filtering tuicc's own window" below for the known limitation
        # this path (and only this path) still has.
        self.conn.command(f"mark --add {mark}")

    def dismiss_self(self) -> None:
        self.conn.command(f"[con_mark={MARK_PREFIX}{os.getpid()}] move scratchpad")

    def focus_self(self) -> None:
        self.conn.command(f"[con_mark={MARK_PREFIX}{os.getpid()}] focus")

    def get_state(self) -> WMState:
        return parse_tree(self.conn.get_tree())

Four concrete differences, and why each exists:

  • _unwrap_floating — i3 nests every floating window inside a floating_con container that holds none of the window's own properties (no app_id, no name, no rect that matches the actual window). The real data is one level down, in node.nodes[0]. Sway skips this step entirely because it flattens floating windows before you ever see them.
  • app_id order flippedleaf.window_class or leaf.app_id instead of sway's leaf.app_id or leaf.window_class. i3 is X11-based and never has app_id; falling through to "unknown" without checking window_class first would silently discard real data i3 actually provides.
  • De-duplication against floating_nodes — this is the one that would have shipped as a real bug. i3's workspace.leaves() returns floating windows in addition to tiled ones, so naively porting sway's two-loop structure produces every floating window twice: once correctly flagged floating=True from the floating_nodes loop, once incorrectly flagged floating=False from leaves(). The fix is computing floating_ids once, up front, and skipping those ids in the leaves() loop.
  • focus_region uses "workspace number {id}", not "workspace {id}" — both sway and i3 will create a new workspace if the argument doesn't exactly match an existing workspace's full name (which can include free text, not just a number, e.g. "3: web"). Prefixing with number tells the WM "match by numeric id, ignore any trailing name," which is almost always what you want when region_id came from tuicc's own model rather than from the user typing a name. move_window_to_region's command is identical between the two providers — both use the same "move container to workspace number {region_id}" syntax.
  • mark_self's criteria keyword[app_id="..."] on sway, [class="..."] on i3, matching each WM's own criteria syntax for the same self_app_id value. close_window, dismiss_self, and focus_self are otherwise byte-identical between the two providers.

If you're targeting a wlroots-based WM (sway, or a sway fork like scroll), start from sway.py. If you're targeting anything else speaking i3's IPC protocol (i3 itself, or another i3-compatible fork), start from i3.py and read the four differences above as a checklist of things to re-verify for your specific WM, not as universal truths — a different i3-family WM might handle floating windows differently again.

Notice in both files that _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, i3.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, in both providers. An earlier draft of the sway 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.

Filtering tuicc's own window, dismissing it, and reclaiming focus

Without mark_self(), tuicc would list itself as a window in its own sidebar and preview — its own terminal window is, after all, a real window your WM knows about. Both reference providers solve this the same way: sway and i3 both support marks, arbitrary labels a client can attach to a window over IPC, at runtime, with no cooperation needed from the terminal emulator hosting it. parse_tree() skips any leaf carrying a mark starting with MARK_PREFIX, before it's ever turned into a Window — filtered once, at the source, so every module (sidebar, preview, and anything future) automatically gets clean data with no per-module filtering needed. The same mark is then reused by dismiss_self() (moves the marked window to the scratchpad) and focus_self() (focuses it) — three methods, one shared identity.

mark_self(app_id=None) has two paths:

  • With app_id (from [wm] self_app_id in config.toml, matching how you actually launch tuicc — see README's "Summoning tuicc") — marks by WM criteria ([app_id="..."] on sway, [class="..."] on i3), a lookup with no dependency on focus state at all. This is the recommended path, and the one both reference providers document as the setup users should actually use.
  • Without app_id (the fallback, when self_app_id isn't configured) — mark --add <name> with no criteria prefix applies to whatever's currently focused, assuming that's still tuicc's own window at the moment mark_self() runs (called once, at startup, right after the provider is built). This is a known, narrower limitation than it used to be: launching several instances in rapid, back-to-back succession (not normal keypress-paced usage) can still race this specific assumption — a not-yet-focused instance could mark a different instance's window as "itself." Setting self_app_id sidesteps this entirely; the fallback exists only for the case where a caller genuinely has no known app_id to give.

Why a prefix with the process's own PID, not a fixed string: sway(5) states marks must be globally unique — "each identifier can only be set on a single window at a time." A shared literal string like "tuicc-self" breaks the moment a second tuicc instance runs: its mark_self() call would silently steal the mark away from the first instance (marks move, they don't duplicate), so the first instance would immediately stop filtering itself out. Suffixing with os.getpid() makes each instance's own mark genuinely unique, so multiple tuicc windows can coexist without stealing each other's mark. Filtering checks the prefix, not an exact match, so every instance still correctly excludes every other tuicc window too, not just its own — verified with two simultaneously-running instances, each correctly invisible to the other's preview.

dismiss_self() and focus_self() both target the same mark, via [con_mark=<mark>] move scratchpad and [con_mark=<mark>] focus respectively — no separate identity-resolution logic, they just reuse whatever mark_self() already established. dismiss_self() is the WM-side half of tuicc's persistent-process lifecycle (see Architecture: Process lifecycle) — main.py calls it instead of exiting, wherever a handler used to mean "quit." focus_self() is called right after main.py's pending_moves loop moves a freshly spawned or restored window into place — without it, the newly-mapped window silently steals keyboard focus away from tuicc on sway/i3 (and most other WMs), since focus-on-map happens regardless of stacking order even though tuicc, a floating window, stays visually on top. This isn't an edge case; it breaks the launcher's first spawn every time if left unimplemented.

If your WM has no equivalent concept to marks: leave all three methods unimplemented. None of them are @abstractmethod for this reason — the base class's defaults are documented no-ops. Your provider will work otherwise; tuicc will just show up as an ordinary window in its own sidebar/preview (no mark_self()), won't hide on dismiss (no dismiss_self()), and may lose input focus to spawned windows (no focus_self()) — known degraded cases, not crashes, but focus_self()'s gap in particular is severe enough to prioritize if your WM can support it at all.

One more thing worth knowing if your WM's own window placement matters here: filtering tuicc out of the window list doesn't, by itself, stop it from occupying space in a tiled split — a filtered-but-still-tiled tuicc window would leave a "hole" in its neighbors' geometry (they were laid out assuming it was there). tuicc's own recommended setup works around this by launching straight into the scratchpad (a WM config rule, e.g. sway's for_window [app_id="tuicc_scratch"] move scratchpad — see README's "Summoning tuicc"), so it's never part of the tiled tree's space allocation in the first place — this is a WM-config concern for whoever's running tuicc, not something your provider needs to solve in code.

Handling window layouts that don't map cleanly onto sway's model

sway and i3 both 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:

  1. Normalizing against the workspace rect stops making sense when the workspace has no fixed width. scroll's own IPC still reports a rect for 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.
  2. 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 or i3 (see Architecture: Floating windows) — for an infinite-strip WM, consider whether the provider should report rect values 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. scroll's IPC exposes a per-window fully_visible boolean that's a natural fit for exactly this kind of interpretation — worth investigating before inventing your own visibility heuristic if you're targeting scroll specifically.

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.

Registering it

# src/tuicc/providers/registry.py
from tuicc.providers.your_wm import YourWMProvider

PROVIDERS = {
    "sway": SwayProvider,
    "i3": I3Provider,
    "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).

Testing without a running WM instance

There are two complementary ways to test a provider without curses running, and they answer different questions.

Live REPL exploration is for finding out what your WM's IPC actually returns — the discovery step covered above. It requires a running instance of your WM, and it's how you'll find surprises like i3's floating_con wrapping in the first place:

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

Fixture-based regression tests are for locking in what you just discovered, so it stays true after you (or anyone else) touch the provider later — without needing your WM running at all. Once you've confirmed your provider works live, capture a real tree as JSON:

# tools/dump_tree.py
import json, sys
from i3ipc import Connection

with open(sys.argv[1], "w") as f:
    json.dump(Connection().get_tree().ipc_data, f, indent=2)
python tools/dump_tree.py tests/fixtures/yourwm_basic.json

Then write a test that rebuilds an i3ipc.Con tree from the saved JSON and runs it through your provider's parsing function — no live connection needed, because Con accepts a plain dict and takes None for the parent and connection arguments it doesn't have:

import json
from pathlib import Path
from i3ipc import Con
from tuicc.providers.your_wm import parse_tree

FIXTURES = Path(__file__).parent / "fixtures"

def load_state(name):
    with open(FIXTURES / f"{name}.json") as f:
        return parse_tree(Con(json.load(f), None, None))

def test_floating_windows_are_flagged():
    state = load_state("yourwm_basic")
    windows = [w for r in state.regions for w in r.windows]
    assert any(w.floating for w in windows)

Both sway.py and i3.py have exactly this pattern in tests/test_sway_provider.py and test_i3_provider.py — recorded from real sessions (including one deliberately constructed scene mixing tiled and overlapping floating windows, to catch exactly the kind of double-counting bug the i3 provider had). Reading those tests alongside the providers they cover is a good way to see what's actually worth asserting: not every field, just the ones a future change is most likely to silently break — scratchpad/hidden workspaces getting excluded, rect values staying inside expected bounds, floating windows not appearing twice.

The two approaches aren't a strict pipeline — you'll likely bounce between them, exploring live when something looks wrong in a fixture-based test, and recording a new fixture once you've confirmed a fix live.

If you get stuck

Open an issue or a draft PR. I'd genuinely like to see this work on more than sway and i3, and I'm specifically researching what a scroll/niri-style provider would need right now — if that's your situation, it's worth checking in before duplicating effort.

Clone this wiki locally