window management library
git clone https://git.lucas.co/cce-window-manager.git
feat: snapshot-style Policy/Compositor traits, driven by DefaultPolicy
The trait boundary goes live, reshaped to the arrange-pass convention:
the mechanism owns all state, captures an ActionCtx snapshot (camera,
mode, pan targets, viewport, cursor output, hovered/focused windows,
grid period, per-window virtual rects + expose eligibility) and
Policy::action returns a Command list the Compositor impl applies in
order (SetCamera / PanTo / StopPanAnimation / Focus / MoveWindow /
Relayout / RefreshCamera). An empty list means "not mine" and the
compositor's legacy arms handle the action. actions::DefaultPolicy is
the live impl: the camera actions — keyed zoom, cell-aligned pans,
View1-4 jumps, SetViewport1-4 sends, and both Expose directions — are
decided here, moved verbatim from the compositor's execute_action arms
(including the overview-by-fiat rule and the no-seat Expose fallback).
The speculative event-driven trait methods and their WindowInfo/
OutputInfo/PointerEvent types are deleted; new flows grow snapshot
methods only alongside a real mechanism caller.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/actions.rs | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/api.rs | 137 ++++++++++++++++-----------
src/lib.rs | 8 +-
3 files changed, 376 insertions(+), 55 deletions(-)
diff --git a/src/actions.rs b/src/actions.rs
new file mode 100644
index 0000000..822ee72
--- /dev/null
+++ b/src/actions.rs
@@ -0,0 +1,286 @@
+// Action dispatch policy: `DefaultPolicy` decides user actions as pure
+// snapshot → command mappings. The camera actions (zoom, pan, viewport
+// jumps, overview) live here, moved verbatim from the compositor's
+// execute_action arms; an action this policy doesn't claim returns an empty
+// vec and the mechanism's remaining legacy arms handle it.
+
+use crate::api::{Action, ActionCtx, Command, Policy, WindowId};
+use crate::camera::{self, Camera};
+use crate::pan;
+
+pub struct DefaultPolicy;
+
+impl Policy for DefaultPolicy {
+ fn action(&mut self, ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ match action {
+ Action::ZoomIn | Action::ZoomOut | Action::ZoomReset => zoom(ctx, action),
+ Action::PanLeft | Action::PanRight | Action::PanUp | Action::PanDown => {
+ pan_step(ctx, action)
+ }
+ Action::View1 | Action::View2 | Action::View3 | Action::View4 => view(ctx, action),
+ Action::SetViewport1
+ | Action::SetViewport2
+ | Action::SetViewport3
+ | Action::SetViewport4 => set_viewport(ctx, action),
+ Action::Expose => expose(ctx),
+ _ => Vec::new(),
+ }
+ }
+}
+
+/// The four fixed viewport anchors shared by View1-4 and SetViewport1-4.
+fn anchor_point(action: Action) -> (f64, f64) {
+ match action {
+ Action::View1 | Action::SetViewport1 => (0.0, 0.0),
+ Action::View2 | Action::SetViewport2 => (2000.0, 0.0),
+ Action::View3 | Action::SetViewport3 => (0.0, 2000.0),
+ _ => (2000.0, 2000.0),
+ }
+}
+
+fn window(ctx: &ActionCtx, id: WindowId) -> Option<&crate::api::ActionWindow> {
+ ctx.windows.iter().find(|w| w.id == id)
+}
+
+/// Keyed zooms pivot about the viewport center.
+fn zoom(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let dir = match action {
+ Action::ZoomIn => 1.0,
+ Action::ZoomOut => -1.0,
+ _ => 0.0,
+ };
+ let new_zoom = camera::keyed_zoom(ctx.camera.zoom, dir);
+ let cam = camera::zoom_about_anchor(
+ ctx.camera,
+ ctx.viewport_w / 2.0,
+ ctx.viewport_h / 2.0,
+ new_zoom,
+ );
+ vec![
+ Command::SetCamera { camera: cam, overview: Some(camera::is_overview(cam.zoom)) },
+ Command::Relayout,
+ ]
+}
+
+/// Keyed pans move cell-by-cell and ease to an aligned viewport. Stepping
+/// from the pending target (not the current offset) lets rapid presses queue
+/// one cell apiece.
+fn pan_step(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let (dx, dy) = match action {
+ Action::PanLeft => (-1.0, 0.0),
+ Action::PanRight => (1.0, 0.0),
+ Action::PanUp => (0.0, -1.0),
+ _ => (0.0, 1.0),
+ };
+ let mut x = None;
+ let mut y = None;
+ if dx != 0.0 {
+ let base = ctx.pan_target_x.unwrap_or(ctx.camera.pan_x);
+ x = Some(pan::aligned_step(base, ctx.grid_period, dx));
+ }
+ if dy != 0.0 {
+ let base = ctx.pan_target_y.unwrap_or(ctx.camera.pan_y);
+ y = Some(pan::aligned_step(base, ctx.grid_period, dy));
+ }
+ vec![Command::PanTo { x, y }]
+}
+
+/// Jump the viewport to one of the four fixed anchors, keeping the zoom.
+fn view(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let (tx, ty) = anchor_point(action);
+ let cam = camera::center_on(tx, ty, ctx.viewport_w, ctx.viewport_h, ctx.camera.zoom);
+ vec![Command::SetCamera { camera: cam, overview: None }, Command::Relayout]
+}
+
+/// Send the focused window to one of the four fixed anchors (centered on
+/// it). Note the legacy quirk kept as-is: the window extent is output px,
+/// halved without dividing by zoom.
+fn set_viewport(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let (tx, ty) = anchor_point(action);
+ let Some(id) = ctx.focused else { return Vec::new() };
+ let Some(win) = window(ctx, id) else { return Vec::new() };
+ vec![
+ Command::MoveWindow { id, x: tx - win.w / 2.0, y: ty - win.h / 2.0 },
+ Command::Relayout,
+ ]
+}
+
+/// Toggle overview. Exit re-centers at zoom 1 — on the hovered window
+/// (focusing it) when there is one, else on the virtual point under the
+/// cursor — in the cursor's output. Enter fits the bounding box of all
+/// eligible windows into the first enabled output.
+fn expose(ctx: &ActionCtx) -> Vec<Command> {
+ if ctx.overview {
+ let out = ctx.cursor_viewport;
+ let (ow, oh) = (out.width as f64, out.height as f64);
+ if !ctx.has_cursor {
+ // No seat: fall back to the origin at zoom 1.
+ return vec![
+ Command::StopPanAnimation,
+ Command::SetCamera {
+ camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
+ overview: Some(false),
+ },
+ Command::RefreshCamera,
+ ];
+ }
+ if let Some(id) = ctx.hovered {
+ if let Some(win) = window(ctx, id) {
+ let cam =
+ camera::center_on(win.x + win.w / 2.0, win.y + win.h / 2.0, ow, oh, 1.0);
+ return vec![
+ Command::Focus(id),
+ Command::StopPanAnimation,
+ Command::SetCamera { camera: cam, overview: Some(false) },
+ Command::RefreshCamera,
+ ];
+ }
+ }
+ let vx = ctx.camera.pan_x + (ctx.cursor_x - out.x as f64) / ctx.camera.zoom;
+ let vy = ctx.camera.pan_y + (ctx.cursor_y - out.y as f64) / ctx.camera.zoom;
+ let cam = camera::center_on(vx, vy, ow, oh, 1.0);
+ vec![
+ Command::StopPanAnimation,
+ Command::SetCamera { camera: cam, overview: Some(false) },
+ Command::RefreshCamera,
+ ]
+ } else {
+ let mut bounds: Option<(f64, f64, f64, f64)> = None;
+ for w in ctx.windows.iter().filter(|w| w.expose_eligible) {
+ let (min_x, min_y, max_x, max_y) =
+ bounds.unwrap_or((f64::MAX, f64::MAX, f64::MIN, f64::MIN));
+ bounds = Some((
+ min_x.min(w.x),
+ min_y.min(w.y),
+ max_x.max(w.x + w.w),
+ max_y.max(w.y + w.h),
+ ));
+ }
+ let Some((min_x, min_y, max_x, max_y)) = bounds else { return Vec::new() };
+ let cam = camera::fit_bounds(min_x, min_y, max_x, max_y, ctx.viewport_w, ctx.viewport_h);
+ // Overview by fiat even when the fit lands at zoom 1 (a desktop
+ // smaller than the screen): the next Expose must exit, not re-enter.
+ vec![
+ Command::SetCamera { camera: cam, overview: Some(true) },
+ Command::RefreshCamera,
+ ]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::{ActionWindow, Rect};
+ use crate::slotmap::Key;
+
+ fn wid(index: u32) -> WindowId {
+ WindowId(Key { generation: 0, index })
+ }
+
+ fn ctx() -> ActionCtx {
+ ActionCtx {
+ camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
+ overview: false,
+ pan_target_x: None,
+ pan_target_y: None,
+ viewport_w: 1920.0,
+ viewport_h: 1080.0,
+ cursor_viewport: Rect { x: 0, y: 0, width: 1920, height: 1080 },
+ has_cursor: true,
+ cursor_x: 960.0,
+ cursor_y: 540.0,
+ hovered: None,
+ focused: None,
+ grid_period: 512.0,
+ windows: Vec::new(),
+ }
+ }
+
+ fn dispatch(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ DefaultPolicy.action(ctx, action)
+ }
+
+ #[test]
+ fn unclaimed_actions_return_empty() {
+ assert!(dispatch(&ctx(), Action::Close).is_empty());
+ assert!(dispatch(&ctx(), Action::Spawn).is_empty());
+ }
+
+ #[test]
+ fn zoom_in_sets_overview_and_relayouts() {
+ let cmds = dispatch(&ctx(), Action::ZoomIn);
+ assert_eq!(cmds.len(), 2);
+ let Command::SetCamera { camera, overview } = cmds[0] else { panic!() };
+ assert!((camera.zoom - 1.1).abs() < 1e-9);
+ assert_eq!(overview, Some(true));
+ assert_eq!(cmds[1], Command::Relayout);
+ // Reset from zoomed goes back to normal.
+ let mut c = ctx();
+ c.camera.zoom = 2.0;
+ let Command::SetCamera { camera, overview } = dispatch(&c, Action::ZoomReset)[0] else { panic!() };
+ assert_eq!(camera.zoom, 1.0);
+ assert_eq!(overview, Some(false));
+ }
+
+ #[test]
+ fn pan_steps_one_aligned_cell_from_pending_target() {
+ let Command::PanTo { x, y } = dispatch(&ctx(), Action::PanRight)[0] else { panic!() };
+ assert_eq!((x, y), (Some(512.0), None));
+ // A pending target queues the next cell from there.
+ let mut c = ctx();
+ c.pan_target_x = Some(512.0);
+ let Command::PanTo { x, .. } = dispatch(&c, Action::PanRight)[0] else { panic!() };
+ assert_eq!(x, Some(1024.0));
+ }
+
+ #[test]
+ fn set_viewport_needs_focus_and_centers_it() {
+ assert!(dispatch(&ctx(), Action::SetViewport2).is_empty());
+ let mut c = ctx();
+ c.focused = Some(wid(7));
+ c.windows.push(ActionWindow { id: wid(7), x: 0.0, y: 0.0, w: 400.0, h: 300.0, expose_eligible: true });
+ let cmds = dispatch(&c, Action::SetViewport2);
+ assert_eq!(cmds[0], Command::MoveWindow { id: wid(7), x: 1800.0, y: -150.0 });
+ assert_eq!(cmds[1], Command::Relayout);
+ }
+
+ #[test]
+ fn expose_enter_fits_eligible_windows_only() {
+ let mut c = ctx();
+ c.windows.push(ActionWindow { id: wid(1), x: 0.0, y: 0.0, w: 400.0, h: 300.0, expose_eligible: true });
+ c.windows.push(ActionWindow { id: wid(2), x: 5000.0, y: 0.0, w: 400.0, h: 300.0, expose_eligible: false });
+ let cmds = dispatch(&c, Action::Expose);
+ let Command::SetCamera { camera, overview } = cmds[0] else { panic!() };
+ assert_eq!(overview, Some(true));
+ // Only window 1 counts: 400x300 fits without zooming out.
+ assert_eq!(camera.zoom, 1.0);
+ assert_eq!(cmds[1], Command::RefreshCamera);
+ // No eligible windows: not claimed, nothing happens.
+ c.windows.clear();
+ assert!(dispatch(&c, Action::Expose).is_empty());
+ }
+
+ #[test]
+ fn expose_exit_prefers_the_hovered_window() {
+ let mut c = ctx();
+ c.overview = true;
+ c.camera.zoom = 0.5;
+ c.windows.push(ActionWindow { id: wid(3), x: 1000.0, y: 2000.0, w: 400.0, h: 300.0, expose_eligible: true });
+ c.hovered = Some(wid(3));
+ let cmds = dispatch(&c, Action::Expose);
+ assert_eq!(cmds[0], Command::Focus(wid(3)));
+ assert_eq!(cmds[1], Command::StopPanAnimation);
+ let Command::SetCamera { camera, overview } = cmds[2] else { panic!() };
+ assert_eq!(overview, Some(false));
+ assert_eq!(camera.zoom, 1.0);
+ // Centered on the window's center (1200, 2150).
+ assert_eq!(camera.pan_x, 1200.0 - 960.0);
+ assert_eq!(camera.pan_y, 2150.0 - 540.0);
+ // Without a hovered window, exit centers the point under the cursor.
+ c.hovered = None;
+ c.camera.pan_x = 100.0;
+ let Command::SetCamera { camera, .. } = dispatch(&c, Action::Expose)[1] else { panic!() };
+ // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
+ assert_eq!(camera.pan_x, 2020.0 - 960.0);
+ }
+}
diff --git a/src/api.rs b/src/api.rs
index 442e9e5..7491d9e 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -1,18 +1,21 @@
-// The Policy / Compositor trait boundary.
+// The Policy / Compositor trait boundary — snapshot-style.
//
-// `Policy` is implemented by the window-management side: it receives events
-// and decides placement, focus, decoration, and background — using only the
-// plain-data types in this file, never FFI. `Compositor` is implemented by
-// the mechanism side (`window_manager.rs` and friends): it executes those
-// decisions against the wlroots/scenefx scene graph.
+// `Policy` is implemented policy-side (`actions::DefaultPolicy`): its methods
+// take a plain-data snapshot the mechanism captured at dispatch time and
+// return `Command`s — the same snapshot → plan convention as the arrange
+// pass, with the mechanism owning all state. `Compositor` is implemented by
+// the mechanism (`window_manager.rs`): it applies one `Command` at a time
+// against the wlroots/scenefx world.
//
-// Skeleton status: nothing implements these traits yet. The migration plan is
-// to split `arrange_views()` into a policy half (compute placements) and a
-// mechanism half (apply to scene), then route window lifecycle, input actions,
-// and the animation tick through `Policy`. Effects are declarative on purpose:
-// new scenefx capabilities extend `EffectSpec` without changing either trait.
+// Migration status: `Policy::action` is live — the compositor routes user
+// actions through it and falls back to its legacy arms only for actions the
+// policy doesn't claim. Further flows (window lifecycle, the animation tick)
+// grow new snapshot-taking methods here as they migrate; don't add
+// speculative signatures ahead of a real mechanism caller. Effects are
+// declarative on purpose: new scenefx capabilities extend `EffectSpec`
+// without changing either trait.
-use super::state::SavedState;
+use crate::camera::Camera;
/// Opaque handle to a window. Wraps the `SlotMap` key that the mechanism side
/// uses internally; policy code never sees a pointer.
@@ -181,14 +184,6 @@ impl Action {
}
}
-#[derive(Debug, Clone)]
-pub struct WindowInfo {
- pub app_id: String,
- pub title: String,
- pub role: WindowRole,
- pub cmdline: String,
-}
-
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: i32,
@@ -239,45 +234,81 @@ pub struct GridSpec {
pub line_width: i32,
}
-#[derive(Debug, Clone, Copy)]
-pub struct OutputInfo {
- pub width: i32,
- pub height: i32,
- pub scale: f32,
+/// Everything `Policy::action` may consult, captured by the mechanism at
+/// dispatch time. Seat- and scene-dependent answers (cursor output, hovered
+/// window, focus) are resolved into the snapshot up front — the arrange-pass
+/// convention; policy never queries back mid-decision.
+#[derive(Debug, Clone)]
+pub struct ActionCtx {
+ pub camera: Camera,
+ /// The mechanism is in overview mode. Set by fiat on Expose enter, so
+ /// this is NOT always `camera::is_overview(zoom)` — an overview fit can
+ /// land at zoom 1.
+ pub overview: bool,
+ /// Pending pan-animation targets, if the camera is mid-ease.
+ pub pan_target_x: Option<f64>,
+ pub pan_target_y: Option<f64>,
+ /// First enabled output's extent — the legacy "viewport" for keyed zooms
+ /// and View jumps (the multi-output quirk, preserved by construction).
+ pub viewport_w: f64,
+ pub viewport_h: f64,
+ /// Output box under the cursor, falling back to the first enabled
+ /// output: the viewport Expose enters/exits in.
+ pub cursor_viewport: Rect,
+ /// False when there is no seat; the cursor fields then hold zeros.
+ pub has_cursor: bool,
+ pub cursor_x: f64,
+ pub cursor_y: f64,
+ /// Non-status, non-background window under the cursor.
+ pub hovered: Option<WindowId>,
+ pub focused: Option<WindowId>,
+ /// Desktop grid period (cell size + gap width) for cell-aligned panning.
+ pub grid_period: f64,
+ pub windows: Vec<ActionWindow>,
}
+/// A window as `Policy::action` sees it.
#[derive(Debug, Clone, Copy)]
-pub enum PointerEvent {
- Press { window: Option<WindowId>, x: f64, y: f64, button: u32 },
- Release { window: Option<WindowId>, x: f64, y: f64, button: u32 },
- Motion { x: f64, y: f64 },
+pub struct ActionWindow {
+ pub id: WindowId,
+ /// Virtual-space position. `w`/`h` are the mechanism's working extent in
+ /// output px (box_geom, defaulted to 800x600 while unmapped).
+ pub x: f64,
+ pub y: f64,
+ pub w: f64,
+ pub h: f64,
+ /// Participates in the overview fit: mapped, not minimized, not
+ /// status/background, not popup/overlay.
+ pub expose_eligible: bool,
}
-/// Commands from policy to mechanism. Implemented by the compositor side;
-/// every method maps onto existing `WindowManager` / scene operations.
-pub trait Compositor {
- fn place(&mut self, window: WindowId, rect: Rect);
- fn focus(&mut self, window: Option<WindowId>);
- fn raise(&mut self, window: WindowId);
- fn close(&mut self, window: WindowId);
- /// Pans/zooms windows and the background in the same frame.
- fn set_viewport(&mut self, pan_x: f64, pan_y: f64, zoom: f64);
- fn set_decoration(&mut self, window: WindowId, spec: DecorationSpec);
- fn set_effects(&mut self, window: WindowId, spec: EffectSpec);
- fn set_background(&mut self, spec: BackgroundSpec);
- fn spawn(&mut self, cmdline: &str);
+/// One mechanism write, returned by policy decisions and applied in order —
+/// the command-stream counterpart of the arrange plan.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Command {
+ /// Write the camera. `overview: None` leaves the mode untouched.
+ SetCamera { camera: Camera, overview: Option<bool> },
+ /// Set pan-animation targets (a `None` axis is left alone) and start
+ /// easing toward them.
+ PanTo { x: Option<f64>, y: Option<f64> },
+ StopPanAnimation,
+ Focus(WindowId),
+ /// Reposition a window in virtual space.
+ MoveWindow { id: WindowId, x: f64, y: f64 },
+ /// Full re-arrange (the mechanism's `dirty_windowing`).
+ Relayout,
+ /// Camera-only refresh: the fast viewport path when the WM is idle.
+ RefreshCamera,
}
-/// Events from mechanism to policy. Implemented by the window-management side.
+/// Decisions, policy-side. Implemented by `actions::DefaultPolicy`.
pub trait Policy {
- fn window_mapped(&mut self, c: &mut dyn Compositor, window: WindowId, info: &WindowInfo);
- fn window_unmapped(&mut self, c: &mut dyn Compositor, window: WindowId);
- fn window_meta_changed(&mut self, c: &mut dyn Compositor, window: WindowId, info: &WindowInfo);
- fn action(&mut self, c: &mut dyn Compositor, action: &Action, arg: Option<&str>);
- fn pointer(&mut self, c: &mut dyn Compositor, event: PointerEvent);
- fn output_changed(&mut self, c: &mut dyn Compositor, outputs: &[OutputInfo]);
- /// Animation driver: easing for pan/zoom targets, effect transitions.
- fn tick(&mut self, c: &mut dyn Compositor, dt: f64);
- fn save_state(&self) -> SavedState;
- fn restore_state(&mut self, c: &mut dyn Compositor, state: SavedState);
+ /// Decide a user action against the snapshot. An empty vec means "not
+ /// mine" — the mechanism falls through to its remaining legacy arms.
+ fn action(&mut self, ctx: &ActionCtx, action: Action) -> Vec<Command>;
+}
+
+/// Execution, mechanism-side. Implemented by the compositor's WindowManager.
+pub trait Compositor {
+ fn apply(&mut self, cmd: &Command);
}
diff --git a/src/lib.rs b/src/lib.rs
index fe48726..027c730 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,8 +8,11 @@
//
// Modules:
// - `api`: the `Policy` / `Compositor` trait boundary and the plain-data
-// vocabulary (WindowId, WindowRole, DecorationSpec, Action, …). Traits
-// are a skeleton — defined but not yet driven by the compositor.
+// vocabulary (WindowId, WindowRole, Action, ActionCtx, Command, …).
+// Snapshot-style: policy methods take mechanism-built snapshots and
+// return command lists; the compositor applies them.
+// - `actions`: `DefaultPolicy` — the live `Policy` impl; camera actions
+// (zoom/pan/view/overview) are decided here.
// - `arrange`: the whole arrange pass as pure functions
// (snapshot → plan → apply instructions).
// - `bindings`: keybinding vocabulary — action names, chord grammar,
@@ -27,6 +30,7 @@
// - `slotmap`: generational-index map (river-derived, 0BSD); `api::WindowId`
// wraps its `Key`.
+pub mod actions;
pub mod api;
pub mod arrange;
pub mod bindings;