git.lucas.co / cce-compositor
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.