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 opaqueuserdatapointer. - A thin C shim owns the awkward FFI. Everything in
shim/: wlroots’wl_listener/wl_signalglue, and anything that needs to reach into a wlroots struct’s actual fields.
That second point is the why. wlroots types are largely opaque to Rust
— bindgen, pointed at the real headers, emits most structs as an anonymous
_address byte blob rather than named fields, because the C side uses
patterns (intrusive linked lists via wl_container_of, unions, bitfields)
that don’t have a safe 1:1 Rust representation. Two consequences follow
directly:
- Listener wiring goes through the shim. wlroots signals you (a new
output appeared, a surface mapped, a key was pressed) via
wl_signal+wl_listener— an intrusive C list threaded through the struct you’re listening on. The shim wraps this once, generically, as asignal_addhelper that exposes a plain(userdata, data)callback to Rust. Rust never touches awl_listenerdirectly. - Struct field reads go through the shim. If Rust needs something living
inside an opaque wlroots struct — an output’s
width/height, a surface’s role, an array literal likewlr_scene_rect_create’sconst float[4]— that read (or write) happens in a small C function inshim/, which returns a plain value or plain pointer that Rust can represent safely.
Everything else — creating a wlr_scene, adding an output to a
wlr_output_layout, tying a layout slot to a scene output — is a plain
function call with plain pointer arguments, so it stays directly in Rust with
no shim wrapper at all.
Rule of thumb: if it needs a wlroots struct’s insides, a C array literal, or the listener list, it goes in the shim; otherwise it stays in Rust.
bindgen and the shim, concretely
build.rs runs bindgen over wrapper.h with an explicit allowlist —
.allowlist_function(...), .allowlist_type(...) — so Rust only sees the
slice of the wlroots API 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
| Path | What it is |
|---|---|
src/main.rs | Orchestration: builds the compositor, runs the event loop |
src/state.rs | Server, Output, Toplevel, Workspace — the Rust-owned state |
src/config.rs | Dependency-free config-file parser |
src/layout.rs | The split tree (Node) and its pure operations — insert/remove/resize |
src/tiling.rs | Tiling 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.rs | Per-concern policy modules |
src/ffi.rs | extern "C" declarations mirroring shim/oxide_shim.h |
shim/*.c, shim/oxide_shim.h | The C shim — listener glue + opaque struct field access |
build.rs, wrapper.h | The FFI pipeline: pkg-config, wayland-scanner, cc, bindgen |
This division isn’t fixed in stone — it’s a rule of thumb refined while building each stage, not a spec written up front. See the build phases for how it was actually arrived at.
Design & ideas
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 split — Stage 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:
- Nothing is fatal. A line that doesn’t parse warns on stderr and is
skipped. A missing config file means defaults. A config with zero
bindlines still has every default binding. 0xin always starts. - User config merges, never replaces. A
bindline overrides exactly that key combination; every unmentioned default stays active. A two-line config is a two-line diff, not a fork of the whole keymap. - Explicit over implicit. Monitor placement is literal pixel
coordinates per named connector (
monitor = eDP-1, 0x0) — no relative keywords, no DPI auto-scale heuristics. The config states what happens; nothing else does.
Workspaces and outputs
Nine workspaces, with one invariant: a workspace is never visible on two outputs at once. Switching to a workspace already shown on another monitor swaps the two monitors’ workspaces instead of duplicating it. New windows open on the monitor the cursor is on (focus-follows-monitor). The model stays predictable regardless of how many outputs are attached.
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_neighborcould 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:
- Resolves wlroots/wayland/etc. include and link flags via
pkg-config. - Generates the
xdg-shellprotocol header withwayland-scannerintoOUT_DIR(wlroots’ own xdg-shell header#includes this, and it isn’t a system header — it has to be generated from the protocol XML on every machine that builds 0xin). - Compiles the C shim (
shim/*.c) via thecccrate. - Runs
bindgenoverwrapper.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_autocreatepicks the nested Wayland backend automatically and 0xin opens as an ordinary window on the host desktop.OXIN_MOD=alt cargo nested -- kittysets the modifier to Alt (since the host compositor usually grabs Super-chords before a nested client sees them) and launches a test client against 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_autocreatedetects there’s noWAYLAND_DISPLAYand picks the DRM/KMS backend instead — this is 0xin as a real session, not a nested toy.LIBSEAT_BACKEND=logindlets logind hand the active VT its devices without needing theseatgroup.
Full recipes, verification commands, and known gotchas (multi-GPU device
selection, VT-switch repaint behavior, headless screenshot verification) live
in Running & Verifying and the in-repo
notes/ directory, which
is the day-to-day working reference this chapter is distilled from.
Running & verifying
Building a compositor raises an obvious problem: how do you check that a
change actually works, when the thing you built is the environment
everything else renders inside? This chapter is less “here’s the command”
(the README covers that) and more
about the verification habits that came out of building 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=altexists 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:
- A real client’s own behavior as a signal.
wayland-infoconnecting 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_seatgap at Stage 3 was caught. - Log markers plus a screenshot, for anything visual:
wlroots’ debug log (on viatarget/debug/0xin >/tmp/0xin.log 2>&1 & PID=$!; sleep 3 grim /tmp/0xin.png # screenshot the host screen, including 0xin's window kill $PIDoxide_log_init()) is very verbose — the first few hundred lines are EGL/DMA-BUF format enumeration — so the useful signal is 0xin’s ownprintln!markers plus lines likeAllocated ... GBM buffer/DMA-BUF imported, read alongside the PNG.
Keyboard wiring (as opposed to actual typing) is verified the same way — log markers like “keyboard attached” / “keyboard focus -> toplevel” confirm the plumbing is connected; actually typing into a client is checked by hand, by focusing the nested window on the host and typing.
Config, without a window
The config parser (src/config.rs) doesn’t need a display to verify at all:
XDG_CONFIG_HOME=/tmp/cfg WLR_BACKENDS=headless target/debug/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/cardN — WLR_DRM_DEVICES=/dev/dri/cardN forces the right
one.
VT switching (Ctrl+Alt+F1..F12) is its own verification loop: switch away,
switch back, and watch for a clean repaint rather than a black/frozen
screen. That specific failure mode — outputs come back black after a VT
resume — is what drove the session-active-signal handling described in
Stage 6; a forced repaint on the first few
frames after resume is the fix, and the regression test for it is “does the
screen come back,” not something a unit test can cover.
Why phase gates
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):
- Explain the concept first — what’s being built, why it’s needed, which Wayland/wlroots/Linux concept it touches, what’s unsafe or ABI-specific.
- Make the smallest useful change — no large generated drops.
- Show and explain every file and function touched.
- Say how to test it, then actually run it and show the real output — never claim something works without verifying it.
- Don’t advance to the next stage until the current one is understood.
- Every commit should be understandable on its own.
What a stage actually is
Each stage below has the same shape: what it is, why it matters, its stated
deliverable (verbatim from KICKOFF.md), how it actually went, and its
current status. “How it actually went” is the part a plan can’t predict in
advance — VT-switch black-screen bugs, opaque-struct FFI surprises, a
reversibility bug in directional window navigation that only appears at four
or more windows. The gate isn’t the plan; it’s the verified result.
Stages 0–5 are done and described in full; 6, 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
#includesxdg-shell-protocol.h, which isn’t a system header —build.rsgenerates it withwayland-scannerintoOUT_DIRas part of the FFI pipeline from Stage 0. - A real client refuses to start without a seat.
footfailed with “no seats available” until a minimalwl_seat(oxide_seat_create) existed — input handling proper doesn’t land until Stage 4, but the global has to exist earlier than that for any real app to even try connecting.
Verified with: cargo nested -- foot — a terminal appears in the nested
window.
Status: done.
Stage 4 — Input
What it is. Wiring real keyboards and pointers into the seat created (as a bare global) back in Stage 3, and routing their events to the right client — keyboard focus, pointer focus, and the xkb layer that turns raw scancodes into actual keysyms.
Why it matters. A tiling window manager is defined by what the keyboard and mouse do — without real input routing, there’s no way to drive anything built afterward: no keybindings, no click-to-focus, no directional navigation.
Deliverable (from KICKOFF.md): seat, keyboard via xkb, pointer, focus
routing. I can type into and click the terminal.
How it went
New input devices arrive via the backend’s new_input signal
(handle_new_input in src/input.rs
), routed by device
type — keyboards get an xkb keymap and are attached to the seat;
pointers/touch devices are attached to the cursor set up in main()
(oxide_cursor_setup), which sits over the output layout and routes motion
through the scene graph’s own hit-testing to figure out which surface is
under the pointer.
With no synthetic-input tool available in the dev environment, this stage established the verification split that Running & Verifying describes in full: wiring is checked via log markers (“keyboard attached”, “keyboard focus -> toplevel”), actual typing/clicking is checked by hand by focusing the nested window on the host desktop.
Status: done. Click-to-focus and keyboard input both work; this is also
where src/keybindings.rs starts existing as a module, even though the
configurable keybinding system proper is a Stage 5
deliverable.
Stage 5 — Window Management (the heart of it)
What it is. The stage that turns 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 (9–11) with their own gates, keeping the one-stage-one-deliverable rule honest.
Deliverable (from KICKOFF.md): the everyday app ecosystem runs —
bars, screenshots, fullscreen video, X11 apps.
How it’s gone so far
Several of these landed early, driven by real need rather than roadmap order — a bar and a wallpaper are hard to live without day-to-day, so layer-shell support arrived well before this stage was “next”:
wlr-layer-shell-unstable-v1— bars, panels, and wallpaper (e.g. quickshell) render in the correct z-order (into the layer trees set up back in Stage 2) and reserve their exclusive screen space, so tiled windows never sit underneath them. One real bug here: layer surfaces that arrive before any output exists yet were being silently dropped; the fix tracks them as pending and attaches them to the next output that shows up, instead of requiring output-then-surface ordering.- Server-side decorations (
xdg-decoration-unstable-v1) — 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 likegrimandwf-recordercapture 0xin’s real composited output directly.xdg-outputspecifically exists because screenshot tools need to learn each output’s logical position/size, orgrimfails with a 0×0 capture. - Fullscreen — both client-requested (F11,
mpv --fs, honored on map for apps launched fullscreen) and compositor-driven (Mod+Ftoggle). A fullscreen window covers its output’s full box in a dedicated scene layer above the bars but below overlay surfaces, and other windows stay tiled beneath it. Per the xdg-shell protocol every state request must be answered with a configure even when denied — 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:
- It’s a dialog — the toplevel has a
parentset (whatxdg_toplevel.set_parentconveys; 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. - 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.
- A config rule says so —
float = <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.
movewindowon 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_downwlr_seat_touch_notify_motionwlr_seat_touch_notify_upwlr_seat_touch_notify_cancelwlr_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-1at scale2.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_keywlr_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_viewporterwp_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:
- the keyboard or touch recognizer produces a physical trigger;
- configuration maps that trigger to an action;
- the shared dispatcher performs the action.
The current named touch triggers are:
bottom-upbottom-downedge-left-inedge-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:
workspacenextworkspaceprevmovetoworkspacenextmovetoworkspaceprevkeyboardshowkeyboardhidekeyboardtoggle
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 testexercises 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 awlr_output_statewithWLR_OUTPUT_STATE_ENABLEDset to the requested value — the same commit-state mechanismoxide_output_enablealready 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’srepaint_framescounter and schedules a frame. A disabled output stops receivingframeevents 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.ccompiles two shader variants once at startup (oxide_gles2_corner_program_create, called fromsrc/main.rsright after the renderer/allocator are created) — one forGL_TEXTURE_2D, one forGL_TEXTURE_EXTERNAL_OES/samplerExternalOES, sincewlr_gles2_texture_get_attribscan 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_radiusruns fromsrc/toplevel.rs‘shandle_commit— the same per-commit hook that already reapplieswindow_opacity— gated oncorner_radius > 0and 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 bywlr_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, sincewlr_scene_output_commitrenders 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, fromhandle_destroy) — the sharedcorner_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; seemain.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_radiusis currently in the surface’s buffer-pixel units, not logical pixels adjusted for output scale. The FP5 profile’smonitor = DSI-1, 0x0, 2.4(2.4× scale) means a givencorner_radiusvalue 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 ascorner_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.