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 opaqueuserdatapointer. - A thin C shim owns the awkward FFI. Everything in
shim/: wlroots’wl_listener/wl_signalglue, 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:
- 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 asignal_addhelper that exposes a plain(userdata, data)callback to Rust. Rust never touches awl_listenerdirectly. - 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 likewlr_scene_rect_create’sconst float[4]— that read (or write) happens in a small C function inshim/, 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
| Path | What it is |
|---|---|
src/main.rs | Orchestration: builds the compositor, runs the event loop |
src/state.rs | Server, Output, Toplevel, Workspace — the Rust-owned state |
src/config.rs | Dependency-free config-file parser |
src/tiling.rs | Spiral/dwindle layout, directional focus/move, layer arrangement |
src/output.rs, input.rs, toplevel.rs, layer_shell.rs, decoration.rs, keybindings.rs | Per-concern policy modules |
src/ffi.rs | extern "C" declarations mirroring shim/oxide_shim.h |
shim/*.c, shim/oxide_shim.h | The C shim — listener glue + opaque struct field access |
build.rs, wrapper.h | The 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:
- 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
bindlines still has every default binding. 0xide always starts. - User config merges, never replaces. A
bindline 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. - 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:
- Resolves wlroots/wayland/etc. include and link flags via
pkg-config. - Generates the
xdg-shellprotocol header withwayland-scannerintoOUT_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). - Compiles the C shim (
shim/*.c) via thecccrate. - Runs
bindgenoverwrapper.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_autocreatepicks the nested Wayland backend automatically and 0xide opens as an ordinary window on the host desktop.OXIDE_MOD=alt cargo nested -- kittysets 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_autocreatedetects there’s noWAYLAND_DISPLAYand picks the DRM/KMS backend instead — this is 0xide as a real session, not a nested toy.LIBSEAT_BACKEND=logindlets logind hand the active VT its devices without needing theseatgroup.
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=altexists 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:
- A real client’s own behavior as a signal.
wayland-infoconnecting 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_seatgap at Stage 3 was caught. - Log markers plus a screenshot, for anything visual:
wlroots’ debug log (on viatarget/debug/0xide >/tmp/0xide.log 2>&1 & PID=$!; sleep 3 grim /tmp/0xide.png # screenshot the host screen, including 0xide's window kill $PIDoxide_log_init()) is very verbose — the first few hundred lines are EGL/DMA-BUF format enumeration — so the useful signal is 0xide’s ownprintln!markers plus lines likeAllocated ... 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/cardN — WLR_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):
- 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.
- Make the smallest useful change — no large generated drops.
- Show and explain every file and function touched.
- Say how to test it, then actually run it and show the real output — never claim something works without verifying it.
- Don’t advance to the next stage until the current one is understood.
- 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
#includesxdg-shell-protocol.h, which isn’t a system header —build.rsgenerates it withwayland-scannerintoOUT_DIRas part of the FFI pipeline from Stage 0. - A real client refuses to start without a seat.
footfailed with “no seats available” until a minimalwl_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 (9–11) 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 likegrimandwf-recordercapture 0xide’s real composited output directly.xdg-outputspecifically exists because screenshot tools need to learn each output’s logical position/size, orgrimfails with a 0×0 capture. - Fullscreen — both client-requested (F11,
mpv --fs, honored on map for apps launched fullscreen) and compositor-driven (Mod+Ftoggle). 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.