window management library
git clone https://git.lucas.co/cce-window-manager.git
feat: window-management actions join DefaultPolicy
Seven more execute_action arms become pure snapshot -> command
decisions: Close and Minimize (act on the focused window, then the new
FocusNextVisible command refocuses by the mechanism's rule), FocusNext/
FocusPrev (ring of focus_cyclable windows in stable id order, wrapping,
starting at the ends when nothing is focused), directional focus
(focus::directional_focus over virtual-surface centers, w*scale), the
Fullscreen toggle (locks into Fullscreen; leaving unlocks back to the
viewport-resolved mode, Cascade if that resolution is itself
Fullscreen), and ModeNext/ModeNextShared (Floating<->Fullscreen cycle;
Shared hits every visible window in the focused window's mode).
ActionWindow grows scale/mode/resolved_mode/visible/focus_cyclable;
new commands: CloseWindow, SetMinimized, SetWindowMode, Raise,
FocusNextVisible. Spawn/Toggle/Reload/Exit/WindowSwitcher/Move/Resize
stay mechanism-side (fork/exec, I/O, seat ops).
Co-Authored-By: Claude Fable 5 <[email protected]>
src/actions.rs | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/api.rs | 25 ++++++
2 files changed, 295 insertions(+), 5 deletions(-)
diff --git a/src/actions.rs b/src/actions.rs
index 822ee72..af41271 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -6,7 +6,9 @@
use crate::api::{Action, ActionCtx, Command, Policy, WindowId};
use crate::camera::{self, Camera};
+use crate::focus;
use crate::pan;
+use crate::tiling::TilingMode;
pub struct DefaultPolicy;
@@ -23,6 +25,15 @@ impl Policy for DefaultPolicy {
| Action::SetViewport3
| Action::SetViewport4 => set_viewport(ctx, action),
Action::Expose => expose(ctx),
+ Action::Close => close(ctx),
+ Action::Minimize => minimize(ctx),
+ Action::FocusNext | Action::FocusPrev => focus_cycle(ctx, action),
+ Action::FocusUp | Action::FocusDown | Action::FocusLeft | Action::FocusRight => {
+ focus_directional(ctx, action)
+ }
+ Action::Fullscreen => fullscreen(ctx),
+ Action::ModeNext => mode_next(ctx),
+ Action::ModeNextShared => mode_next_shared(ctx),
_ => Vec::new(),
}
}
@@ -167,6 +178,132 @@ fn expose(ctx: &ActionCtx) -> Vec<Command> {
}
}
+/// Close the focused window, then refocus by the mechanism's next-visible
+/// rule.
+fn close(ctx: &ActionCtx) -> Vec<Command> {
+ let Some(id) = ctx.focused else { return Vec::new() };
+ vec![Command::CloseWindow(id), Command::FocusNextVisible, Command::Relayout]
+}
+
+/// Minimize the focused window, then refocus.
+fn minimize(ctx: &ActionCtx) -> Vec<Command> {
+ let Some(id) = ctx.focused else { return Vec::new() };
+ vec![
+ Command::SetMinimized { id, minimized: true },
+ Command::FocusNextVisible,
+ Command::Relayout,
+ ]
+}
+
+/// The focus-cycling ring: cyclable windows in stable id order.
+fn focus_ring(ctx: &ActionCtx) -> Vec<&crate::api::ActionWindow> {
+ let mut ring: Vec<_> = ctx.windows.iter().filter(|w| w.focus_cyclable).collect();
+ ring.sort_by_key(|w| w.id.0.index);
+ ring
+}
+
+/// FocusNext/FocusPrev walk the ring; with nothing focused they start at
+/// its first/last entry.
+fn focus_cycle(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let ring = focus_ring(ctx);
+ let n = ring.len();
+ if n == 0 {
+ return Vec::new();
+ }
+ let forward = action == Action::FocusNext;
+ let current = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
+ let target = match current {
+ Some(idx) => {
+ if forward {
+ (idx + 1) % n
+ } else {
+ (idx + n - 1) % n
+ }
+ }
+ None => {
+ if forward {
+ 0
+ } else {
+ n - 1
+ }
+ }
+ };
+ let id = ring[target].id;
+ vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
+}
+
+/// Directional focus over the ring's window centers on the virtual surface.
+fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+ let ring = focus_ring(ctx);
+ let centers: Vec<(f64, f64)> = ring
+ .iter()
+ .map(|w| (w.x + w.w * w.scale / 2.0, w.y + w.h * w.scale / 2.0))
+ .collect();
+ let focused_idx = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
+ let dir = focus::Direction::from_action(action)
+ .expect("arm only matches directional focus actions");
+ let Some(target) = focus::directional_focus(¢ers, focused_idx, dir) else {
+ return Vec::new();
+ };
+ let id = ring[target].id;
+ vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
+}
+
+/// Toggle fullscreen on the focused window. Leaving fullscreen unlocks the
+/// window back to its viewport-resolved mode (Cascade if that resolution is
+/// itself Fullscreen).
+fn fullscreen(ctx: &ActionCtx) -> Vec<Command> {
+ let Some(id) = ctx.focused else { return Vec::new() };
+ let Some(win) = window(ctx, id) else { return Vec::new() };
+ let cmd = if win.mode == TilingMode::Fullscreen {
+ let target = if win.resolved_mode == TilingMode::Fullscreen {
+ TilingMode::Cascade
+ } else {
+ win.resolved_mode
+ };
+ Command::SetWindowMode { id, mode: target, locked: false }
+ } else {
+ Command::SetWindowMode { id, mode: TilingMode::Fullscreen, locked: true }
+ };
+ vec![cmd, Command::Relayout]
+}
+
+/// The keyed mode cycle.
+fn next_mode(current: TilingMode) -> TilingMode {
+ let cycle = [TilingMode::Floating, TilingMode::Fullscreen];
+ cycle
+ .iter()
+ .position(|m| *m == current)
+ .map(|i| cycle[(i + 1) % cycle.len()])
+ .unwrap_or(TilingMode::Floating)
+}
+
+/// Cycle the focused window's mode.
+fn mode_next(ctx: &ActionCtx) -> Vec<Command> {
+ let Some(id) = ctx.focused else { return Vec::new() };
+ let Some(win) = window(ctx, id) else { return Vec::new() };
+ vec![
+ Command::SetWindowMode { id, mode: next_mode(win.mode), locked: true },
+ Command::Relayout,
+ ]
+}
+
+/// Cycle every visible window that shares the focused window's mode.
+fn mode_next_shared(ctx: &ActionCtx) -> Vec<Command> {
+ let Some(id) = ctx.focused else { return Vec::new() };
+ let Some(win) = window(ctx, id) else { return Vec::new() };
+ let current = win.mode;
+ let next = next_mode(current);
+ let mut cmds: Vec<Command> = ctx
+ .windows
+ .iter()
+ .filter(|w| w.visible && w.mode == current)
+ .map(|w| Command::SetWindowMode { id: w.id, mode: next, locked: true })
+ .collect();
+ cmds.push(Command::Relayout);
+ cmds
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -177,6 +314,23 @@ mod tests {
WindowId(Key { generation: 0, index })
}
+ /// A plain visible, cyclable, expose-eligible Floating window.
+ fn win(index: u32, x: f64, y: f64, w: f64, h: f64) -> ActionWindow {
+ ActionWindow {
+ id: wid(index),
+ x,
+ y,
+ w,
+ h,
+ scale: 1.0,
+ mode: TilingMode::Floating,
+ resolved_mode: TilingMode::Floating,
+ visible: true,
+ focus_cyclable: true,
+ expose_eligible: true,
+ }
+ }
+
fn ctx() -> ActionCtx {
ActionCtx {
camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
@@ -202,8 +356,117 @@ mod tests {
#[test]
fn unclaimed_actions_return_empty() {
- assert!(dispatch(&ctx(), Action::Close).is_empty());
assert!(dispatch(&ctx(), Action::Spawn).is_empty());
+ assert!(dispatch(&ctx(), Action::Reload).is_empty());
+ assert!(dispatch(&ctx(), Action::Exit).is_empty());
+ }
+
+ #[test]
+ fn close_and_minimize_need_focus_and_refocus() {
+ assert!(dispatch(&ctx(), Action::Close).is_empty());
+ let mut c = ctx();
+ c.focused = Some(wid(4));
+ assert_eq!(
+ dispatch(&c, Action::Close),
+ vec![Command::CloseWindow(wid(4)), Command::FocusNextVisible, Command::Relayout]
+ );
+ assert_eq!(
+ dispatch(&c, Action::Minimize),
+ vec![
+ Command::SetMinimized { id: wid(4), minimized: true },
+ Command::FocusNextVisible,
+ Command::Relayout
+ ]
+ );
+ }
+
+ #[test]
+ fn focus_cycle_walks_id_order_and_wraps() {
+ let mut c = ctx();
+ // Inserted out of id order; the ring sorts by id: 1, 5, 9.
+ c.windows.push(win(9, 0.0, 0.0, 100.0, 100.0));
+ c.windows.push(win(1, 200.0, 0.0, 100.0, 100.0));
+ c.windows.push(win(5, 400.0, 0.0, 100.0, 100.0));
+ c.focused = Some(wid(9));
+ // Next from the last entry wraps to the first.
+ assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
+ // Prev from 9 goes to 5.
+ assert_eq!(dispatch(&c, Action::FocusPrev)[0], Command::Focus(wid(5)));
+ // No focus: Next starts at the ring's first entry.
+ c.focused = None;
+ assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
+ // Non-cyclable windows are not in the ring.
+ for w in c.windows.iter_mut() {
+ w.focus_cyclable = false;
+ }
+ assert!(dispatch(&c, Action::FocusNext).is_empty());
+ }
+
+ #[test]
+ fn focus_directional_picks_by_center() {
+ let mut c = ctx();
+ c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
+ c.windows.push(win(2, 500.0, 0.0, 100.0, 100.0));
+ c.focused = Some(wid(1));
+ let cmds = dispatch(&c, Action::FocusRight);
+ assert_eq!(cmds[0], Command::Focus(wid(2)));
+ assert_eq!(cmds[1], Command::Raise(wid(2)));
+ // Nothing to the left of window 1.
+ assert!(dispatch(&c, Action::FocusLeft).is_empty());
+ }
+
+ #[test]
+ fn fullscreen_toggles_and_unlocks_to_resolved_mode() {
+ let mut c = ctx();
+ c.focused = Some(wid(1));
+ c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
+ // Enter: lock to Fullscreen.
+ assert_eq!(
+ dispatch(&c, Action::Fullscreen)[0],
+ Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true }
+ );
+ // Exit: unlock back to the resolved mode.
+ c.windows[0].mode = TilingMode::Fullscreen;
+ c.windows[0].resolved_mode = TilingMode::Grid;
+ assert_eq!(
+ dispatch(&c, Action::Fullscreen)[0],
+ Command::SetWindowMode { id: wid(1), mode: TilingMode::Grid, locked: false }
+ );
+ // Exit when the viewport itself resolves Fullscreen: fall to Cascade.
+ c.windows[0].resolved_mode = TilingMode::Fullscreen;
+ assert_eq!(
+ dispatch(&c, Action::Fullscreen)[0],
+ Command::SetWindowMode { id: wid(1), mode: TilingMode::Cascade, locked: false }
+ );
+ }
+
+ #[test]
+ fn mode_next_cycles_and_shared_hits_all_matching() {
+ let mut c = ctx();
+ c.focused = Some(wid(1));
+ c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
+ c.windows.push(win(2, 200.0, 0.0, 100.0, 100.0));
+ let mut hidden = win(3, 400.0, 0.0, 100.0, 100.0);
+ hidden.visible = false;
+ c.windows.push(hidden);
+ // Floating -> Fullscreen on the focused window only.
+ assert_eq!(
+ dispatch(&c, Action::ModeNext),
+ vec![
+ Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
+ Command::Relayout
+ ]
+ );
+ // Shared: every visible window in the focused window's mode cycles;
+ // the invisible one is untouched.
+ assert_eq!(
+ dispatch(&c, Action::ModeNextShared),
+ vec![
+ Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
+ Command::SetWindowMode { id: wid(2), mode: TilingMode::Fullscreen, locked: true },
+ Command::Relayout
+ ]
+ );
}
#[test]
@@ -238,7 +501,7 @@ mod tests {
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 });
+ c.windows.push(win(7, 0.0, 0.0, 400.0, 300.0));
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);
@@ -247,8 +510,10 @@ mod tests {
#[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 });
+ c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
+ let mut ineligible = win(2, 5000.0, 0.0, 400.0, 300.0);
+ ineligible.expose_eligible = false;
+ c.windows.push(ineligible);
let cmds = dispatch(&c, Action::Expose);
let Command::SetCamera { camera, overview } = cmds[0] else { panic!() };
assert_eq!(overview, Some(true));
@@ -265,7 +530,7 @@ mod tests {
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.windows.push(win(3, 1000.0, 2000.0, 400.0, 300.0));
c.hovered = Some(wid(3));
let cmds = dispatch(&c, Action::Expose);
assert_eq!(cmds[0], Command::Focus(wid(3)));
diff --git a/src/api.rs b/src/api.rs
index 7491d9e..1ca50d1 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -16,6 +16,7 @@
// without changing either trait.
use crate::camera::Camera;
+use crate::tiling::TilingMode;
/// Opaque handle to a window. Wraps the `SlotMap` key that the mechanism side
/// uses internally; policy code never sees a pointer.
@@ -277,6 +278,19 @@ pub struct ActionWindow {
pub y: f64,
pub w: f64,
pub h: f64,
+ /// Per-window output scale; the virtual footprint is `w * scale`.
+ pub scale: f64,
+ /// The window's own tiling mode (as set, before viewport resolution).
+ pub mode: TilingMode,
+ /// The mode the window resolves to through the viewport-mode rules
+ /// (mechanism's `get_mode_for_window`) — what leaving Fullscreen
+ /// falls back to.
+ pub resolved_mode: TilingMode,
+ /// Mapped and not in Closing/Init teardown/startup.
+ pub visible: bool,
+ /// In the focus-cycling set: currently rendered, not minimized, not a
+ /// status bar.
+ pub focus_cyclable: bool,
/// Participates in the overview fit: mapped, not minimized, not
/// status/background, not popup/overlay.
pub expose_eligible: bool,
@@ -293,6 +307,17 @@ pub enum Command {
PanTo { x: Option<f64>, y: Option<f64> },
StopPanAnimation,
Focus(WindowId),
+ /// Focus whichever visible window the mechanism's next-visible rule
+ /// picks — the refocus step after closing/minimizing the focused window.
+ FocusNextVisible,
+ /// Raise a window to the top of the stacking order.
+ Raise(WindowId),
+ /// Ask the window to close (and let teardown proceed).
+ CloseWindow(WindowId),
+ SetMinimized { id: WindowId, minimized: bool },
+ /// Set a window's tiling mode; `locked` pins it against viewport-mode
+ /// resolution.
+ SetWindowMode { id: WindowId, mode: TilingMode, locked: bool },
/// Reposition a window in virtual space.
MoveWindow { id: WindowId, x: f64, y: f64 },
/// Full re-arrange (the mechanism's `dirty_windowing`).