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

0xin

0xin 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. 0xin 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, ...)
                    │
                    ▼
   ┌────────────────────────────────┐
   │             0xin               │   ← 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; 0xin 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. 0xin 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 0xin itself — see Architecture.

Why phases

Rather than a progress percentage or a loose TODO list, 0xin’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

0xin 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

0xin 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 0xin and wlroots, repeats itself inside 0xin’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 0xin 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/layout.rsThe split tree (Node) and its pure operations — insert/remove/resize
src/tiling.rsTiling orchestration: syncs the tree to live state, 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

0xin 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

Tiled windows sit in an explicit split tree (layout::Node: Leaf or Split { vertical, ratio, first, second }), one per workspace, shaped like the dwindle spiral described in Stage 5 by default but with a persisted ratio per splitStage 10’s addition. Mod+Ctrl+hjkl (resizewindow) adjusts the ratio of whichever split borders the focused window in that direction; every other window’s ratio is untouched, including across opening and closing unrelated windows.

Windows themselves stay unaware of the tree: which window is tiled-position i is still decided purely by Workspace.windows’ order (now filtered to non-floating, non-fullscreen ones), exactly as before. The tree only adds the one thing a plain Vec had no room for — a number that survives between refresh() calls. Directional navigation (spatial_neighbor) still uses Stage 5’s geometric heuristic rather than the tree’s actual adjacency, so the corner-touch ambiguity documented there is unchanged.

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

Floating windows

Windows that shouldn’t tile, don’t: dialogs (a toplevel with a parent set — file pickers, “Save as…”), windows that declare a fixed size, and anything matched by a float = <app_id> config rule open floating instead — centered, painted above the tiled layer. Dialogs and fixed-size windows keep their own natural size (that’s the point of floating them); rule windows and the manual float toggle use the configured default size (float_size, a percentage of the screen’s usable area). Everything else tiles; floating is the exception, decided per window, never a mode the whole workspace switches into. Floating windows move and resize with Mod+drag (left moves, right resizes) or keyboard nudges. The details are in the Stage 9 chapter.

Decorations

0xin 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:

  • Runtime control — a socket/IPC for querying and scripting the compositor without keybindings.
  • Exact tree-based directional navigation — now that the split tree exists, spatial_neighbor could use its real adjacency instead of Stage 5’s geometric heuristic, fully resolving the corner-touch case. Not pursued in Stage 10 since it wasn’t needed for the resize deliverable.

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

Environment & toolchain

0xin 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: 0xin 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.

A direnv .envrc is committed (run direnv allow once after cloning, if you use direnv — it’s optional). Today it only turns backtraces on (RUST_BACKTRACE=1); it’s also the designated place for PKG_CONFIG_PATH/LD_LIBRARY_PATH if we ever pin our own wlroots build instead of the Arch package. Per-scenario variables (WLR_BACKENDS, WLR_WL_OUTPUTS, OXIN_MOD, …) deliberately stay on the command line — they select a run mode and don’t belong in ambient env.

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 0xin).
  3. Compiles the C shim (shim/*.c) via the cc crate.
  4. Runs bindgen over wrapper.h, allowlisting only the functions/types 0xin 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 0xin opens as an ordinary window on the host desktop. OXIN_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 0xin’s own socket.
  • Real TTY (DRM/KMS) — from a free virtual terminal, logged in: LIBSEAT_BACKEND=logind ~/proj/0xin/target/debug/0xin kitty 2>~/0xin-tty.log. wlr_backend_autocreate detects there’s no WAYLAND_DISPLAY and picks the DRM/KMS backend instead — this is 0xin 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 0xin 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 0xin 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 0xin.
  • The host may already own the modifier you want. OXIN_MOD=alt exists because the host compositor grabs Super-chords before a nested 0xin 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 0xin’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/0xin >/tmp/0xin.log 2>&1 &
    PID=$!; sleep 3
    grim /tmp/0xin.png     # screenshot the host screen, including 0xin'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 0xin’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/0xin >log 2>&1 &
grep -E '0xin: (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 0xin.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 OXIN_MOD=alt target/debug/0xin 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

0xin’s roadmap isn’t a backlog or a progress bar — it’s a series of 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, 8, and 9 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 — “0xin 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 0xin 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 0xin 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 0xin.

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 0xin 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 0xin.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. 0xin 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 ~/proj/0xin/target/debug/0xin foot 2>~/0xin-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 0xin on virtio-gpu, rather than 0xin being launched from an already-running Linux install.

Deliverable (from KICKOFF.md): minimal Linux + initramfs/rootfs boots straight into 0xin 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) — 0xin 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 0xin’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 — 0xin 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 used to get force-tiled 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.

How it went

The key realization: tiled and floating aren’t two branches of the layout code — they’re two different protocol postures, and the difference shows up in the very first configure a client ever receives. A tiled window gets tiled states plus a binding size (Stage 8’s fix for browsers overriding their tile). A floating window gets the exact opposite: no tiled states and a 0×0 configure (“pick your own size”) — the client’s natural size is precisely what floating exists to preserve. That’s why float detection runs on the initial commit, not on map: by map time the first configure has long been answered.

A window floats when any of three signals match, checked in order:

  1. It’s a dialog — the toplevel has a parent set (what xdg_toplevel.set_parent conveys; GTK file pickers, “Save as…” dialogs). This is the deliverable case, and it’s re-checked on map as a backstop for clients that set the parent late.
  2. It declares a fixed size — committed min and max sizes that are equal and nonzero on both axes. Tiling a window that cannot resize only stretches or letterboxes it.
  3. A config rule says sofloat = <app_id> lines, matched case-insensitively (e.g. float = pavucontrol).

The first two keep the client’s natural size — that’s the point of floating them. Rule windows are different: they’re ordinary apps told to float, and an ordinary app’s “natural” size is whatever it last remembered — so they open at the configured default instead: float_size = 60% x 60% (percentages of the usable area; that’s also the built-in default).

On map the window is centered in the active output’s usable area at the natural size it just committed, in a new scene layer between the tiled windows and the layer-shell top layer — floating windows paint above tiles but never above bars (fullscreen keeps its own, higher layer). The spiral skips them entirely: refresh() and the initial-configure tile prediction now share one tiled_windows() filter, so the predicted tile count and the placed tile count can’t drift apart.

Two follow-on behaviors fell out of testing rather than planning:

  • Oversized clients. A real GTK file picker remembered a size taller than the output, so naive centering pushed its header (and its Open button) off the top edge. The fix caps the size hint to the usable area and clamps the position into it — the hint is non-binding without tiled states, but the position clamp guarantees the top-left corner stays reachable no matter what size the client insists on.
  • Keyboard moves. movewindow on a floating window has no tile to swap with, so it nudges the window 50 px in that direction instead, clamped to the usable area (the same clamp mouse moves use).

Mod+V (togglefloating) flips the focused window between the postures: tiled → floating resizes to the float_size default, centered (keeping the tile’s size looked arbitrary in practice — whatever the spiral last assigned); floating → tiled restores the tiled states so refresh()’s sizes bind again.

The stage closed with pointer grabs: Mod+left-drag moves a floating window, Mod+right-drag resizes it (bottom-right-corner semantics, 50 px minimum). A grab is a mode the cursor enters — from press to release the seat never hears about the pointer, so the client under the cursor sees neither the button nor the motion; the release is swallowed along with the press it belongs to. The shim keeps owning the cursor event handlers and asks Rust “is this yours?” at the two decision points (button, motion) — the same policy/glue split as keybindings. All grab state lives in Rust: mode, window, and the cursor-position/window-rect pair captured at grab start (motion applies deltas against the start state, never the previous event, so rounding can’t accumulate). One safety detail found by thinking rather than crashing: a client closing mid-drag must clear the grab, or the next motion event dereferences a freed window.

Verified nested with logs and screenshots: a tiled kitty, a GTK app whose file chooser (parent set) mapped floating and centered with its header visible, and a float = rule floating an ordinary terminal by app id.

Status

Done. The gate — a file picker opens floating and centered — is verified, and the full interaction set is in: automatic detection, config rules, float_size, the toggle, keyboard nudges, and mouse move/resize.

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.

How it went

The tree (layout::Node) is a binary Leaf/Split { vertical, ratio, first, second }, one per workspace (Workspace.tree), shaped exactly like the old spiral at the default 0.5 ratio — a parity test checks the two produce bit-identical rects. The key design choice: leaves carry no payload. Which window occupies tiled-position i is decided entirely by tiled_windows(ws)’s order, the same as it always was; the tree only adds the one thing that was missing, a persisted ratio per split. That statelessness is what makes MoveWindow’s .swap() need zero tree code — swapping two Vec entries already reproduces “swap tiling position” without touching a single Node.

Keeping the tree’s leaf count exactly matched to tiled_windows(ws).len() turned out to be the real work of the stage: every place a window starts or stops tiling — map, unmap/destroy, set_floating, set_fullscreen, move_to_workspace — now calls tree_track/tree_untrack around the state flip. tree_insert_at/tree_remove are pure and ratio-preserving: removing a window collapses its parent split into whichever sibling remains (ratios elsewhere untouched), and inserting elsewhere than the end (a window rejoining the tiled set after a float/fullscreen toggle) walks to the exact position its Workspace.windows order implies. The old spiral survives only as spiral_rects, #[cfg(test)]-gated — the reference oracle the tree gets checked against, no longer called at runtime. The initial-configure size prediction (Stage 8) now simulates an append on a clone of the real tree (predict_tile_rect) instead of recomputing from a count, so a client’s first frame still matches what it actually gets on map.

tree_resize is the deliverable itself: from the focused window’s leaf, walk up to the nearest ancestor split whose axis matches the pressed direction (vertical for Left/Right, horizontal for Up/Down) and move that split’s one shared edge — deliberately local, no cascading to a farther split even when one exists further up. The first attempt made the “wrong” direction a no-op (reasoning: that edge is the window’s own outer boundary, nothing to push into) — wrong, because it’s still the same shared edge, just moved the other way. Fixed so every direction on the matching axis does something: whichever key grows the focused window, the opposite always undoes it, on either side of the split. Bound to Mod+Ctrl+hjkl.

Not pursued: the “fully reversible neighbor relationships” half of why it matters above. spatial_neighbor still uses Stage 5’s geometric heuristic, corner-touch case and all — swapping it for exact tree adjacency is now possible (the tree has the real structure spatial_neighbor was missing) but wasn’t necessary for the resize deliverable, so it’s left as a possible follow-up rather than done here.

Verified with cargo test (parity, insert/remove ratio-preservation, resize’s grow/shrink/clamp/locality) and manually on a real TTY session: opened several windows, resized one with Mod+Ctrl+L, opened another window and confirmed the resized one kept its size, and confirmed the opposite key (Mod+Ctrl+H) shrinks it back.

Status

Done. The gate — resize a window from the keyboard and the layout keeps it — is verified both by tests and on real hardware.

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 0xinctl-style command tool.

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

Status

Partially implemented. Stage 20 introduces the first plain-text Unix control socket and bundled 0xinctl client for live wallpaper replacement. The original deliverable—querying windows and switching workspaces—remains open, as do broader state subscriptions and config reload.

Stage 12 — Native Touch & Mobile

What it is. This stage extends the input path from keyboard and pointer devices to native Wayland touch. It establishes the compositor capabilities needed by Linux phones and introduces the first device profile used to verify them.

Phase gate. A standalone 0xin DRM/KMS session on a phone accepts independent multitouch contacts, returns safely to the session chooser, and remains recoverable over SSH.

Native touch, not mouse emulation

Touch devices are attached to the existing wlr_cursor, which maps their absolute device coordinates into the output layout. On touch-down, 0xin hit-tests the scene and forwards the sequence to the selected surface with the seat touch API:

  • wlr_seat_touch_notify_down
  • wlr_seat_touch_notify_motion
  • wlr_seat_touch_notify_up
  • wlr_seat_touch_notify_cancel
  • wlr_seat_touch_notify_frame

Each contact keeps its own touch ID, target client, device, and layout-to-surface offset. A contact remains attached to the surface where it started, as required by Wayland, even when the finger moves across another window.

Contacts are never converted into pointer events. Applications therefore receive real multitouch: one-finger scrolling and two-finger pinch gestures can coexist, and additional fingers remain independent.

Device lifecycle

The seat advertises touch capability only while at least one touch device is present. Device destruction removes listeners, detaches the device from the cursor, and cancels affected client sequences. This matters during VT switches and session pauses, where a backend can remove an input device without first sending every expected touch-up event.

Device profiles

Phone-specific hardware and session defaults stay outside the compositor core. The first reference profile, profiles/fp5/, targets a Fairphone 5 running postmarketOS and provides:

  • DSI-1 at scale 2.4
  • a portrait-first split direction
  • a standalone greetd/Phrog session entry
  • a wrapper that selects the DRM and libinput backends
  • an isolated postmarketOS build/runtime library path

That reference device remains recoverable with:

ssh fp5 'pkill -TERM -x 0xin'

The session wrapper exits with the compositor, returning control to greetd.

Reference-hardware verification

The implementation was tested both nested and directly on the current FP5 reference display. weston-simple-touch was used only as a diagnostic Wayland client—not as the compositor. It displayed a separate color for every simultaneous contact, verified from one through seven fingers. Foot also received native one-finger scrolling.

The standalone session was then tested on its DSI-1 output at 1224×2700, including SSH recovery back to the Phrog chooser.

Boundary

Touch delivery is a general compositor capability, not a device-specific fork. Other phones with supported wlroots backends and standard Wayland/libinput devices should use the same core implementation and supply their own profile only where hardware or session defaults differ.

Gesture policy—such as edge-swiping between workspaces—belongs in a separate optional touch-policy module rather than in this low-level forwarding path.

Stage 13 — Virtual Keyboard & Mobile Scaling

What it is. This stage lets a Wayland on-screen keyboard inject keys into the focused application and fixes the protocol and scaling details needed by real mobile clients.

Phase gate. In a standalone phone session, wvkbd accepts native touch, types into Foot without stealing focus, and Firefox renders at the correct fractional scale.

Virtual keyboard protocol

0xin publishes zwp_virtual_keyboard_manager_v1. When wvkbd creates a virtual keyboard, the input shim attaches its wlr_keyboard to the existing seat. Key and modifier events are forwarded through:

  • wlr_seat_keyboard_notify_key
  • wlr_seat_keyboard_notify_modifiers

Virtual keys bypass compositor keybindings. This prevents an on-screen Super, Control, or letter key from accidentally triggering 0xin policy instead of reaching the focused client.

This stage provides a manually visible keyboard. Automatic show/hide still requires the text-input and input-method protocols and is a later capability.

Layer surfaces must not steal application focus

wvkbd is a layer-shell surface with keyboard interactivity disabled. Tapping it must deliver touch to the keyboard while leaving keyboard focus on Foot or Firefox.

The focus policy therefore only moves keyboard focus to:

  • an XDG toplevel; or
  • a layer surface that explicitly requests keyboard interactivity.

Ordinary panels and on-screen keyboards can receive pointer/touch input without becoming the seat’s keyboard focus.

The hidden XDG popup dependency

wvkbd 0.20 creates an XDG popup for key-preview feedback and refuses all input until both its layer surface and popup receive their initial configure. Even --no-popup disables only drawing; the protocol object is still created.

0xin now listens for new XDG popups and, after each popup’s initial surface commit, schedules its first configure with wlr_xdg_surface_schedule_configure. Destroy listeners clean up a popup that disappears before completing the handshake.

Without this step, touch reached wvkbd correctly but wvkbd intentionally emitted no virtual key events.

Fractional scale and Firefox

The current FP5 reference output uses scale 2.4. Integer buffer scale alone caused Firefox’s EGL subsurface to be interpreted at roughly three times its intended logical size, leaving most browser chrome outside the display.

0xin now publishes:

  • wp_viewporter
  • wp_fractional_scale_manager_v1

wlroots’ scene graph applies viewport state and advertises preferred fractional scale to visible surfaces. Firefox can consequently submit integer-sized buffers whose logical destination matches the 2.4-scaled output.

Fractional scaling is a general HiDPI compositor feature, not phone-specific behavior.

Portrait layout policy

The generic setting:

first_split = horizontal

changes the first dwindle division from left/right to top/bottom. The current phone profile enables it so two applications retain the full portrait width. Desktop 0xin keeps the original vertical first split by default.

Initial XDG configure prediction and final tree placement use the same setting, so a newly mapped application receives its correct size before drawing its first frame.

Reference-hardware verification

On the current FP5 reference device:

  • wvkbd typed letters, numbers, modifiers, Backspace, Enter, and Control combinations into Foot;
  • tapping wvkbd did not take keyboard focus away from Foot;
  • native touch scrolling continued to work;
  • Firefox rendered at the correct fractional scale; and
  • Foot and Firefox tiled top/bottom under the phone profile.

The Stage 13 temporary session started Foot and a manually visible wvkbd-mobintl. Stage 14 adds explicit show/hide gestures; text-input-driven automatic activation and the permanent phone shell remain separate milestones.

Stage 14 — Mobile Keyboard Gestures

What it is. This stage adds an opt-in, compositor-owned bottom gesture handle for showing and hiding an on-screen keyboard without changing keyboard focus.

Phase gate. On the FP5 reference profile, a one-finger upward swipe from the bottom handle shows wvkbd and a downward swipe from the handle above the visible keyboard hides it.

Gesture recognition

The input shim reserves an enlarged touch target around the small bottom pill while the keyboard is hidden:

  • an upward movement of 60 logical pixels while hidden requests show;
  • a keyboard touch moving at least 70 logical pixels downward and reaching the bottom 28-pixel edge requests hide;
  • taps and shorter movements remain ordinary client input.

All touches beginning outside the target retain the existing native Wayland touch path. This avoids interpreting application scrolling as compositor policy.

When the keyboard is hidden the handle sits at the bottom edge and an upward swipe is captured immediately. Hiding follows SXMO-style delayed arbitration: the touch-down first reaches wvkbd, so taps remain usable keys. Only when the motion qualifies as a downward swipe ending at the bottom does 0xin cancel that client touch sequence and run the mapped bottom-down action.

Keyboard control

The original Stage 14 configuration was process-specific. Stage 16 replaces it with a generic virtual-keyboard controller plus input mappings:

virtual_keyboard_show = pkill -USR2 -x wvkbd-mobintl
virtual_keyboard_hide = pkill -USR1 -x wvkbd-mobintl
virtual_keyboard_height = 125
gesture_handle = hidden

gesture = bottom-up, keyboardshow
gesture = bottom-down, keyboardhide

gesture_handle accepts visible (the default) or hidden. Hiding it removes only the rendered pill; the configured bottom gesture target remains active. The FP5 profile now hides the pill after its gesture layout has become familiar, while another touch profile can retain the discoverability hint.

The configured commands use wvkbd’s documented SIGUSR2 show and SIGUSR1 hide controls. The keyboard stays alive and connected to the virtual-keyboard protocol while hidden, avoiding protocol and startup latency on every gesture.

The height is in logical output pixels and must match the keyboard surface’s scaled portrait height. A client configured in buffer pixels needs conversion: the FP5 uses -H 300 at output scale 2.4, so its configured logical height is 125 as a startup fallback. Once the layer surface maps, Stage 16 replaces this estimate with the actual bottom exclusive zone. Desktop configurations omit the gesture mappings, so they get no handle and no intercepted edge touches; they can still bind the keyboard actions to physical keys.

FP5 reference profile

The FP5 wrapper now starts:

wvkbd-mobintl --hidden --no-popup -H 300 -L 200

Its configuration enables the gesture and declares the matching 125-logical- pixel portrait height (300 / 2.4). This is a reference hardware profile, not device-specific compositor behavior; another mobile profile can select a different process, scale, and height.

Text-input and input-method protocols remain future work. They can eventually show the keyboard automatically when a text field gains focus, while this explicit gesture remains useful as a user override and recovery path.

Client library isolation

The FP5 wrapper uses a private sysroot in LD_LIBRARY_PATH so the compositor can load its pinned wlroots build. Spawned clients must not inherit that path: doing so made Firefox ESR load incompatible compositor-side libraries and crash before mapping a window. All 0xin spawn paths now remove LD_LIBRARY_PATH from the child environment while the already-running compositor retains its loaded libraries.

Stage 15 — Edge Workspace Gestures

What it is. This stage adds opt-in one-finger side-edge gestures for cycling through 0xin workspaces on a touch-first device.

Phase gate. On the FP5 reference profile, an inward swipe from the left edge selects the previous workspace and an inward swipe from the right edge selects the next.

Ownership and thresholds

A touch must begin within 28 logical pixels of an output’s left or right edge. Once captured, it belongs to the compositor for the full touch sequence:

  • dragging at least 70 logical pixels rightward from the left edge selects the previous workspace;
  • dragging at least 70 logical pixels leftward from the right edge selects the next workspace;
  • lifting before the threshold performs no action.

The narrow start region is the important policy boundary. Horizontal scrolling, carousels, and Firefox gestures that begin elsewhere continue through native Wayland touch unchanged. The keyboard handle is checked first, so its bottom gesture remains available independently.

While the virtual keyboard is visible, the side-edge strips stop at its top edge. The keyboard owns its entire surface, including edge-column keys such as Tab, Backspace, P, and Return; workspace swipes remain available in the application area above it.

Workspace behavior

The gesture calls the same workspace-switching function as keyboard bindings. It therefore preserves per-workspace layout and focus, updates scene visibility, and follows the existing multi-output swap rule. The nine workspaces form a ring: previous from workspace 1 selects workspace 9, while next from workspace 9 selects workspace 1.

Configuration

Stage 16 expresses the opt-in behavior as mappings to shared actions:

gesture = edge-left-in, workspaceprev
gesture = edge-right-in, workspacenext

The FP5 reference profile enables it. Desktop configurations default to no gesture mappings, keeping the full touchscreen surface available to applications. Desktop users can invoke the same actions from keys:

bind = MOD, bracketleft, workspaceprev
bind = MOD, bracketright, workspacenext

Earlier mobile testing demonstrated native application gestures but did not include compositor workspace gestures; Stage 12 explicitly deferred that policy. Stage 15 is the first implementation of edge workspace switching.

Stage 16 — Unified Input Mappings

What it is. Keyboard chords and compositor touch gestures now resolve to the same Action values and execute through one dispatcher. Device input recognition is separate from compositor policy.

Phase gate. Workspace and virtual-keyboard actions can be invoked from either configured keys or configured touch gestures, while a desktop config with no gestures reserves no touch regions.

Trigger, mapping, action

The input path has three layers:

  1. the keyboard or touch recognizer produces a physical trigger;
  2. configuration maps that trigger to an action;
  3. the shared dispatcher performs the action.

The current named touch triggers are:

  • bottom-up
  • bottom-down
  • edge-left-in
  • edge-right-in

They use the Stage 14/15 logical-coordinate activation regions and thresholds. Only triggers present in gesture = lines are enabled in the C recognizer. Consequently an unconfigured desktop touchscreen loses no input to compositor gesture policy.

Recognizer regions also respect active input surfaces: when the configured virtual keyboard is visible, workspace edge triggers are limited to the area above it so edge keys remain normal client input.

Each trigger has at most one mapping. A later line for the same trigger replaces the earlier line, matching keyboard chord override semantics.

Shared actions

The action parser and dispatcher add:

  • workspacenext
  • workspaceprev
  • movetoworkspacenext
  • movetoworkspaceprev
  • keyboardshow
  • keyboardhide
  • keyboardtoggle

For example, a touch-first profile can use:

gesture = edge-left-in, workspaceprev
gesture = edge-right-in, workspacenext

while a desktop profile can expose the same behavior through:

bind = MOD, bracketleft, workspaceprev
bind = MOD, bracketright, workspacenext

Workspace next/previous wraps around the existing nine-workspace ring and uses the same focus, scene visibility, and multi-output rules as numbered keyboard bindings.

Virtual-keyboard controller

Virtual-keyboard policy is no longer tied to a particular gesture or process name:

virtual_keyboard_show = pkill -USR2 -x wvkbd-mobintl
virtual_keyboard_hide = pkill -USR1 -x wvkbd-mobintl
virtual_keyboard_height = 125
gesture_handle = hidden

The show/hide/toggle actions run those commands and update compositor keyboard visibility, recognizer state, and handle position. Missing commands make the corresponding action a logged no-op. A convertible or desktop setup may bind keyboardtoggle to a key; the FP5 maps explicit show/hide gestures.

The optional gesture_handle = hidden setting suppresses the rendered bottom-center pill without disabling its recognizer. visible is the default for profiles that want a discoverability hint.

Hardware buttons use the same keybinding path when libinput/xkb exposes a standard keysym. For example, the FP5 profile controls its default audio sink:

bind = , XF86AudioRaiseVolume, spawn, pactl set-sink-volume @DEFAULT_SINK@ +5%
bind = , XF86AudioLowerVolume, spawn, pactl set-sink-volume @DEFAULT_SINK@ -5%

There are deliberately no raw /dev/input/event* paths or Linux key codes in the configuration. This keeps the mappings usable on other Wayland devices with conventional media buttons. The present binding engine handles each key press independently; recognizing SXMO-like double/triple presses and holds is future work for a generic trigger layer, not FP5-specific compositor policy.

virtual_keyboard_height is only a startup fallback. After the keyboard’s layer surface maps, 0xin derives its real top edge from the bottom exclusive zone already computed by layer-shell arrangement. The handle and recognizer therefore follow the actual scaled surface rather than relying on a device-specific pixel conversion.

The controller commands are intentionally generic. The current FP5 profile uses wvkbd signals, while a future input-method implementation can replace the controller without changing gesture or key mappings.

Scope

This stage unifies keys and compositor touch gestures. Pointer buttons, axes, stylus input, and touchpad gesture protocols can add trigger types later without adding new policy dispatch paths.

Stage 17 — Top-edge Shell Gestures

What it is. This stage adds opt-in horizontal and downward swipes starting in a thin top-edge strip, plus an upward swipe that reaches that strip. They enter the same configurable action dispatcher as keyboard chords and the existing bottom and side-edge gestures.

Gate: On the FP5 reference profile, a horizontal edge-to-edge top swipe spans approximately the full brightness range, while shorter swipes adjust it proportionally. A downward swipe opens Fuzzel and an upward swipe to the top closes it. Profiles without these mappings reserve no top-edge touch area.

Configuration

The physical triggers are direction names rather than brightness or menu actions:

gesture = top-right, spawn, brightnessctl set +5%
gesture = top-left, spawn, brightnessctl set 5%-
gesture = top-down, spawn, pgrep -x fuzzel >/dev/null || fuzzel
gesture = to-top, spawn, pkill -x fuzzel

This separation is intentional. The triggers may launch any shared action, while brightness and menu commands remain profile policy. The FP5 uses brightnessctl and Fuzzel, but another device can select different controllers or map the gestures to unrelated shell-toolkit actions.

Recognition and application input

A configured top gesture must start within 28 logical pixels of an output’s top edge. Horizontal direction locks after 30 logical pixels of deliberate movement. The recognizer then dispatches one configured directional action for every 5% of output width crossed, up to 20 times. With the FP5 profile’s 5% relative brightness commands this makes a full-width swipe cover 100%, while retaining device-specific brightness policy in configuration. A downward top-edge swipe uses the same start strip and a 70-logical-pixel threshold. The top gesture is tested before the side-edge workspace strips, giving the small top corners unambiguous top-edge behavior.

The recognizer owns a top-edge sequence from touch-down because a compositor cannot retract an isolated Wayland touch from one client without cancelling that client’s other active points. Central touches continue directly to applications. If neither top trigger is configured, no top strip is claimed, which keeps the behavior opt-in for desktops and other Wayland devices.

The to-top trigger behaves differently: a central touch is initially forwarded to its application and becomes a gesture only after moving upward by 70 logical pixels and reaching the top 28 pixels. At that point the compositor cancels the client touch sequence and dispatches the configured close command.

Verification

  • Configuration parser tests cover both horizontal trigger names, independent bits in the enabled-trigger mask, and commands containing percentage syntax.
  • cargo test exercises the mapping path.
  • The FP5 profile is built natively and tested against its standard backlight device through brightnessctl.

Stage 18 — Multi-finger Window Gestures

What it is. This stage adds opt-in two- and three-finger directional touchscreen triggers to the shared input-action mapping layer.

Gate: On the FP5 reference profile, a two-finger directional swipe swaps the focused tiled window with its spatial neighbor, a three-finger vertical swipe closes it, and a three-finger horizontal swipe sends it around the workspace ring.

Configuration

gesture = two-up, movewindow, u
gesture = two-down, movewindow, d
gesture = two-left, movewindow, l
gesture = two-right, movewindow, r

gesture = three-up, close
gesture = three-down, close
gesture = three-left, movetoworkspaceprev
gesture = three-right, movetoworkspacenext

The movewindow and close actions are the same actions available to keyboard bindings. movetoworkspacenext and movetoworkspaceprev extend that shared action vocabulary with relative, wrapping workspace targets. Moving a window does not switch the displayed workspace.

The FP5 maps a leftward three-finger swipe to the previous workspace and a rightward swipe to the next workspace, so the window follows the physical direction of the fingers.

Touch arbitration

One-finger central touch remains native application input. If a second finger arrives while multi-finger triggers are enabled, 0xin cancels the client’s Wayland touch sequence and promotes both points to a compositor gesture. A third finger promotes that same group to the three-finger trigger set.

Recognition uses centroid travel of at least 70 logical pixels. Every finger must travel at least 35 logical pixels in the selected direction, preventing a single moving finger plus stationary taps from firing an action. A gesture fires at most once and remains compositor-owned until all fingers lift.

Profiles without any two-* or three-* mappings do not enable this arbitration path.

Scope

These triggers describe physical input, not FP5 policy. Other touchscreen Wayland devices can reuse them with different shared actions. Touchpad gesture protocols remain separate future input sources because they arrive through libinput gesture events rather than touchscreen contact points.

Stage 19 — Declarative Session Startup

What it is. Repeated exec_once entries in 0xin.conf launch shell and session clients after the compositor’s Wayland socket is ready.

Gate: The FP5 session wrapper launches only 0xin. The profile config starts Patin and wvkbd declaratively and in declaration order.

Configuration

exec_once = ~/.local/bin/patin
exec_once = wvkbd-mobintl --hidden --no-popup -H 300 -L 200

Unlike scalar settings, exec_once is intentionally repeatable. Commands are preserved in file order and started as separate child processes. “Once” means once per 0xin process start; restarting the compositor creates a new session and launches the commands again. Runtime config reload is not currently implemented, so reload duplication is not a concern.

Client environment

0xin waits until its backend has started and WAYLAND_DISPLAY points to its new socket before launching startup commands. Commands use the same shell spawn path as configured input actions, including:

  • shell expansion and quoting;
  • inherited session variables such as XDG_CURRENT_DESKTOP;
  • removal of the compositor-only private LD_LIBRARY_PATH;
  • restoration of normal child signal handling.

This makes startup clients peers of applications launched later through Patin or input mappings, rather than wrapper-owned special cases.

Profile versus compositor policy

The mechanism is generic compositor configuration. The particular FP5 command list is reference-profile policy: other Wayland devices may start a different shell, bar, notification daemon, wallpaper process, or no session clients at all.

The dedicated run-touch-test.sh remains responsible for environment required before the compositor exists—DRM/libinput backend selection, libseat, and the private wlroots runtime path. It no longer embeds the user-session process list.

Stage 20 — Internal Wallpaper Control

What it is. 0xin decodes and renders PNG/JPEG wallpapers into owned wlroots scene buffers, with persistent configuration and a bundled live control command.

Gate: A configured image appears cover-scaled behind windows on every output. 0xinctl wallpaper PATH replaces it without restarting, and 0xinctl wallpaper clear restores the solid background.

Persistent configuration

background = 0.04 0.04 0.06
wallpaper = ~/Pictures/wallpaper.jpg

The image is independently cover-scaled to each output. This fills the output without distortion and crops overflow around the center. PNG transparency reveals the existing solid background beneath it. An unreadable or invalid image logs an error and retains that fallback rather than preventing startup.

Paths beginning with ~/ expand against the compositor session’s HOME. PNG and JPEG support comes from a pure-Rust decoder linked into 0xin; no wallpaper daemon or system image-decoding library is required.

Runtime control

0xinctl wallpaper ~/Pictures/another.png
0xinctl wallpaper clear

The compositor creates a Unix socket under XDG_RUNTIME_DIR, scoped by its WAYLAND_DISPLAY. 0xinctl derives the same path from its environment, sends a line request, and reports success or a decoding/rendering error. This makes it suitable for Patin, keybindings, file pickers, and ordinary shell scripts.

Runtime changes are intentionally ephemeral: they update the current scene but do not rewrite 0xin.conf. The configured image is restored on the next compositor start.

Rendering architecture

Rust decodes and cover-resizes each image to RGBA8888. The C shim copies those pixels into a custom wlr_buffer implementing data-pointer access, then adds a wlr_scene_buffer above the output’s solid fallback and below every layer-shell or application surface. Scene-node destruction releases the final wlroots buffer lock and frees the owned pixels.

Wallpaper changes decode every output before replacing existing nodes, so an invalid path leaves the current wallpaper intact. Output hotplug creates a newly scaled node from the active configured/runtime path; output destruction removes its image buffer.

Scope and limitations

  • Scaling mode is currently cover.
  • One image applies to all outputs, scaled independently.
  • Decoding occurs synchronously on a control request. The Triangle filter keeps this bounded enough for the current mobile/debug target; a worker-thread handoff can be added if very large images still cause visible latency.
  • The control socket currently exposes wallpaper operations only. It is the first implemented slice of the broader Stage 11 runtime-control design.

Stage 21 — Application Window Opacity

What it is. 0xin can apply a configurable opacity to ordinary XDG application windows, allowing its built-in wallpaper or solid background to show through.

Gate: With window_opacity = 0.8, application windows render translucently while compositor backgrounds and layer-shell UI remain fully opaque. With no setting, behavior is unchanged.

Configuration

window_opacity = 0.8

The accepted range is 0.0 through 1.0: 0.0 is fully transparent and 1.0 is fully opaque. The default is 1.0, so existing desktop and device profiles do not become translucent unless they opt in. Invalid values are warned about and ignored.

The FP5 reference profile uses 0.8. The option itself is device-independent and works for the same XDG windows on desktop and mobile backends.

Rendering scope

The compositor walks the buffers below each XDG toplevel scene tree and sets their wlroots scene-buffer opacity. It reapplies the value as surfaces commit and map so buffers that are attached after scene-tree creation receive the same opacity.

This first implementation intentionally excludes layer-shell surfaces. Panels, Patin, virtual keyboards, and similar shell UI therefore remain fully opaque. The compositor-owned solid background and wallpaper are also unaffected.

Opacity is currently a single global startup setting. Per-application rules and live changes can build on this mechanism later.

Stage 22 — Hold Bindings & Session Exit

What it is. Physical keys can trigger an action only after remaining pressed for a configured duration, and 0xinctl quit provides a clean, compositor-owned session exit.

Gate: Holding the FP5 power key for two seconds opens a fuzzel session menu. Releasing it early does nothing. Choosing logout returns to Phrog.

Hold mappings

hold = , XF86PowerOff, 2000, spawn, session-menu

The format is MODS, KEY, MILLISECONDS, ACTION[, ARG]. Durations from 100 through 60000 milliseconds are accepted. A press arms a Wayland event-loop timer; release cancels it. Both events are consumed so applications never see an unmatched power-key press or release.

This mechanism is general input mapping and is not FP5-specific. If a chord has both bind and hold mappings, release before the threshold runs the normal bind; reaching the threshold runs only the hold action. The FP5 profile currently uses only the two-second session-menu action and reserves short press for a touch-capable secure lock client.

Clean session exit

0xinctl quit

The control request asks 0xin to terminate its Wayland display loop normally. The FP5 wrapper then exits and greetd/Phrog regains control. This is the supported interactive equivalent of terminating 0xin from an SSH recovery shell.

The included FP5 menu is a small fuzzel dmenu script with logout, reboot, shutdown, and cancel choices. Reboot and shutdown call systemctl so logind and the system policy layer remain responsible for authorization. The script replaces the earlier Hyprland-specific hypr-phone-menu, whose logout action depended on hyprctl dispatch exit.

Secure locking is implemented separately through ext-session-lock-v1; see Stage 23.

Stage 23 — Secure Session Lock

What it is. 0xin implements ext-session-lock-v1, allowing an external authentication client such as patin-lock to lock the session without trusting a normal application or layer-shell overlay as a security boundary.

Gate: Patin acquires the lock, its surface covers every output, its touch keyboard authenticates through PAM, and killing it leaves an opaque fail-closed screen.

FP5 policy

bind = , XF86PowerOff, spawn, pgrep -x patin-lock >/dev/null || patin-lock
hold = , XF86PowerOff, 2000, spawn, ~/.local/bin/0xin-session-menu

The protocol and short/hold mapping are general 0xin capabilities. The FP5 profile uses Patin as a replaceable external client, not a compositor component. Releasing before two seconds launches the lock; holding through the threshold opens the session menu instead. pgrep makes repeat presses idempotent.

Security model

As soon as a lock request is accepted, 0xin:

  • raises a compositor-owned opaque fallback above applications, fullscreen windows, Patin, wvkbd, and every other layer;
  • clears application keyboard focus;
  • cancels active touch sequences and disables compositor gestures;
  • rejects ordinary compositor bindings and runtime-control requests;
  • creates, positions, and configures one lock surface for each output;
  • routes pointer, touch, physical-keyboard, and virtual-keyboard input to the mapped lock surface.

Only the protocol’s authenticated unlock request removes the fallback, restores gesture policy, and focuses the previous workspace. A second lock client is rejected while one is active.

Failure and output handling

If the lock client disconnects without unlocking, 0xin remains locked behind the opaque fallback. A replacement lock client may connect so the user can recover without restarting the compositor. New outputs receive fallback covers while locked; output removal destroys its corresponding cover.

Authentication remains the lock client’s responsibility. 0xin does not read password databases or implement PAM itself.

Mobile input boundary

The existing wvkbd is an ordinary layer-shell client. Keeping it below the session-lock tree is necessary: allowing arbitrary shell surfaces above a lock would let another client cover or imitate the authentication UI. A phone-ready locker therefore needs an integrated touch PIN/password UI, which patin-lock now supplies. Swaylock remains suitable for protocol testing and for docked use with a physical keyboard.

Verification

Verified on the FP5 reference target on 30 July 2026: patin-lock acquired the 0xin session lock, rendered its integrated touch keyboard, authenticated the user through PAM, and unlocked normally.

After enabling the short-power mapping, repository validation passed:

$ cargo test --all-targets
34 tests passed

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/0xin/book`

$ git diff --check
(no output, exit 0)

Stage 24 — Output Power Management (DPMS)

What it is. 0xin implements wlr-output-power-management-unstable-v1, letting an external client (e.g. patin-lock) request a real display power-off/on per output — an actual DPMS toggle, distinct from the opaque compositor-owned cover raised while ext-session-lock-v1 is engaged (see Stage 23). The lock-fallback cover keeps the desktop hidden and secure; this protocol is what lets a client also stop driving the panel, saving power.

Gate: A client can request a specific output be powered off and back on; 0xin actually disables/re-enables that output’s physical signal, and windows that were on-screen before power-off reappear correctly after power-on. The power-off half of this gate is verified (see below); the power-on half is implemented but its nested-backend verification stalled — see Verification.

How it’s wired

wlroots implements the entire wire protocol server-side (wlr_output_power_manager_v1_create) — 0xin only creates the global at startup and listens for its set_mode signal (src/output_power.rs, shim/output_power.c). On each event it:

  • resolves the signal’s target output against server.outputs;
  • calls oxide_output_set_powered (shim/output.c), which commits a wlr_output_state with WLR_OUTPUT_STATE_ENABLED set to the requested value — the same commit-state mechanism oxide_output_enable already uses when an output first comes up;
  • on power-on, forces a full repaint the same way VT-resume already does (src/output.rs): resets that output’s repaint_frames counter and schedules a frame. A disabled output stops receiving frame events entirely, so nothing else is needed for power-off — idle windows just stop being asked to repaint.

The protocol XML isn’t shipped as a system package on this toolchain (only wlr-layer-shell-unstable-v1 is), so it’s vendored in-repo at protocols/wlr-output-power-management-unstable-v1.xml, generated into a server header by wayland-scanner in build.rs — the same approach already used for layer-shell.

Scope

This stage covers the 0xin compositor side only: exposing the global and actually applying on/off requests. Deciding when to power a display off (idle timeout, immediately on lock, etc.) is a policy decision for whichever client speaks the protocol — patin-lock integration is tracked separately in that project.

Verification

$ cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s)

Runtime-verified nested with a throwaway client binding zwlr_output_power_manager_v1 directly (no wlr-randr/wlopm available in this environment): it bound the global and wl_output, received the required initial mode = ON event, then requested set_mode(OFF). 0xin logged output 0 powered off and its nested host-side window disappeared immediately — exactly the expected DPMS-off behavior — reproduced twice.

set_mode(ON) after an OFF was not confirmed end-to-end: 0xin receives the request and starts the re-enable commit (buffer allocation, GL FBO creation — the same wlr_output_commit_state call oxide_output_enable already uses successfully at startup) but the call stalls before returning, inside wlroots’ own nested (wlr_wl) backend commit implementation — code this stage never touches. The compositor process stays alive/healthy throughout (not a crash), and none of 0xin’s own downstream logic (oxide_output_schedule_frame, the repaint_frames reset, the confirming eprintln!) ever runs, since it sits after the call that hangs. This points at a characteristic of the nested dev backend’s window-recreation handshake specifically — a backend rarely exercised past its first enable — rather than a bug in this stage’s code. The real DRM/KMS backend (what patin-lock will actually run against on hardware) is where enable/disable is a first-class, well-trodden operation; this repo’s own convention is to build/verify nested first (docs/running.md), so re-verifying the power-on direction against real hardware before relying on it is a recommended follow-up, not yet done here.

Stage 25 — Double-tap to Solo a Window

What it is. An opt-in touchscreen trigger: double-tapping a tiled window temporarily makes it the only visible window on its workspace — everything else hides, it fills the usable area — and double-tapping it again restores the exact prior tiled layout.

Gate: With several tiled windows on a workspace, double-tapping one solos it (others hidden, it fills the usable area, bars stay visible); double-tapping it again restores every window to precisely the layout — including any manually-resized splits — it had before.

Configuration

gesture = double-tap, solo

solo/togglesolo is also a plain shared action, usable from an ordinary keyboard bind (e.g. bind = MOD, S, solo) — it toggles the focused window, the same way fullscreen and togglefloating do.

Why not the existing fullscreen mechanism

Mod+F fullscreen already covers other tiled windows visually (its scene node paints above them), but it isn’t a fit for a quick “peek” gesture:

  • It tells the client it’s protocol-fullscreen (wlr_xdg_toplevel_set_fullscreen) — apps react to that (video players change behavior, browsers exit on Escape). Double-tap shouldn’t lie about fullscreen state for what’s meant to be a transient view.
  • It removes the window’s leaf from the split tree and reinserts it on toggle-off, which resets the sibling split’s ratio to 0.5 each round trip — repeatedly peeking and restoring the same window would silently erode a manually-tuned layout.

Solo instead never touches the split tree at all — it’s a per-workspace solo: Option<*mut Toplevel> that purely decides which window’s scene node is enabled and where the solo target is placed (the usable area, not the full output box — this isn’t true fullscreen, panels/bars stay visible). Toggling off requires no explicit restore: the very next tiling pass simply recomputes every rect from the untouched split tree.

Touch recognition

No tap is swallowed to disambiguate the pair — both taps of a recognized double-tap are still delivered to the client normally, exactly like any ordinary tap. Holding back the first tap’s delivery until a second tap confirms it (or a timeout elapses) would add a forced delivery delay to every single tap on every window, a well-known cost of naive double-tap disambiguators. An app with its own double-tap handling (a map’s zoom, a video’s seek) reacts to both taps too — an accepted tradeoff.

A completed touch-up is classified as a tap when its total travel stays under a small pixel threshold; two same-surface taps within a short time and position window count as a pair. Session lock disables the trigger automatically, the same way it disables every other gesture.

Multi-monitor note

The trigger resolves the tapped window directly from the hit-tested surface, not through the shared active-workspace lookup other gesture actions use — that lookup depends on the pointer cursor’s last position, which touch events never update, so on a multi-monitor setup without a separate mouse it could otherwise target the wrong output’s focused window.

Scope

Keyboard focus-cycling (focusnext/focusprev/movefocus) has no solo-awareness yet — cycling focus while a window is solo’d can move keyboard focus to a hidden window. Not addressed in this stage; a guard against that is a natural follow-up if it proves surprising in practice.

Stage 26 — Rounded Window Corners

What it is. An opt-in corner_radius config value that rounds tiled and floating application windows’ corners with real per-pixel masking — not an illusion.

Gate: corner_radius = N in config rounds every tiled/floating window’s corners with a clean, anti-aliased edge that’s correct regardless of what’s behind it (wallpaper image, another window, window_opacity < 1.0); fullscreen windows are unaffected; corner_radius = 0 (the default) costs nothing.

Why this needed real rendering work

wlroots’ scene-graph/render-pass API has no corner-radius or arbitrary-mask primitive — confirmed by reading wlr/types/wlr_scene.h and wlr/render/pass.h directly. wlr_render_pass_add_texture/add_rect are a closed, two-op vtable: rectangular clip only, a flat alpha scalar, no shader hook. (window_opacity was easy because wlr_scene_buffer has a plain opacity field; there’s no equivalent for radius.) A cheaper illusion (painting background-colored cutouts over each corner) was considered and rejected — it only looks right over a flat solid background and breaks under a wallpaper image or window_opacity < 1.0, both already used by the FP5 profile.

The only real path: pull the client’s committed texture’s raw GL handle (wlr_gles2_texture_get_attribs), render it through a compositor-owned custom GLES2 shader — a rounded-rect signed-distance function that discards/ fades fragments outside the radius — into a compositor-owned GPU buffer (wlr_swapchain_create/wlr_renderer_begin_buffer_pass), then swap that buffer into the scene graph in place of the client’s own (wlr_scene_buffer_set_buffer). This is a genuinely bigger piece of engineering than any other 0xin feature so far — 0xin had never driven raw GL directly before this; every prior frame was 100% delegated to wlr_scene_output_commit.

How it’s wired

  • shim/gles2_corner.c compiles two shader variants once at startup (oxide_gles2_corner_program_create, called from src/main.rs right after the renderer/allocator are created) — one for GL_TEXTURE_2D, one for GL_TEXTURE_EXTERNAL_OES/samplerExternalOES, since wlr_gles2_texture_get_attribs can report either depending on the client’s buffer import path, and using the wrong sampler type for the bound texture renders solid black, not a GL error. NULL on any compile/link failure — corner-radius masking is then silently unavailable rather than crashing the compositor.
  • oxide_toplevel_apply_corner_radius runs from src/toplevel.rs‘s handle_commit — the same per-commit hook that already reapplies window_opacity — gated on corner_radius > 0 and the window not being fullscreen. It finds the toplevel’s own root-surface scene buffer (never a popup/subsurface — both are parented under the same scene tree by wlr_scene_xdg_surface_create, and must stay unmasked), renders through the shader into a per-toplevel swapchain (Toplevel.corner_swapchain, recreated when the surface’s buffer size changes), and swaps the result in. Every piece of GL state the draw touches (program, texture bindings, viewport, blend state, vertex attrib arrays) is saved and restored around it, since wlr_scene_output_commit renders the rest of that output’s scene moments later the same frame — leftover state there would corrupt other windows’ rendering, not just this one.
  • The per-toplevel swapchain is freed on window destroy (oxide_swapchain_destroy, from handle_destroy) — the shared corner_program, like the renderer/allocator it’s built from, lives for the process’s lifetime (0xin deliberately skips tearing down top-level wlroots globals on shutdown; see main.rs).
  • Fullscreen windows are excluded entirely — rounding a window’s edges against the bare screen looks wrong, and it’s also a real performance win (fullscreen is exactly the “video playing, committing every frame” worst case for the extra GPU pass this costs).

Known limitations (by design, for this first cut)

  • corner_radius is currently in the surface’s buffer-pixel units, not logical pixels adjusted for output scale. The FP5 profile’s monitor = DSI-1, 0x0, 2.4 (2.4× scale) means a given corner_radius value will look visually smaller there than the same value on an unscaled desktop output. Verified correct on the nested (unscaled) Intel path; the logical-vs-physical adjustment for fractional-scale outputs is an open follow-up, not yet resolved.
  • Damage tracking regresses for masked windows. Every masked commit repaints the whole buffer (wlr_scene_buffer_set_buffer‘s plain variant), losing wlroots’ fine-grained per-region damage tracking for as long as corner_radius > 0. Accepted for this first cut.
  • Real, non-hypothetical battery/perf cost. An extra GPU pass on every commit of every visible masked window — the same device (FP5) this project added DPMS power-off support to save battery on. No throttling/coalescing is implemented.
  • Scanout loss. A masked window can never be handed straight to a KMS plane for direct scanout — the scene graph sees a compositor-rendered copy, not the client’s original buffer, for as long as it’s masked.
  • Popups/subsurfaces, layer-shell surfaces (bars/panels), the wallpaper, and session-lock surfaces are all unaffected — none of them route through toplevel.rs::handle_commit.

Verification

Verified nested (Intel Iris Xe, unscaled): corner_radius = 24 with a kitty test window — screenshotted via grim, all four corners show clean, correctly anti-aliased rounding with no premultiplied-alpha fringing, holding up across repeated commits (not just the first frame). No GL state corruption observed in neighboring host windows sharing the same frame. Window-destroy cleanup verified: killing a masked window produces no wlroots assertions or buffer-lock leak warnings, and the compositor process survives.

Not yet verified on real hardware (FP5/Adreno). GLES2/EGL behavior — shader precision qualifiers, extension availability, swapchain format/ modifier compatibility — can differ from the nested Intel path; this is exactly the class of bug a nested-only test can miss. Recommended before enabling corner_radius on the FP5 profile.