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

commit991fba370b20768df16537596ecf9edbf76c0e14
parentdff12fd07a
authorLucas Galante <[email protected]>
date2026-07-16 09:45
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;