window management library
git clone https://git.lucas.co/cce-window-manager.git
feat: bindings module — keybinding vocabulary for the wm domain
Action::name()/from_name() define the canonical snake_case action names
(plus legacy aliases) users write in input.kdl's cce-window-manager
domain. bindings.rs adds the chord grammar (parse_chord, keys stay XKB
keysym names), the resolved BindingTable (insertion order = priority,
add_default never shadows), and DEFAULT_BINDINGS — the stock fallback
set previously hardcoded in the compositor's config loader.
Also adds CLAUDE.md documenting the crate.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PXSsCppeDFmSRRM5NAug5a
CLAUDE.md | 143 ++++++++++++++++++++++++++++++++++++
src/api.rs | 85 +++++++++++++++++++++
src/bindings.rs | 223 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/lib.rs | 4 +
4 files changed, 455 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..70626de
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,143 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this crate is
+
+`cce-window-manager` is the **pure-Rust window-management policy layer** of the cce
+Wayland desktop, extracted from the `cce` compositor crate (`cce-fx`). It contains
+no FFI, no wlroots pointers, and only two dependencies (`log`, `serde`). The
+compositor is its sole consumer: it depends on this crate by path and re-exports it
+as `crate::policy` / `crate::tiling` / `crate::slotmap`.
+
+The governing split is **policy vs. mechanism**:
+
+- **Policy (this crate)** decides placement, focus, decoration, and background —
+ as pure functions over plain-data snapshots.
+- **Mechanism (the `cce` compositor)** owns the scene graph, seats, shells, and
+ sockets. It builds snapshots from FFI state, calls into this crate, and applies
+ the returned plans.
+
+Nothing here does I/O. Even `state.rs` (persisted session state for
+`~/.local/state/cce/state.json`) is serialization/matching only — the save/load
+I/O lives in the compositor's `window_manager.rs`.
+
+## Version control
+
+This directory is its **own git repository**
+(codeberg.org/lsgalante/cce-window-manager), cloned side-by-side with the other
+`cce-*` crates to form an uncommitted build workspace at the parent directory.
+Commit here, not at the workspace root. The crate must **build standalone** — no
+`workspace = true` dependency inheritance; versions are declared in this
+`Cargo.toml`.
+
+## Commands
+
+```sh
+cargo build # standalone build (fast; no compositor deps)
+cargo test # run all tests (38 unit tests, all in-crate)
+cargo test snap:: # tests in one module
+cargo test -p cce-window-manager # same, from the workspace root
+```
+
+Because this crate is pure Rust, building/testing it never triggers the
+compositor's native `build.rs` pipeline — prefer working here directly when the
+change is policy-side.
+
+Tests live in `#[cfg(test)]` modules inside `arrange.rs`, `snap.rs`, and
+`slotmap.rs`. This crate is where the DE's testable logic is concentrated —
+placement/snapping changes should come with unit tests (the existing test
+modules show the style: small numeric scenarios with worked-out expectations in
+comments).
+
+## Architecture
+
+### The arrange pass: snapshot → plan → apply (`arrange.rs`)
+
+The core of the crate. The compositor builds `WindowSnapshot`s / `OutputSnapshot`s
+/ `ArrangeParams` once per frame (seat-dependent answers like `being_moved` and
+`active_resize` are captured into the snapshot so the pure pass never queries
+mid-computation), then `arrange()` returns an `ArrangePlan` of per-window
+`WindowPlan` write instructions. Every `WindowPlan` field is an `Option`: `None`
+means "leave untouched", so the mechanism apply loop is a flat sequence of
+`if let Some` writes.
+
+Key conventions inside the pass:
+
+- The output loop is **last-wins**: every output pass re-plans every window, so
+ with multiple outputs the final plan reflects the last one (mirroring the
+ mechanism loop it replaced).
+- `classify_window()` maps each window to a `WindowClass`
+ (`Background`/`StatusBar`/`Hidden`/`Overlay`/`Normal`); an Overlay window
+ mid-drag arranges as Normal.
+- Placement is composed from per-section pure functions — `compute_usable_area`,
+ `place_overlay_window`, `place_normal_window`, `layout_status_bars`,
+ `maximized_transition` — each individually callable and tested.
+- `maximized_transition()` is a state-machine step (Enter saves restore geometry,
+ Exit restores it); the saved state itself lives on the mechanism side.
+
+### The `Policy` / `Compositor` trait boundary (`api.rs`)
+
+`api.rs` defines the plain-data vocabulary (`WindowId`, `WindowRole`, `Action`,
+`Rect`, `DecorationSpec`, `EffectSpec`, `BackgroundSpec`, …) and two traits:
+`Policy` (events from mechanism → policy) and `Compositor` (commands from policy
+→ mechanism). **Skeleton status: nothing implements these traits yet** — the
+migration plan is to route the compositor's window lifecycle, input actions, and
+animation tick through them. Effects are declarative on purpose: new scenefx
+capabilities extend `EffectSpec` without changing either trait.
+`WindowRole::from_app_id()` is the single place the special app_id conventions
+(`cce-wallpaper`, `cce-status*`) are interpreted.
+
+### Grid snapping (`snap.rs`)
+
+Magnetic snapping math for interactive move/resize, plus the hard grid snap for
+`Maximized` windows. Conventions that everything here assumes:
+
+- Coordinates are **virtual-surface content coordinates**.
+- Snapping is **border-inclusive**: the border's *outer* edge lands on the snap
+ target (content is inset by `border_width`).
+- Targets are the **visible cell edges**, not raw grid lines: the desktop grid
+ has period `cell_size + gap_width` and each cell fades inward by `cell_inset`,
+ so left/top edges snap to `k*period + inset` and right/bottom edges to
+ `k*period + cell_size - inset`.
+- `resize_axis()` is the **single source** of interactive-resize sizing — both
+ the compositor's seat op and the arrange snapshot derive sizes from it, so a
+ snapped result can't be overridden by an unsnapped recomputation. Don't add a
+ second place that computes resize sizes.
+
+### Keybindings (`bindings.rs`)
+
+The crate owns what a binding *means*; the compositor owns the physical half
+(reading `~/.config/cce/input.kdl`, XKB keysym lookup, key delivery).
+
+- `Action::name()` / `Action::from_name()` (in `api.rs`) — the canonical
+ snake_case action names users write in the `cce-window-manager` domain of
+ `input.kdl`, plus legacy aliases (`close`, `fullscreen`, `toggle_overview`).
+- `parse_chord("super+shift+h")` — strict chord grammar; the key stays an XKB
+ keysym *name* (`Chord.key: String`) because name→code lookup needs xkbcommon.
+- `BindingTable` — insertion order is priority order (`resolve` = first match,
+ mirroring the compositor's dispatch loop). `add` warns-by-return on shadowing;
+ `add_default` never shadows. The compositor loads input.kdl entries first,
+ then legacy config.kdl bindings, then `DEFAULT_BINDINGS`.
+- The compositor re-exports `bindings::Binding` as `crate::config::Keybind`.
+
+### Supporting modules
+
+- `tiling.rs` — `TilingMode` enum (serialized into saved state — renaming
+ variants breaks `state.json` compatibility) and the cascade/grid/fullscreen
+ tiling formulas.
+- `state.rs` — `SavedState` / `SavedWindowState` serde types. New fields need
+ `#[serde(default)]` to keep old state files loadable.
+- `slotmap.rs` — generational-index map (river-derived, 0BSD-licensed — keep the
+ SPDX header). `api::WindowId` wraps its `Key`.
+
+## Hard constraints
+
+- **No FFI, no I/O, no compositor types.** If a change needs scene-graph access,
+ a socket, or a file, that half belongs in the `cce` compositor; this crate gets
+ the pure computation and plain-data types.
+- Dependencies are intentionally minimal (`log`, `serde`). Adding one is a design
+ decision, not a convenience.
+- When policy code needs a new fact about a window/output, add it to the
+ snapshot structs and have the mechanism fill it in — never query back into the
+ compositor from inside the pure pass.
diff --git a/src/api.rs b/src/api.rs
index 0a2f0ee..31b9358 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -81,6 +81,91 @@ pub enum Action {
PanDown,
}
+impl Action {
+ /// Canonical snake_case name — what users write in the
+ /// `cce-window-manager` domain of `input.kdl`.
+ pub fn name(&self) -> &'static str {
+ match self {
+ Action::None => "none",
+ Action::Spawn => "spawn",
+ Action::Toggle => "toggle",
+ Action::Close => "close_window",
+ Action::FocusNext => "focus_next",
+ Action::FocusPrev => "focus_prev",
+ Action::WindowSwitcher => "window_switcher",
+ Action::Move => "move",
+ Action::Resize => "resize",
+ Action::Exit => "exit",
+ Action::Reload => "reload",
+ Action::Fullscreen => "toggle_fullscreen",
+ Action::LayoutNext => "layout_next",
+ Action::ModeNext => "mode_next",
+ Action::ModeNextShared => "mode_next_shared",
+ Action::View1 => "view_1",
+ Action::View2 => "view_2",
+ Action::View3 => "view_3",
+ Action::View4 => "view_4",
+ Action::SetViewport1 => "set_viewport_1",
+ Action::SetViewport2 => "set_viewport_2",
+ Action::SetViewport3 => "set_viewport_3",
+ Action::SetViewport4 => "set_viewport_4",
+ Action::Expose => "expose",
+ Action::Minimize => "minimize",
+ Action::OverlayLeft => "overlay_left",
+ Action::OverlayRight => "overlay_right",
+ Action::ZoomIn => "zoom_in",
+ Action::ZoomOut => "zoom_out",
+ Action::ZoomReset => "zoom_reset",
+ Action::PanLeft => "pan_left",
+ Action::PanRight => "pan_right",
+ Action::PanUp => "pan_up",
+ Action::PanDown => "pan_down",
+ }
+ }
+
+ /// Inverse of `name()`, plus aliases from the old `config.kdl`
+ /// vocabulary (`close`, `fullscreen`, `toggle_overview`).
+ pub fn from_name(name: &str) -> Option<Action> {
+ Some(match name.trim() {
+ "none" => Action::None,
+ "spawn" => Action::Spawn,
+ "toggle" => Action::Toggle,
+ "close_window" | "close" => Action::Close,
+ "focus_next" => Action::FocusNext,
+ "focus_prev" => Action::FocusPrev,
+ "window_switcher" => Action::WindowSwitcher,
+ "move" => Action::Move,
+ "resize" => Action::Resize,
+ "exit" => Action::Exit,
+ "reload" => Action::Reload,
+ "toggle_fullscreen" | "fullscreen" => Action::Fullscreen,
+ "layout_next" => Action::LayoutNext,
+ "mode_next" => Action::ModeNext,
+ "mode_next_shared" => Action::ModeNextShared,
+ "view_1" => Action::View1,
+ "view_2" => Action::View2,
+ "view_3" => Action::View3,
+ "view_4" => Action::View4,
+ "set_viewport_1" => Action::SetViewport1,
+ "set_viewport_2" => Action::SetViewport2,
+ "set_viewport_3" => Action::SetViewport3,
+ "set_viewport_4" => Action::SetViewport4,
+ "expose" | "toggle_overview" => Action::Expose,
+ "minimize" => Action::Minimize,
+ "overlay_left" => Action::OverlayLeft,
+ "overlay_right" => Action::OverlayRight,
+ "zoom_in" => Action::ZoomIn,
+ "zoom_out" => Action::ZoomOut,
+ "zoom_reset" => Action::ZoomReset,
+ "pan_left" => Action::PanLeft,
+ "pan_right" => Action::PanRight,
+ "pan_up" => Action::PanUp,
+ "pan_down" => Action::PanDown,
+ _ => return None,
+ })
+ }
+}
+
#[derive(Debug, Clone)]
pub struct WindowInfo {
pub app_id: String,
diff --git a/src/bindings.rs b/src/bindings.rs
new file mode 100644
index 0000000..7fb7826
--- /dev/null
+++ b/src/bindings.rs
@@ -0,0 +1,223 @@
+// Keybinding vocabulary and resolution for the window-manager domain.
+//
+// This crate owns what a binding MEANS: the action names users write in
+// `input.kdl` (`Action::from_name`), the chord grammar ("super+shift+h"),
+// the resolved table, and the stock defaults. The mechanism side owns the
+// physical half: reading the file, XKB keysym lookup, and key delivery.
+//
+// A `Chord` keeps its key as an XKB keysym NAME — resolving names to keysym
+// codes needs xkbcommon, so the compositor does that and this crate only
+// ever sees the resulting `u32`.
+
+use super::api::Action;
+
+/// Modifier bitmask values (river seat conventions — the same values the
+/// compositor has always packed into its keybind masks).
+pub mod mods {
+ pub const SHIFT: u32 = 0x01;
+ pub const CTRL: u32 = 0x04;
+ pub const ALT: u32 = 0x08;
+ pub const SUPER: u32 = 0x40;
+}
+
+fn mod_from_name(name: &str) -> Option<u32> {
+ match name {
+ "shift" => Some(mods::SHIFT),
+ "ctrl" | "control" => Some(mods::CTRL),
+ "alt" | "mod1" | "meta" => Some(mods::ALT),
+ "super" | "mod4" | "logo" | "win" => Some(mods::SUPER),
+ _ => None,
+ }
+}
+
+/// A parsed key chord: modifier mask plus the key's XKB keysym name.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Chord {
+ pub mods: u32,
+ pub key: String,
+}
+
+/// Parse `"super+shift+h"` → mods SUPER|SHIFT, key `"h"`. The last segment
+/// is the key (an XKB keysym name, e.g. `slash`, `equal`, `Left`); every
+/// segment before it must be a known modifier. Strict on purpose: a typo'd
+/// modifier returns `None` so the loader can warn, instead of silently
+/// binding the wrong chord.
+pub fn parse_chord(s: &str) -> Option<Chord> {
+ let mut mods = 0u32;
+ let mut segments = s.split('+').map(str::trim);
+ let key = segments.next_back()?;
+ if key.is_empty() {
+ return None;
+ }
+ for seg in segments {
+ mods |= mod_from_name(&seg.to_lowercase())?;
+ }
+ Some(Chord { mods, key: key.to_string() })
+}
+
+/// One resolved binding: chord (mods + keysym code) → action, with the
+/// command argument for `Spawn`/`Toggle`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Binding {
+ pub mods: u32,
+ pub keysym: u32,
+ pub action: Action,
+ pub command: Option<String>,
+}
+
+/// The resolved binding table. Insertion order is priority order: `resolve`
+/// returns the first match, so load primary sources before fallbacks and
+/// use `add_default` for anything that must not shadow what's already there.
+#[derive(Debug, Clone, Default)]
+pub struct BindingTable {
+ bindings: Vec<Binding>,
+}
+
+impl BindingTable {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn contains(&self, mods: u32, keysym: u32) -> bool {
+ self.bindings.iter().any(|b| b.mods == mods && b.keysym == keysym)
+ }
+
+ /// Push unconditionally. Returns `true` if an earlier binding already
+ /// claims the chord (the new one is shadowed) so the caller can warn.
+ pub fn add(&mut self, binding: Binding) -> bool {
+ let shadowed = self.contains(binding.mods, binding.keysym);
+ self.bindings.push(binding);
+ shadowed
+ }
+
+ /// Push only if the chord is still free. Returns whether it was added.
+ pub fn add_default(&mut self, binding: Binding) -> bool {
+ if self.contains(binding.mods, binding.keysym) {
+ false
+ } else {
+ self.bindings.push(binding);
+ true
+ }
+ }
+
+ /// First match wins, mirroring the compositor's dispatch loop.
+ pub fn resolve(&self, mods: u32, keysym: u32) -> Option<&Binding> {
+ self.bindings.iter().find(|b| b.mods == mods && b.keysym == keysym)
+ }
+
+ pub fn iter(&self) -> impl Iterator<Item = &Binding> {
+ self.bindings.iter()
+ }
+
+ pub fn len(&self) -> usize {
+ self.bindings.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.bindings.is_empty()
+ }
+
+ pub fn into_bindings(self) -> Vec<Binding> {
+ self.bindings
+ }
+}
+
+/// A stock binding: chord with the key still as a keysym name.
+#[derive(Debug, Clone, Copy)]
+pub struct DefaultBinding {
+ pub mods: u32,
+ pub key: &'static str,
+ pub action: Action,
+}
+
+/// The built-in fallback set (previously hardcoded in the compositor's
+/// config loader). Applied with `add_default` after every configured source,
+/// so any of these chords can be rebound in `input.kdl`.
+pub const DEFAULT_BINDINGS: &[DefaultBinding] = &[
+ DefaultBinding { mods: mods::SUPER, key: "Left", action: Action::OverlayLeft },
+ DefaultBinding { mods: mods::SUPER, key: "Right", action: Action::OverlayRight },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Up", action: Action::PanUp },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Down", action: Action::PanDown },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Left", action: Action::PanLeft },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Right", action: Action::PanRight },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL | mods::SHIFT, key: "equal", action: Action::ZoomIn },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "minus", action: Action::ZoomOut },
+ DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "equal", action: Action::ZoomReset },
+ DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "r", action: Action::Reload },
+];
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_chord_splits_mods_and_key() {
+ assert_eq!(
+ parse_chord("super+shift+h"),
+ Some(Chord { mods: mods::SUPER | mods::SHIFT, key: "h".into() })
+ );
+ assert_eq!(parse_chord("escape"), Some(Chord { mods: 0, key: "escape".into() }));
+ // Keysym names keep their case; modifiers are case-insensitive.
+ assert_eq!(
+ parse_chord("Super+Ctrl+Left"),
+ Some(Chord { mods: mods::SUPER | mods::CTRL, key: "Left".into() })
+ );
+ }
+
+ #[test]
+ fn parse_chord_rejects_invalid() {
+ assert_eq!(parse_chord(""), None);
+ assert_eq!(parse_chord("super+"), None); // empty key
+ assert_eq!(parse_chord("hyper+x"), None); // unknown modifier
+ }
+
+ #[test]
+ fn action_names_round_trip() {
+ // Every variant's canonical name resolves back to the variant.
+ for action in [
+ Action::None, Action::Spawn, Action::Toggle, Action::Close,
+ Action::FocusNext, Action::FocusPrev, Action::WindowSwitcher,
+ Action::Move, Action::Resize, Action::Exit, Action::Reload,
+ Action::Fullscreen, Action::LayoutNext, Action::ModeNext,
+ Action::ModeNextShared, Action::View1, Action::View2,
+ Action::View3, Action::View4, Action::SetViewport1,
+ Action::SetViewport2, Action::SetViewport3, Action::SetViewport4,
+ Action::Expose, Action::Minimize, Action::OverlayLeft,
+ Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
+ Action::ZoomReset, Action::PanLeft, Action::PanRight,
+ Action::PanUp, Action::PanDown,
+ ] {
+ assert_eq!(Action::from_name(action.name()), Some(action), "{}", action.name());
+ }
+ // Legacy aliases from the old config.kdl vocabulary.
+ assert_eq!(Action::from_name("close"), Some(Action::Close));
+ assert_eq!(Action::from_name("fullscreen"), Some(Action::Fullscreen));
+ assert_eq!(Action::from_name("toggle_overview"), Some(Action::Expose));
+ assert_eq!(Action::from_name("no_such_action"), None);
+ }
+
+ #[test]
+ fn table_priority_is_insertion_order() {
+ let mut t = BindingTable::new();
+ let close = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
+ let min = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Minimize, command: None };
+ assert!(!t.add(close.clone())); // first claim: not shadowed
+ assert!(t.add(min)); // same chord: shadowed
+ assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&close));
+ assert_eq!(t.resolve(mods::SUPER, 0x72), None);
+ }
+
+ #[test]
+ fn add_default_never_shadows() {
+ let mut t = BindingTable::new();
+ let user = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
+ t.add(user.clone());
+ let stock = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Fullscreen, command: None };
+ assert!(!t.add_default(stock));
+ assert_eq!(t.len(), 1);
+ assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&user));
+ let free = Binding { mods: mods::SUPER, keysym: 0x72, action: Action::Fullscreen, command: None };
+ assert!(t.add_default(free));
+ assert_eq!(t.len(), 2);
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index d461d9a..150c188 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,9 @@
// are a skeleton — defined but not yet driven by the compositor.
// - `arrange`: the whole arrange pass as pure functions
// (snapshot → plan → apply instructions).
+// - `bindings`: keybinding vocabulary — action names, chord grammar,
+// `BindingTable`, stock defaults. The compositor feeds it plain data
+// parsed from `input.kdl`; keysym name→code lookup stays mechanism-side.
// - `tiling`: `TilingMode` and pure layout formulas.
// - `snap`: magnetic grid snapping for interactive move/resize.
// - `state`: persisted session state (serialization/matching only; the
@@ -21,6 +24,7 @@
pub mod api;
pub mod arrange;
+pub mod bindings;
pub mod slotmap;
pub mod snap;
pub mod state;