git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

CLAUDE.md (15.4K)

  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 crate is
  6 
  7 `cce-window-manager` is the **pure-Rust window-management policy layer** of the cce
  8 Wayland desktop, extracted from the `cce` compositor crate (`cce-fx`). It contains
  9 no FFI, no wlroots pointers, and only two dependencies (`log`, `serde`). The
 10 compositor is its sole consumer: it depends on this crate by path and re-exports it
 11 as `crate::policy` / `crate::tiling` / `crate::slotmap`.
 12 
 13 The governing split is **policy vs. mechanism**:
 14 
 15 - **Policy (this crate)** decides placement, focus, decoration, and background —
 16   as pure functions over plain-data snapshots.
 17 - **Mechanism (the `cce` compositor)** owns the scene graph, seats, shells, and
 18   sockets. It builds snapshots from FFI state, calls into this crate, and applies
 19   the returned plans.
 20 
 21 Nothing here does I/O. Even `state.rs` (persisted session state for
 22 `~/.local/state/cce/state.json`) is serialization/matching only — the save/load
 23 I/O lives in the compositor's `window_manager.rs`.
 24 
 25 ## Version control
 26 
 27 This directory is its **own git repository**, sitting side-by-side with the other
 28 `cce-*` crates to form an uncommitted build workspace at the parent directory. Its
 29 `origin` is the local *bare* repo `~/git/cce-window-manager.git`, a real pushable
 30 remote: **committing is not publishing — `git push origin main` is**, after which
 31 `gitsite.timer` mirrors it to `https://git.lucas.co/cce-window-manager.git` (kept as the
 32 `published` remote; it is the old static mirror and never accepted a push).
 33 Commit here, not at the workspace root. The crate must **build standalone** — no
 34 `workspace = true` dependency inheritance; versions are declared in this
 35 `Cargo.toml`.
 36 
 37 ## Commands
 38 
 39 ```sh
 40 cargo build                      # standalone build (fast; no compositor deps)
 41 cargo test                       # run all tests (~175 unit tests, all in-crate)
 42 cargo test snap::                # tests in one module
 43 cargo test -p cce-window-manager # same, from the workspace root
 44 ```
 45 
 46 Because this crate is pure Rust, building/testing it never triggers the
 47 compositor's native `build.rs` pipeline — prefer working here directly when the
 48 change is policy-side.
 49 
 50 Tests live in `#[cfg(test)]` modules at the bottom of the module they cover —
 51 every module has one except `api.rs` (plain-data vocabulary) and `lib.rs`
 52 (module declarations only). This crate is where the DE's testable logic is concentrated —
 53 placement/snapping changes should come with unit tests (the existing test
 54 modules show the style: small numeric scenarios with worked-out expectations in
 55 comments).
 56 
 57 ## Architecture
 58 
 59 ### The arrange pass: snapshot → plan → apply (`arrange.rs`)
 60 
 61 The core of the crate. The compositor builds `WindowSnapshot`s / `OutputSnapshot`s
 62 / `ArrangeParams` once per frame (seat-dependent answers like `being_moved` and
 63 `active_resize` are captured into the snapshot so the pure pass never queries
 64 mid-computation), then `arrange()` returns an `ArrangePlan` of per-window
 65 `WindowPlan` write instructions. Every `WindowPlan` field is an `Option`: `None`
 66 means "leave untouched", so the mechanism apply loop is a flat sequence of
 67 `if let Some` writes.
 68 
 69 Key conventions inside the pass:
 70 
 71 - The output loop is **last-wins**: every output pass re-plans every window, so
 72   with multiple outputs the final plan reflects the last one (mirroring the
 73   mechanism loop it replaced).
 74 - `classify_window()` maps each window to a `WindowClass`
 75   (`Background`/`StatusBar`/`Grid`/`Hidden`/`Overlay`/`Normal`); an Overlay
 76   window mid-drag arranges as Normal, and `Grid` is the world-anchored grid
 77   client, placed at its patch's virtual origin with scale `zoom / patch.scale`.
 78 - Placement is composed from per-section pure functions — `compute_usable_area`,
 79   `place_overlay_window`, `place_normal_window`, `layout_status_bars`,
 80   `tiled_transition` — each individually callable and tested.
 81 - `tiled_transition()` is a state-machine step (Enter saves restore geometry,
 82   Exit restores it); the saved state itself lives on the mechanism side. It was
 83   `maximized_transition` until the mode it steps was renamed `Maximized` →
 84   `Tiled` (see `tiling.rs` below).
 85 
 86 ### The `Policy` / `Compositor` trait boundary (`api.rs` + `actions.rs`)
 87 
 88 `api.rs` defines the plain-data vocabulary (`WindowId`, `WindowRole`, `Action`,
 89 `Rect`, `ActionCtx`, `Command`, `DecorationSpec`, `EffectSpec`,
 90 `BackgroundSpec`, …) and two traits, **snapshot-style** (the arrange-pass
 91 convention — the mechanism owns all state): `Policy::action(ctx, action) ->
 92 Vec<Command>` decides against a mechanism-built `ActionCtx` snapshot, and
 93 `Compositor::apply(cmd)` (implemented by the compositor's `WindowManager`)
 94 executes one command at a time. `actions.rs` holds `DefaultPolicy`, the live
 95 `Policy` impl: the camera actions (keyed zoom, cell-aligned pans, View jumps,
 96 SetViewport sends, Overview both directions) are decided there; an **empty
 97 command list means "not mine"** and the compositor falls through to its legacy
 98 arms. New flows grow snapshot methods here only alongside a real mechanism
 99 caller — no speculative signatures. Effects are declarative on purpose: new
100 scenefx capabilities extend `EffectSpec` without changing either trait.
101 `WindowRole::from_app_id()` is the single place the special app_id conventions
102 (`cce-wallpaper`, `cce-status*`) are interpreted.
103 
104 ### Grid snapping (`snap.rs`)
105 
106 Magnetic snapping math for interactive move/resize, the hard grid snap for
107 `Tiled` windows (`tiled_span`), and `is_cell_aligned` — the geometric test
108 that decides whether a window IS tiled (every content edge on a visible cell
109 edge). Conventions that everything here assumes:
110 
111 - Coordinates are **virtual-surface content coordinates**.
112 - Snapping is **border-inclusive**: the border's *outer* edge lands on the snap
113   target (content is inset by `border_width`).
114 - Targets are the **visible cell edges**, not raw grid lines: the desktop grid
115   has period `cell_size + gap_width` and each cell fades inward by `cell_inset`,
116   so left/top edges snap to `k*period + inset` and right/bottom edges to
117   `k*period + cell_size - inset`.
118 - `resize_axis()` is the **single source** of interactive-resize sizing — both
119   the compositor's seat op and the arrange snapshot derive sizes from it, so a
120   snapped result can't be overridden by an unsnapped recomputation. Don't add a
121   second place that computes resize sizes.
122 
123 ### Keybindings (`bindings.rs`)
124 
125 The crate owns what a binding *means*; the compositor owns the physical half
126 (reading `~/.config/cce/input.kdl`, XKB keysym lookup, key delivery).
127 
128 - `Action::name()` / `Action::from_name()` (in `api.rs`) — the canonical
129   snake_case action names users write in the `cce-window-manager` domain of
130   `input.kdl`, plus legacy aliases (`close`, `fullscreen`, `expose`, `toggle_overview`).
131   `overview` toggles; `overview_enter` / `overview_exit` are its one-way
132   halves, for users who want a key per direction. Asking for the mode you
133   are already in returns no commands — a deliberate no-op, since the
134   mechanism has no legacy arm for either action to fall through to. Like
135   the zoom chords, both ship unbound. `overview_exit` lands on the FOCUSED
136   window, where the toggle lands on the hovered one: a key press carries no
137   cursor position, so the pointer's resting place is not evidence of where
138   the user meant to go.
139 - `move_window_left` / `_right` / `_up` / `_down` (`actions::move_window`) step
140   the focused window one grid period. A **tiled** window (`move_tiled`) swaps
141   with the tiled window already holding the destination: an aligned window
142   stays aligned without snapping, and occupancy is a rect overlap against the
143   destination rather than a cell-index comparison — equivalent, and it needs
144   nothing added to `ActionCtx`. A swap exchanges origins, not boxes, so each
145   window keeps its size. A **floating** window (`move_floating`) just moves by
146   the same period and covers whatever is there — floating windows overlap
147   freely, so there is nothing to swap with; it is also the keyboard's only
148   way to bring a floating window back on screen after a restore parks it off
149   the viewport. Declines (no commands, hence a no-op) when nothing is focused,
150   the focused window is in any other mode (Fullscreen, the internal roles),
151   the grid is degenerate, or — tiled only — MORE than one tiled window is in
152   the destination, where "swap with it" names no particular window. Unbound
153   by default; reachable as `ccectl move-window-left` etc., the relative
154   counterpart to `ccectl move-window <square>`.
155 - `parse_chord("super+shift+h")` — strict chord grammar; the key stays an XKB
156   keysym *name* (`Chord.key: String`) because name→code lookup needs xkbcommon.
157 - `BindingTable` — insertion order is priority order (`resolve` = first match,
158   mirroring the compositor's dispatch loop). `add` warns-by-return on shadowing;
159   `add_default` never shadows. The compositor loads input.kdl entries first,
160   then legacy config.kdl bindings, then `DEFAULT_BINDINGS`.
161 - The compositor re-exports `bindings::Binding` as `crate::config::Keybind`.
162 
163 ### Supporting modules
164 
165 - `background.rs` — per-frame desktop-grid geometry: `grid_frame(spec, cam,
166   viewport, output)` → `GridFrame` (modulo tree shift for the infinite grid,
167   backdrop extent, density-faded cell lattice with safety caps). The
168   compositor's `output.rs` keeps the scene rects/pool and scenefx encodings;
169   `Layout::background_spec()` builds the `api::GridSpec`.
170 - `cells.rs` — chess-style addressing for desktop-grid squares: the origin
171   square is `A1`, letters run right and numbers run DOWN, both 1-based with
172   no zero (`-A1` is left of the origin, `A-1` above it, columns past Z carry
173   on Excel-style). Provides `square_label`/`parse_square`, `square_rect` /
174   `block_rect`, `window_span` (which squares a window covers) and
175   `remap_block` (re-tile a block across a grid-geometry change). Its grid
176   math must agree with `snap.rs` exactly — same virtual-surface content
177   coordinates, same period/inset — or a "tiled" window would not land on a
178   named square.
179 - `spawn.rs` — where a window launched *at* a square should land, as opposed
180   to reopening where it last was. `place_at_cell` keeps the invocation square
181   as one of the block's corners and picks WHICH corner by growing away from
182   what is already there (top-left preferred, then the others, scored by
183   collisions first and off-screen area second); `nearest_free` then steps a
184   block off anything still occupying it. It never searches for somewhere
185   else to be, so the result stays predictable.
186 - `camera.rs` — viewport pan/zoom math (`Camera` = pan_x/pan_y/zoom):
187   `zoom_about_anchor` (wheel zoom at cursor, keyed zoom at viewport center),
188   `center_on`, `fit_bounds` (overview fit), `pan_into_view` (focus-follow:
189   the MINIMAL pan that brings a window fully into view, its corrected edge
190   landing `VIEW_MARGIN` in — since 2026-09-12 it replaces a
191   `FOCUS_VISIBLE_THRESHOLD` rule that centered anything less than
192   three-quarters visible, throwing away the spatial relationship the user had
193   just navigated by), `visible_fraction` (now feeding `recalled_origin`, not
194   focus), `is_overview`. The
195   mechanism owns the actual fields and animation; these are pure maps.
196   `recalled_origin` decides where a remembered FLOATING window reopens: its
197   remembered origin when at least `RESTORE_VISIBLE_MIN` (a quarter) of it
198   would be in view, or when it is `on_tiled_desk` (overlaps the tiled
199   windows' bounding box inflated by one viewport — the caller passes the
200   box, `None` for no tiled windows), else centered in the current view — a
201   floating window a screen away from the camera AND from every tiled window
202   is lost, not remembered; one parked beside a column is placed. Tiled
203   windows are the grid's and never go through it.
204 - `ramp.rs` — speed-ramp evaluation for duration-based camera transitions.
205   `SpeedRamp::from_spec` parses the DE-wide ramp spec string cce-ui's Ramp
206   widget writes and integrates that SPEED profile into a cumulative
207   `progress(t)` curve normalized to end at exactly 1 (so any profile arrives
208   on target; zero-speed segments read as dwell, an all-zero ramp yields
209   `None` and callers fall back to their non-ramp animation). The parser and
210   interpolation are deliberately MIRRORED from cce-ui rather than shared —
211   this crate stays dependency-minimal — so the two must be kept in step.
212 - `focus.rs` — directional focus selection (`directional_focus` over window
213   footprints (`Rect`) in virtual coordinates: a candidate's near edge must be
214   past the focused window's midpoint, same-row/column candidates win over
215   off-axis ones, then the smallest edge gap; no wraparound). Edges, not
216   centers: a wide window directly above a narrow one has a center to its
217   right, and a center rule used to focus it on "right". Consumed by the
218   compositor's `FocusUp/Down/Left/Right` action
219   arm; default chords are super+k/j/h/l via `bindings::DEFAULT_BINDINGS`.
220 - `pan.rs` — cell-aligned viewport panning: `aligned_step` gives the keyed
221   PanLeft/… actions their animation targets (pan offsets that are multiples
222   of the grid period).
223 - `tiling.rs` — `TilingMode` enum: a window is `Floating` or `Tiled` (all
224   content edges on visible desktop-grid cell edges; tiled windows report the
225   xdg maximized state), plus `Fullscreen` and the internal `Popup` /
226   `Overlay` / `Status` / `Utility` roles. `Utility` is `Status` minus the
227   docking: a tool window whose shape its own contents decide, floating and
228   movable like any window but offered no resize affordance and given no saved
229   geometry. It is never inferred from a sizing hint — only the client declares
230   it, via `set_utility` on the cce window-management protocol. Serialized into
231   saved state — serde aliases map the retired names (`Cascade`/`Grid` →
232   `Floating`, `Maximized` → `Tiled`); keep aliases when renaming variants.
233 - `overview.rs` — overview-mode move rules: `displace` relocates windows a
234   drag covers (past an overlap threshold) to the side the drag vacated,
235   called by the mechanism on every motion event of an overview move.
236 - `query.rs` — window-query resolution: how a user-supplied query string
237   (`ccectl focus-window` / `center-window`, window-stream subscriptions)
238   picks a window. An all-numeric query is tried as an exact window id first,
239   then matched case-insensitively against app_ids with exact beating
240   substring. The mechanism supplies the candidates (mapped windows, in
241   window order); this module owns only the matching rules.
242 - `state.rs` — `SavedState` / `SavedWindowState` serde types, plus
243   `SavedGrid`: the grid the file's geometries were measured under, so a
244   session under a different grid re-tiles Tiled entries onto their squares
245   (`cells::remap_block`) instead of growing them from misaligned pixels.
246   New fields need `#[serde(default)]` to keep old state files loadable.
247 - `slotmap.rs` — generational-index map (river-derived, 0BSD-licensed — keep the
248   SPDX header). `api::WindowId` wraps its `Key`.
249 
250 ## Hard constraints
251 
252 - **No FFI, no I/O, no compositor types.** If a change needs scene-graph access,
253   a socket, or a file, that half belongs in the `cce` compositor; this crate gets
254   the pure computation and plain-data types.
255 - Dependencies are intentionally minimal (`log`, `serde`). Adding one is a design
256   decision, not a convenience.
257 - When policy code needs a new fact about a window/output, add it to the
258   snapshot structs and have the mechanism fill it in — never query back into the
259   compositor from inside the pure pass.