git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

CLAUDE.md (46.6K)

  1 # CLAUDE.md
  2 
  3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
  4 
  5 ## What this is
  6 
  7 `cce-fx` is a standalone Wayland compositor + tiling window manager written in Rust,
  8 built directly on **wlroots 0.20** (via FFI) and a vendored **scenefx** for
  9 blur/rounded-corner scene effects. It began as a Rust rewrite of the
 10 [river](https://isaacfreund.com/software/river) compositor — hence the GPL-3.0
 11 license, `SPDX-FileCopyrightText: © 2020 The River Developers` headers, and river
 12 protocol XML files you'll see throughout `src/server/` and `protocol/`.
 13 
 14 This crate lives inside a larger Cargo workspace (the workspace root is the **parent**
 15 directory `../Cargo.toml`, which lists ~20 `cce-*` sibling apps). This crate is the
 16 compositor; the siblings (`cce-status-interface`, `cce-system-interface`, etc.) are
 17 clients that talk to it over its sockets. Intra-workspace dependencies: `cce-ui`
 18 (`../cce-ui`, config helpers) and **`cce-window-manager`** (`../cce-window-manager`,
 19 its own repo) — the pure-Rust window-management **policy layer** (arrange pass,
 20 `TilingMode`, saved state, the `Policy`/`Compositor` trait boundary, slotmap). It was
 21 extracted from this crate's `src/server/policy/`; `src/lib.rs` re-exports it as
 22 `crate::policy` / `crate::tiling` / `crate::slotmap`, so mechanism code keeps using
 23 the historical paths.
 24 
 25 ## Build / run / install
 26 
 27 ```sh
 28 make build      # cargo build --release
 29 make run        # cargo run --bin cce-fx
 30 make install    # build + install to ~/.local/bin (see below)
 31 make clean      # cargo clean
 32 ```
 33 
 34 `make install` builds, then delegates to `./scripts/ccebuild install --no-build cce-fx`,
 35 which installs `cce-fx` (symlinked as `cce`), `ccectl`, the `scripts/*` helpers, and
 36 `gpu-watcher.service` into `~/.local/bin` / `~/.config/systemd/user`. It reads binaries
 37 from `../target/release/` because the workspace target dir is at the parent. The recipe
 38 invokes the in-repo `./scripts/ccebuild` rather than the one on `PATH`: this crate is
 39 what *installs* ccebuild, so it cannot depend on it already being present.
 40 
 41 ### `scripts/ccebuild` — the DE-wide build/install tool
 42 
 43 This crate owns **`ccebuild`**, the entry point for building and installing the whole
 44 workspace (see the workspace guide `./WORKSPACE.md` for the full command list). It lives here
 45 because this crate already ships helper scripts to `~/.local/bin`, and because the
 46 workspace root is not a git repo so nothing there can be versioned.
 47 
 48 It derives every binary from `cargo metadata` instead of hand-written lists — the
 49 per-crate Makefiles used to name their binaries manually, which silently left crates
 50 with extra `[[bin]]` targets uninstalled. All the crate Makefiles are now thin wrappers
 51 around it. When touching it, keep two invariants:
 52 
 53 - **`prune` detects dead crates from `.fingerprint/` only.** Those dirs are exactly
 54   `<pkg>-<hex hash>`. Deriving names from `deps/` instead picks up incremental
 55   artifacts like `cce_terminal-0qsvll1iqr9dj` whose non-hex suffix survives stripping
 56   and looks like an unknown crate — that false positive selected *live* caches for
 57   deletion. `incremental/` is excluded from deletion for the same reason: a pattern
 58   loose enough to match those suffixes also matches live siblings like
 59   `cce-authenticator`.
 60 - **Never widen the artifact glob.** `cce-status*` also matches the live
 61   `cce-status-interface`, and `cce*` matches the entire tree (a 180G false reading).
 62   Matching is anchored: a basename must equal a dead crate name exactly, or that name
 63   plus a hex hash.
 64 
 65 It also installs the **`.desktop` entries** crates ship at their own root into
 66 `$XDG_DATA_HOME/applications` (then `update-desktop-database`), discovered by
 67 `desktop_entries()` and filtered per package exactly like units. Discovery is
 68 `-maxdepth 2` — crate root only — so keep the file next to `Cargo.toml`; units get
 69 `-maxdepth 3` because `cce-compositor/scripts/` holds one, which is what the shared
 70 `file_crate_dir()` helper unwraps. These entries were unversioned hand-written files
 71 in `~/.local/share/applications` until 2026-08-16; see `./WORKSPACE.md` for the
 72 `Exec=`/`MimeType=` rules that go with them.
 73 
 74 **Portal declarations** (`<crate>/portals/*.portal`, the file that tells
 75 xdg-desktop-portal a backend's bus name and interfaces) install to
 76 `$XDG_DATA_HOME/xdg-desktop-portal/portals/` the same filtered way
 77 (`portal_files()`); `cce-shortcuts-portal` ships the first. `file_crate_dir()`
 78 must know every such subdirectory name (`scripts`, `dbus`, `portals`) or the
 79 package filter reads the subdirectory as the crate and drops the file.
 80 
 81 **App icons** install from any crate's `hicolor/` tree (`app_icons()`), mirrored
 82 verbatim into `$XDG_DATA_HOME/icons/hicolor/` — so an icon's size and context are
 83 its directory, not a rule in the script, and `48x48/apps` would need no edit here.
 84 In practice the tree is `cce-icons/hicolor/`, whose files are all **symlinks** into
 85 its own `svg/`; that makes `-type l` load-bearing in the `find`, because `-type f`
 86 alone matches none of them and would report a clean install of nothing. The install
 87 is deliberately *not* package-filtered: `cce-icons` has no `Cargo.toml`, so
 88 `crate_selected()` can never match it. See `cce-icons/hicolor/README.md` for why the
 89 target is `hicolor` rather than the `cce` theme, and why it ships no `index.theme`.
 90 
 91 **Helper scripts** are installed from **any** crate's `scripts/` dir, not just
 92 this one's (`crate_scripts()`, same per-package filtering). A script belongs in
 93 the repo whose code it is about — `cce-keyring-selftest` reports on the keyring
 94 chain, so it ships from `cce-display-manager/scripts/` — and anything installed
 95 from outside a repo is unversioned and gone on a fresh clone, which is how that
 96 script and the `.desktop` entries above both started out.
 97 
 98 `ccebuild restart` deliberately cannot reach the compositor: `cce-fx` is not a user
 99 unit (startcce launches it), and restarting it would tear down the session.
100 
101 Building emits a harmless warning that per-package `[profile.*]` in this `Cargo.toml`
102 is ignored because profiles are only honored at the workspace root.
103 
104 ### `scripts/cce-shadow` — an invisible session to verify in
105 
106 `cce-shadow start` runs a second `cce-fx` on the wlroots **headless** backend: a
107 real output, real scenefx rendering, real clients, but nothing is ever scanned
108 out, so it does not touch the screen, focus or input of whoever is using the
109 machine. It is the replacement for the nested (wayland-backend) approach, which
110 needed a visible window and had to be re-centred before every capture.
111 
112 ```sh
113 cce-shadow [--instance NAME] start [--new|--fresh|--restore|--scale N|--gpu PATH|--exec CMD]
114 cce-shadow ctl windows          # ccectl against the shadow
115 cce-shadow spawn cce-files
116 cce-shadow shot [name]          # PNG path on stdout
117 cce-shadow shot-window [name]   # one window rather than the whole output
118 cce-shadow list | prune         # instances; reclaim stopped agent-N trees
119 cce-shadow status | logs | run <cmd> | env | stop [--all]
120 ```
121 
122 **Instances — how two agents share the machine.** Several shadows run at once,
123 selected by `--instance NAME` or `CCE_SHADOW_INSTANCE`; each is a directory
124 under `$CCE_SHADOW_BASE` (default `~/.local/state/cce-shadow`), and the default
125 name is `default`. `start --new` claims an unused `agent-N` and prints it — the
126 opening move for an agent that must not disturb another's run. The isolation
127 falls out of that one directory: separate homes mean separate windows, and a
128 `stop` sweep that cannot see the other session's clients. The display is not a
129 collision point either, since `cce-fx` picks its socket with
130 `wl_display_add_socket_auto` and the script reads the name back out of the log,
131 so the second compositor lands on a different one unprompted.
132 
133 Without this, two agents share one session, and each one's `stop` — or plain
134 `start`, which clears saved window state — tears down the other's run *silently*,
135 because `start` reports an existing session as success. `prune` exists for the
136 same reason in reverse: an agent that dies never calls `stop`, and a leaked
137 headless compositor runs forever. It deletes stopped `agent-N` trees only;
138 instances named by hand are left alone, since pruning takes their `shots/` too.
139 
140 **Ownership** closes the other half. Naming instances stops two sessions
141 sharing one by accident, but not `stop --all` and `prune` reaching across
142 deliberately — an `agent-1` did vanish mid-verification, tree and all, with
143 three sessions live on the machine. So `start` records who started the
144 instance in `run/owner`, and those two commands skip anything a *different
145 live* session owns, saying so rather than passing over it in silence.
146 `--force` overrides; targeting an instance by name is never restricted, since
147 that is deliberate. `list` shows the verdict as `me` / `other` / `orphan` /
148 `none`.
149 
150 The token is `<pid>:<starttime>` of the first ancestor that is not a shell —
151 an agent's `claude`, or a human's terminal emulator. Neither the script nor
152 its parent works: each invocation is a fresh setsid'd session leader, and
153 `$PPID` is the throwaway shell of one tool call, dead by the next, so an
154 instance would read as an orphan to the very session that started it. The
155 start time is what stops a recycled pid from inheriting someone's ownership.
156 An unowned instance (one from before this change) or an orphaned one is fair
157 game — that is the leak `prune` is for.
158 What stays global across instances is the D-Bus name claims in the script's "Do
159 not run" list — those are one-at-a-time for the whole machine.
160 
161 `CCE_SHADOW_DIR` still overrides the tree wholesale, bypassing instance
162 resolution. A pre-instance tree (`home/` `run/` `shots/` directly under the
163 base) is migrated into `default` on first use — but *not* while it is still
164 running, since its pidfile is at the old path and moving it would strand a live
165 compositor no command could reach again.
166 
167 Four things in the tree are load-bearing, and each was a bug before it was a
168 feature:
169 
170 - **`HOME` is isolated** because screenshots go to a hardcoded
171   `$HOME/Pictures/screenshots` and ignore XDG entirely.
172 - **`XDG_STATE_HOME` is isolated** because `state.json` otherwise restores the
173   *live* session's windows, respawning a duplicate of every open app. `start`
174   additionally discards the shadow's own `state.json` unless `--restore`, so a
175   run never inherits the previous one's windows.
176 - **`stop` sweeps clients by environment**, matching `HOME=$SHADOW_HOME` in
177   `/proc/<pid>/environ`. They cannot be found by process group (the compositor
178   `setsid`s what it spawns) and must not be found by name (the live session runs
179   the same binaries — matching `cce-files` would kill the user's file manager).
180   Skipping the sweep leaves clients alive that reattach when the next `start`
181   reuses the display name, which looks exactly like session restore gone wrong.
182 - **A `notifications { screenshots (bool)false }` key is written into the
183   seeded config.** The compositor only defaults this off when the config is
184   *unreadable*; a config that exists but omits the key defaults it ON, and the
185   seeded config is a copy of the user's, which omits it. Without it every
186   capture fires a `notify-send` toast onto the user's real screen, because the
187   D-Bus session bus is necessarily shared.
188 
189 The GPU pin (`--gpu`, default: first non-NVIDIA render node) is not cosmetic:
190 full-output capture works anywhere, but `screenshot window` reads the client's
191 imported dmabuf and reports read format `0x0` when the compositor is on the
192 NVIDIA node and the client rendered elsewhere.
193 
194 Not reachable this way, so still live-session work: real DRM/KMS modesetting and
195 page-flip timing, suspend/resume, and libinput hardware paths (gestures, accel)
196 — injected events do not exercise them.
197 
198 ### Two binaries
199 
200 - **`cce-fx`** (`src/bin/cce.rs`, symlinked to `cce`) — the compositor server.
201   Any arg other than `client`/`help` just starts the server (`cce_fx::run_server()`).
202 - **`ccectl`** (`src/bin/ccectl.rs`) — thin IPC client; all logic is in
203   `src/cce_ctl.rs` (`run_cce_ctl`). Run `ccectl` with no args to see the full command
204   list (layout, mode, pointer-*, key*, bind, spawn, notify, exit, …).
205 
206 ### System dependencies (checked by `build.rs`)
207 
208 Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
209 `pixman-1`, `libinput`, `libevdev`; linked directly: `GLESv2`, `EGL`, `drm`, `gbm`,
210 `lcms2`. Also required at build time: `meson` + `ninja` (to compile the vendored
211 `scenefx/` statically on first build), `wayland-scanner`, and the system
212 `wayland-protocols` XML files under `/usr/share/wayland-protocols/`.
213 
214 ## Tests
215 
216 Eight modules carry unit tests — `backdrop.rs` (the most of any, covering the
217 measurement and the desktop/window blend), `window_manager.rs`, `config.rs`,
218 `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
219 `text.rs`. They cluster where the logic is
220 pure and the FFI is not, which is the only kind of thing testable in a crate
221 this deep in wlroots. The arrange/slotmap tests live in the sibling
222 `cce-window-manager` crate — run them with `cargo test -p cce-window-manager`.
223 The library crate name is `cce_fx` (underscored).
224 
225 ```sh
226 cargo test --lib                  # all library tests
227 cargo test --lib <name>           # single test by (substring) name
228 cargo test --lib config::         # tests in the config module
229 ```
230 
231 ### `verify/` — behavioral tests in a shadow session
232 
233 For behavior the unit tests cannot reach (it needs a running compositor),
234 `verify/` holds self-contained test drivers that start a private `cce-shadow`
235 instance, drive it with real Wayland clients, and assert on what the
236 compositor observably does. `verify/clients/` is the shared client crate —
237 deliberately **not** a workspace member (severed with an empty `[workspace]`,
238 own `target/`, invisible to ccebuild), built on demand by the drivers:
239 
240 - **`vkey`** — injects key events through `zwp_virtual_keyboard_v1`
241   (wtype-style; evdev keycodes plus `mod:MASK` args for held modifiers).
242   This exercises the same `KeyboardGroup::handle_group_key` path hardware
243   keys take, so keybindings and builtins fire for injected keys. `vkey hold`
244   keeps the virtual keyboard alive until killed: a headless seat has no
245   keyboard otherwise, and a Chromium/Electron client that gains focus there
246   crashes on a modifiers event with no keymap before it.
247 - **`float-pair`** — one client, two parentless Floating toplevels with one
248   app_id: a 1024x800 main window, then an "Authorize" dialog that insists on
249   400x370 (min == max, ignores the configure) and is activated with a token
250   BEFORE its first buffer, the way Chromium/Electron open a dialog.
251   `--reactivate SECS` later activates the by-then-unfocused main window — an
252   activation for an already-mapped window. Prints one milestone per line.
253 - **`status-stub`** — maps an xdg toplevel with a `cce-status*` app_id 400px
254   tall, which `any_expanded_status_segment` reads as an open in-surface menu
255   (expanded is geometric: thicker than `layout.bar_height`). It subscribes to
256   the status socket's `dismiss` topic, prints one line per push, and shrinks
257   to a bar strip on the first one — reacting the way the real bar does.
258 
259 `./verify/escape-dismiss-test` composes the two to prove all three gates of
260 the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
261 Escape stays out of the arm, a plain Escape while expanded pushes exactly one
262 dismiss (and the stub's shrink is visible in `ctl windows`), and a plain
263 Escape with nothing expanded stays quiet. The compositor binary is whatever
264 `cce-shadow` resolves (installed first, then `target/release`); extra args
265 pass through to `cce-shadow start`, so `--bin ../target/release/cce-fx` pins
266 the tree's own build.
267 
268 ## Build pipeline (`build.rs`)
269 
270 `build.rs` does a lot before Rust compiles:
271 1. Runs `meson setup build` (first time) + `meson compile` inside `scenefx/`, static.
272 2. Generates server headers for upstream protocols and header + `private-code` C for
273    the custom `river-*` / `cce-*` protocols (`protocol/`), using `wayland-scanner`.
274    `clean_xml` reorders files whose XML declaration follows a leading comment.
275 3. Compiles `src/server/wlroots_log_wrapper.c` + the generated protocol `.c` files
276    into a static `wlroots_log_wrapper` lib.
277 4. Runs `bindgen` over `wrapper.h` → `$OUT_DIR/bindings.rs`, blocklisting a handful of
278    types that are hand-defined `#[repr(C)]` in Rust instead.
279 
280 **"First time" means the guard is `!Path::new("scenefx/build").exists()`** (step
281 1, `build.rs`) — so once that directory exists `setup` never runs again, and
282 every later build is `meson compile -C build` alone, which cannot reconfigure.
283 Meson bakes absolute paths into a configured build dir, so **relocating the
284 workspace root kills it permanently**: the dir still points at where the tree
285 used to be, and nothing in the build recovers it. `cargo clean` and `make
286 clean` both only clear `../target/`; `scenefx/build/` is gitignored
287 (`.gitignore:6`, `scenefx/.gitignore:2`), so a fresh clone never has one and is
288 fine, while a *moved* tree carries the dead one along.
289 
290 It surfaces in `meson compile`, not `meson setup`, which is what makes it
291 confusing — ninja goes to regenerate `build.ninja` and meson dies with
292 
293 ```
294 ERROR: Neither source directory '<old absolute path>' nor build directory '.' contain a build file meson.build
295 ```
296 
297 The old path in that message is the entire diagnosis: it names where the
298 workspace used to live. Recovery is `rm -rf cce-compositor/scenefx/build` and
299 one more build to reconfigure, about a minute. Cost an afternoon on
300 2026-08-28, when a build dir configured under the workspace's former
301 `~/Dropbox/cce` path survived the move to `~/projects/cce`.
302 
303 ## Architecture
304 
305 Everything lives under `src/server/` and is re-exported flat from `src/lib.rs` via
306 `#[path = ...]` module declarations. FFI-heavy: expect large `unsafe` blocks, raw
307 pointers into wlroots C structs, and `wl_listener` callbacks throughout.
308 
309 ### Key FFI idiom — `container_of!`
310 
311 `src/server/server.rs` defines the `container_of!` macro (the Rust equivalent of
312 Zig's `@fieldParentPtr` / the C `wl_container_of`). wlroots delivers events through
313 embedded `wl_listener` fields; callbacks use `container_of!(listener, Struct, field)`
314 to recover the owning Rust struct from a listener pointer. Many wlroots structs are
315 also redefined as hand-written `#[repr(C)]` mirrors in `server.rs` because bindgen
316 treats them as opaque.
317 
318 ### Central files (by size/importance)
319 
320 - **`server.rs`** — `Server` struct: owns the wlroots backend, renderer, `wl_display`,
321   xwayland, and all the manager sub-objects. `Server::init()` / `deinit()` wire up
322   every wlroots global. `run_server.rs` is the entry point: parses args, inits the
323   server, loads config + persisted state, adds the wayland socket, spawns the init
324   program (`~/.config/cce/init` via `sh -c`) and the IPC + status servers, then
325   `wl_display_run`.
326 - **`window_manager.rs`** (~7.5k lines) — the heart of the mechanism side. Holds the
327   WM state, the camera fields, window lists, the IPC command dispatcher
328   `process_ipc_command()`, the `Policy::action` snapshot builder
329   (`build_action_ctx`) and the `Compositor` command applier. IPC requests arrive on
330   an mpsc channel; the IPC thread bumps an eventfd after each send, and that fd is a
331   `wl_event_loop_add_fd` source (`handle_ipc_event`) which drains the channel, so all
332   mutation happens on the main thread and the loop sleeps until a command exists.
333   (It was a 10 ms polling timer until 2026-09-10 — 100 wakeups/s at total idle. The
334   status server thread had the same shape, a `try_recv` loop with a 20 ms sleep; it
335   now `poll()`s its sockets plus a wake eventfd. Nothing in the compositor should
336   tick while idle: a timer that re-arms itself unconditionally is a bug.) Decision logic (camera math, action
337   dispatch, snapping, refocus, grid geometry) lives in `cce-window-manager`.
338 - **`window.rs`** (~5.8k lines) — per-window model and rendering (borders, blur,
339   viewport transforms).
340 - **`crate::tiling`** (from `cce-window-manager`) — `TilingMode` enum: `Floating`,
341   `Tiled` (grid-aligned; the window reports xdg maximized), `Fullscreen`,
342   `Popup`, `Overlay`, `Status`, `Utility`. Tiled-ness is geometric: the seat
343   op's end (`seat.rs::op_end`) promotes/demotes via
344   `policy::snap::is_cell_aligned`. **A window grabbed Tiled snaps HARD
345   through the whole drag** — its move lands on cell starts
346   (`snap::snap_move_tiled`) and its resize lands each dragged edge on a cell
347   edge, whole cells only (`snap::resize_axis_tiled`, used by both the seat
348   op and `get_active_resize_dimensions`) — so it comes out of the drag still
349   Tiled; the magnetic pull (`snap_move`, `resize_axis`) is for Floating
350   windows deciding whether to tile. `Utility` is the one mode a client asks for
351   outright — `cce_window_management.rs` sets it on `set_utility` — and it is a
352   self-sizing float: no resize affordance, no saved geometry (see
353   `xdg_toplevel.rs`, which sizes it and `Status` from their own content, and
354   `xwayland_window.rs`, which excludes it from the tiled report alongside
355   `Floating`/`Popup`).
356 - Input stack: `input_manager.rs`, `seat.rs`, `cursor.rs`, `keyboard*.rs`,
357   `xkb_*.rs`, `libinput_*.rs`, `pointer_*.rs`, `tablet*.rs`, `text_input.rs`,
358   `input_relay.rs`/`input_popup.rs` (IME).
359 - Shell/surface: `xdg_toplevel.rs`, `xdg_popup.rs`, `shell_surface.rs`,
360   `layer_shell.rs`, `xwayland_window.rs`, `xwayland_override_redirect.rs`,
361   `drag_icon.rs`, `wm_node.rs`.
362 - Output: `output.rs`, `output_manager.rs`. Session: `lock_manager.rs`,
363   `idle_inhibit_manager.rs`. Rendering: `scene.rs`, `scene_node_data.rs`.
364 
365 ### Window move/resize handles
366 
367 Pointer move and resize exist **only in adjust mode** (overview, or Super
368 held), and the resize handles are **eight discs inside** the content rect —
369 one at the midpoint of each side, one on each corner — where the old band
370 sat outside the edges and the ring that followed hugged them.
371 
372 - `cursor::get_border_zone` is the hit test: it returns `BorderZone::None`
373   outright unless `wm.mode == Overview`, so in normal mode a window cannot be
374   dragged or resized at all. Only the pointer is gated — `move_window_*`,
375   `ccectl move-window`, and a client repositioning itself all still work in
376   normal mode.
377 - Inside the ring, **all four edges resize**, the top included. Dragging the
378   window's body is what moves it in overview, so the top edge no longer has
379   to be spent on moving the way the outside band's did.
380 - The handles are drawn by **one scenefx node**, `wlr_scene_frame`
381   (`scenefx/render/fx_renderer/shaders/frame.frag`): eight discs of
382   diameter `band` (= `border.handle_width`, screen px), each its own zone.
383   The side discs are tangent to their side; a corner disc sits on the
384   corner's 45° diagonal, tangent to the rounded corner arc when that arc is
385   wider than the disc and tucked into the two straight edges otherwise.
386   **`window::handle_disc_layout` is the one layout function**: `draw_borders`
387   places the eight invisible square catchers (`border.segments`) from it,
388   `cursor::get_border_zone` hit-tests the discs from it (a pixel of slack
389   for the rim), and the shader repeats the same arithmetic from the same
390   inputs (size, corner radius, band) — keep the three in step. Between two
391   discs the pointer reaches the app; the old four full-band catchers are
392   disabled for exactly that reason. Not eight rounded scene rects: a scene
393   rect takes the renderer's global corner shape, a squircle, so a rect with
394   radius half its size is not a circle.
395   The shader's zone logic works in TOP-DOWN box-local coordinates
396   (`gl_FragCoord` minus the box position, unflipped): `corner_dist` flips
397   its own copy, and mirroring the zone coordinate the same way once
398   swapped every zone label vertically — the top edge lit the bottom. And a
399   hover swap must repaint even when no reveal value moves: in adjust mode
400   the discs are already fully revealed, so `step_border_fade` compares the
401   hovered zone against the one last drawn (`border_hover_drawn`), or the
402   shader keeps showing the previous zone until an unrelated commit repaints.
403 - **The disc diameter is a SCREEN size, not a world one**, floored at
404   `HOVER_BAND_MIN` and capped at a fifth of the window's shorter on-screen
405   side. Overview is zoomed *out*, so a handle that scaled with the window
406   would be smallest exactly where it is the only way to resize; the cap
407   keeps a zoomed-out window from being mostly handle. `draw_borders` and
408   `cursor::get_border_zone` each derive it the same way and must stay in
409   step.
410 - **A Floating window lying over the adjust target is dimmed** to
411   `border.overlap_opacity` (default 0.4; 1.0 disables) while the mode is
412   on, so it does not hide the handles. `Window::adjust_dim_wanted` walks
413   the render list bottom-up: only windows ABOVE the target that overlap it
414   on screen qualify (one beneath hides nothing). `step_adjust_dim` eases
415   `adjust_dim` on the same border-fade timer as the ring, and
416   `effective_opacity` folds it into the scene-tree opacity `render_finish`
417   sets — set the tree opacity through that, never from
418   `rendering_requested.opacity` directly, or the dim is clobbered on the
419   next commit. Anything that can change who covers whom re-arms the fade:
420   `arrange_views`, `raise_window`, and every `op_update` step.
421 - The **open/close dissolve** is a third multiplier on the same machinery:
422   `Window::map_fade`, stepped by `step_map_fade` on the border-fade timer and
423   folded into `effective_opacity` beside `adjust_dim`. `Window::map` starts the
424   open ramp (`start_map_fade`; `wants_map_fade` excludes status segments, the
425   wallpaper and the grid), and the `fade-out` control-socket command starts the
426   close ramp for whichever windows and Overlay layer surfaces belong to the
427   CALLER — resolved from `IpcRequest::peer_pid` (SO_PEERCRED), never from a
428   name in the command. Layer surfaces run the same ramp on their own timer
429   (`LayerSurface::start_fade`) because they are not in `wm.windows`. The ramp
430   is LINEAR, unlike the borders' exponential approach: an exponential close
431   fade never reaches zero, and the client is holding its surface open against a
432   deadline. Durations are `surface { fade in_ms out_ms }`; see
433   "Window fades" in WORKSPACE.md for the client half of the contract.
434 - `handle_width` under `border` in config.kdl is the diameter. `taper`,
435   `swell_curve`, `bulge`, `corner_length` and `segment_gap` belonged to the
436   retired ring profiles (an even ring, then a wave of hills and valleys):
437   still parsed and passed to the node, no longer drawn.
438 - The shader's zone numbering MUST match `BorderElement::index()`; it is what
439   the hovered-zone uniform selects on.
440 - `window::window_takes_handles` is the single predicate for which windows get
441   handles (excluding Popup, Fullscreen, Status, Utility, circular, hidden),
442   used by both the hit test and the drawing. Keep those in step: a handle that
443   is drawn but not honoured — or honoured but not drawn — is the failure mode
444   this arrangement exists to prevent. The grab zone IS the disc (plus a pixel
445   of rim), not a band: a press between two discs is a body press and moves.
446 - **Holding Super is window-adjust mode at zoom 1**: the same handles and
447   body-drag as overview, gated by one predicate,
448   `WindowManager::window_adjust_active()` (overview OR `adjust_held`).
449   But NOT hover-to-focus: the ring lands on the window **under the
450   pointer** (`Cursor::adjust_hover`, set by `passthrough` — the same
451   target overview uses), focused or not, and focus stays put — so pressing
452   Super arms whatever the pointer is already on, and a focus chord pressed
453   next acts on the window the user had. **A drag never focuses the window it moves or resizes** (the grab
454   paths in `handle_button` call no `seat.focus`; `op_start_pointer` raises
455   a Floating one instead); a tap on the band or body — press+release
456   without motion — is a click and focuses in `op_end`.
457   `adjust_held` is refreshed from the keyboard's modifier mask on every
458   modifiers event (`refresh_adjust_held`), which also re-runs the pointer
459   passthrough so the ring lands under a still pointer on key-down and the
460   app gets its hover back on key-up. `ccectl key-down 125` holds it in a
461   shadow (injection bypasses the device mask, so it keeps its own flag).
462   A background press with Super held is an ordinary desktop press — only
463   overview exits on it.
464 - Handles are shown on the **adjust target only** — `Window::is_adjust_target`:
465   the window under the pointer (`Cursor::adjust_hover`), in overview and
466   with Super held alike; a pointer on the background shows none — for as
467   long as the mode is on (`step_border_fade`'s `all_on` branch, `draw_borders`'
468   `handles_live`, and `get_border_zone` all ask it). Separately, **focus
469   follows the pointer in overview** (the ring does not key on it): the
470   motion path focuses the hovered toplevel — guarded on an actual change,
471   because `seat.focus` raises a Floating window *before* its same-focus
472   short-circuit, so an unguarded call would raise and relayout on every
473   motion event — and with `suppress_focus_pan` set, so hovering never moves
474   the camera; only clicks and the keyboard may. The ring's fade is
475   timer-driven, so both `WindowManager::set_mode` and `seat.focus` arm it —
476   assigning `self.mode` or `self.focused` directly would leave the ring
477   waiting for an unrelated redraw. The hit test and the invisible catcher
478   rects are focused-gated too; hover-to-focus is what keeps that workable,
479   since reaching a window's edge focuses it on the way.
480 - A client drawing an in-surface popover (a cce-ui menu — one buffer with
481   the window since cce-ui's Phase 6x) hints its rect via
482   `zcce_toplevel_v1.set_popover_region` (manager v7); the ring is clipped
483   away beneath it (shader `exclusion`) and its band does not grab there, so
484   the menu reads as in front of the chrome. The protocol XML lives in BOTH
485   repos — cce-ui's copy strips the `enum="river_output_v1..."` attribute its
486   scanner cannot resolve; never sync the file over it wholesale.
487 - The per-side foam clipping the outside band carried is gone: it split a gap
488   SHARED with a neighbouring window, and an inside ring shares nothing.
489 - **Right-click opens the window context menu** — `scripts/cce-app-menu`, a
490   `cce-cloud --json` popup like `cce-desktop-menu` and cce-grid's item menu.
491   It opens from a right-click on a handle disc in either adjust mode, and in
492   **overview from a right-click anywhere on the window**, since the client
493   never sees buttons there (`should_block_button`) and the press is the
494   compositor's to spend; Overlay (chrome) and Utility (no handles, no mode)
495   bodies are excluded. The menu's "Window Mode" page — a second JSON page
496   reached through a `target_page` button, which switches pages without
497   closing the popup — sets the mode with `ccectl set-mode <mode> <id>`, one
498   window by id. Not `ccectl mode`, which appends a persistent app_id rule.
499   `cce-desktop-menu` carries the same page for the FOCUSED window: the
500   background right-click passes it as `-i <id> -a <app_id>` (settable modes
501   only) because it drops focus right after the spawn, so the script could
502   not ask for it.
503 
504 None of this is policy — `cce-window-manager` was untouched. The mode is
505 already in `ActionCtx`, but what a *pointer* may grab is mechanism.
506 
507 ### Config
508 
509 Loaded on startup from **`$XDG_CONFIG_HOME/cce/config.kdl`** (falls back to
510 `~/.config/cce/config.kdl`). An adjacent `input.kdl` is merged in for key bindings and
511 input settings. **The format is KDL** (via the `kdl` crate; `parse_kdl_config`).
512 `config.rs` maps parsed values onto `WindowManager` state (layout gaps,
513 border/blur/desktop styling, keybindings → `Action`s, startup programs, output/display
514 settings). Live reconfiguration comes in over IPC (`ccectl reload`, `bind`, `layout …`,
515 `config-done`, etc.).
516 
517 Per-output settings live under `output { <name> … }` as properties or child nodes:
518 `scale`, `brightness_interval` / `brightness_up` / `brightness_down`, and
519 **`size_mm="344x215"`** — the panel's real size, written into the `wlr_output`'s
520 physical size (via the `river_wlr_output_set_phys_size` shim) *before* its
521 `wl_output` global exists, so every client's geometry event carries it in place of
522 the EDID figure. That is the number cce-ui's `units::Metric` divides the logical
523 size by to resolve a `(mm)` config length (see `../cce-ui/CLAUDE.md`, Units). Set it
524 when EDID lies (TVs, projectors) or is absent (headless, the shadow: `HEADLESS-1`
525 reports 0×0 and clients fall back to an assumed 96 ppi). `ccectl outputs [--json]`
526 prints, per output, mode / scale / logical size / mm / logical px per mm and where
527 the mm came from (`configured`, `measured`, `none`); the creation log line says the
528 same.
529 
530 **Swipe binds peek before they fire.** A three-finger swipe bound to a
531 directional focus or pan (`focus_left (gesture)"swipe3_left"` in input.kdl)
532 fires once the accumulated travel passes `window_manager { swipe_threshold }`
533 (libinput units, default 50; `WindowManager::swipe_threshold`). Short of
534 that the camera *leans* toward the bind the
535 swipe is heading for, 1:1 with the fingers along the swipe's dominant axis
536 only (a hand's sideways drift must not lean the camera vertically, or the
537 fire eases a wobble back) and proportional to the travel —
538 `window_manager { swipe_peek }` screen px at the threshold (default 60, 0
539 disables; `WindowManager::swipe_peek_px` — not under `input`, whose
540 config.kdl block input.kdl's replaces wholesale), clamped there — and eases
541 back to where it started if the fingers lift first (`handle_swipe_end`), so
542 a hesitant swipe shows where it would go without going. Only binds whose
543 action `cursor::action_navigates` (focus/pan left/right/up/down) peek, and
544 only toward a direction that has one; a four-finger overview toggle leaves
545 the desktop still. When the bind fires (`handle_swipe_update`) the action
546 runs against the camera where the lean left it, and **the camera never
547 reverses at the fire**: a window that needs a pan gets its ease from
548 there, a window already in view sets no target and the camera simply stops
549 where the lean left it, and a target on the leaned axis that would head
550 back toward where the swipe began is dropped. It does not predict the
551 destination (tried on 2026-09-22 — a lean along the policy's predicted pan,
552 nothing at all when the target was in view — and retired the same day: the
553 lean is meant to answer the finger, not the layout), and it does not spring
554 back to the origin (the first version did, and a switch between two
555 windows both in view leaned out and back on every swipe). A shadow drives it staged — `ccectl pointer-swipe begin 3`,
556 `update <dx> <dy>`, `end` — and reads the lean and its return back with
557 `ccectl camera` (pan, zoom, pan target).
558 
559 **Idle timeouts** — `idle { display_off <s>; sleep <s>; sleep_command "…" }`,
560 both 0 (off) by default — are `src/server/idle.rs`, a `Server` subcomponent
561 rather than window-manager state: two `wl_event_loop` timers re-armed from
562 `Seat::handle_activity`, held disarmed while `IdleInhibitManager::check_active`
563 reports an inhibitor. "Display off" reuses the wlr-output-power-management
564 path (`OutputStateValue::DisabledSoft` + `dirty_windowing`): the output stays
565 in the layout, nothing re-arranges, and no frame events fire while it is dark.
566 Only outputs the timeout darkened (`Output::idle_off`) are woken by the next
567 input, so one a client turned off with `wlopm` stays as the client left it.
568 The sleep command is `sh -c` under a fork, reaped by the server's SIGCHLD
569 handler; `systemctl suspend` returns as soon as the job is queued, so resume
570 is detected from the wlroots session's `active` signal instead (the
571 `river_wlr_session_get_active_signal` shim — `wlr_session` is opaque to
572 bindgen), treated as activity so a lid-open lights the screen without a key.
573 Note that until 2026-09-16 the hardware pointer handlers (`handle_motion`,
574 `handle_motion_absolute`, `handle_button`, `handle_axis`) and `handle_group_key`
575 never called `handle_activity` at all — only tablet, touch and gestures did —
576 so `ext-idle-notify` clients were never told about mouse or keyboard use;
577 injected `ccectl pointer-*`/`keypress` events count as activity too, which is
578 what lets a shadow session exercise the timeouts (`ccectl idle timeouts 2 0`,
579 then `ccectl outputs` reads `enabled=false`, then any injected input reads
580 `true`). `ccectl idle` prints the state; `idle wake|sleep|display on|off` act
581 now. Untested in a shadow, which has no session: the resume wake.
582 
583 Persistent window state is saved to **`~/.local/state/cce/state.json`**
584 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
585 (`save_state` / `load_state` / `spawn_restored_windows`). A window's
586 `cmdline` comes from `/proc/<pid>/cmdline`, which is what the process
587 *exec'd into*, not what launched it: an `exec` wrapper in `~/.local/bin`
588 (Inkscape's `GDK_SCALE=1` wrapper) reads as `/usr/bin/inkscape`, and a
589 restore that replays that path skips the wrapper. So `save_state` records
590 the **bare name** whenever the name's first `PATH` hit is a different file
591 from the one running (`path_shadowed_name`), and the restore's `sh -c`
592 resolves it the way the launcher did. The absolute path is kept when PATH
593 agrees with it.
594 A restored **floating** window is recalled into the current view
595 (`policy::camera::recalled_origin`, applied at the end of `try_restore`)
596 when its remembered position would show less than a quarter of it: the
597 camera at restore is wherever the session left it, and a floating window a
598 screen away from that is lost, not remembered. Tiled windows stay where the
599 grid has them. **Except on the tiled desk**: a floating window within one
600 viewport of the tiled windows' bounding box (`tiled_desk_bounds` — the
601 session's Tiled entries still queued plus the live Tiled windows) keeps its
602 remembered spot however far the camera is, since the columns beside it are
603 what the user pans along (cce-data-editor parked left of the first column
604 came back mid-view every login before 2026-09-14). The recall is for a
605 window with no tiled neighbour within a screen.
606 
607 ### xdg-activation
608 
609 `handle_request_activate` (`server.rs`) runs for every activation wlroots
610 accepts — the token was checked against a recent input serial or the
611 requesting surface's focus. For a MAPPED window it now does what `ccectl
612 focus-window` does: un-minimize, `seat.focus`, `raise_window`, dirty. Until
613 2026-09-21 it only fired the "needs attention" D-Bus notification, so an
614 activation for an already-mapped window changed nothing on screen. A request
615 that lands before the map (Chromium/Electron activate a new window between
616 its app_id and its first buffer, so the log reads `Restoring saved state` →
617 `xdg activation request` → `Seat::focus`) is left to the map path, which
618 focuses under its own settle rules. Every `Seat::focus` on a Floating window
619 raises it, and `render_finish` keeps the floating plane above the tiled one
620 in render-list order, so focus IS visibility for a float — `ccectl windows`
621 prints `stack=N` (render-list position, higher is nearer) so that order can
622 be asserted from a shadow without a screenshot.
623 
624 Two hazards in `try_restore` bite a second toplevel of a running app, which
625 the app_id-only third pass of `match_last_window_state` hands the main
626 window's remembered entry (a transient is excluded, a parentless dialog is
627 not): `minimized` is taken from a session entry only, never from a borrowed
628 one — a dialog born minimized is focused, listed and invisible — and a
629 Floating window whose borrowed origin coincides with a mapped sibling's is
630 cascaded off it (`cascade_off_siblings`, 40 px diagonal steps). Reproduce
631 either with `verify/clients` `float-pair` (one client, two parentless
632 toplevels, the second activated before its first buffer) or a two-window
633 Electron app; Chromium in a shadow needs `vkey hold` running first, since a
634 headless seat has no keyboard and Chromium crashes in
635 `xkb_state_update_mask` on a modifiers event that no keymap preceded.
636 
637 ### IPC & status sockets
638 
639 - **Control socket** `/tmp/cce-{WAYLAND_DISPLAY}.sock` (`ipc_server.rs`): line-oriented
640   request/reply over a Unix socket. `ccectl` / `cce_ctl.rs` is the client.
641 - **Status socket** `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` (`status_server.rs`): runs
642   on its own thread; a client sends one subscription line (`layout`, `title`,
643   `modifiers`, `dismiss`, or `backdrop <app_id>`) and receives text lines on every
644   change. This feeds the status bar (`cce-status-interface`). The main loop pushes
645   updates through a `StatusSender` mpsc handle.
646 
647   **What may start a transaction.** `dirty_windowing()` schedules a full
648   manage/arrange/render pass, and on an idle desktop the answer to "why is the
649   window manager busy" is always some call site that dirties on a routine
650   commit. Two were found on 2026-09-10 and gated: a status segment's *every*
651   commit (`handle_window_commit`, now only when the surface size changed — the
652   clock ticking once a second used to cost an arrange each time) and a title
653   change (`notify_title`, now only when a mode rule matches on `title=`; the
654   built-in policy is the only manager, `wm.object` is never bound, so nothing
655   else in the manage sequence reads a title — the status bar's `title` topic
656   and the state file are fed directly instead). `CCE_DIRTY_TRACE=1` logs one
657   debug line per dirty call with its `#[track_caller]` site; it is the tool
658   for this question, and costs nothing when unset. `CCE_DIRTY_BACKTRACE=1`
659   adds a full backtrace per call (expensive). The state file is written by a
660   one-shot timer (`schedule_save_state`, at most once a second) rather than
661   on every transaction: `save_state` reads `/proc` for every window, and a
662   drag is one transaction per pointer event.
663 
664   **Per-frame work is gated too.** `Output::render_and_commit` measures the
665   status backdrops only when the window manager's `layout_epoch` (bumped per
666   transaction) or the camera moved, or 250 ms passed — not every vblank.
667   The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
668   before the file is even looked for. The window-stream tick runs only while
669   the stream hub has subscribers (the accept thread's eventfd arms it), and a
670   failed tearing test is not repeated every frame of the same fullscreen
671   episode. `Window::role()` and its `is_status_bar`/`is_grid`/`is_wallpaper`
672   wrappers borrow the app id rather than allocating; keep it that way, they
673   run several times per pointer-motion event.
674 
675   **Blur re-renders only where damage reaches** (scenefx `apply_blur_region`,
676   fixed 2026-09-11). `pixman_region32_intersect` returns allocation success,
677   not "non-empty"; the vendored code tested that return, so every blur node
678   counted as touched by every frame's damage and re-blurred — nine status
679   segments cost ~1.3 ms of CPU per frame whenever anything on screen moved
680   (measured: 1670 µs → 495 µs per frame with an animating client far from
681   the bar). A node whose box lies within the blur sample size (2^(passes+1) ×
682   radius = 80 px at the default 3/5) of the damage still re-blurs, as it must.
683   `CCE_BLUR_DEBUG=1` logs each blur node render (`blur entry …`) and each
684   compensation decision (`blur_region …`) — the tool for "why is this blur
685   re-rendering". Known, not fixed: the *optimized* (cached) blur behind a
686   translucent window is not re-baked when content beneath it changes, only on
687   explicit camera/grid dirtying, so a video under a blurred window shows a
688   frozen ghost; buffer commits never pass through `scene_node_update` with
689   damage in this scenefx, which is the path the cache's dirtying hangs off.
690 
691   **`backdrop` is the one per-subscriber topic** — it names the asking segment,
692   because the whole point is that the two ends of a bar sit over different things.
693   Lines are `<luma> <spread>` (0-100 each) or `unknown`. It answers a question a
694   Wayland client cannot: what its translucent module boxes are composited *over*,
695   so it can raise its text contrast to match. The measurement is geometry, not a
696   readback where it can be — the desktop background is drawn from a declarative
697   spec, so `backdrop.rs` computes cell-vs-gap coverage under each segment rect
698   on the CPU (`Output::measure_status_backdrops`, per frame, gated by
699   `update_status`'s equality check).
700 
701   A window covering part of a segment is the case that has to be *read*:
702   `Output::read_window_region` composites that window's surfaces (subsurfaces
703   included) over just the overlapping strip via
704   `screenshot::read_texture_region`, and `backdrop::blend` folds the result
705   into the desktop measurement for the rest of the segment. Two gates keep that
706   readback off the render thread's back, and the second one matters more than
707   the first: a 250ms throttle, and a check that the window's summed surface
708   commit sequence changed at all (`river_wlr_surface_current_seq`). A window
709   nobody is typing in is read exactly once. Content that still cannot be read —
710   no committed buffer, an unsupported read format, an implausibly large strip —
711   falls back to `backdrop::UNKNOWN`.
712 
713 ### Portal global shortcuts
714 
715 A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
716 `GlobalShortcuts` interface for one (1Password's Quick Access does), and the
717 portal frontend hands that to a backend. `../cce-shortcuts-portal` is that
718 backend and **`src/server/global_shortcuts.rs` is this side of it** — a
719 table of `(session, id, mods, keysym)` on the window manager
720 (`portal_shortcuts`) with a control-socket command to fill it and a status
721 topic to report it:
722 
723 - `shortcut bind <session> <id> <trigger>` parses a shortcuts-spec trigger
724   (`CTRL+SHIFT+space`; modifiers `CTRL`/`ALT`/`SHIFT`/`LOGO`, key an xkb
725   keysym name) and replies `ok <trigger_description>` (`Ctrl+Shift+Space`)
726   or `error: …`. A chord in `keybinds` is refused — the user's config owns
727   it — as is one another session already holds. `unbind <session> [<id>]`,
728   `clear` and `list` are the rest. Nothing is persisted; the backend sends
729   `clear` when it starts.
730 - The chord is matched in `handle_group_key` after the builtins and the
731   config keybinds, through the same two-level keysym lookup
732   (`keyboard_group::match_chord`, which `match_cce_keybind` now wraps), as
733   `KeyConsumer::PortalShortcut`. Press AND release are pushed as one-shot
734   lines on the status socket's `shortcuts` topic —
735   `activated|deactivated <session> <id> <time_msec>` — since the portal has
736   a `Deactivated` signal; neither edge reaches the client.
737 
738 The compositor never learns which app asked: the session object path is
739 the only identity it carries, and it is one whitespace-free token, which is
740 why ids come percent-encoded (`Quick%20Access`) and stay that way here.
741 Drive it in a shadow with `ccectl shortcut bind /s/1 x CTRL+SHIFT+space`
742 and `verify/clients`' `vkey mod:5 57` — not `ccectl keypress`, which goes
743 straight to the focused client and never meets the chord matcher.
744 
745 ## Conventions
746 
747 - This is systems FFI code: raw pointers, `unsafe`, and manual wlroots listener wiring
748   are the norm. When adding a wlroots event handler, follow the existing pattern —
749   embed a `wl_listener`, register it, and recover `self` with `container_of!`.
750 - Keep river's SPDX/copyright headers on files that carry them.
751 - `scratch/` and `scratch/*` (and the many `.png`/`.log`/`patch*.py` files in the
752   parent dir) are ad-hoc debugging artifacts, not part of the build.