Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0xide

0xide is a from-scratch tiling Wayland compositor, written in Rust on top of wlroots. It’s the userspace sibling of snert, a from-scratch kernel project — same working style, one layer up the stack.

Most people who want a tiling Wayland compositor install one. 0xide exists for a different reason: to understand, concept by concept, what a Wayland compositor actually is — the backend, the Wayland server, the renderer, xdg-shell, input routing, tiling, real display output — by building each one instead of importing it. It’s a learning project first and a daily driver second, though it’s grown into something usable on real hardware. Design decisions are collected in Design & ideas.

The mental model

A Wayland compositor sits between two things it doesn’t own:

        clients (foot, firefox, ...)
                    │
                    ▼
   ┌────────────────────────────────┐
   │             0xide               │   ← this project
   │  policy: tiling, workspaces,    │
   │  keybindings, config            │
   └────────────────────────────────┘
                    │
                    ▼
              wlroots (C library)
        DRM/KMS, GLES2 renderer, libinput,
        scene graph, protocol plumbing
                    │
                    ▼
             the Linux kernel

wlroots is the engine; 0xide is the driver. wlroots does the parts that are the same for every compositor — talking to the kernel’s display and input subsystems, decoding the Wayland wire protocol, drawing GL buffers. 0xide decides policy: which window goes where, what a keypress does, how workspaces and monitors relate. That split is deliberate and shows up again, one level down, inside 0xide itself — see Architecture.

Why phases

Rather than a progress percentage or a loose TODO list, 0xide’s roadmap is a sequence of stages, each ending in a concrete thing you can see or test — “a nested window shows a solid color,” “a terminal appears,” “I can type into it.” That’s not a documentation choice, it’s how the project is actually built: see Why phase gates for the reasoning, and the Build Phases chapters for what each stage was and how it went.

Status

0xide runs nested inside another Wayland session for fast iteration, and as a real DRM/KMS session on a bare TTY. It tiles windows, switches workspaces, reads a config file, survives VT switching, and drives multiple monitors with configurable position and scale. It’s being grown into something to daily-drive, one capability at a time — see the repository README for the current feature list, or jump straight to the build phases for the story of how it got there.

Architecture: the two-plane split

0xide is really two systems layered on top of each other, with a hard rule about who owns what — the same discipline the Introduction described one level up, between 0xide and wlroots, repeats itself inside 0xide’s own source tree.

The ownership contract

  • Rust owns policy and flow. Everything in src/: the window list, the tiling layout, workspaces, keybindings, config parsing, and the overall program flow — building the display/backend/renderer/allocator, wiring the scene graph, running the event loop. Server (the top-level state struct) lives in Rust and gets passed to C as an opaque userdata pointer.
  • A thin C shim owns the awkward FFI. Everything in shim/: wlroots’ wl_listener/wl_signal glue, and anything that needs to reach into a wlroots struct’s actual fields.

That second point is the why. wlroots types are largely opaque to Rust — bindgen, pointed at the real headers, emits most structs as an anonymous _address byte blob rather than named fields, because the C side uses patterns (intrusive linked lists via wl_container_of, unions, bitfields) that don’t have a safe 1:1 Rust representation. Two consequences follow directly:

  1. Listener wiring goes through the shim. wlroots signals you (a new output appeared, a surface mapped, a key was pressed) via wl_signal + wl_listener — an intrusive C list threaded through the struct you’re listening on. The shim wraps this once, generically, as a signal_add helper that exposes a plain (userdata, data) callback to Rust. Rust never touches a wl_listener directly.
  2. Struct field reads go through the shim. If Rust needs something living inside an opaque wlroots struct — an output’s width/height, a surface’s role, an array literal like wlr_scene_rect_create’s const float[4] — that read (or write) happens in a small C function in shim/, which returns a plain value or plain pointer that Rust can represent safely.

Everything else — creating a wlr_scene, adding an output to a wlr_output_layout, tying a layout slot to a scene output — is a plain function call with plain pointer arguments, so it stays directly in Rust with no shim wrapper at all.

Rule of thumb: if it needs a wlroots struct’s insides, a C array literal, or the listener list, it goes in the shim; otherwise it stays in Rust.

bindgen and the shim, concretely

build.rs runs bindgen over wrapper.h with an explicit allowlist.allowlist_function(...), .allowlist_type(...) — so Rust only sees the slice of the wlroots API 0xide actually calls, rather than the whole (huge, partially-unstable) surface. The same build.rs also compiles shim/*.c via the cc crate and generates the xdg-shell protocol header with wayland-scanner, since wlroots’ own xdg-shell header #includes it and it’s not a system header.

The shim itself is split one file per protocol/concern (shim/output.c, shim/input.c, shim/xdg_shell.c, shim/layer_shell.c, shim/decoration.c, shim/core.c), each declared in one header (shim/oxide_shim.h) that src/ffi.rs mirrors with extern "C" decls.

Where this shows up in the tree

PathWhat it is
src/main.rsOrchestration: builds the compositor, runs the event loop
src/state.rsServer, Output, Toplevel, Workspace — the Rust-owned state
src/config.rsDependency-free config-file parser
src/tiling.rsSpiral/dwindle layout, directional focus/move, layer arrangement
src/output.rs, input.rs, toplevel.rs, layer_shell.rs, decoration.rs, keybindings.rsPer-concern policy modules
src/ffi.rsextern "C" declarations mirroring shim/oxide_shim.h
shim/*.c, shim/oxide_shim.hThe C shim — listener glue + opaque struct field access
build.rs, wrapper.hThe FFI pipeline: pkg-config, wayland-scanner, cc, bindgen

This division isn’t fixed in stone — it’s a rule of thumb refined while building each stage, not a spec written up front. See the build phases for how it was actually arrived at.

Design & ideas

0xide is a dynamic tiling window manager for Wayland, written in Rust on wlroots. This chapter documents its design decisions — how the layout, configuration, and workspace model work and why — and the ideas planned next. It is updated as decisions are made.

Layout

There is one tiling layout — the spiral/dwindle described in Stage 5 — and it is recomputed from the window list’s order on every change. There is no persisted layout tree, no per-window split ratios, no manual layout mode.

The tradeoff: layout is a pure function of a Vec — reproducible, unit- testable against exact computed rectangles, with no layout state to corrupt or desynchronize. The cost is that some layout-shape questions have no clean answer; the corner-touch ambiguity in directional navigation is a direct consequence, and is documented with a test that pins the behavior. If per-window split control becomes a requirement, the structural fix is an explicit split-tree — listed under planned ideas below.

Configuration

Three rules, applied throughout src/config.rs:

  1. Nothing is fatal. A line that doesn’t parse warns on stderr and is skipped. A missing config file means defaults. A config with zero bind lines still has every default binding. 0xide always starts.
  2. User config merges, never replaces. A bind line overrides exactly that key combination; every unmentioned default stays active. A two-line config is a two-line diff, not a fork of the whole keymap.
  3. Explicit over implicit. Monitor placement is literal pixel coordinates per named connector (monitor = eDP-1, 0x0) — no relative keywords, no DPI auto-scale heuristics. The config states what happens; nothing else does.

Workspaces and outputs

Nine workspaces, with one invariant: a workspace is never visible on two outputs at once. Switching to a workspace already shown on another monitor swaps the two monitors’ workspaces instead of duplicating it. New windows open on the monitor the cursor is on (focus-follows-monitor). The model stays predictable regardless of how many outputs are attached.

Decorations

0xide always claims server-side decoration and draws nothing in its place: every window is a bare, borderless rectangle. In a tiler the layout itself conveys what title bars and borders would — window position and focus are already visible from the arrangement.

Planned ideas

Under consideration, not committed:

  • An explicit split-tree layout — per-window split ratios, interactive resize, and fully reversible directional navigation; the structural answer to the corner-touch limitation above.
  • Floating exceptions — per-window rules for clients that shouldn’t tile (pickers, dialogs).
  • Runtime control — a socket/IPC for querying and scripting the compositor without keybindings.

When one of these lands, it moves out of this list and into a stage chapter.

Environment & toolchain

0xide is built and run on Arch Linux, as ordinary stable-Rust userspace (no no_std, no custom target) — the toolchain is pinned in rust-toolchain.toml so a fresh checkout always builds with the exact version it was developed against.

System dependencies

wlroots0.19 wayland wayland-protocols libxkbcommon libinput libdrm seatd mesa pixman pkgconf clang

The version that matters most is wlroots: 0xide targets wlroots 0.19 specifically (Arch package wlroots0.19, pkg-config name wlroots-0.19). wlroots’ API moves between minor versions, so this pin isn’t cosmetic — build.rs resolves flags via pkg-config wlroots-0.19 rather than a bare wlroots, and every wlroots header requires -DWLR_USE_UNSTABLE defined or it expands to #error (wlroots treats most of its own API as unstable by design; the flag is an explicit “I know” acknowledgement, not a mistake to work around).

clang/libclang is a build dependency, not a runtime one — bindgen needs it to parse the wlroots C headers into Rust FFI declarations.

The FFI pipeline

build.rs does four things, in order, every build:

  1. Resolves wlroots/wayland/etc. include and link flags via pkg-config.
  2. Generates the xdg-shell protocol header with wayland-scanner into OUT_DIR (wlroots’ own xdg-shell header #includes this, and it isn’t a system header — it has to be generated from the protocol XML on every machine that builds 0xide).
  3. Compiles the C shim (shim/*.c) via the cc crate.
  4. Runs bindgen over wrapper.h, allowlisting only the functions/types 0xide actually calls (see Architecture for why the allowlist exists and what it means for opaque struct types).

See build.rs for the exact allowlist and flag wiring.

Running it

Two run modes, both via cargo aliases in .cargo/config.toml:

  • cargo nested — the fast dev loop. Inside an existing Wayland session, wlr_backend_autocreate picks the nested Wayland backend automatically and 0xide opens as an ordinary window on the host desktop. OXIDE_MOD=alt cargo nested -- kitty sets the modifier to Alt (since the host compositor usually grabs Super-chords before a nested client sees them) and launches a test client against 0xide’s own socket.
  • Real TTY (DRM/KMS) — from a free virtual terminal, logged in: LIBSEAT_BACKEND=logind ~/Projects/0xide/target/debug/0xide kitty 2>~/0xide-tty.log. wlr_backend_autocreate detects there’s no WAYLAND_DISPLAY and picks the DRM/KMS backend instead — this is 0xide as a real session, not a nested toy. LIBSEAT_BACKEND=logind lets logind hand the active VT its devices without needing the seat group.

Full recipes, verification commands, and known gotchas (multi-GPU device selection, VT-switch repaint behavior, headless screenshot verification) live in Running & Verifying and the in-repo notes/ directory, which is the day-to-day working reference this chapter is distilled from.

Running & verifying

Building a compositor raises an obvious problem: how do you check that a change actually works, when the thing you built is the environment everything else renders inside? This chapter is less “here’s the command” (the README covers that) and more about the verification habits that came out of building 0xide without a synthetic-input tool available in the dev environment (no wtype/ydotool) — every recipe here exists because “just try it and see” wasn’t always possible.

Nested first, always

The nested Wayland backend — cargo nested — is the primary loop for a reason: it’s the only mode where 0xide runs inside something that can already show you its output, with no modesetting, no VT, no real display hardware involved. Every stage was built and mostly verified nested before it was ever tried on a real TTY.

Two things about the nested backend are easy to get bitten by:

  • It only receives keys when the host compositor gives its window focus. A “keybinding does nothing” bug is, more often than not, a focus bug in the host desktop, not in 0xide.
  • The host may already own the modifier you want. OXIDE_MOD=alt exists because the host compositor grabs Super-chords before a nested 0xide window ever sees them.

Verifying without synthetic input

With no way to script a keypress or click into the agent loop, verification leans on two things instead:

  1. A real client’s own behavior as a signal. wayland-info connecting and listing 0xide’s globals (wl_shm, zwp_linux_dmabuf_v1, wl_compositor, wl_data_device_manager, …) proves the Wayland server is actually up, independent of anything visual. A real app (foot, kitty) refusing to start with “no seats available” is exactly as informative as it succeeding — it’s how the missing-wl_seat gap at Stage 3 was caught.
  2. Log markers plus a screenshot, for anything visual:
    target/debug/0xide >/tmp/0xide.log 2>&1 &
    PID=$!; sleep 3
    grim /tmp/0xide.png     # screenshot the host screen, including 0xide's window
    kill $PID
    
    wlroots’ debug log (on via oxide_log_init()) is very verbose — the first few hundred lines are EGL/DMA-BUF format enumeration — so the useful signal is 0xide’s own println! markers plus lines like Allocated ... GBM buffer / DMA-BUF imported, read alongside the PNG.

Keyboard wiring (as opposed to actual typing) is verified the same way — log markers like “keyboard attached” / “keyboard focus -> toplevel” confirm the plumbing is connected; actually typing into a client is checked by hand, by focusing the nested window on the host and typing.

Config, without a window

The config parser (src/config.rs) doesn’t need a display to verify at all:

XDG_CONFIG_HOME=/tmp/cfg WLR_BACKENDS=headless target/debug/0xide >log 2>&1 &
grep -E '0xide: (loaded|no config|modifier|config line)' log

An unparseable config line warns on stderr and is skipped — startup never fails on a bad config line — so the grep above is also the fastest way to confirm a hand-edited 0xide.conf actually parsed the way you intended.

Multi-output, nested

The nested Wayland backend honors WLR_WL_OUTPUTS=2, opening two host windows — enough to verify per-output tiling, focus-follows-monitor, and (later) config-driven monitor position/scale without touching real hardware:

WLR_WL_OUTPUTS=2 OXIDE_MOD=alt target/debug/0xide foot

Two output <name> online @ X,Y WxH — workspace N log lines confirm both outputs came up and where the layout placed them.

Real hardware

On a bare TTY, wlr_backend_autocreate picks the DRM/KMS backend instead (no WAYLAND_DISPLAY to detect). The one recurring hardware quirk worth knowing before you hit it: on a machine with two GPUs, wlroots may pick the wrong /dev/dri/cardNWLR_DRM_DEVICES=/dev/dri/cardN forces the right one.

VT switching (Ctrl+Alt+F1..F12) is its own verification loop: switch away, switch back, and watch for a clean repaint rather than a black/frozen screen. That specific failure mode — outputs come back black after a VT resume — is what drove the session-active-signal handling described in Stage 6; a forced repaint on the first few frames after resume is the fix, and the regression test for it is “does the screen come back,” not something a unit test can cover.

Why phase gates

0xide’s roadmap isn’t a backlog or a progress bar — it’s nine stages, each defined by one concrete deliverable: a thing you can point at, run, and see working. “A nested window shows a solid color.” “A terminal appears.” “I can type into it.” That’s a deliberate constraint, not an accident of how the notes got organized.

The alternative, and why it was rejected

An installer-shaped project plan says “when everything is done, you get a working system” — you can’t meaningfully check progress until the very end, because the pieces don’t do anything in isolation. That shape is fine for software you’re assembling from parts you already trust. It’s the wrong shape for software you’re building specifically to understand, because it lets you accumulate code you don’t actually understand yet, as long as it compiles — the gap only shows up later, at the worst possible time, when three unverified layers are stacked on top of each other.

A phase gate inverts that: you don’t move to the next stage until the current one has a working, demonstrable deliverable and you understand why it works. Concretely, that’s the learning-first workflow this project runs on (from KICKOFF.md):

  1. Explain the concept first — what’s being built, why it’s needed, which Wayland/wlroots/Linux concept it touches, what’s unsafe or ABI-specific.
  2. Make the smallest useful change — no large generated drops.
  3. Show and explain every file and function touched.
  4. Say how to test it, then actually run it and show the real output — never claim something works without verifying it.
  5. Don’t advance to the next stage until the current one is understood.
  6. Every commit should be understandable on its own.

What a stage actually is

Each stage below has the same shape: what it is, why it matters, its stated deliverable (verbatim from KICKOFF.md), how it actually went, and its current status. “How it actually went” is the part a plan can’t predict in advance — VT-switch black-screen bugs, opaque-struct FFI surprises, a reversibility bug in directional window navigation that only appears at four or more windows. The gate isn’t the plan; it’s the verified result.

Stages 0–5 are done and described in full; 6 and 8 are substantially working. The rest are open — those chapters are short and will grow as the work happens, the same way the rest of this book grows: after the fact, from what was actually built, not written speculatively in advance.

The roadmap itself also grows. Stages 0–8 were the bootstrap era — from “a window shows a solid color” to a compositor that runs real hardware and real apps. Stages 9–11 are the daily-driver era: floating windows, a split-tree layout, runtime control. New stages get added when new work earns a gate of its own; what never changes is the rule that each stage has exactly one concrete, testable deliverable.

Stage 0 — Foundation & FFI

What it is. The very first thing any wlroots compositor needs: a linked wlroots, a backend, a renderer, and something on screen. No Wayland clients exist yet — this stage is purely about getting Rust and wlroots talking to each other at all.

Why it matters. Every later stage depends on the FFI shape decided here. wlroots is a C library built around signals (wl_signal/wl_listener) and structs with fields Rust can’t safely see without help — get the FFI strategy wrong here and every subsequent stage inherits the pain. This is also where the Rust/C-shim split described in Architecture was decided, not assumed up front.

Deliverable (from KICKOFF.md): link wlroots from Rust (bindgen vs C shim, decided together); open a wlroots backend (nested) and a renderer; clear the screen to a solid color; structured logging. A nested window shows a solid color — “0xide alive.”

How it went

The FFI strategy landed on both, not one or the other: bindgen generates the raw function/type bindings from an explicit allowlist in build.rs, and a thin C shim (shim/) handles the parts bindgen can’t make safe — signal/listener glue and opaque struct field reads. That split, made here, held for every stage after.

build.rs came together as a four-step pipeline: resolve wlroots/wayland flags via pkg-config, generate the xdg-shell protocol header with wayland-scanner, compile the shim with the cc crate, then run bindgen over wrapper.h. wlr_backend_autocreate was the first real wlroots call — it inspects the environment and picks the nested Wayland backend when run inside an existing session, which became the fast dev loop for every stage from here on (see Running & Verifying).

Status: done. cargo nested opens a window and clears it to a solid color, with oxide_log_init() wiring wlroots’ own debug log to stderr.

Stage 1 — Wayland Server Up

What it is. Turning the wlroots backend from Stage 0 into an actual Wayland server — the thing client applications connect to. This is where wl_display, the event loop, and the first client-facing globals (wl_compositor, wl_shm) show up.

Why it matters. A compositor’s whole job, from a client’s point of view, is being the other end of the Wayland protocol. Until a client can connect and see globals, nothing else — windows, input, tiling — has anywhere to attach.

Deliverable (from KICKOFF.md): wl_display, event loop, wl_compositor, wl_shm; advertise the socket (WAYLAND_DISPLAY); accept a client connection. wayland-info connects and lists globals.

How it went

wl_display_create plus wl_display_get_event_loop gave the event loop wlr_backend_autocreate needed. wlr_compositor_create supplies wl_compositor (surfaces, regions) but not wl_shm — that comes from wlr_renderer_init_wl_display, called on the renderer created in Stage 0. That distinction — which call actually advertises which global — is easy to get backwards and only becomes obvious by checking with a real client.

wl_display_add_socket_auto opens the Unix socket (e.g. wayland-2) and main() exports it as WAYLAND_DISPLAY before spawning any client, so spawned test programs talk to 0xide and not to whatever nested host they’re running under.

Verified with: cargo nested -- wayland-info — the client connects and lists wl_shm, zwp_linux_dmabuf_v1, wl_compositor, wl_subcompositor, wl_data_device_manager, interleaved with wlroots’ own debug log. See Running & Verifying for why a real client’s own output, rather than a screenshot, was the right verification tool at this stage — there was nothing to see yet.

Status: done.

Stage 2 — Outputs & Render Loop

What it is. Moving from “clear the screen once” (Stage 0) to a real, continuous render loop driven by wlroots’ scene graph — the data structure that holds everything that gets drawn and knows how to repaint only what changed (damage tracking).

Why it matters. Every window, background, and layer-shell surface added in later stages is a node in this scene graph. Getting the output/scene/ layout wiring right here is what lets later stages just add nodes instead of hand-rolling their own render passes.

Deliverable (from KICKOFF.md): wlr_output, wlr_scene, per-frame render with damage. A stable, damage-tracked frame on nested + headless.

How it went

Three pieces get created once in main() and tied together: wlr_scene_create (the scene graph itself), wlr_output_layout_create (where outputs sit in space), and wlr_scene_attach_output_layout, which keeps a scene-output positioned to match its layout slot automatically. Each new output (handled in handle_new_output, see src/output.rs ) gets a wlr_scene_output tied to a layout slot via wlr_scene_output_layout_add_output, and a frame listener that calls wlr_scene_output_render on every frame the output signals it’s ready for one.

The scene is organized as an ordered stack of layer trees — direct children of the scene root, created in a fixed order so creation order becomes paint order (background → layer-shell bottom → normal app windows → layer-shell top → layer-shell overlay). That ordering, decided here, is what Stage 8’s layer-shell support slots into later without needing to touch the scene wiring again.

Status: done, and the scene/output/layout structure from this stage is unchanged in shape today — later stages extended it (per-output tiling in Stage 5, forced repaint-on-resume in Stage 6) rather than replacing it.

Stage 3 — First Window

What it is. The stage where 0xide stops being an empty colored rectangle and starts hosting real applications: xdg-shell, the protocol real apps (terminals, browsers) use to become an app window (“toplevel”) rather than a bare surface.

Why it matters. Nothing downstream — tiling, focus, keybindings — means anything without a real window to apply it to.

Deliverable (from KICKOFF.md): map a real client surface. A terminal (foot) appears in 0xide.

How it went

wlr_xdg_shell_create advertises the xdg_wm_base global apps bind to; its new_toplevel signal is hooked (via the shim, since it’s a wl_signal/wl_listener) to handle_new_toplevel, which puts the new window’s surface into the scene graph built in Stage 2.

Two gotchas surfaced here, both now folded into the working notes:

  • wlroots’ xdg-shell header itself needs a generated file. It #includes xdg-shell-protocol.h, which isn’t a system header — build.rs generates it with wayland-scanner into OUT_DIR as part of the FFI pipeline from Stage 0.
  • A real client refuses to start without a seat. foot failed with “no seats available” until a minimal wl_seat (oxide_seat_create) existed — input handling proper doesn’t land until Stage 4, but the global has to exist earlier than that for any real app to even try connecting.

Verified with: cargo nested -- foot — a terminal appears in the nested window.

Status: done.

Stage 4 — Input

What it is. Wiring real keyboards and pointers into the seat created (as a bare global) back in Stage 3, and routing their events to the right client — keyboard focus, pointer focus, and the xkb layer that turns raw scancodes into actual keysyms.

Why it matters. A tiling window manager is defined by what the keyboard and mouse do — without real input routing, there’s no way to drive anything built afterward: no keybindings, no click-to-focus, no directional navigation.

Deliverable (from KICKOFF.md): seat, keyboard via xkb, pointer, focus routing. I can type into and click the terminal.

How it went

New input devices arrive via the backend’s new_input signal (handle_new_input in src/input.rs ), routed by device type — keyboards get an xkb keymap and are attached to the seat; pointers/touch devices are attached to the cursor set up in main() (oxide_cursor_setup), which sits over the output layout and routes motion through the scene graph’s own hit-testing to figure out which surface is under the pointer.

With no synthetic-input tool available in the dev environment, this stage established the verification split that Running & Verifying describes in full: wiring is checked via log markers (“keyboard attached”, “keyboard focus -> toplevel”), actual typing/clicking is checked by hand by focusing the nested window on the host desktop.

Status: done. Click-to-focus and keyboard input both work; this is also where src/keybindings.rs starts existing as a module, even though the configurable keybinding system proper is a Stage 5 deliverable.

Stage 5 — Window Management (the heart of it)

What it is. The stage that turns 0xide from “a compositor that can show one window” into an actual tiling window manager: multiple windows sharing the screen automatically, workspaces, a config file, and keybindings to drive all of it. This is the largest stage by far, and the one that’s kept growing well past its original deliverable.

Why it matters. This is the whole point of the project’s shape — a dynamic tiler, not a floating WM. Everything here is policy (see Architecture), which is why it all lives in Rust with no shim involvement beyond the scene-node calls tiling needs to position windows.

Deliverable (from KICKOFF.md): multiple windows, a tiling layout + workspaces, move/resize, keybindings, a config file. Usable tiling WM behavior.

The tiling layout: spiral / dwindle

src/tiling.rs’s refresh() implements a spiral (dwindle) layout: each new window recursively splits the remaining space, alternating vertical (left/right) and horizontal (top/bottom) by depth. There’s no persisted tree structure — the whole layout is recomputed from Workspace.windows: Vec<*mut Toplevel>’s list order on every call. That’s a deliberate simplicity tradeoff: it makes the layout trivially reproducible from state, at the cost of some geometric ambiguity explored below.

Workspaces and multi-output

Nine workspaces, switchable and movable-to from the keyboard. Once multi-monitor entered the picture, tiling became per-output: each Output tracks which workspace it’s showing, refresh() hides any workspace not displayed on any output and tiles each output’s workspace within that output’s own box, and switching to a workspace already shown on another monitor swaps the two outputs’ workspaces rather than showing one workspace on two screens at once. New outputs claim the lowest-numbered free workspace, and get focus-follows-monitor: new windows open on whichever monitor the cursor is currently over.

Per-output monitor position and scale are config-driven (monitor = NAME, XxY[, SCALE] in 0xide.conf) — an output with no matching config entry keeps wlroots’ default auto-placement. This was kept deliberately simple: explicit pixel coordinates per named connector, no relative-position keywords, no DPI-based auto-scale heuristic — the config author computes and writes the offsets themselves.

Config file

src/config.rs is a dependency-free, line-based parser — no external crate — for key = value lines plus a compact bind = MODS, KEY, ACTION[, ARG] syntax. Keybinding config merges with, rather than replaces, the built-in defaults: Config::load() always seeds the full default bind set first, then each bind line in the user’s config overrides just that one key combination and leaves every other default in place — so a config with two or three bind lines still has working workspace switches, close, and quit. An unparseable line warns on stderr and is skipped; nothing in config parsing is ever fatal to startup.

Keybindings: from cyclic to spatial

Window navigation went through a real design change mid-stage. It started as cyclic Mod+J/Mod+K (next/previous in list order) and was replaced with spatial Mod+H/J/K/L — focus or move to whichever tiled window is actually left/down/up/right of the current one on screen, no wraparound.

The first implementation picked a directional neighbor by nearest center-point (weighted toward the primary axis). That worked for two or three windows but broke down at four or more: because the spiral layout can produce one large window opposite several smaller stacked ones, center- distance could pick a window with no actual shared border — pressing Up from a bottom-right pane could land in a large far-left pane instead of the pane directly above it, and the relation wasn’t even symmetric (Right from A could reach C, without Left from C reaching back to A). The fix, confirmed with a real computed spiral fixture rather than hand-derived geometry, switched to an edge/overlap-based heuristic (i3/sway-style): prefer whichever candidate shares the largest overlapping border on the axis perpendicular to the movement direction, falling back to center-distance only when nothing overlaps at all. One residual limitation is understood and accepted rather than silently swept under the rug: two windows that touch at only a single corner point (not a real shared edge) can’t be made fully reversible by any geometric heuristic on a flat, list-order-driven layout — fixing that fully would mean representing the layout as an explicit split-tree instead, out of scope for now.

Status

Done, in the sense of meeting and exceeding the original deliverable — tiling, workspaces, config, and keybindings are all in daily use — but this stage is the one most likely to keep growing (more layouts, resize, floating exceptions) rather than being considered permanently closed the way Stages 0–3 are.

Stage 6 — Real Display (DRM/KMS)

What it is. Moving off the nested Wayland backend and onto real display hardware: a bare virtual terminal, DRM/KMS modesetting, and a real login session via libseat, instead of a window inside someone else’s compositor.

Deliverable (from KICKOFF.md): run on a VT via seatd/libseat; multi-output layout and modesetting. 0xide as a real session on hardware/VM.

How it’s gone so far

wlr_backend_autocreate already picks the DRM/KMS backend automatically when there’s no WAYLAND_DISPLAY to detect — no separate code path was needed, just a different environment to run in. On a bare TTY: LIBSEAT_BACKEND=logind ~/Projects/0xide/target/debug/0xide foot 2>~/0xide-tty.log, with LIBSEAT_BACKEND=logind letting logind hand the active VT its devices without a seat group membership.

Two real bugs came out of this that a nested session can’t surface at all, since nesting never tears down or re-modesets an output:

  • VT switching crashed on output destroy. Switching away and losing the session mid-flight needed proper output-destroy handling — removing the frame/destroy listeners and background scene node before wlroots finishes tearing the output down, or wlroots asserts on a non-empty frame listener list.
  • Returning from a VT switch came back black. The outputs aren’t destroyed on a VT switch — they’re re-modeset to black — and idle clients never repaint on their own, so regaining the VT showed nothing. The fix hooks the session’s active-change signal: on resume, every window’s scene node is torn down and recreated (a client’s buffer survives, but its old scene node stops presenting it after the modeset), then a few forced repaints are scheduled per output so the freshly-rebuilt scene actually gets painted once the output is back.

Multi-output tiling (per-output workspaces, focus-follows-monitor, config-driven position/scale) was built and verified nested first — see Stage 5 — and confirmed working the same way on real hardware.

Status

In progress / substantially working. Single and multi-display both run on real hardware, VT switching survives without crashing or losing windows, and config-driven monitor placement matches real connector names and dimensions. Not yet covered: hotplug removal mid-session beyond the already-handled destroy path, and further real-hardware edge cases as they turn up.

Stage 7 — Boot-into-VM

What it is. The “boot a Linux kernel, then straight into our own userspace” milestone: a minimal Linux kernel plus initramfs/rootfs that boots directly into 0xide on virtio-gpu, rather than 0xide being launched from an already-running Linux install.

Deliverable (from KICKOFF.md): minimal Linux + initramfs/rootfs boots straight into 0xide on virtio-gpu. “Boot a Linux kernel, then our userspace.”

Status

Not started. This is the stage after Stage 6 in KICKOFF.md’s roadmap; real-hardware DRM/KMS work is still ongoing, and the cargo vm runner this stage implies (mirroring cargo nested/cargo headless) doesn’t exist yet. Notably out of scope even once this lands: running on the snert kernel itself — this project targets Linux only for now, and isn’t being design-constrained for a future snert port.

This chapter will be filled in once the work actually starts, the same way every earlier stage was — after building it, not before.

Stage 8 — Protocol Compat

What it is. The protocols that make the everyday app ecosystem run: bars and wallpaper, decoration control, screenshots, fullscreen, and eventually X11 apps. Originally a broader “polish” catch-all, this stage was narrowed once the daily-driver work outgrew it — features like floating windows and layout rework now have their own stages (911) with their own gates, keeping the one-stage-one-deliverable rule honest.

Deliverable (from KICKOFF.md): the everyday app ecosystem runs — bars, screenshots, fullscreen video, X11 apps.

How it’s gone so far

Several of these landed early, driven by real need rather than roadmap order — a bar and a wallpaper are hard to live without day-to-day, so layer-shell support arrived well before this stage was “next”:

  • wlr-layer-shell-unstable-v1 — bars, panels, and wallpaper (e.g. quickshell) render in the correct z-order (into the layer trees set up back in Stage 2) and reserve their exclusive screen space, so tiled windows never sit underneath them. One real bug here: layer surfaces that arrive before any output exists yet were being silently dropped; the fix tracks them as pending and attaches them to the next output that shows up, instead of requiring output-then-surface ordering.
  • Server-side decorations (xdg-decoration-unstable-v1) — 0xide always claims decoration mode, so clients skip drawing their own title bar/CSD: bare, borderless windows by default.
  • Screenshots/screen recording (wlr-screencopy-unstable-v1 + xdg-output) — tools like grim and wf-recorder capture 0xide’s real composited output directly. xdg-output specifically exists because screenshot tools need to learn each output’s logical position/size, or grim fails with a 0×0 capture.
  • Fullscreen — both client-requested (F11, mpv --fs, honored on map for apps launched fullscreen) and compositor-driven (Mod+F toggle). A fullscreen window covers its output’s full box in a dedicated scene layer above the bars but below overlay surfaces, and other windows stay tiled beneath it. Per the xdg-shell protocol every state request must be answered with a configure even when denied — 0xide previously wasn’t listening at all, which was a protocol violation, not just a missing feature. Closely related fix from the same work: windows are declared tiled in their very first configure, carrying their predicted tile size — without a tiled state the configure size is only a floating-style hint, and clients with a remembered window size (browsers especially) would map at their own size and overflow across outputs.

Not yet started: XWayland (X11 app compatibility) — the one remaining gate for this stage.

Status

Substantially done. Layer-shell, decorations, screencopy, and fullscreen are all in daily use; XWayland closes it out.

Stage 9 — Floating Windows

What it is. The first stage of the daily-driver era: windows that shouldn’t tile, don’t. File pickers, “Save as…” dialogs, and fixed-size utility windows get force-tiled today like any other toplevel — the single most disruptive behavior in day-to-day use of a pure tiler.

Deliverable (from KICKOFF.md): a file picker opens floating and centered instead of being tiled.

The intended shape: xdg-shell already marks dialog-like toplevels (a set parent, or min/max size hints pinning the window to a fixed size) — those float automatically, centered over their parent, at their preferred size. Per-app config rules and a manual float toggle can follow once the automatic detection covers the common cases.

Status

Not started — next up. This chapter will be filled in once the work lands, the same way every earlier stage was: after building it, not before.

Stage 10 — Split-Tree Layout

What it is. Replacing the flat, list-order-driven spiral layout with an explicit split tree: every tiled window is a leaf in a tree of vertical/horizontal splits, each split with its own adjustable ratio.

Why it matters. The current layout is a pure function of the window list’s order — simple, reproducible, unit-testable (see Design & ideas), but it can’t express “make this window a bit wider” and it’s the structural cause of the corner-touch ambiguity in directional navigation documented in Stage 5. A real tree fixes both: per-window resize, and fully reversible neighbor relationships.

Deliverable (from KICKOFF.md): resize a window from the keyboard and the layout keeps it.

Status

Not started. This is the largest planned rework of the tiling engine — deliberately queued after floating windows (Stage 9), which is smaller and independent of the layout representation.

Stage 11 — Runtime Control

What it is. A control socket (IPC): query the compositor’s state and drive it from outside — scripts, status bars, one-off shell commands — without everything having to be a keybinding.

Why it matters. Keybindings cover interactive use; a socket covers everything else: a bar showing the active workspace, scripted window arrangements, toggling settings without editing the config and restarting. It’s also the natural place for a 0xidectl-style command tool.

Deliverable (from KICKOFF.md): a shell script lists windows and switches workspaces without touching a keybinding.

Status

Not started. Design is open — protocol shape (plain text vs JSON), what state to expose, and whether config reload belongs here too will be decided when the stage begins.