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.