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.