Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
CLAUDE.md (86.8K)
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 **`ccectl focus-window` returns once the window holds still** (since
195 2026-10-08). Focusing a window that hangs off the view pans it in, animated
196 over about half a second, and the window's real geometry moves with the
197 camera — so a `pointer-move-to` + `pointer-press` sent straight after
198 landed where the window was mid-flight. It cost a session a phantom bug:
199 cce-fonts' 20 px search box "would not take focus" while its large preview
200 box, which a mid-flight click still hits, did. ccectl now sends
201 `focus-window --wait`, and the compositor holds the reply
202 (`SettleWaiter`, `poll_settle_waiters`) until no camera pan, pinch or
203 relayout is in flight and the window's on-screen box has held still for
204 three 16 ms polls — ~80 ms for a window already in view, ~500 ms after a
205 pan, `SETTLE_TIMEOUT_MS` (3 s) at most, which replies
206 `ok (still moving after 3000 ms)`. `--no-wait` gives the old immediate reply.
207 Apps are untouched: `cce_core::ipc::focus_window` sends no flag and still
208 gets `ok` at once (it waits a second at most, and wants exactly `ok`).
209 `cce-shadow ctl` runs the INSTALLED ccectl, so this reaches it after
210 `ccebuild install --no-build cce-fx`.
211
212 Not reachable this way, so still live-session work: real DRM/KMS modesetting and
213 page-flip timing, suspend/resume, and libinput hardware paths (gestures, accel)
214 — injected events do not exercise them.
215
216 ### Two binaries
217
218 - **`cce-fx`** (`src/bin/cce.rs`, symlinked to `cce`) — the compositor server.
219 Any arg other than `client`/`help` just starts the server (`cce_fx::run_server()`).
220 - **`ccectl`** (`src/bin/ccectl.rs`) — thin IPC client; all logic is in
221 `src/cce_ctl.rs` (`run_cce_ctl`). Run `ccectl` with no args to see the full command
222 list (layout, mode, pointer-*, key*, bind, spawn, notify, exit, …).
223
224 ### System dependencies (checked by `build.rs`)
225
226 Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
227 `pixman-1`, `libinput`, `libevdev`; linked directly: `GLESv2`, `EGL`, `drm`, `gbm`,
228 `lcms2`. Also required at build time: `meson` + `ninja` (to compile the vendored
229 `scenefx/` statically on first build), `wayland-scanner`, and the system
230 `wayland-protocols` XML files under `/usr/share/wayland-protocols/`.
231
232 ## Tests
233
234 Nineteen modules carry unit tests — `window_manager.rs` (the most of any, among
235 them the saved-state matchers: same-program borrowing, untitled entries),
236 `config.rs` (among them `backdrop_compress_params`), `idle.rs`,
237 `idle_inhibit_manager.rs`, `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
238 `text.rs`, `global_shortcuts.rs` (trigger parsing),
239 `cursor.rs` (the swipe lean's direction, `swipe_lean`), `min_sizes.rs`,
240 `selection.rs` (the rubber band's rect and its hit rule),
241 `touch.rs` (edge-swipe progress, finger centroid/spread tracking, swipe vs pinch),
242 `ipc_server.rs` (command framing and cutting off a stalled subscriber),
243 `status_server.rs` (a slow reader, and a client that never reads),
244 `keyboard_group.rs` (the keys a locked session keeps),
245 `osk.rs` (a touch shows the keyboard once, and only while it is fresh) and
246 `sleep_lock.rs` (logind's sleep delay). They cluster where the logic is
247 pure and the FFI is not, which is the only kind of thing testable in a crate
248 this deep in wlroots. The arrange/slotmap tests live in the sibling
249 `cce-window-manager` crate — run them with `cargo test -p cce-window-manager`.
250 The library crate name is `cce_fx` (underscored).
251
252 ```sh
253 cargo test --lib # all library tests
254 cargo test --lib <name> # single test by (substring) name
255 cargo test --lib config:: # tests in the config module
256 ```
257
258 ### `verify/` — behavioral tests in a shadow session
259
260 For behavior the unit tests cannot reach (it needs a running compositor),
261 `verify/` holds self-contained test drivers that start a private `cce-shadow`
262 instance, drive it with real Wayland clients, and assert on what the
263 compositor observably does. `verify/clients/` is the shared client crate —
264 deliberately **not** a workspace member (severed with an empty `[workspace]`,
265 own `target/`, invisible to ccebuild), built on demand by the drivers:
266
267 - **`vkey`** — injects key events through `zwp_virtual_keyboard_v1`
268 (wtype-style; evdev keycodes plus `mod:MASK` args for held modifiers).
269 This exercises the same `KeyboardGroup::handle_group_key` path hardware
270 keys take, so keybindings and builtins fire for injected keys. `vkey hold`
271 keeps the virtual keyboard alive until killed: a headless seat has no
272 keyboard otherwise, and a Chromium/Electron client that gains focus there
273 crashes on a modifiers event with no keymap before it.
274 - **`float-pair`** — one client, two parentless Floating toplevels with one
275 app_id: a 1024x800 main window, then an "Authorize" dialog that insists on
276 400x370 (min == max, ignores the configure) and is activated with a token
277 BEFORE its first buffer, the way Chromium/Electron open a dialog.
278 `--reactivate SECS` later activates the by-then-unfocused main window — an
279 activation for an already-mapped window. Prints one milestone per line.
280 - **`status-stub`** — maps an xdg toplevel with a `cce-status*` app_id 400px
281 tall, which `any_expanded_status_segment` reads as an open in-surface menu
282 (expanded is geometric: thicker than `layout.bar_height`). It subscribes to
283 the status socket's `dismiss` topic, prints one line per push, and shrinks
284 to a bar strip on the first one — reacting the way the real bar does.
285
286 - **`popup-nest`** — a fixed-size window with a menu (`xdg_popup`) and a
287 submenu nested on it, both asking to flip sideways and slide vertically
288 the way Chrome's three-dot menu does, printing each popup's configured
289 position relative to its parent. `--menu-at`, `--menu`, `--sub-at` and
290 `--sub` move and size them.
291
292 - **`xembed-icon`** — a legacy X11 tray icon docked the way Wine's systray
293 docks one: waits for a `_NET_SYSTEM_TRAY_S0` owner, draws in the visual it
294 advertises, sends SYSTEM_TRAY_REQUEST_DOCK, paints one solid colour and
295 prints each milestone (docked, embedded, every button it receives).
296 `--recolor SECS COLOR` and `--exit-after SECS` exercise the icon updating
297 and leaving. `--popup WxH` makes a right-click open an override-redirect
298 popup the way a Windows tray app does — bottom-aligned at the click and
299 clamped to the screen top, i.e. over a top bar — which reports every move
300 and closes on a press outside it: the bridge's popup placement and the
301 `clickaway` topic, together. Needs `cce-shadow start --xwayland`; see
302 `../cce-status-interface/CLAUDE.md` for the bridge it tests.
303
304 - **`or-flip`** — an X11 window that maps override-redirect, then is
305 unmapped, has the flag cleared and maps again (`--cycles N`), so wlroots
306 emits `set_override_redirect` and the record changes kind. Until
307 2026-09-26 the override-redirect side freed its record without dropping it
308 from `wm.override_redirects`, and the next frame's `apply_x11_scale`
309 segfaulted the compositor: a Wine tray icon handed back to the root window
310 by the XEmbed bridge did exactly this and took the live session down. The
311 compositor surviving a run is the assertion.
312
313 `./verify/popup-constrain-test` drives `popup-nest` to prove submenus are
314 fitted to the real screen (`xdg_popup.rs::handle_reposition`): a tall
315 submenu low in the window slides up to fit, and one near the right edge
316 flips left. wlroots wants the unconstrain box in the ROOT window's surface
317 coordinates, which each popup finds through `XdgPopup::root_tree`; until
318 2026-09-25 the box was measured from a submenu's parent menu, which pushed
319 Chrome's tall submenus past the top of the screen and stopped them flipping
320 at the right edge.
321
322 `./verify/image-selection-test` needs no client of its own: cce-grid and
323 cce-terminal are the clients, and the desktop images it seeds are what the
324 overview band and a group move have to pick up (see "Overview
325 drag-selection" below).
326
327 `./verify/escape-dismiss-test` composes the two to prove all three gates of
328 the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
329 Escape stays out of the arm, a plain Escape while expanded pushes exactly one
330 dismiss (and the stub's shrink is visible in `ctl windows`), and a plain
331 Escape with nothing expanded stays quiet. The compositor binary is whatever
332 `cce-shadow` resolves (installed first, then `target/release`); extra args
333 pass through to `cce-shadow start`, so `--bin ../target/release/cce-fx` pins
334 the tree's own build.
335
336 ## Build pipeline (`build.rs`)
337
338 `build.rs` does a lot before Rust compiles:
339 1. Runs `meson setup build` (first time) + `meson compile` inside `scenefx/`, static.
340 2. Generates server headers for upstream protocols and header + `private-code` C for
341 the custom `river-*` / `cce-*` protocols (`protocol/`), using `wayland-scanner`.
342 `clean_xml` reorders files whose XML declaration follows a leading comment.
343 3. Compiles `src/server/wlroots_log_wrapper.c` + the generated protocol `.c` files
344 into a static `wlroots_log_wrapper` lib.
345 4. Runs `bindgen` over `wrapper.h` → `$OUT_DIR/bindings.rs`, blocklisting a handful of
346 types that are hand-defined `#[repr(C)]` in Rust instead.
347
348 **"First time" means the guard is `!Path::new("scenefx/build").exists()`** (step
349 1, `build.rs`) — so once that directory exists `setup` never runs again, and
350 every later build is `meson compile -C build` alone, which cannot reconfigure.
351 Meson bakes absolute paths into a configured build dir, so **relocating the
352 workspace root kills it permanently**: the dir still points at where the tree
353 used to be, and nothing in the build recovers it. `cargo clean` and `make
354 clean` both only clear `../target/`; `scenefx/build/` is gitignored
355 (`.gitignore:6`, `scenefx/.gitignore:2`), so a fresh clone never has one and is
356 fine, while a *moved* tree carries the dead one along.
357
358 It surfaces in `meson compile`, not `meson setup`, which is what makes it
359 confusing — ninja goes to regenerate `build.ninja` and meson dies with
360
361 ```
362 ERROR: Neither source directory '<old absolute path>' nor build directory '.' contain a build file meson.build
363 ```
364
365 The old path in that message is the entire diagnosis: it names where the
366 workspace used to live. Recovery is `rm -rf cce-compositor/scenefx/build` and
367 one more build to reconfigure, about a minute. Cost an afternoon on
368 2026-08-28, when a build dir configured under the workspace's former
369 `~/Dropbox/cce` path survived the move to `~/projects/cce`.
370
371 ## Architecture
372
373 Everything lives under `src/server/` and is re-exported flat from `src/lib.rs` via
374 `#[path = ...]` module declarations. FFI-heavy: expect large `unsafe` blocks, raw
375 pointers into wlroots C structs, and `wl_listener` callbacks throughout.
376
377 ### Key FFI idiom — `container_of!`
378
379 `src/server/server.rs` defines the `container_of!` macro (the Rust equivalent of
380 Zig's `@fieldParentPtr` / the C `wl_container_of`). wlroots delivers events through
381 embedded `wl_listener` fields; callbacks use `container_of!(listener, Struct, field)`
382 to recover the owning Rust struct from a listener pointer. Many wlroots structs are
383 also redefined as hand-written `#[repr(C)]` mirrors in `server.rs` because bindgen
384 treats them as opaque.
385
386 ### Central files (by size/importance)
387
388 - **`server.rs`** — `Server` struct: owns the wlroots backend, renderer, `wl_display`,
389 xwayland, and all the manager sub-objects. `Server::init()` / `deinit()` wire up
390 every wlroots global. `run_server.rs` is the entry point: parses args, inits the
391 server, loads config + persisted state, adds the wayland socket, spawns the init
392 program (`~/.config/cce/init` via `sh -c`) and the IPC + status servers, then
393 `wl_display_run`.
394 - **`window_manager.rs`** (~8.4k lines) — the heart of the mechanism side. Holds the
395 WM state, the camera fields, window lists, the IPC command dispatcher
396 `process_ipc_command()`, the `Policy::action` snapshot builder
397 (`build_action_ctx`) and the `Compositor` command applier. IPC requests arrive on
398 an mpsc channel; the IPC thread bumps an eventfd after each send, and that fd is a
399 `wl_event_loop_add_fd` source (`handle_ipc_event`) which drains the channel, so all
400 mutation happens on the main thread and the loop sleeps until a command exists.
401 (It was a 10 ms polling timer until 2026-09-10 — 100 wakeups/s at total idle. The
402 status server thread had the same shape, a `try_recv` loop with a 20 ms sleep; it
403 now `poll()`s its sockets plus a wake eventfd. Nothing in the compositor should
404 tick while idle: a timer that re-arms itself unconditionally is a bug.) Decision logic (camera math, action
405 dispatch, snapping, refocus, grid geometry) lives in `cce-window-manager`.
406 - **`window.rs`** (~5.8k lines) — per-window model and rendering (borders, blur,
407 viewport transforms).
408 - **`crate::tiling`** (from `cce-window-manager`) — `TilingMode` enum: `Floating`,
409 `Tiled` (grid-aligned; the window reports xdg maximized), `Fullscreen`,
410 `Popup`, `Overlay`, `Status`, `Utility`. A Wine window answers "maximized"
411 by maximizing itself, which for a captionless one (Ubisoft Connect) is the
412 whole monitor and so a FULLSCREEN request; `XwaylandWindow::absorbs_wine_echo`
413 swallows that echo and the tile's held size lets Wine settle on MAXIMIZED.
414 The same FULLSCREEN is what such an app's OWN maximize button sends; a
415 captionless Wine window whose `_MOTIF_WM_HINTS` functions offer maximize
416 (`is_wine_maximize`, read over the XWM's xcb connection) is tiled instead,
417 while a WS_POPUP game — no maximize function — still goes fullscreen.
418 Tiled-ness is geometric: the seat
419 op's end (`seat.rs::op_end`) promotes/demotes via
420 `policy::snap::is_cell_aligned`. **A window grabbed Tiled snaps HARD
421 through the whole drag** — its move lands on cell starts
422 (`snap::snap_move_tiled`) and its resize lands each dragged edge on a cell
423 edge, whole cells only (`snap::resize_axis_tiled`, used by both the seat
424 op and `get_active_resize_dimensions`) — so it comes out of the drag still
425 Tiled; the magnetic pull (`snap_move`, `resize_axis`) is for Floating
426 windows deciding whether to tile. `Utility` is the one mode a client asks for
427 outright — `cce_window_management.rs` sets it on `set_utility` — and it is a
428 self-sizing float: no resize affordance, no saved geometry (see
429 `xdg_toplevel.rs`, which sizes it and `Status` from their own content, and
430 `xwayland_window.rs`, which excludes it from the tiled report alongside
431 `Floating`/`Popup`).
432 - Input stack: `input_manager.rs`, `seat.rs`, `cursor.rs`, `keyboard*.rs`,
433 `xkb_*.rs`, `libinput_*.rs`, `pointer_*.rs`, `tablet*.rs`, `text_input.rs`,
434 `input_relay.rs`/`input_popup.rs` (IME).
435 - Shell/surface: `xdg_toplevel.rs`, `xdg_popup.rs`, `shell_surface.rs`,
436 `layer_shell.rs`, `xwayland_window.rs`, `xwayland_override_redirect.rs`,
437 `drag_icon.rs`, `wm_node.rs`. An override-redirect window whose WM_CLASS
438 class is `cce-xembed-tray` gets no scene node at all
439 (`is_xembed_tray_container`): it is the tray bridge's container for a
440 legacy X11 tray icon, which X must have mapped for the icon to draw but
441 which is shown in the status bar instead (`cce-status-interface`'s
442 `cce-xembed-tray`).
443 An override-redirect window never takes the keyboard from ANOTHER client
444 (`focus_if_desired`): it gets it only when the seat holds nothing or a
445 window/popup of its own process. wlroots' `override_redirect_wants_focus`
446 cannot tell a Wine tooltip from a Wine menu — both are
447 `_NET_WM_WINDOW_TYPE_DIALOG` with `WM_TAKE_FOCUS` and the same Win32
448 styles — so until 2026-09-27 the tooltip Wine's `explorer.exe` shows on
449 every forwarded tray click took focus from whatever the user was typing
450 in, and kept it after closing. The price is keyboard navigation in the
451 menu of an app with no focused window (a tray menu); the pointer still
452 drives it.
453 - Output: `output.rs`, `output_manager.rs`. Session: `lock_manager.rs`,
454 `idle_inhibit_manager.rs`. Rendering: `scene.rs`, `scene_node_data.rs`.
455
456 ### A node must draw everything it claims as opaque
457
458 `scene_node_opaque_region` (scenefx `wlr_scene.c`) is a promise: whatever a
459 node reports there is culled from every node beneath it AND from the black
460 background clear, so a pixel inside it that the node's shader draws at less
461 than full alpha blends over whatever the buffer last held. An alpha-1
462 `wlr_scene_rect` with a corner radius reports its box minus the corner
463 squares, and until 2026-09-28 `quad_round.frag` did not honour that: its
464 inlined distance was one pixel off vertically (top row alpha 0, the next
465 0.5) and its AA ramp left every straight edge pixel at 0.5. On the desktop
466 grid — opaque black rounded cells over the gap-coloured backdrop — that was
467 a tint of whatever had last covered a cell's top row, constant in the first
468 row and halving per repaint in the second, invisible after a full redraw
469 only because the clear colour and the cells are both black. The shader now
470 takes its distance from `corner_dist`, like the clip and the buffer corner
471 cut. To look for this kind of residue, screenshot a shadow before and after
472 drawing over the grid and diff; every desktop pixel should be a multiple of
473 the gap colour.
474
475 ### Window move/resize handles
476
477 Pointer move and resize exist **only in adjust mode** (overview, or Super
478 held), and the resize handles are **eight discs inside** the content rect —
479 one at the midpoint of each side, one on each corner — where the old band
480 sat outside the edges and the ring that followed hugged them.
481
482 - `cursor::get_border_zone` is the hit test: it returns `BorderZone::None`
483 outright unless `wm.mode == Overview`, so in normal mode a window cannot be
484 dragged or resized at all. Only the pointer is gated — `move_window_*`,
485 `ccectl move-window`, and a client repositioning itself all still work in
486 normal mode.
487 - Inside the ring, **all four edges resize**, the top included. Dragging the
488 window's body is what moves it in overview, so the top edge no longer has
489 to be spent on moving the way the outside band's did.
490 - The handles are drawn by **one scenefx node**, `wlr_scene_frame`
491 (`scenefx/render/fx_renderer/shaders/frame.frag`): eight discs of
492 diameter `band` (= `border.handle_width`, screen px), each its own zone.
493 A corner disc sits on the corner's 45° diagonal, tangent to the rounded
494 corner arc when that arc is wider than the disc and tucked into the two
495 straight edges otherwise, and **the side discs take the same inset**, so
496 the three discs along an edge are inline (since 2026-10-07; before, the
497 side discs hugged their side and sat outboard of a rounded corner's).
498 **`window::handle_disc_layout` is the one layout function**: `draw_borders`
499 places the eight invisible square catchers (`border.segments`) from it,
500 `cursor::get_border_zone` hit-tests the discs from it (a pixel of slack
501 for the rim), and the shader repeats the same arithmetic from the same
502 inputs (size, corner radius, band) — keep the three in step. Between two
503 discs the pointer reaches the app; the old four full-band catchers are
504 disabled for exactly that reason. Not eight rounded scene rects: a scene
505 rect takes the renderer's global corner shape, a squircle, so a rect with
506 radius half its size is not a circle.
507 The shader's zone logic works in TOP-DOWN box-local coordinates
508 (`gl_FragCoord` minus the box position, unflipped): `corner_dist` flips
509 its own copy, and mirroring the zone coordinate the same way once
510 swapped every zone label vertically — the top edge lit the bottom. And a
511 hover swap must repaint even when no reveal value moves: in adjust mode
512 the discs are already fully revealed, so `step_border_fade` compares the
513 hovered zone against the one last drawn (`border_hover_drawn`), or the
514 shader keeps showing the previous zone until an unrelated commit repaints.
515 - **The disc diameter is a SCREEN size, not a world one**, floored at
516 `HOVER_BAND_MIN` and capped at a fifth of the window's shorter on-screen
517 side. Overview is zoomed *out*, so a handle that scaled with the window
518 would be smallest exactly where it is the only way to resize; the cap
519 keeps a zoomed-out window from being mostly handle. `draw_borders` and
520 `cursor::get_border_zone` each derive it the same way and must stay in
521 step.
522 - **A Floating window lying over the adjust target is dimmed** to
523 `border.overlap_opacity` (default 0.4; 1.0 disables) while the mode is
524 on, so it does not hide the handles. `Window::adjust_dim_wanted` walks
525 the render list bottom-up: only windows ABOVE the target that overlap it
526 on screen qualify (one beneath hides nothing). `step_adjust_dim` eases
527 `adjust_dim` on the same border-fade timer as the ring, and
528 `effective_opacity` folds it into the scene-tree opacity `render_finish`
529 sets — set the tree opacity through that, never from
530 `rendering_requested.opacity` directly, or the dim is clobbered on the
531 next commit. Anything that can change who covers whom re-arms the fade:
532 `arrange_views`, `raise_window`, and every `op_update` step.
533 - The **open/close dissolve** is a third multiplier on the same machinery:
534 `Window::map_fade`, stepped by `step_map_fade` on the border-fade timer and
535 folded into `effective_opacity` beside `adjust_dim`. `Window::map` starts the
536 open ramp (`start_map_fade`; `wants_map_fade` excludes status segments, the
537 wallpaper and the grid), and the `fade-out` control-socket command starts the
538 close ramp for whichever windows and Overlay layer surfaces belong to the
539 CALLER — resolved from `IpcRequest::peer_pid` (SO_PEERCRED), never from a
540 name in the command. Layer surfaces run the same ramp on their own timer
541 (`LayerSurface::start_fade`) because they are not in `wm.windows`. The ramp
542 is LINEAR, unlike the borders' exponential approach: an exponential close
543 fade never reaches zero, and the client is holding its surface open against a
544 deadline. Durations are `surface { fade in_ms out_ms }`; see
545 "Window fades" in WORKSPACE.md for the client half of the contract.
546 - `handle_width` under `border` in config.kdl is the diameter. `taper`,
547 `swell_curve`, `bulge`, `corner_length` and `segment_gap` belonged to the
548 retired ring profiles (an even ring, then a wave of hills and valleys):
549 still parsed and passed to the node, no longer drawn.
550 - The shader's zone numbering MUST match `BorderElement::index()`; it is what
551 the hovered-zone uniform selects on.
552 - **Three window buttons sit in the top row** (since 2026-10-07), left of
553 the top-right disc: minimize, maximize, float/tile toggle — zones 8-10,
554 `BorderElement::{Minimize, Maximize, ToggleTile}`, inline with the
555 handles and `HANDLE_BUTTON_STEP` diameters apart. Only a Floating or
556 Tiled window takes them (`window::window_takes_buttons`, asked by the hit
557 test and `draw_borders`, which hands the shader `buttons` = 0/1/2 —
558 none/Floating/Tiled, the last two picking the toggle's glyph). They drop
559 out when the top row cannot fit all six discs, and the Top disc leaves
560 the midpoint only when it would crowd them; `handle_disc_layout` returns
561 how many discs are live, and the shader repeats that rule. A button is a
562 click, not a grab: `get_border_zone` returns `BorderZone::Button`, the
563 adjust-mode press records it in `Cursor::button_press` and returns, and
564 the release acts (`WindowManager::press_window_button`) only if the
565 pointer is still on that button. Minimize and maximize focus the window
566 and run the `minimize` / `toggle_fullscreen` actions — maximize is
567 fullscreen, since Tiled already reports xdg maximized — and the toggle
568 is `set-mode floating|tiled`. A fullscreen window takes no handles, so it
569 leaves fullscreen by the key, not a button.
570 - `window::window_takes_handles` is the single predicate for which windows get
571 handles (excluding Popup, Fullscreen, Status, Utility, circular, hidden),
572 used by both the hit test and the drawing. Keep those in step: a handle that
573 is drawn but not honoured — or honoured but not drawn — is the failure mode
574 this arrangement exists to prevent. The grab zone IS the disc (plus a pixel
575 of rim), not a band: a press between two discs is a body press and moves.
576 - **Holding Super is window-adjust mode at zoom 1**: the same handles and
577 body-drag as overview, gated by one predicate,
578 `WindowManager::window_adjust_active()` (overview OR `adjust_held`).
579 But NOT hover-to-focus: the ring lands on the window **under the
580 pointer** (`Cursor::adjust_hover`, set by `passthrough` — the same
581 target overview uses), focused or not, and focus stays put — so pressing
582 Super arms whatever the pointer is already on, and a focus chord pressed
583 next acts on the window the user had. **A drag never focuses the window it moves or resizes** (the grab
584 paths in `handle_button` call no `seat.focus`; `op_start_pointer` raises
585 a Floating one instead); a tap on the band or body — press+release
586 without motion — is a click and focuses in `op_end`.
587 `adjust_held` is refreshed from the keyboard's modifier mask on every
588 modifiers event (`refresh_adjust_held`), which also re-runs the pointer
589 passthrough so the ring lands under a still pointer on key-down and the
590 app gets its hover back on key-up. `ccectl key-down 125` holds it in a
591 shadow (injection bypasses the device mask, so it keeps its own flag).
592 A background press with Super held is an ordinary desktop press — only
593 overview exits on it.
594 - Handles are shown on the **adjust target only** — `Window::is_adjust_target`:
595 the window under the pointer (`Cursor::adjust_hover`), in overview and
596 with Super held alike; a pointer on the background shows none — for as
597 long as the mode is on (`step_border_fade`'s `all_on` branch, `draw_borders`'
598 `handles_live`, and `get_border_zone` all ask it). Separately, **focus
599 follows the pointer in overview** (the ring does not key on it): the
600 motion path focuses the hovered toplevel — guarded on an actual change,
601 because `seat.focus` raises a Floating window *before* its same-focus
602 short-circuit, so an unguarded call would raise and relayout on every
603 motion event — and with `suppress_focus_pan` set, so hovering never moves
604 the camera; only clicks and the keyboard may. The ring's fade is
605 timer-driven, so both `WindowManager::set_mode` and `seat.focus` arm it —
606 assigning `self.mode` or `self.focused` directly would leave the ring
607 waiting for an unrelated redraw. The hit test and the invisible catcher
608 rects are focused-gated too; hover-to-focus is what keeps that workable,
609 since reaching a window's edge focuses it on the way.
610 - A client drawing an in-surface popover (a cce-ui menu — one buffer with
611 the window since cce-ui's Phase 6x) hints its rect via
612 `zcce_toplevel_v1.set_popover_region` (manager v7); the ring is clipped
613 away beneath it (shader `exclusion`) and its band does not grab there, so
614 the menu reads as in front of the chrome. The protocol XML lives in BOTH
615 repos — cce-ui's copy strips the `enum="river_output_v1..."` attribute its
616 scanner cannot resolve; never sync the file over it wholesale.
617 - The per-side foam clipping the outside band carried is gone: it split a gap
618 SHARED with a neighbouring window, and an inside ring shares nothing.
619 - **Right-click opens the window context menu** — `scripts/cce-app-menu`, a
620 `cce-cloud --json` popup like `cce-desktop-menu` and cce-grid's item menu.
621 It opens from a right-click on a handle disc in either adjust mode, and in
622 **overview from a right-click anywhere on the window**, since the client
623 never sees buttons there (`should_block_button`) and the press is the
624 compositor's to spend; Overlay (chrome) and Utility (no handles, no mode)
625 bodies are excluded. The menu's "Window Mode" page — a second JSON page
626 reached through a `target_page` button, which switches pages without
627 closing the popup — sets the mode with `ccectl set-mode <mode> <id>`, one
628 window by id. Its marks and arrows are cce-cloud's glyphs, not text: the
629 mode rows lead with `"● "` / `"○ "` (drawn as the circle glyphs), and the
630 page and Back rows carry only their words, cce-cloud adding the chevrons
631 from `target_page` (see cce-cloud's CLAUDE.md, the `Json` mode). Not `ccectl mode`, which appends a persistent app_id rule.
632 `cce-desktop-menu` carries the same page for the FOCUSED window: the
633 background right-click passes it as `-i <id> -a <app_id>` (settable modes
634 only) because it drops focus right after the spawn, so the script could
635 not ask for it.
636
637 None of this is policy — `cce-window-manager` was untouched. The mode is
638 already in `ActionCtx`, but what a *pointer* may grab is mechanism.
639
640 ### Overview drag-selection
641
642 A left press on the bare desktop in overview, dragged, stretches a rubber
643 band from the press point, and every window the band touches is selected,
644 live (`src/server/selection.rs`; state on `WindowManager::selection`). It is
645 cce-designer's network-cursor region brought to windows: a new drag replaces
646 the selection, a press on a window outside it drops it, no modifiers. The
647 hit rule differs on purpose — the designer asks whether a node's cell is
648 inside the region, but a window spans many cells and the background between
649 two of them is a strip, so touching is enough.
650
651 - **The background press no longer exits overview on the spot.** It starts a
652 `PointerOpType::Select` seat op — the one op whose `window_ptr` is null —
653 and the RELEASE decides: past `selection::DRAG_THRESHOLD` (5 px) it was a
654 drag; short of it, a click, which drops the selection if there is one and
655 leaves overview if there is none. `Cursor::left_click_on_bg_in_overview`
656 went with the press-time exit.
657 - **Pressing the body of a selected window moves the whole selection.** The
658 grab fills `Seat::group_move` with the other selected windows and their
659 virtual positions; `op_update`'s Move arm carries them by the offset the
660 grabbed window actually took, snap included, so a group of Tiled windows
661 steps in whole cells and re-tiles on release (`Seat::settle_tiling`, the
662 geometric detection `op_end` always ran, now per window). Carried windows
663 count as `is_window_being_moved` and are skipped by the overview
664 displacement. A tap moves nothing and un-tiles nothing.
665 - **The band is anchored in virtual coordinates**, so the edge auto-pan
666 scrolls the desk under a held drag and the band grows with it. The Select
667 arm queues the op frame like any other op for that reason: the edge-pan
668 tick moves the camera and leaves the relayout to `op_update`.
669 - **Drawn outside `interactive_tree`**, in a tree on the scene root placed
670 just above it: `Scene::at` stops at the first node it meets, and a node
671 with no `SceneNodeData` reads as background, so a highlight inside the
672 interactive tree turns a press on a selected window into a press on the
673 desktop. Each box is a fill rect plus a `wlr_scene_bevel` in its
674 glint-only focus branch, in `bevel_focus_color`;
675 `WindowManager::draw_selection` places them per frame from
676 `Output::render_and_commit`.
677 - The selection lives only in overview (`set_mode` clears it) and a
678 destroyed window is dropped from it and from `group_move`
679 (`Window::destroy`).
680 - **The desktop images select too** (since 2026-09-30). They are
681 `cce-grid`'s pinned items, which the compositor otherwise knows only as
682 the grid surface's input region, so the grid reports them over the
683 control socket — `grid-items <id>:<x>:<y>:<w>:<h> ...`, virtual units,
684 the whole list on every change, ids per grid process — and
685 `Selection::desktop_items` keeps the list (dropped with the grid window,
686 `selection_forget`). The band picks them up by the same touch rule,
687 `draw_selection` washes them like windows (square-cornered: they are
688 quads), and a group move carries them: `Seat::group_items` is filled
689 beside `group_move` at the grab, `carry_group_items` moves the
690 compositor's rects by the group's offset (the grabbed window's, snap
691 included) and pushes `move <id>:<x>:<y> ...` on the status socket's
692 `selection` topic, and `op_end` pushes `drop`, on which the grid saves
693 its sidecar and reports afresh. A press on a SELECTED image is the
694 compositor's, not the grid's: `PointerOpType::GroupMove`, the one op
695 besides Select with no window — the pointer's own travel moves windows
696 and images alike, snapped to whole cells when a carried window was Tiled
697 (measured on that window, kept in `start_win_virtual_*`). A press on an
698 unselected image drops the selection and goes to the grid, which drags
699 it as before. A background click clears images too (`has_selection`), so
700 a click with only images selected drops them rather than leaving
701 overview. See `../cce-grid/CLAUDE.md` for the grid's half.
702 - `ccectl selection` prints `selected=<ids|-> items=<ids|->
703 band=<virtual rect|-> desk=<id@x,y,wxh;...|->` — `items` are the selected
704 images, `desk` every image the grid has reported, which is how a shadow
705 sees the report land and where a group move left them; `ccectl camera`
706 prints `mode=` too. In a shadow: `pointer-move-to`, `pointer-press left`,
707 `pointer-move-to`, `selection`, `pointer-release left`. Keep the band off
708 the screen edges or the edge pan scrolls the desk mid-assertion.
709 `./verify/image-selection-test` drives the whole of the above — the
710 report, the band, both grab sides, the click rules, the exit — against a
711 cce-grid it runs itself (`CCE_GRID`, else the workspace's release build).
712
713 ### Launching from overview
714
715 An app launched while in overview **does not leave it** (since 2026-10-05).
716 When a desk window (anything but Popup/Overlay, a status segment, the
717 wallpaper or the grid) maps during overview, `Window::map` calls
718 `WindowManager::pan_overview_to_window`, which keeps the zoom and pans
719 just far enough to show the whole window (`pan_to_virtual_rect`, the same
720 `pan_into_view` rule focus follows). A window that already fits moves
721 nothing. The new window still takes focus. The pan runs whatever
722 `center_on_spawn` says, since the focus loop's pan skips a first focus
723 without it, and not while a camera ramp (an overview enter still flying)
724 owns the camera. Before this, `exit_overview_to_window` flew the camera
725 to zoom 1 on the new window and switched to Normal. In a shadow:
726 `ctl overview`, `spawn foot`, then `ctl camera` still reads
727 `mode=Overview`.
728
729 ### Config
730
731 Loaded on startup from **`$XDG_CONFIG_HOME/cce/config.kdl`** (falls back to
732 `~/.config/cce/config.kdl`). An adjacent `input.kdl` is merged in for key bindings and
733 input settings. **The format is KDL** (via the `kdl` crate; `parse_kdl_config`).
734 `config.rs` maps parsed values onto `WindowManager` state (layout gaps,
735 border/blur/desktop styling, keybindings → `Action`s, startup programs, output/display
736 settings). Live reconfiguration comes in over IPC (`ccectl reload`, `bind`, `layout …`,
737 `config-done`, etc.).
738
739 Per-output settings live under `output { <name> … }` as properties or child nodes:
740 `scale`, `brightness_interval` / `brightness_up` / `brightness_down`, and
741 **`size_mm="344x215"`** — the panel's real size, written into the `wlr_output`'s
742 physical size (via the `river_wlr_output_set_phys_size` shim) *before* its
743 `wl_output` global exists, so every client's geometry event carries it in place of
744 the EDID figure. That is the number cce-ui's `units::Metric` divides the logical
745 size by to resolve a `(mm)` config length (see `../cce-ui/CLAUDE.md`, Units). Set it
746 when EDID lies (TVs, projectors) or is absent (headless, the shadow: `HEADLESS-1`
747 reports 0×0 and clients fall back to an assumed 96 ppi). `ccectl outputs [--json]`
748 prints, per output, mode / scale / logical size / mm / logical px per mm and where
749 the mm came from (`configured`, `measured`, `none`); the creation log line says the
750 same.
751
752 **Swipe binds peek before they fire.** A three-finger swipe bound to a
753 directional focus or pan (`focus_left (gesture)"swipe3_left"` in input.kdl)
754 fires once the accumulated travel passes `window_manager { swipe_threshold }`
755 (libinput units, default 70; `WindowManager::swipe_threshold`; it was 50
756 until 2026-09-24, when a replay of logged swipes showed every deliberate
757 first step travelling 75 or more, so 70 drops only hesitant ones). Short of
758 that the camera *leans* toward the bind the
759 swipe is heading for, 1:1 with the fingers and in their direction
760 (`cursor::swipe_lean`, since 2026-09-24; before that it leaned along the
761 dominant axis only): its size comes from the dominant axis, and the
762 minor axis leans in proportion to the swipe's slope, so a diagonal swipe
763 leans diagonally. A swipe within 15° of an axis
764 (`SWIPE_LEAN_STRAIGHT_SLOPE`) still leans straight, since a hand's
765 sideways drift leaning the camera was a wobble, and the turn off the axis
766 is smooth past that band. The size is proportional to the travel —
767 `window_manager { swipe_peek }` screen px at the threshold (default 60, 0
768 disables; `WindowManager::swipe_peek_px` — not under `input`, whose
769 config.kdl block input.kdl's replaces wholesale), clamped there — and eases
770 back to where it started if the fingers lift first (`handle_swipe_end`), so
771 a hesitant swipe shows where it would go without going. With animations
772 off (`cce_ui::motion`) nothing leans: the camera holds still until the
773 bind fires and the step's pan lands at once. **A fire does
774 not end the swipe** (since 2026-09-24): the accumulated travel restarts
775 from zero at the fire, and a further `window_manager {
776 swipe_repeat_threshold }` of travel (libinput units, default four times
777 `swipe_threshold`; `WindowManager::swipe_repeat_threshold`) without
778 lifting fires again — for a focus or pan bind (`action_navigates`) only:
779 any other swipe bind, the four-finger overview toggle included, fires
780 once per gesture and the rest of it is ignored (`Cursor::swipe_spent`).
781 Three windows over is one long swipe, with more
782 resistance after the first step so it does not run on through the next
783 window — and a reversal after a step goes straight back. The lean
784 toward a further step is smaller too: it reaches `window_manager {
785 swipe_repeat_peek }` (screen px, default half of `swipe_peek`;
786 `WindowManager::swipe_repeat_peek_px`) at the repeat threshold, so it
787 moves far more slowly per unit of travel than the first step's lean. The factor was
788 two at first and read as too eager: replaying a session's logged swipes
789 (every `handle_swipe_update` is logged at info with its delta, in
790 `$XDG_RUNTIME_DIR/cce/cce.log`) showed ordinary single swipes travelling
791 150-250 units, so many stepped twice and then reversed to correct
792 (left-left-right-right); four times removes nearly all of those while a
793 long deliberate swipe still steps again. libinput's swipe deltas are
794 accelerated, so a fast flick covers far more travel than a slow push of
795 the same length. The lean scales
796 with whichever threshold is in force. The lean after a step rides on the focus ease
797 the step started (its pan target moves with the fingers) instead of
798 freezing it, and a lift short of the next threshold eases only that lean
799 back out; the steps stay. Clients are sent one cancelled `swipe_end` at
800 the first fire and hear nothing more of the gesture. **A focus swipe aims
801 where the fingers went** (since 2026-09-24): when the bind that fires is
802 a `focus_*`, the step's travel becomes a direction
803 (`cursor::swipe_focus_vector`, each axis's sense read from the bind table,
804 so mirrored binds mirror it and an axis without focus binds does not aim),
805 and `WindowManager::focus_toward` hands it to the policy crate's
806 `vector_focus`: the nearest window center within `window_manager {
807 swipe_focus_cone }` degrees (default 45; `swipe_focus_cone_deg`) of a ray
808 from the focused window's center takes focus, and a swipe toward nothing
809 in the cone does not fire at all: `WindowManager::focus_toward_lands` asks
810 the policy first, side-effect free, and on no the lean holds at its limit
811 like a wall (`Cursor::swipe_dead_end`, asked once per swipe), with the
812 travel scaled back onto the threshold so turning the fingers round unwinds
813 the lean at once; the lift eases it out. Until 2026-10-06 such a step
814 fired: first it froze the previous step's pan part way and kept each
815 step's lean, walking the camera off the window it had just focused, and
816 then (fixed that morning by easing the lean out at the fire) it snapped
817 the camera back while the fingers were still going out, every threshold —
818 a sawtooth jitter for as long as the swipe ran on.
819 With no focused window there is no ray, and the four-way action runs for
820 its entry rule. The keyboard's focus chords stay four-way. Only binds whose
821 action `cursor::action_navigates` (focus/pan left/right/up/down) peek, and
822 only toward a direction that has one; a four-finger overview toggle leaves
823 the desktop still. When the bind fires (`handle_swipe_update`) the action
824 runs against the camera where the lean left it: a window that needs a pan
825 gets its ease from there, and a window already in view sets no target, so
826 the camera simply stops where the lean left it rather than springing back.
827 **A pan back against the lean is kept.** It arises only when the lean
828 pushed the new window's near edge off screen, or leaned away from the side
829 it sits on, and it is only as large as bringing the window in needs. Until
830 2026-09-24 such a target was dropped so the camera never reversed, which
831 left the newly focused window clipped whenever the lean overshot; the
832 diagonal lean and aiming by finger direction made that common. It does not predict the
833 destination (tried on 2026-09-22 — a lean along the policy's predicted pan,
834 nothing at all when the target was in view — and retired the same day: the
835 lean is meant to answer the finger, not the layout), and it does not spring
836 back to the origin (the first version did, and a switch between two
837 windows both in view leaned out and back on every swipe). A shadow drives it staged — `ccectl pointer-swipe begin 3`,
838 `update <dx> <dy>`, `end` — and reads the lean and its return back with
839 `ccectl camera` (pan, zoom, pan target).
840
841 **Idle timeouts** — `idle { display_off <s>; sleep <s>; sleep_command "…" }`,
842 both 0 (off) by default — are `src/server/idle.rs`, a `Server` subcomponent
843 rather than window-manager state: two `wl_event_loop` timers re-armed from
844 `Seat::handle_activity`, held disarmed while `IdleInhibitManager::check_active`
845 reports an inhibitor. "Display off" reuses the wlr-output-power-management
846 path (`OutputStateValue::DisabledSoft` + `dirty_windowing`): the output stays
847 in the layout, nothing re-arranges, and no frame events fire while it is dark.
848 Only outputs the timeout darkened (`Output::idle_off`) are woken by the next
849 input, so one a client turned off with `wlopm` stays as the client left it.
850 The sleep command is `sh -c` under a fork, reaped by the server's SIGCHLD
851 handler; `systemctl suspend` returns as soon as the job is queued, so resume
852 is detected from the wlroots session's `active` signal instead (the
853 `river_wlr_session_get_active_signal` shim — `wlr_session` is opaque to
854 bindgen), treated as activity so a lid-open lights the screen without a key.
855 The Power plan's per-mode overrides (`/run/cce/idle_display_off`,
856 `idle_sleep`; `CCE_IDLE_PLAN_DIR` moves them) are followed by an inotify watch
857 on that directory, an fd source on the event loop, so nothing ticks at rest;
858 the 1 s stat poll that was the only mechanism until 2026-10-05 is now the
859 fallback while the directory is missing, and it switches back to the watch
860 once the directory appears.
861 Note that until 2026-09-16 the hardware pointer handlers (`handle_motion`,
862 `handle_motion_absolute`, `handle_button`, `handle_axis`) and `handle_group_key`
863 never called `handle_activity` at all — only tablet, touch and gestures did —
864 so `ext-idle-notify` clients were never told about mouse or keyboard use;
865 injected `ccectl pointer-*`/`keypress` events count as activity too, which is
866 what lets a shadow session exercise the timeouts (`ccectl idle timeouts 2 0`,
867 then `ccectl outputs` reads `enabled=false`, then any injected input reads
868 `true`). `ccectl idle` prints the state; `idle wake|sleep|display on|off` act
869 now. Untested in a shadow, which has no session: the resume wake.
870
871 **Every sleep locks first** (since 2026-10-01; before, nothing ever started
872 the lock screen and a laptop woke to its desktop). `LockManager::lock_now` locks
873 from the compositor's side with no client yet — the normal tree off, each output
874 rendering the blank locked tree — and then starts `cce-lock` through the
875 respawn timer, which binds via `handle_new_lock`'s already-locked branch; a
876 locker that never comes leaves the session locked, the crash path's guarantee.
877 The idle sleep and `ccectl idle sleep` go through `IdleManager::lock_then_sleep`
878 (sleep on `on_locked`, or after `LOCK_BEFORE_SLEEP_MS` regardless; activity in
879 between calls the sleep off and keeps the lock). logind's own sleeps — lid,
880 power key, `systemctl suspend` — are `sleep_lock.rs`: a thread holding a
881 `delay` sleep inhibitor that answers `PrepareForSleep(true)` with `ccectl
882 lock` (whose reply waits for `send_locked`) and then lets logind go. Real seats
883 only; a shadow has no session and starts no thread, so test `ccectl lock` and
884 `idle sleep` there (give the shadow a harmless `idle { sleep_command }` first —
885 its seeded config's default is `systemctl suspend`, the REAL machine) and the
886 logind half with `cargo test --lib sleep_lock -- --ignored`. While locked,
887 `handle_group_key` runs no binding but VT switches and config keybinds on
888 volume/brightness/media keys (`allowed_while_locked`), and no input-method
889 grab; every other key goes to the lock surface.
890
891 Persistent window state is saved to **`~/.local/state/cce/state.json`**
892 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
893 (`save_state` / `load_state` / `spawn_restored_windows`). A window's
894 `cmdline` comes from `/proc/<pid>/cmdline`, which is what the process
895 *exec'd into*, not what launched it: an `exec` wrapper in `~/.local/bin`
896 (Inkscape's `GDK_SCALE=1` wrapper) reads as `/usr/bin/inkscape`, and a
897 restore that replays that path skips the wrapper. So `save_state` records
898 the **bare name** whenever the name's first `PATH` hit is a different file
899 from the one running (`path_shadowed_name`), and the restore's `sh -c`
900 resolves it the way the launcher did. The absolute path is kept when PATH
901 agrees with it.
902 **The restore relaunches the saved `argv`, quoted, never `cmdline`**
903 (`restore_command`, since 2026-10-02). `cmdline` is argv joined with spaces
904 and stays what the matchers compare (`saved_by_program`, `same_app`), but run
905 through `sh -c` it executed an argument's own shell characters: a viewer left
906 open on `x$(cmd).pdf` ran `cmd` at the next login, and a URL with `&` split in
907 two. Each argument is now `shell_quote`d. An entry saved before `argv` existed
908 relaunches only when its cmdline is plain words (`plain_cmdline`), else it is
909 skipped with a warning and its geometry still applies when started by hand.
910 foot's `--working-directory=` is a plain argv entry for the same reason.
911 Wine/Proton windows record their Windows-side exe path (`C:\...`) as the
912 command, which `/bin/sh` cannot run, so the restore never relaunches them
913 (`relaunchable`) — and draws no login placeholder for them either: until
914 2026-09-26 Ubisoft Connect's plate stood a minute over the empty desk,
915 waiting for a window nothing had started. The entry stays queued, so the app
916 still lands on its saved spot when the user launches it.
917 Beside it, **`min-sizes.json`** (`min_sizes.rs`) keeps the minimum sizes
918 X11 apps revealed by refusing a smaller configure mid-drag — Wine sends no
919 minimum for a resizable window, so Ubisoft Connect fought every shrink past
920 1214x804. Only a floor counts (two different sizes answered with the same
921 one, `xwayland_window::learned_min`), since a stored value is permanent;
922 it is keyed by app_id, program and title, kept in X11 pixels, applied at
923 map, and lowered when a window maps smaller than it. The table is read once
924 and held in memory, so editing the file under a running compositor does
925 nothing (the next learned value rewrites it): `ccectl min-size list` shows
926 it, `min-size forget <app_id|id>` drops an open window's entry and the
927 minimum it is held to, and `forget-entry <n>` drops one for an app that is
928 not running. A minimum the app's DPI decided (Ubisoft Connect: ~1214x689
929 DIPs, so 2428x1378 X11 px at Wine's default 192 from `Xft.dpi`) is lowered
930 in the prefix's `Control Panel\Desktop` `LogPixels`, not here.
931 A restored **floating** window is recalled into the current view
932 (`policy::camera::recalled_origin`, applied at the end of `try_restore`)
933 when its remembered position would show less than a quarter of it: the
934 camera at restore is wherever the session left it, and a floating window a
935 screen away from that is lost, not remembered. Tiled windows stay where the
936 grid has them. **Except on the tiled desk**: a floating window within one
937 viewport of the tiled windows' bounding box (`tiled_desk_bounds` — the
938 session's Tiled entries still queued plus the live Tiled windows) keeps its
939 remembered spot however far the camera is, since the columns beside it are
940 what the user pans along (cce-data-editor parked left of the first column
941 came back mid-view every login before 2026-09-14). The recall is for a
942 window with no tiled neighbour within a screen.
943
944 **A settings window opens over its app** with a `mode_rule` that names it
945 by title and says `over_sibling`:
946
947 ```kdl
948 mode_rule mode="floating" app_id="md.obsidian.Obsidian" title="Settings" over_sibling=(bool)true
949 ```
950
951 Obsidian (and Electron apps generally) open Settings as a PARENTLESS
952 toplevel, so nothing marks it a dialog; until 2026-09-29 it borrowed the
953 main window's saved entry by app_id — Tiled, latched, at the main window's
954 size — and the overlap rule pushed it to a free cell. Three things in
955 `try_restore` make the rule work. An untitled window of an app some title
956 rule names **waits for its title** before restoring at all (Electron sets
957 the app_id first, and a rule on the title cannot be judged without one).
958 A matching title rule then **outranks a borrowed entry** — never the
959 window's own (`rule_skips_restore`), so a plain title rule still honours a
960 geometry the user gave that window. And with `over_sibling`, while a mapped
961 window of the same app_id is up (`find_sibling`: the focused one when it
962 qualifies), the window is a **satellite** (`Window::satellite`): no saved
963 state applies, it sizes itself, `try_center_on_sibling` centres it over the
964 sibling (`centered_over`, slid into the view on any axis it fits) with the
965 same commit-time redo the view-centred modals use, and `save_state` neither
966 saves it nor lets it keep the app's `last_window_states` slot. Reproduce
967 with `verify/clients` `float-pair --dialog-honours-configure`: tile the
968 main window, and the "Authorize" window maps Tiled at 1404x1076 without a
969 rule, Floating at its own 400x370 over the main window with one.
970
971 **A prompt opens centred on the view** with a `mode_rule` that says
972 `center` (since 2026-09-30):
973
974 ```kdl
975 mode_rule mode="tiled" app_id="com.onepassword.OnePassword" title=" — 1Password"
976 mode_rule mode="floating" app_id="com.onepassword.OnePassword" title="1Password" center=(bool)true
977 ```
978
979 It puts the window on the same path as the built-in session modals
980 (`Window::try_center_on_view`, gated by `wants_view_center`: the hardcoded
981 `cce-authenticator`/`cce-filesystem-chooser` list OR a matching rule):
982 forced Floating and `mode_locked`, never minimized, centred on the camera
983 as it stands at map with the remembered SIZE kept and the position
984 discarded, re-centred on the commit that brings a self-sizer's real size,
985 and `hint_placed` so no spawn pan follows it. 1Password's authorization
986 popup is a parentless Electron toplevel under the vault window's app_id
987 with the bare title `1Password`; it saved as Tiled and reopened at that
988 one spot on the desk wherever the camera was. Two rules because title
989 matching is substring and the first match wins: the vault window's titles
990 all end in ` — 1Password`, so the first rule takes them and only the bare
991 title reaches the second. Reproduce with `float-pair --app-id X
992 --dialog-honours-configure` under `title="Main window"` / `title="Authorize"
993 center` rules: pan the camera, respawn, and `ctl windows` shows the
994 dialog at the screen's centre while the main window sits where it was.
995
996 **A window with no title matches `title=""`** (since 2026-10-05,
997 `mode_rule_matches`): an unset title counts as the empty string, which
998 every `title=` substring rule is tested against, so only `title=""` can
999 reach it. The Claude app's quick-entry popup is an untitled parentless
1000 toplevel under the main window's app_id; with no rule able to name it, it
1001 borrowed the main window's Tiled entry (the border drawn at the main
1002 window's size around a far smaller popup, and focus panning the camera to
1003 it). The live config names the main window `title="Claude"` first and then
1004 `title="" center`. `float-pair --untitled-dialog` reproduces it.
1005
1006 **A client reconnecting maps unfocused**: a window that vanishes without
1007 the compositor asking it to close (`Window::unmap` → `note_vanished`) lets
1008 the next window of the same app_id AND the same program (`proc_args`
1009 argv[0]) within 5 s map without taking focus (`take_recent_vanish`) — a
1010 cce-ui client rebuilding its surface on a fresh connection must not steal
1011 focus back. The program half is from 2026-09-26: keyed on app_id alone,
1012 every Proton program is `steam_proton`, so a game Ubisoft Connect launched
1013 a second after closing one of its own windows was held unfocused, and the
1014 user's fullscreen key went to the window that kept focus. An unreadable
1015 program (the process already gone) falls back to the app_id.
1016
1017 Restored windows map unfocused, and their FIRST focus pans the camera only
1018 once the session has seen deliberate input (`WindowManager::startup_input_seen`,
1019 gated in `Seat::focus`), so apps settling in at login do not drag the view
1020 around. Deliberate means a button press, a key press, or the start of a
1021 touchpad swipe or pinch (`handle_swipe_begin`/`handle_pinch_begin`); pointer
1022 motion and a hold do not count. Swipes were added on 2026-09-26: a login
1023 navigated only by three-finger swipes left each restored window's first
1024 focus wherever it sat, often half off screen, while a second focus panned.
1025
1026 ### Fullscreen steps aside for focus
1027
1028 A fullscreen window lives in `layers.fullscreen`, above every desk window, so
1029 until 2026-10-01 focusing another window (the Super+Tab switcher,
1030 `focus-window`, a focus chord) moved the keyboard to a window nobody could
1031 see. Now `Window::fullscreen_yields` drops it to `layers.bottom` — behind
1032 every window, above the grid — whenever a desk window (Floating, Tiled,
1033 Utility, another Fullscreen; not its own dialogs, a popup or a status
1034 segment) is ahead of it in `focus_history`. It stays fullscreen: the client
1035 is not resized or told anything. Focusing it again puts it back on top. The
1036 predicate reads the MRU history, not live seat focus, so a launcher or the
1037 switcher opening (overlay UI never enters the history) does not pop it back
1038 over the window you switched to. `Seat::focus` dirties windowing whenever a
1039 fullscreen window exists, because the stacking pass that applies this only
1040 runs on a transaction.
1041
1042 **Stepped aside, it stays on the desk** (since 2026-10-03; before, it stayed
1043 pinned to the output, so a focus chord's pan left it fixed behind the new
1044 window like a backdrop). While fullscreen, `virtual_x/y` is the desk spot
1045 the window covers — set on the enter transition in `manage_finish` (after
1046 `saved_virtual_x/y` takes the restore position) and kept in step with the
1047 camera while the window is on top — and `place_fullscreen_windows`
1048 (`arrange_views`) draws a yielded one there at output size, scaled with the
1049 zoom, so the camera pans away from it. Focusing it again pans the camera to
1050 exactly that spot (`Window::fullscreen_anchor_pan`, from `focus_follow_pan`)
1051 and it rides the desk until the ease lands, then pins — no jump at either
1052 end. `save_state` records `saved_virtual_x/y` for a fullscreen window, as it
1053 did before the spot existed. **Overview treats it the same way**: there a
1054 fullscreen window is never on top (`fullscreen_on_top`), so it is a slab on
1055 the desk stacked behind every window even while focused (hover focuses it
1056 in overview, and pinning it then would cover the desk), and through any
1057 camera flight (`camera_ramp_anim`, an eased zoom) it flies with the desk.
1058 An overview exit onto it lands exactly on its spot — `exit_onto_window`
1059 centres its output-sized rect — and only then pins; `set_mode` dirties
1060 windowing while a fullscreen window exists so the restack runs.
1061
1062 **The spot survives a relaunch** (since 2026-10-06). `save_state` records
1063 it as `fullscreen_at` beside the pre-fullscreen `virtual_x/y` (still the
1064 spot it had when it left fullscreen, `last_fullscreen_at`, for a window
1065 closed windowed), and `try_restore` hands it to the window as
1066 `restore_fullscreen_at` — even for an `xwayland_hidpi_except` game, which
1067 takes nothing else from its entry. The first fullscreen enter lands there
1068 instead of on the view and eases the camera along
1069 (`pan_to_restored_fullscreen_spot`). Before, every enter took the view,
1070 so Trackmania opened wherever the user was looking. A camera pan relays
1071 out without a transaction, so `place_fullscreen_windows` schedules the
1072 save itself when a pinned spot moves. `last_window_states` is keyed on
1073 app_id AND program (`last_state_slot`) for the same game: every Proton
1074 program is `steam_proton`, and Ubisoft Connect, still up after the game
1075 closed, used to overwrite its entry. In a shadow, an X11 client named in
1076 `xwayland_hidpi_except` stands in (`mpv --vo=x11`, toggled with `xdotool
1077 windowstate --remove/--add FULLSCREEN`, since a fullscreen set before map
1078 sends no request).
1079
1080 ### xdg-activation
1081
1082 `handle_request_activate` (`server.rs`) runs for every activation wlroots
1083 accepts — the token was checked against a recent input serial or the
1084 requesting surface's focus. For a MAPPED window it now does what `ccectl
1085 focus-window` does: un-minimize, `seat.focus`, `raise_window`, dirty. Until
1086 2026-09-21 it only fired the "needs attention" D-Bus notification, so an
1087 activation for an already-mapped window changed nothing on screen. A request
1088 that lands before the map (Chromium/Electron activate a new window between
1089 its app_id and its first buffer, so the log reads `Restoring saved state` →
1090 `xdg activation request` → `Seat::focus`) is left to the map path, which
1091 focuses under its own settle rules. Every `Seat::focus` on a Floating window
1092 raises it, and `render_finish` keeps the floating plane above the tiled one
1093 in render-list order, so focus IS visibility for a float — `ccectl windows`
1094 prints `stack=N` (render-list position, higher is nearer) so that order can
1095 be asserted from a shadow without a screenshot.
1096
1097 Two hazards in `try_restore` bite a second toplevel of a running app, which
1098 the app_id-only third pass of `match_last_window_state` hands the main
1099 window's remembered entry (a transient is excluded, a parentless dialog is
1100 not): `minimized` is taken from a session entry only, never from a borrowed
1101 one — a dialog born minimized is focused, listed and invisible — and a
1102 Floating window whose borrowed origin coincides with a mapped sibling's is
1103 cascaded off it (`cascade_off_siblings`, 40 px diagonal steps). Reproduce
1104 either with `verify/clients` `float-pair` (one client, two parentless
1105 toplevels, the second activated before its first buffer) or a two-window
1106 Electron app; Chromium in a shadow needs `vkey hold` running first, since a
1107 headless seat has no keyboard and Chromium crashes in
1108 `xkb_state_update_mask` on a modifiers event that no keymap preceded.
1109
1110 ### IPC & status sockets
1111
1112 - **Control socket** `/tmp/cce-{WAYLAND_DISPLAY}.sock` (`ipc_server.rs`): line-oriented
1113 request/reply over a Unix socket. `ccectl` / `cce_ctl.rs` is the client.
1114 `read_command` frames a request (since 2026-10-02): the first line, ended
1115 by its newline, the client closing, or a 50 ms pause after some bytes
1116 (clients that send neither still work), at most 64 KiB — an overlong one
1117 is refused, never cut short and run as the old single 4 KiB `read` did —
1118 and a connection silent for 5 s is dropped. A NUL byte is refused (it
1119 reached xkbcommon's `CString::new(..).unwrap()` from `shortcut bind`), and
1120 `handle_ipc_event` runs each command under `catch_unwind`, since a panic
1121 unwinding out of that `extern "C"` callback aborts the whole session.
1122 Numbers parse through `parse_finite` (no NaN/inf into pointer or camera
1123 math) and injected swipe/pinch steps clamp to `MAX_INJECTED_STEPS`. The
1124 status and stream sockets read their subscription line through
1125 `read_line_bounded` (total deadline and size cap): a per-read timeout let a
1126 byte-a-second client hold the thread that serves every other subscriber.
1127 - **Status socket** `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` (`status_server.rs`): runs
1128 on its own thread; a client sends one subscription line (`layout`, `title`,
1129 `modifiers`, `dismiss`, …) and receives text lines on every
1130 change. This feeds the status bar (`cce-status-interface`). The main loop pushes
1131 updates through a `StatusSender` mpsc handle.
1132
1133 **`selection`** is a one-shot topic too, for the desktop grid alone: the
1134 overview drag-selection carrying its images pushes `move <id>:<x>:<y>
1135 ...` per step and `drop` at the release (see "Overview drag-selection").
1136
1137 **`clickaway`** is a one-shot topic (like `dismiss` and `shortcuts`): a
1138 `press` line for each button press that lands on NO X11 surface while some
1139 override-redirect X window is showing (`handle_button`). Xwayland only sees
1140 the pointer over its own surfaces, so an X11 popup — a Wine tray app's
1141 menu above all — never hears a press on a Wayland window and stays open;
1142 until 2026-09-26 only a click on one of the app's own X windows closed it.
1143 The tray bridge (`cce-status-interface`'s `cce-xembed-tray`) subscribes and
1144 closes the popup its forwarded click opened by addressing it a press just
1145 outside itself. The bridge's hidden icon containers have no scene tree, so
1146 they never count as showing.
1147
1148 **What may start a transaction.** `dirty_windowing()` schedules a full
1149 manage/arrange/render pass, and on an idle desktop the answer to "why is the
1150 window manager busy" is always some call site that dirties on a routine
1151 commit. Two were found on 2026-09-10 and gated: a status segment's *every*
1152 commit (`handle_window_commit`, now only when the surface size changed — the
1153 clock ticking once a second used to cost an arrange each time) and a title
1154 change (`notify_title`, now only when a mode rule matches on `title=`; the
1155 built-in policy is the only manager, `wm.object` is never bound, so nothing
1156 else in the manage sequence reads a title — the status bar's `title` topic
1157 and the state file are fed directly instead). `CCE_DIRTY_TRACE=1` logs one
1158 debug line per dirty call with its `#[track_caller]` site; it is the tool
1159 for this question, and costs nothing when unset. `CCE_DIRTY_BACKTRACE=1`
1160 adds a full backtrace per call (expensive). The state file is written by a
1161 one-shot timer (`schedule_save_state`, at most once a second) rather than
1162 on every transaction: `save_state` reads `/proc` for every window, and a
1163 drag is one transaction per pointer event. Each window's argv is cached by
1164 pid (`proc_args_cache`, pruned to live windows each save) — `proc_args`
1165 stats every `PATH` entry; only foot's shell cwd is read fresh. The
1166 unchanged check compares compact JSON, and a write goes to
1167 `state.json.tmp` and is renamed over, so a crash cannot truncate it.
1168
1169 **A forced grid rebuild runs with the grid tree disabled** (`draw_grid`,
1170 since 2026-10-06): every frame of a zoom flight resizes and moves every
1171 pooled cell rect and rim, and a scene setter on a live node re-walks the
1172 scene for what it touched, while under a disabled ancestor it returns at
1173 once — so the tree goes off around the rebuild and on after (6 overview
1174 flights in a shadow: 440-540 ms of compositor CPU -> 180-200 ms). **A
1175 refused commit schedules the next frame** (`render_and_commit`): the
1176 damage stays pending, but nothing else asked for a frame, so the EBUSY
1177 bursts the panel's commits hit left the screen stale until something
1178 else moved; the error is logged once per 10 s with a count.
1179
1180 **Per-frame work is gated too.** The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
1181 before the file is even looked for. The window-stream tick runs only while
1182 the stream hub has subscribers (the accept thread's eventfd arms it), and a
1183 failed tearing test is not repeated every frame of the same fullscreen
1184 episode. `Window::role()` and its `is_status_bar`/`is_grid`/`is_wallpaper`
1185 wrappers borrow the app id rather than allocating; keep it that way, they
1186 run several times per pointer-motion event.
1187
1188 **Blur re-renders only where damage reaches** (scenefx `apply_blur_region`,
1189 fixed 2026-09-11). `pixman_region32_intersect` returns allocation success,
1190 not "non-empty"; the vendored code tested that return, so every blur node
1191 counted as touched by every frame's damage and re-blurred — nine status
1192 segments cost ~1.3 ms of CPU per frame whenever anything on screen moved
1193 (measured: 1670 µs → 495 µs per frame with an animating client far from
1194 the bar). A node whose box lies within the blur sample size (2^(passes+1) ×
1195 radius = 80 px at the default 3/5) of the damage still re-blurs, as it must.
1196 `CCE_BLUR_DEBUG=1` logs each blur node render (`blur entry …`) and each
1197 compensation decision (`blur_region …`) — the tool for "why is this blur
1198 re-rendering". Known, not fixed: the *optimized* (cached) blur behind a
1199 translucent window is not re-baked when content beneath it changes, only on
1200 explicit camera/grid dirtying, so a video under a blurred window shows a
1201 frozen ghost; buffer commits never pass through `scene_node_update` with
1202 damage in this scenefx, which is the path the cache's dirtying hangs off.
1203
1204 **A bake near the output edge is a guess** (fixed 2026-10-01). Through a
1205 frozen pan (a swipe) each optimized node keeps its bake and only bakes the
1206 strips it newly shows (`optimized_blur_render`, anchored at
1207 `baked_x/baked_y` with `baked_region`). But a pixel baked within the blur's
1208 reach (sample size / output scale, ~40 layout px at scale 2) of an output
1209 edge sampled that edge's clamped pixels, not the backdrop beyond it — so a
1210 window that hung off the screen and was swiped on kept a seam of smeared
1211 grid along where the edge had been, up to the reach wide. Such pixels go
1212 into `edge_region` as well as `baked_region`, and are re-baked once a pan
1213 carries them clear of every edge (`optimized re-bake edge guess` under
1214 `CCE_BLUR_DEBUG=1`). A window sitting at the edge re-bakes nothing. To
1215 reproduce, the content just past the edge must differ from the content at
1216 it: a black cell on both sides blurs the same either way, which is why the
1217 first shadow attempts showed nothing. Put a grid gap just off screen.
1218
1219 **Status text contrast is backdrop compression** (`module { backdrop_compress }`
1220 in the bar's config, the minimum WCAG ratio its text must hold). A Wayland
1221 client cannot see what its translucent module boxes are composited over, so
1222 the compositor fixes the backdrop instead: `backdrop_compress_params`
1223 (`config.rs`) turns the ratio and the bar's `module { text_color }` into a
1224 luminance ceiling, `Window::sync_backdrop_compress` sets it on the segment's
1225 blur node and droplet lens (`wlr_scene_blur_set_compress` /
1226 `wlr_scene_droplet_set_compress`), and scenefx's `tex.frag` / `droplet.frag`
1227 (`compress_backdrop`) pull every backdrop pixel brighter than half the
1228 ceiling smoothly under it — or, for dark text, lift the shadows. It replaced
1229 (2026-10-01) the per-segment `backdrop` status topic, whose CPU geometry
1230 measurement and window-content readbacks ran every frame to feed a bar-side
1231 scrim.
1232
1233 ### Touchscreens
1234
1235 `src/server/touch.rs`. Until 2026-10-04 the seat never offered the touch
1236 capability, so no client ever bound `wl_touch` and a touchscreen did
1237 nothing. The seat now offers it while a touch device is attached
1238 (`Seat::touch_devices`, counted in `attach_device` / `detach_device`), and
1239 **each finger is routed once, at touch-down** (`touch::TouchRoute`, decided
1240 by `Cursor::touch_route_at`):
1241
1242 - **`Client`** — the surface under it belongs to a client that bound
1243 `wl_touch` (`wlr_surface_accepts_touch`: GTK, Qt, Chromium, Xwayland,
1244 foot, and every cce-ui app since cce-ui's `backend/touch.rs`) and the
1245 compositor is not in window-adjust mode. It gets real touch events,
1246 focus as a click would give, and the press-time menu dismissals
1247 (`press_dismissals`, shared with `handle_button`). Motion is mapped through
1248 the surface frame frozen at down (origin plus the scene buffer's scale,
1249 the pointer implicit grab's `grab_origin`/`grab_scale`), so a finger that
1250 slides off the window keeps reporting surface-local positions to it.
1251 - **`Pointer`** — everything else: a left button held where the finger is,
1252 run through the real `handle_button` and motion path, which is how the
1253 desktop and all the compositor's own presses — overview (where every
1254 finger takes this route, even over a touch client), the adjust handles,
1255 window body drags and the rubber band — work by finger with no touch path
1256 of their own. One finger at a time, since the pointer is singular. **The
1257 press waits** (since 2026-10-05): the pointer hovers at the down point,
1258 and the press lands there only once the finger moves past `TAP_SLOP`
1259 (10 px; then the drag follows) or lifts (a tap). That is what lets a
1260 second or third finger turn the touch into a gesture with no half-made
1261 click to take back.
1262 - **`Ignored`** — a second finger while one drives the pointer, or any
1263 finger while a real button or seat op holds it.
1264 - **`Claimed`** — owned by a gesture, below.
1265
1266 **Gestures** (since 2026-10-05, `touch::Claim`). While one is live every
1267 finger is the compositor's; fingers already given to clients get
1268 `wl_touch.cancel` when it begins.
1269
1270 - **Desk pan and zoom.** One finger dragged on the bare desk pans it, in
1271 normal mode (in overview it stays the selection band). A second finger
1272 joining a finger that went down on the desk — in either mode — makes it
1273 a two-finger pan that pinch-zooms about the midpoint
1274 (`queue_pan`/`queue_pinch`, as the trackpad's). The lift coasts on the
1275 pan's velocity, like a trackpad pan, unless the fingers had rested. Two
1276 fingers that start on a window are the app's (a browser's pinch-zoom).
1277 - **Three or four fingers**, anywhere, claim as the third lands (unless a
1278 pointer finger is mid-drag). `decide` waits for `DECIDE_TRAVEL` of
1279 centroid travel (a swipe) or a `DECIDE_SCALE` spread change (a pinch),
1280 counting the most fingers seen, so four fingers landing one at a time are
1281 a four-finger gesture. A swipe runs through the touchpad's own
1282 `handle_swipe_*` (`gesture_from_touch` keeps it off clients'
1283 pointer-gesture streams and past the trackpad's `gestures { swipe }`
1284 switch), so the `swipe3_*`/`swipe4_*` binds, the lean, repeat steps and
1285 focus aim all apply, with travel in layout px against the same
1286 thresholds. **A touchscreen swipe is natural**: the desk follows the
1287 fingers, so the bind that fires is the way the CAMERA goes — fingers
1288 dragging left fire `swipe3_right`, four fingers dragging up fire
1289 `swipe4_down`. A pinch fires the `pinch3_*`/`pinch4_*` binds
1290 (`pinch_hits`, the trackpad's thresholds) once.
1291 - **Edge swipes** bind like any gesture, in input.kdl:
1292 `overview (gesture)"edge_bottom"` (`config::parse_edge_gesture`;
1293 `edge_left|right|top|bottom`, the edge the finger starts from, optional
1294 modifiers). A first finger landing within `EDGE_ZONE` (24 px) of a
1295 screen edge — one no other output continues past — is held only when
1296 such a bind exists; `EDGE_FIRE` (60 px) inward fires it once. A finger
1297 that goes along the edge or back out is handed to the normal route from
1298 its down point (`edge_release`), one that lifts where it landed is
1299 delivered as the tap it was, late, and a second finger ends the edge
1300 claim the same way. So a bound `edge_top` delays every tap on the status
1301 bar's top 24 px until the lift.
1302
1303 A cancel on a `Pointer` finger releases the button if it was pressed (a
1304 stuck button is worse than a stray drop), and on a `Client` finger sends
1305 `wl_touch.cancel` (`river_wlr_seat_touch_cancel_point`, which voids that
1306 client's whole sequence, the protocol's unit). Unplugging the last
1307 touchscreen cancels any fingers still down. The cursor image goes away on
1308 touch-down (`Cursor::hidden_by_touch`; `set_xcursor` and
1309 `handle_request_set_cursor` both honour it) and real pointer motion brings
1310 it back (`unhide_after_touch`, which clears pointer focus so the client
1311 under it re-enters and sets its cursor again).
1312
1313 Drive it in a shadow with `ccectl touch down <id> <x> <y>`, `motion <id> <x>
1314 <y>`, `up <id>`, `cancel <id>` and `tap <x> <y>` (layout pixels; several ids
1315 down at once are several fingers). The first use sets
1316 `Seat::touch_injected`, which offers the capability as a touchscreen would,
1317 so `weston-simple-touch` under `WAYLAND_DEBUG=1` shows the `Client` route's
1318 `wl_touch` traffic — and the `cancel` a third finger sends it. A shadow's
1319 input.kdl is its own copy: add `edge_*`/`pinch3_*` binds there to try them.
1320
1321 **The on-screen keyboard follows a touched field** (`osk.rs`, since
1322 2026-10-05). A finger on an app's window (`note_touch` at a `Client` down
1323 or lift, or a `Pointer` tap's lift; never a layer surface, so taps on the
1324 board itself do not count) arms it for `TOUCH_WINDOW` (800 ms), and the
1325 first text-input-v3 enable or commit inside that window spends the touch and
1326 runs `cce-keyboard show`. When the field goes (`disable_text_input`: a
1327 disable, a destroy, or focus moving), `cce-keyboard hide` runs after
1328 `HIDE_DELAY_MS` (250 ms) — cancelled by any enable, so moving field to field
1329 keeps one board — and only if this module showed it, so a board summoned by
1330 Super+O stays. Off with `window_manager { osk_on_touch (bool)false }`. It
1331 needed the relay to **enter text inputs without an input method**:
1332 `InputRelay::focus` used to send `enter` only when one was registered
1333 (river's rule), so no client ever enabled a field. It now enters always, a
1334 refocus of the same surface is no longer a leave (it was an `assert`), a
1335 text input bound after its client took focus is entered at creation, and an
1336 input method arriving or leaving no longer re-runs focus. Shadow check:
1337 `ctl touch tap` on a cce-gallery TextBox brings `cce-keyboard show` up;
1338 `pointer-click` on it does not.
1339
1340 ### Portal global shortcuts
1341
1342 A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
1343 `GlobalShortcuts` interface for one (1Password's Quick Access does), and the
1344 portal frontend hands that to a backend. `../cce-shortcuts-portal` is that
1345 backend and **`src/server/global_shortcuts.rs` is this side of it** — a
1346 table of `(session, id, mods, keysym)` on the window manager
1347 (`portal_shortcuts`) with a control-socket command to fill it and a status
1348 topic to report it:
1349
1350 - `shortcut bind <session> <id> <trigger>` parses a shortcuts-spec trigger
1351 (`CTRL+SHIFT+space`; modifiers `CTRL`/`ALT`/`SHIFT`/`LOGO`, key an xkb
1352 keysym name) and replies `ok <trigger_description>` (`Ctrl+Shift+Space`)
1353 or `error: …`. A chord in `keybinds` is refused — the user's config owns
1354 it — as is one another session already holds. `unbind <session> [<id>]`,
1355 `clear` and `list` are the rest. Nothing is persisted; the backend sends
1356 `clear` when it starts.
1357 - The chord is matched in `handle_group_key` after the builtins and the
1358 config keybinds, through the same two-level keysym lookup
1359 (`keyboard_group::match_chord`, which `match_cce_keybind` now wraps), as
1360 `KeyConsumer::PortalShortcut`. Press AND release are pushed as one-shot
1361 lines on the status socket's `shortcuts` topic —
1362 `activated|deactivated <session> <id> <time_msec>` — since the portal has
1363 a `Deactivated` signal; neither edge reaches the client.
1364
1365 The compositor never learns which app asked: the session object path is
1366 the only identity it carries, and it is one whitespace-free token, which is
1367 why ids come percent-encoded (`Quick%20Access`) and stay that way here.
1368 Drive it in a shadow with `ccectl shortcut bind /s/1 x CTRL+SHIFT+space`
1369 and `verify/clients`' `vkey mod:5 57` — not `ccectl keypress`, which goes
1370 straight to the focused client and never meets the chord matcher.
1371
1372 ## Conventions
1373
1374 - This is systems FFI code: raw pointers, `unsafe`, and manual wlroots listener wiring
1375 are the norm. When adding a wlroots event handler, follow the existing pattern —
1376 embed a `wl_listener`, register it, and recover `self` with `container_of!`.
1377 - Keep river's SPDX/copyright headers on files that carry them.
1378 - `scratch/` and `scratch/*` (and the many `.png`/`.log`/`patch*.py` files in the
1379 parent dir) are ad-hoc debugging artifacts, not part of the build.