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

src/actions.rs (43.1K)

   1 // Action dispatch policy: `DefaultPolicy` decides user actions as pure
   2 // snapshot → command mappings. The camera actions (zoom, pan, viewport
   3 // jumps, overview) live here, moved verbatim from the compositor's
   4 // execute_action arms; an action this policy doesn't claim returns an empty
   5 // vec and the mechanism's remaining legacy arms handle it.
   6 
   7 use crate::api::{Action, ActionCtx, Command, OverlaySide, Policy, WindowId};
   8 use crate::camera::{self, Camera};
   9 use crate::focus;
  10 use crate::pan;
  11 use crate::tiling::TilingMode;
  12 
  13 pub struct DefaultPolicy;
  14 
  15 impl Policy for DefaultPolicy {
  16     fn action(&mut self, ctx: &ActionCtx, action: Action, arg: Option<&str>) -> Vec<Command> {
  17         match action {
  18             Action::Toggle => toggle(ctx, arg),
  19             Action::ZoomIn | Action::ZoomOut | Action::ZoomReset => zoom(ctx, action),
  20             Action::PanLeft | Action::PanRight | Action::PanUp | Action::PanDown => {
  21                 pan_step(ctx, action)
  22             }
  23             Action::Overview => toggle_overview(ctx),
  24             // The one-way halves of Overview, for a key per direction
  25             // instead of one key that toggles. Asking for the mode you are
  26             // already in is a no-op: the empty list falls through to the
  27             // mechanism's legacy match, which has no arm for either action,
  28             // so nothing happens — which is the intent, not an oversight.
  29             Action::OverviewEnter => {
  30                 if ctx.overview { Vec::new() } else { enter_overview(ctx) }
  31             }
  32             Action::OverviewExit => {
  33                 if ctx.overview { exit_overview_keyed(ctx) } else { Vec::new() }
  34             }
  35             Action::Close => close(ctx),
  36             Action::Minimize => minimize(ctx),
  37             Action::FocusNext | Action::FocusPrev => focus_cycle(ctx, action),
  38             Action::FocusUp | Action::FocusDown | Action::FocusLeft | Action::FocusRight => {
  39                 focus_directional(ctx, action)
  40             }
  41             Action::MoveWindowLeft
  42             | Action::MoveWindowRight
  43             | Action::MoveWindowUp
  44             | Action::MoveWindowDown => move_window(ctx, action),
  45             Action::Fullscreen => fullscreen(ctx),
  46             Action::ModeNext => mode_next(ctx),
  47             Action::ModeNextShared => mode_next_shared(ctx),
  48             Action::OverlayLeft => {
  49                 vec![Command::SetOverlayPosition(OverlaySide::Left), Command::Relayout]
  50             }
  51             Action::OverlayRight => {
  52                 vec![Command::SetOverlayPosition(OverlaySide::Right), Command::Relayout]
  53             }
  54             Action::VolumeUp
  55             | Action::VolumeDown
  56             | Action::VolumeMute
  57             | Action::MicMute
  58             | Action::BrightnessUp
  59             | Action::BrightnessDown => {
  60                 vec![Command::Spawn(arg.unwrap_or(media_command(action)).to_string())]
  61             }
  62             _ => Vec::new(),
  63         }
  64     }
  65 }
  66 
  67 fn window(ctx: &ActionCtx, id: WindowId) -> Option<&crate::api::ActionWindow> {
  68     ctx.windows.iter().find(|w| w.id == id)
  69 }
  70 
  71 /// Keyed zooms pivot about the viewport center.
  72 fn zoom(ctx: &ActionCtx, action: Action) -> Vec<Command> {
  73     let dir = match action {
  74         Action::ZoomIn => 1.0,
  75         Action::ZoomOut => -1.0,
  76         _ => 0.0,
  77     };
  78     let new_zoom = camera::keyed_zoom(ctx.camera.zoom, dir);
  79     let cam = camera::zoom_about_anchor(
  80         ctx.camera,
  81         ctx.viewport_w / 2.0,
  82         ctx.viewport_h / 2.0,
  83         new_zoom,
  84     );
  85     vec![
  86         Command::SetCamera { camera: cam, overview: Some(camera::is_overview(cam.zoom)), animate: false },
  87         Command::Relayout,
  88     ]
  89 }
  90 
  91 /// Keyed pans move cell-by-cell and ease to an aligned viewport. Stepping
  92 /// from the pending target (not the current offset) lets rapid presses queue
  93 /// one cell apiece.
  94 fn pan_step(ctx: &ActionCtx, action: Action) -> Vec<Command> {
  95     let (dx, dy) = match action {
  96         Action::PanLeft => (-1.0, 0.0),
  97         Action::PanRight => (1.0, 0.0),
  98         Action::PanUp => (0.0, -1.0),
  99         _ => (0.0, 1.0),
 100     };
 101     let mut x = None;
 102     let mut y = None;
 103     if dx != 0.0 {
 104         let base = ctx.pan_target_x.unwrap_or(ctx.camera.pan_x);
 105         x = Some(pan::aligned_step(base, ctx.grid_period_x, dx));
 106     }
 107     if dy != 0.0 {
 108         let base = ctx.pan_target_y.unwrap_or(ctx.camera.pan_y);
 109         y = Some(pan::aligned_step(base, ctx.grid_period_y, dy));
 110     }
 111     vec![Command::PanTo { x, y }]
 112 }
 113 
 114 /// Toggle overview: whichever of `enter_overview` / `exit_overview` the
 115 /// current mode calls for.
 116 fn toggle_overview(ctx: &ActionCtx) -> Vec<Command> {
 117     if ctx.overview {
 118         exit_overview(ctx)
 119     } else {
 120         enter_overview(ctx)
 121     }
 122 }
 123 
 124 /// Leave overview, re-centering at zoom 1 — on the hovered window (focusing
 125 /// it) when there is one, else on the virtual point under the cursor — in
 126 /// the cursor's output.
 127 ///
 128 /// This is the cursor-driven exit: the toggle, and what the keyed exit falls
 129 /// back to when nothing is focused. Callers have already established that
 130 /// the mechanism is in overview; this assumes it.
 131 fn exit_overview(ctx: &ActionCtx) -> Vec<Command> {
 132     let out = ctx.cursor_viewport;
 133     let (ow, oh) = (out.width as f64, out.height as f64);
 134     if !ctx.has_cursor {
 135         // No seat: fall back to the origin at zoom 1.
 136         return vec![
 137             Command::StopPanAnimation,
 138             Command::SetCamera {
 139                 camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
 140                 overview: Some(false),
 141                 animate: true,
 142             },
 143             Command::RefreshCamera,
 144         ];
 145     }
 146     if let Some(win) = ctx.hovered.and_then(|id| window(ctx, id)) {
 147         return exit_onto_window(ctx, win);
 148     }
 149     let vx = ctx.camera.pan_x + (ctx.cursor_x - out.x as f64) / ctx.camera.zoom;
 150     let vy = ctx.camera.pan_y + (ctx.cursor_y - out.y as f64) / ctx.camera.zoom;
 151     let cam = camera::center_on(vx, vy, ow, oh, 1.0);
 152     vec![
 153         Command::StopPanAnimation,
 154         Command::SetCamera { camera: cam, overview: Some(false), animate: true },
 155         Command::RefreshCamera,
 156     ]
 157 }
 158 
 159 /// The keyed exit (`Action::OverviewExit`). A key press carries no cursor
 160 /// position, so the focused window — not whatever the pointer was left
 161 /// hovering — is what says where you meant to land. Falls back to the
 162 /// cursor-driven exit when nothing is focused.
 163 fn exit_overview_keyed(ctx: &ActionCtx) -> Vec<Command> {
 164     match ctx.focused.and_then(|id| window(ctx, id)) {
 165         Some(win) => exit_onto_window(ctx, win),
 166         None => exit_overview(ctx),
 167     }
 168 }
 169 
 170 /// Leave overview centered on one window at zoom 1 — the shared tail of both
 171 /// exits, which differ only in how they choose the window. Focusing it is
 172 /// redundant on the keyed path (it is already focused) and the point of the
 173 /// hovered one; it is idempotent either way.
 174 fn exit_onto_window(ctx: &ActionCtx, win: &crate::api::ActionWindow) -> Vec<Command> {
 175     let out = ctx.cursor_viewport;
 176     let cam = camera::center_on(
 177         win.x + win.w / 2.0,
 178         win.y + win.h / 2.0,
 179         out.width as f64,
 180         out.height as f64,
 181         1.0,
 182     );
 183     vec![
 184         Command::Focus(win.id),
 185         Command::StopPanAnimation,
 186         Command::SetCamera { camera: cam, overview: Some(false), animate: true },
 187         Command::RefreshCamera,
 188     ]
 189 }
 190 
 191 /// Enter overview, fitting the bounding box of all eligible windows into the
 192 /// viewport. An empty desktop yields no commands at all — see the note on
 193 /// `Action::OverviewEnter` about what the mechanism does with that.
 194 fn enter_overview(ctx: &ActionCtx) -> Vec<Command> {
 195     let mut bounds: Option<(f64, f64, f64, f64)> = None;
 196     for w in ctx.windows.iter().filter(|w| w.overview_eligible) {
 197         let (min_x, min_y, max_x, max_y) =
 198             bounds.unwrap_or((f64::MAX, f64::MAX, f64::MIN, f64::MIN));
 199         bounds = Some((
 200             min_x.min(w.x),
 201             min_y.min(w.y),
 202             max_x.max(w.x + w.w),
 203             max_y.max(w.y + w.h),
 204         ));
 205     }
 206     let Some((min_x, min_y, max_x, max_y)) = bounds else { return Vec::new() };
 207     let cam = camera::fit_bounds(min_x, min_y, max_x, max_y, ctx.viewport_w, ctx.viewport_h);
 208     // Overview by fiat even when the fit lands at zoom 1 (a desktop
 209     // smaller than the screen): the next Overview must exit, not re-enter.
 210     vec![
 211         Command::SetCamera { camera: cam, overview: Some(true), animate: true },
 212         Command::RefreshCamera,
 213     ]
 214 }
 215 
 216 /// Do two virtual-space boxes share any area? Strictly — adjacent tiled
 217 /// windows merely touch (they are separated by the gap and two insets, and
 218 /// by nothing at all when both are zero), and touching is not occupying.
 219 fn boxes_overlap(ax: f64, ay: f64, aw: f64, ah: f64, b: &crate::api::ActionWindow) -> bool {
 220     const EPS: f64 = 0.5;
 221     ax < b.x + b.w - EPS && b.x < ax + aw - EPS && ay < b.y + b.h - EPS && b.y < ay + ah - EPS
 222 }
 223 
 224 /// Step the focused window one grid period in the direction of `action`.
 225 ///
 226 /// One period is what separates adjacent cells, so a tiled window that
 227 /// started cell-aligned stays cell-aligned and no snapping is needed. The
 228 /// same step serves a floating window: it has no cell, but a period is the
 229 /// one distance the grid already defines, and stepping the two kinds of
 230 /// window by the same amount keeps the four keys feeling like one motion.
 231 ///
 232 /// The two kinds differ only in what happens at the destination — see
 233 /// `move_tiled` and `move_floating`. Both decline (no commands, which the
 234 /// mechanism treats as "not mine" and, having no legacy arm for these
 235 /// actions, turns into the wanted no-op) when nothing is focused, or the
 236 /// grid is degenerate so a period is zero and the step goes nowhere. Any
 237 /// other mode — Fullscreen, and the compositor's own Popup / Overlay /
 238 /// Status / Utility roles — has no position of its own to step and declines
 239 /// too.
 240 fn move_window(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 241     let Some(win) = ctx.focused.and_then(|id| window(ctx, id)) else {
 242         return Vec::new();
 243     };
 244     let (dx, dy) = match action {
 245         Action::MoveWindowLeft => (-ctx.grid_period_x, 0.0),
 246         Action::MoveWindowRight => (ctx.grid_period_x, 0.0),
 247         Action::MoveWindowUp => (0.0, -ctx.grid_period_y),
 248         _ => (0.0, ctx.grid_period_y),
 249     };
 250     if dx == 0.0 && dy == 0.0 {
 251         return Vec::new();
 252     }
 253     match win.resolved_mode {
 254         TilingMode::Tiled => move_tiled(ctx, win, dx, dy),
 255         TilingMode::Floating => move_floating(win, dx, dy),
 256         _ => Vec::new(),
 257     }
 258 }
 259 
 260 /// A floating window just moves. Floating windows overlap freely, so there
 261 /// is no occupant to swap with and nothing to decline: whatever is at the
 262 /// destination — tiled or floating — is simply covered, exactly as a pointer
 263 /// drag would leave it. This is also the only keyboard route a floating
 264 /// window has back on screen after a restore parks it off the viewport.
 265 fn move_floating(win: &crate::api::ActionWindow, dx: f64, dy: f64) -> Vec<Command> {
 266     vec![Command::MoveWindow { id: win.id, x: win.x + dx, y: win.y + dy }, Command::Relayout]
 267 }
 268 
 269 /// A tiled window steps one cell, swapping with whatever tiled window
 270 /// already holds the destination.
 271 ///
 272 /// Occupancy is decided by overlapping the DESTINATION box against the
 273 /// other tiled windows rather than by comparing cell indices: the two agree,
 274 /// and a rect test needs nothing from the snapshot that is not already
 275 /// there. Declines when MORE than one tiled window is in the destination,
 276 /// where "swap positions with it" names no particular window. Better to
 277 /// refuse than to pick.
 278 fn move_tiled(ctx: &ActionCtx, win: &crate::api::ActionWindow, dx: f64, dy: f64) -> Vec<Command> {
 279     let (nx, ny) = (win.x + dx, win.y + dy);
 280 
 281     let mut occupants = ctx.windows.iter().filter(|w| {
 282         w.id != win.id
 283             && w.visible
 284             && w.resolved_mode == TilingMode::Tiled
 285             && boxes_overlap(nx, ny, win.w, win.h, w)
 286     });
 287     let Some(other) = occupants.next() else {
 288         return vec![Command::MoveWindow { id: win.id, x: nx, y: ny }, Command::Relayout];
 289     };
 290     if occupants.next().is_some() {
 291         return Vec::new();
 292     }
 293     // Swap origins, not boxes: each window keeps its own size, so a step onto
 294     // a differently-shaped neighbour stays a swap rather than a resize.
 295     vec![
 296         Command::MoveWindow { id: win.id, x: other.x, y: other.y },
 297         Command::MoveWindow { id: other.id, x: win.x, y: win.y },
 298         Command::Relayout,
 299     ]
 300 }
 301 
 302 /// The bare program name of a command line: first token, basename only.
 303 pub fn program_name(cmd: &str) -> String {
 304     let first_token = cmd.trim().split_whitespace().next().unwrap_or("");
 305     match first_token.rfind('/') {
 306         Some(pos) => first_token[pos + 1..].to_string(),
 307         None => first_token.to_string(),
 308     }
 309 }
 310 
 311 /// Toggle a program: if a mapped window matches its name, close it (and
 312 /// refocus if it was the focused one); otherwise spawn the command. Matching
 313 /// is case-insensitive — app_id equal to or containing (either way) the
 314 /// program name; a window with NO app_id falls back to a title-contains
 315 /// match. First match in window order wins.
 316 fn toggle(ctx: &ActionCtx, arg: Option<&str>) -> Vec<Command> {
 317     let Some(cmd) = arg else { return Vec::new() };
 318     let prog = program_name(cmd).to_lowercase();
 319     let matched = ctx.windows.iter().find(|w| {
 320         if !w.mapped {
 321             return false;
 322         }
 323         if let Some(aid) = &w.app_id {
 324             let aid = aid.to_lowercase();
 325             aid == prog || aid.contains(&prog) || prog.contains(&aid)
 326         } else if let Some(title) = &w.title {
 327             title.to_lowercase().contains(&prog)
 328         } else {
 329             false
 330         }
 331     });
 332     match matched {
 333         Some(w) => {
 334             let mut cmds = vec![Command::CloseWindow(w.id)];
 335             if ctx.focused == Some(w.id) {
 336                 cmds.push(Command::FocusNextVisible);
 337             }
 338             cmds.push(Command::Relayout);
 339             cmds
 340         }
 341         None => vec![Command::Spawn(cmd.to_string())],
 342     }
 343 }
 344 
 345 /// Close the focused window, then refocus by the mechanism's next-visible
 346 /// rule.
 347 fn close(ctx: &ActionCtx) -> Vec<Command> {
 348     let Some(id) = ctx.focused else { return Vec::new() };
 349     vec![Command::CloseWindow(id), Command::FocusNextVisible, Command::Relayout]
 350 }
 351 
 352 /// Minimize the focused window, then refocus.
 353 fn minimize(ctx: &ActionCtx) -> Vec<Command> {
 354     let Some(id) = ctx.focused else { return Vec::new() };
 355     vec![
 356         Command::SetMinimized { id, minimized: true },
 357         Command::FocusNextVisible,
 358         Command::Relayout,
 359     ]
 360 }
 361 
 362 /// The focus-cycling ring: cyclable windows in stable id order.
 363 fn focus_ring(ctx: &ActionCtx) -> Vec<&crate::api::ActionWindow> {
 364     let mut ring: Vec<_> = ctx.windows.iter().filter(|w| w.focus_cyclable).collect();
 365     ring.sort_by_key(|w| w.id.0.index);
 366     ring
 367 }
 368 
 369 /// FocusNext/FocusPrev walk the ring; with nothing focused they start at
 370 /// its first/last entry.
 371 fn focus_cycle(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 372     let ring = focus_ring(ctx);
 373     let n = ring.len();
 374     if n == 0 {
 375         return Vec::new();
 376     }
 377     let forward = action == Action::FocusNext;
 378     let current = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
 379     let target = match current {
 380         Some(idx) => {
 381             if forward {
 382                 (idx + 1) % n
 383             } else {
 384                 (idx + n - 1) % n
 385             }
 386         }
 387         None => {
 388             if forward {
 389                 0
 390             } else {
 391                 n - 1
 392             }
 393         }
 394     };
 395     let id = ring[target].id;
 396     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 397 }
 398 
 399 /// Directional focus over the ring's window footprints on the virtual surface.
 400 fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 401     let ring = focus_ring(ctx);
 402     let rects: Vec<focus::Rect> = ring
 403         .iter()
 404         .map(|w| focus::Rect::new(w.x, w.y, w.w * w.scale, w.h * w.scale))
 405         .collect();
 406     let focused_idx = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
 407     let dir = focus::Direction::from_action(action)
 408         .expect("arm only matches directional focus actions");
 409     let Some(target) = focus::directional_focus(&rects, focused_idx, dir) else {
 410         return Vec::new();
 411     };
 412     let id = ring[target].id;
 413     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 414 }
 415 
 416 /// Toggle fullscreen on the focused window. Leaving fullscreen puts the
 417 /// window back the way the toggle found it — mode AND lock, so a window
 418 /// tiled by hand stays tiled (the mechanism records both when it applies
 419 /// the Fullscreen `SetWindowMode`). Without that record it unlocks to the
 420 /// viewport-resolved mode (Floating if that resolution is itself
 421 /// Fullscreen). Restoring matters beyond the mode itself: Tiled tells the
 422 /// client it is maximized, and Chromium-family clients drop their
 423 /// client-side shadow band only then.
 424 fn fullscreen(ctx: &ActionCtx) -> Vec<Command> {
 425     let Some(id) = ctx.focused else { return Vec::new() };
 426     let Some(win) = window(ctx, id) else { return Vec::new() };
 427     let cmd = if win.mode == TilingMode::Fullscreen {
 428         let (mode, locked) = match win.pre_fullscreen {
 429             Some((mode, locked)) if mode != TilingMode::Fullscreen => (mode, locked),
 430             _ => {
 431                 let target = if win.resolved_mode == TilingMode::Fullscreen {
 432                     TilingMode::Floating
 433                 } else {
 434                     win.resolved_mode
 435                 };
 436                 (target, false)
 437             }
 438         };
 439         Command::SetWindowMode { id, mode, locked }
 440     } else {
 441         Command::SetWindowMode { id, mode: TilingMode::Fullscreen, locked: true }
 442     };
 443     vec![cmd, Command::Relayout]
 444 }
 445 
 446 /// The keyed mode cycle.
 447 fn next_mode(current: TilingMode) -> TilingMode {
 448     let cycle = [TilingMode::Floating, TilingMode::Fullscreen];
 449     cycle
 450         .iter()
 451         .position(|m| *m == current)
 452         .map(|i| cycle[(i + 1) % cycle.len()])
 453         .unwrap_or(TilingMode::Floating)
 454 }
 455 
 456 /// Cycle the focused window's mode.
 457 fn mode_next(ctx: &ActionCtx) -> Vec<Command> {
 458     let Some(id) = ctx.focused else { return Vec::new() };
 459     let Some(win) = window(ctx, id) else { return Vec::new() };
 460     vec![
 461         Command::SetWindowMode { id, mode: next_mode(win.mode), locked: true },
 462         Command::Relayout,
 463     ]
 464 }
 465 
 466 /// Cycle every visible window that shares the focused window's mode.
 467 fn mode_next_shared(ctx: &ActionCtx) -> Vec<Command> {
 468     let Some(id) = ctx.focused else { return Vec::new() };
 469     let Some(win) = window(ctx, id) else { return Vec::new() };
 470     let current = win.mode;
 471     let next = next_mode(current);
 472     let mut cmds: Vec<Command> = ctx
 473         .windows
 474         .iter()
 475         .filter(|w| w.visible && w.mode == current)
 476         .map(|w| Command::SetWindowMode { id: w.id, mode: next, locked: true })
 477         .collect();
 478     cmds.push(Command::Relayout);
 479     cmds
 480 }
 481 
 482 /// Stock command line for a media-key action (PipeWire's wpctl for audio,
 483 /// brightnessctl for the backlight). A binding's `command="..."` property
 484 /// overrides this wholesale — that's where a custom step size or a different
 485 /// mixer goes.
 486 fn media_command(action: Action) -> &'static str {
 487     match action {
 488         Action::VolumeUp => "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+",
 489         Action::VolumeDown => "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
 490         Action::VolumeMute => "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
 491         Action::MicMute => "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle",
 492         Action::BrightnessUp => "brightnessctl set 5%+",
 493         Action::BrightnessDown => "brightnessctl set 5%-",
 494         _ => "",
 495     }
 496 }
 497 
 498 #[cfg(test)]
 499 mod tests {
 500     use super::*;
 501     use crate::api::{ActionWindow, Rect};
 502     use crate::slotmap::Key;
 503 
 504     fn wid(index: u32) -> WindowId {
 505         WindowId(Key { generation: 0, index })
 506     }
 507 
 508     /// A plain visible, cyclable, overview-eligible Floating window.
 509     fn win(index: u32, x: f64, y: f64, w: f64, h: f64) -> ActionWindow {
 510         ActionWindow {
 511             id: wid(index),
 512             app_id: None,
 513             title: None,
 514             mapped: true,
 515             x,
 516             y,
 517             w,
 518             h,
 519             scale: 1.0,
 520             mode: TilingMode::Floating,
 521             resolved_mode: TilingMode::Floating,
 522             pre_fullscreen: None,
 523             visible: true,
 524             focus_cyclable: true,
 525             overview_eligible: true,
 526         }
 527     }
 528 
 529     fn ctx() -> ActionCtx {
 530         ActionCtx {
 531             camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
 532             overview: false,
 533             pan_target_x: None,
 534             pan_target_y: None,
 535             viewport_w: 1920.0,
 536             viewport_h: 1080.0,
 537             cursor_viewport: Rect { x: 0, y: 0, width: 1920, height: 1080 },
 538             has_cursor: true,
 539             cursor_x: 960.0,
 540             cursor_y: 540.0,
 541             hovered: None,
 542             focused: None,
 543             grid_period_x: 512.0,
 544             grid_period_y: 512.0,
 545             windows: Vec::new(),
 546         }
 547     }
 548 
 549     fn dispatch(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 550         DefaultPolicy.action(ctx, action, None)
 551     }
 552 
 553     #[test]
 554     fn toggle_spawns_or_closes_by_program_match() {
 555         let mut c = ctx();
 556         // No arg: not claimed. No match: spawn.
 557         assert!(dispatch(&c, Action::Toggle).is_empty());
 558         assert_eq!(
 559             DefaultPolicy.action(&c, Action::Toggle, Some("firefox --new-window")),
 560             vec![Command::Spawn("firefox --new-window".to_string())]
 561         );
 562         // App-id containment match (either direction, case-insensitive)
 563         // closes; the focused match also refocuses.
 564         let mut w = win(1, 0.0, 0.0, 100.0, 100.0);
 565         w.app_id = Some("org.mozilla.Firefox".to_string());
 566         c.windows.push(w);
 567         assert_eq!(
 568             DefaultPolicy.action(&c, Action::Toggle, Some("/usr/bin/firefox -P")),
 569             vec![Command::CloseWindow(wid(1)), Command::Relayout]
 570         );
 571         c.focused = Some(wid(1));
 572         assert_eq!(
 573             DefaultPolicy.action(&c, Action::Toggle, Some("firefox")),
 574             vec![Command::CloseWindow(wid(1)), Command::FocusNextVisible, Command::Relayout]
 575         );
 576         // A window without an app_id falls back to title-contains; an
 577         // unmapped window never matches.
 578         let mut t = win(2, 0.0, 0.0, 100.0, 100.0);
 579         t.title = Some("Alacritty scratchpad".to_string());
 580         c.windows.push(t);
 581         assert_eq!(
 582             DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
 583             vec![Command::CloseWindow(wid(2)), Command::Relayout]
 584         );
 585         c.windows[1].mapped = false;
 586         assert_eq!(
 587             DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
 588             vec![Command::Spawn("alacritty".to_string())]
 589         );
 590     }
 591 
 592     #[test]
 593     fn program_name_takes_first_token_basename() {
 594         assert_eq!(program_name("/usr/bin/firefox --new-window"), "firefox");
 595         assert_eq!(program_name("  alacritty -e htop "), "alacritty");
 596         assert_eq!(program_name(""), "");
 597     }
 598 
 599     #[test]
 600     fn unclaimed_actions_return_empty() {
 601         assert!(dispatch(&ctx(), Action::Spawn).is_empty());
 602         assert!(dispatch(&ctx(), Action::Reload).is_empty());
 603         assert!(dispatch(&ctx(), Action::Exit).is_empty());
 604     }
 605 
 606     #[test]
 607     fn close_and_minimize_need_focus_and_refocus() {
 608         assert!(dispatch(&ctx(), Action::Close).is_empty());
 609         let mut c = ctx();
 610         c.focused = Some(wid(4));
 611         assert_eq!(
 612             dispatch(&c, Action::Close),
 613             vec![Command::CloseWindow(wid(4)), Command::FocusNextVisible, Command::Relayout]
 614         );
 615         assert_eq!(
 616             dispatch(&c, Action::Minimize),
 617             vec![
 618                 Command::SetMinimized { id: wid(4), minimized: true },
 619                 Command::FocusNextVisible,
 620                 Command::Relayout
 621             ]
 622         );
 623     }
 624 
 625     #[test]
 626     fn focus_cycle_walks_id_order_and_wraps() {
 627         let mut c = ctx();
 628         // Inserted out of id order; the ring sorts by id: 1, 5, 9.
 629         c.windows.push(win(9, 0.0, 0.0, 100.0, 100.0));
 630         c.windows.push(win(1, 200.0, 0.0, 100.0, 100.0));
 631         c.windows.push(win(5, 400.0, 0.0, 100.0, 100.0));
 632         c.focused = Some(wid(9));
 633         // Next from the last entry wraps to the first.
 634         assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
 635         // Prev from 9 goes to 5.
 636         assert_eq!(dispatch(&c, Action::FocusPrev)[0], Command::Focus(wid(5)));
 637         // No focus: Next starts at the ring's first entry.
 638         c.focused = None;
 639         assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
 640         // Non-cyclable windows are not in the ring.
 641         for w in c.windows.iter_mut() {
 642             w.focus_cyclable = false;
 643         }
 644         assert!(dispatch(&c, Action::FocusNext).is_empty());
 645     }
 646 
 647     #[test]
 648     fn focus_directional_picks_by_footprint() {
 649         let mut c = ctx();
 650         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 651         c.windows.push(win(2, 500.0, 0.0, 100.0, 100.0));
 652         c.focused = Some(wid(1));
 653         let cmds = dispatch(&c, Action::FocusRight);
 654         assert_eq!(cmds[0], Command::Focus(wid(2)));
 655         assert_eq!(cmds[1], Command::Raise(wid(2)));
 656         // Nothing to the left of window 1.
 657         assert!(dispatch(&c, Action::FocusLeft).is_empty());
 658         // The footprint is the scaled extent. A window starting at 80 is
 659         // past window 1's midpoint (50) at scale 1 and wins as the nearer
 660         // edge; at scale 2 window 1 spans 0..200, so it is stacked, not
 661         // ahead, and window 2 wins again.
 662         c.windows.push(win(3, 80.0, 0.0, 100.0, 100.0));
 663         assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(3)));
 664         c.windows[0].scale = 2.0;
 665         assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(2)));
 666     }
 667 
 668     #[test]
 669     fn fullscreen_toggles_and_unlocks_to_resolved_mode() {
 670         let mut c = ctx();
 671         c.focused = Some(wid(1));
 672         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 673         // Enter: lock to Fullscreen.
 674         assert_eq!(
 675             dispatch(&c, Action::Fullscreen)[0],
 676             Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true }
 677         );
 678         // Exit: unlock back to the resolved mode.
 679         c.windows[0].mode = TilingMode::Fullscreen;
 680         c.windows[0].resolved_mode = TilingMode::Tiled;
 681         assert_eq!(
 682             dispatch(&c, Action::Fullscreen)[0],
 683             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
 684         );
 685         // Exit when the viewport itself resolves Fullscreen: fall to Floating.
 686         c.windows[0].resolved_mode = TilingMode::Fullscreen;
 687         assert_eq!(
 688             dispatch(&c, Action::Fullscreen)[0],
 689             Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
 690         );
 691     }
 692 
 693     #[test]
 694     fn fullscreen_exit_restores_the_recorded_mode_and_lock() {
 695         let mut c = ctx();
 696         c.focused = Some(wid(1));
 697         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 698         c.windows[0].mode = TilingMode::Fullscreen;
 699         // A hand-tiled (locked) window comes back Tiled and locked, whatever
 700         // the viewport would resolve it to.
 701         c.windows[0].resolved_mode = TilingMode::Floating;
 702         c.windows[0].pre_fullscreen = Some((TilingMode::Tiled, true));
 703         assert_eq!(
 704             dispatch(&c, Action::Fullscreen)[0],
 705             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: true }
 706         );
 707         // An unlocked Floating window comes back unlocked.
 708         c.windows[0].pre_fullscreen = Some((TilingMode::Floating, false));
 709         assert_eq!(
 710             dispatch(&c, Action::Fullscreen)[0],
 711             Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
 712         );
 713         // A record that itself says Fullscreen is useless: resolved-mode fallback.
 714         c.windows[0].resolved_mode = TilingMode::Tiled;
 715         c.windows[0].pre_fullscreen = Some((TilingMode::Fullscreen, true));
 716         assert_eq!(
 717             dispatch(&c, Action::Fullscreen)[0],
 718             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
 719         );
 720     }
 721 
 722     #[test]
 723     fn mode_next_cycles_and_shared_hits_all_matching() {
 724         let mut c = ctx();
 725         c.focused = Some(wid(1));
 726         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 727         c.windows.push(win(2, 200.0, 0.0, 100.0, 100.0));
 728         let mut hidden = win(3, 400.0, 0.0, 100.0, 100.0);
 729         hidden.visible = false;
 730         c.windows.push(hidden);
 731         // Floating -> Fullscreen on the focused window only.
 732         assert_eq!(
 733             dispatch(&c, Action::ModeNext),
 734             vec![
 735                 Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
 736                 Command::Relayout
 737             ]
 738         );
 739         // Shared: every visible window in the focused window's mode cycles;
 740         // the invisible one is untouched.
 741         assert_eq!(
 742             dispatch(&c, Action::ModeNextShared),
 743             vec![
 744                 Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
 745                 Command::SetWindowMode { id: wid(2), mode: TilingMode::Fullscreen, locked: true },
 746                 Command::Relayout
 747             ]
 748         );
 749     }
 750 
 751     #[test]
 752     fn zoom_in_sets_overview_and_relayouts() {
 753         let cmds = dispatch(&ctx(), Action::ZoomIn);
 754         assert_eq!(cmds.len(), 2);
 755         let Command::SetCamera { camera, overview, .. } = cmds[0] else { panic!() };
 756         assert!((camera.zoom - 1.1).abs() < 1e-9);
 757         assert_eq!(overview, Some(true));
 758         assert_eq!(cmds[1], Command::Relayout);
 759         // Reset from zoomed goes back to normal.
 760         let mut c = ctx();
 761         c.camera.zoom = 2.0;
 762         let Command::SetCamera { camera, overview, .. } = dispatch(&c, Action::ZoomReset)[0] else { panic!() };
 763         assert_eq!(camera.zoom, 1.0);
 764         assert_eq!(overview, Some(false));
 765     }
 766 
 767     #[test]
 768     fn pan_steps_one_aligned_cell_from_pending_target() {
 769         let Command::PanTo { x, y } = dispatch(&ctx(), Action::PanRight)[0] else { panic!() };
 770         assert_eq!((x, y), (Some(512.0), None));
 771         // A pending target queues the next cell from there.
 772         let mut c = ctx();
 773         c.pan_target_x = Some(512.0);
 774         let Command::PanTo { x, .. } = dispatch(&c, Action::PanRight)[0] else { panic!() };
 775         assert_eq!(x, Some(1024.0));
 776     }
 777 
 778     #[test]
 779     fn overview_enter_fits_eligible_windows_only() {
 780         let mut c = ctx();
 781         c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
 782         let mut ineligible = win(2, 5000.0, 0.0, 400.0, 300.0);
 783         ineligible.overview_eligible = false;
 784         c.windows.push(ineligible);
 785         let cmds = dispatch(&c, Action::Overview);
 786         let Command::SetCamera { camera, overview, .. } = cmds[0] else { panic!() };
 787         assert_eq!(overview, Some(true));
 788         // Only window 1 counts: 400x300 fits without zooming out.
 789         assert_eq!(camera.zoom, 1.0);
 790         assert_eq!(cmds[1], Command::RefreshCamera);
 791         // No eligible windows: not claimed, nothing happens.
 792         c.windows.clear();
 793         assert!(dispatch(&c, Action::Overview).is_empty());
 794     }
 795 
 796     #[test]
 797     fn overview_exit_prefers_the_hovered_window() {
 798         let mut c = ctx();
 799         c.overview = true;
 800         c.camera.zoom = 0.5;
 801         c.windows.push(win(3, 1000.0, 2000.0, 400.0, 300.0));
 802         c.hovered = Some(wid(3));
 803         let cmds = dispatch(&c, Action::Overview);
 804         assert_eq!(cmds[0], Command::Focus(wid(3)));
 805         assert_eq!(cmds[1], Command::StopPanAnimation);
 806         let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
 807         assert_eq!(overview, Some(false));
 808         assert_eq!(camera.zoom, 1.0);
 809         // Centered on the window's center (1200, 2150).
 810         assert_eq!(camera.pan_x, 1200.0 - 960.0);
 811         assert_eq!(camera.pan_y, 2150.0 - 540.0);
 812         // Without a hovered window, exit centers the point under the cursor.
 813         c.hovered = None;
 814         c.camera.pan_x = 100.0;
 815         let Command::SetCamera { camera, .. } = dispatch(&c, Action::Overview)[1] else { panic!() };
 816         // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
 817         assert_eq!(camera.pan_x, 2020.0 - 960.0);
 818     }
 819 
 820     #[test]
 821     fn one_way_overview_actions_only_fire_in_the_other_mode() {
 822         let mut c = ctx();
 823         c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
 824 
 825         // Normal mode: Enter does the same thing the toggle would, Exit is
 826         // a no-op (you are already where it would take you).
 827         assert_eq!(dispatch(&c, Action::OverviewEnter), dispatch(&c, Action::Overview));
 828         let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewEnter)[0] else {
 829             panic!()
 830         };
 831         assert_eq!(overview, Some(true));
 832         assert!(dispatch(&c, Action::OverviewExit).is_empty());
 833 
 834         // Overview mode: exactly the reverse.
 835         c.overview = true;
 836         c.camera.zoom = 0.5;
 837         assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
 838         let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewExit)[1] else {
 839             panic!()
 840         };
 841         assert_eq!(overview, Some(false));
 842         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
 843     }
 844 
 845     #[test]
 846     fn the_keyed_exit_lands_on_the_focused_window_not_the_hovered_one() {
 847         let mut c = ctx();
 848         c.overview = true;
 849         c.camera.zoom = 0.5;
 850         c.windows.push(win(1, 1000.0, 2000.0, 400.0, 300.0)); // focused
 851         c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0)); // under the pointer
 852         c.focused = Some(wid(1));
 853         c.hovered = Some(wid(2));
 854 
 855         // The pointer is over window 2, but a key press said nothing about
 856         // the pointer: window 1 wins, and gets (re)focused.
 857         let cmds = dispatch(&c, Action::OverviewExit);
 858         assert_eq!(cmds[0], Command::Focus(wid(1)));
 859         let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
 860         assert_eq!(overview, Some(false));
 861         assert_eq!(camera.zoom, 1.0);
 862         // Centered on window 1's center (1200, 2150).
 863         assert_eq!(camera.pan_x, 1200.0 - 960.0);
 864         assert_eq!(camera.pan_y, 2150.0 - 540.0);
 865 
 866         // The toggle is the cursor-driven path and still prefers the hover.
 867         assert_eq!(dispatch(&c, Action::Overview)[0], Command::Focus(wid(2)));
 868     }
 869 
 870     #[test]
 871     fn the_keyed_exit_falls_back_to_the_cursor_with_nothing_focused() {
 872         let mut c = ctx();
 873         c.overview = true;
 874         c.camera.zoom = 0.5;
 875         c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0));
 876         c.focused = None;
 877         c.hovered = Some(wid(2));
 878         assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
 879 
 880         // ...and with neither, onto the virtual point under the cursor.
 881         c.hovered = None;
 882         c.camera.pan_x = 100.0;
 883         let Command::SetCamera { camera, .. } = dispatch(&c, Action::OverviewExit)[1] else {
 884             panic!()
 885         };
 886         // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
 887         assert_eq!(camera.pan_x, 2020.0 - 960.0);
 888     }
 889 
 890     #[test]
 891     fn overview_enter_on_an_empty_desktop_is_not_claimed() {
 892         // Nothing to fit, so no camera to compute — same empty list the
 893         // toggle returns, and for the same reason.
 894         let c = ctx();
 895         assert!(c.windows.is_empty());
 896         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
 897     }
 898 
 899     /// A ctx on a 100px grid period, so cell (col,row) sits at (col*100,
 900     /// row*100) and the arithmetic in these tests reads directly.
 901     fn grid_ctx() -> ActionCtx {
 902         let mut c = ctx();
 903         c.grid_period_x = 100.0;
 904         c.grid_period_y = 100.0;
 905         c
 906     }
 907 
 908     /// A tiled window one cell wide/high at cell (col,row) on `grid_ctx`.
 909     fn tiled_at(index: u32, col: f64, row: f64) -> ActionWindow {
 910         let mut w = win(index, col * 100.0, row * 100.0, 90.0, 90.0);
 911         w.mode = TilingMode::Tiled;
 912         w.resolved_mode = TilingMode::Tiled;
 913         w
 914     }
 915 
 916     #[test]
 917     fn a_tiled_window_steps_one_cell_into_empty_space() {
 918         let mut c = grid_ctx();
 919         c.windows.push(tiled_at(1, 2.0, 3.0));
 920         c.focused = Some(wid(1));
 921 
 922         for (action, x, y) in [
 923             (Action::MoveWindowRight, 300.0, 300.0),
 924             (Action::MoveWindowLeft, 100.0, 300.0),
 925             (Action::MoveWindowDown, 200.0, 400.0),
 926             (Action::MoveWindowUp, 200.0, 200.0),
 927         ] {
 928             let cmds = dispatch(&c, action);
 929             assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x, y }, "{:?}", action);
 930             assert_eq!(cmds[1], Command::Relayout);
 931         }
 932     }
 933 
 934     #[test]
 935     fn stepping_onto_a_tiled_neighbour_swaps_the_two() {
 936         let mut c = grid_ctx();
 937         c.windows.push(tiled_at(1, 2.0, 3.0));
 938         c.windows.push(tiled_at(2, 3.0, 3.0)); // directly to the right
 939         c.focused = Some(wid(1));
 940 
 941         // Right: lands on window 2, so the two exchange origins.
 942         let cmds = dispatch(&c, Action::MoveWindowRight);
 943         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
 944         assert_eq!(cmds[1], Command::MoveWindow { id: wid(2), x: 200.0, y: 300.0 });
 945         assert_eq!(cmds[2], Command::Relayout);
 946 
 947         // Left is still empty, so that direction is an ordinary move.
 948         let cmds = dispatch(&c, Action::MoveWindowLeft);
 949         assert_eq!(cmds.len(), 2);
 950         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
 951     }
 952 
 953     #[test]
 954     fn a_swap_keeps_each_window_its_own_size() {
 955         let mut c = grid_ctx();
 956         c.windows.push(tiled_at(1, 2.0, 3.0));
 957         let mut wide = tiled_at(2, 3.0, 3.0);
 958         wide.w = 190.0; // two cells wide
 959         c.windows.push(wide);
 960         c.focused = Some(wid(1));
 961 
 962         // Only origins move; neither command carries a size.
 963         let cmds = dispatch(&c, Action::MoveWindowRight);
 964         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
 965         assert_eq!(cmds[1], Command::MoveWindow { id: wid(2), x: 200.0, y: 300.0 });
 966     }
 967 
 968     #[test]
 969     fn a_floating_window_steps_one_period_too() {
 970         let mut c = grid_ctx();
 971         // Floating, and deliberately NOT cell-aligned: the step is a plain
 972         // offset by one period, no snapping to the grid.
 973         c.windows.push(win(1, 230.0, 310.0, 700.0, 666.0));
 974         c.focused = Some(wid(1));
 975 
 976         for (action, x, y) in [
 977             (Action::MoveWindowRight, 330.0, 310.0),
 978             (Action::MoveWindowLeft, 130.0, 310.0),
 979             (Action::MoveWindowDown, 230.0, 410.0),
 980             (Action::MoveWindowUp, 230.0, 210.0),
 981         ] {
 982             let cmds = dispatch(&c, action);
 983             assert_eq!(cmds, vec![Command::MoveWindow { id: wid(1), x, y }, Command::Relayout], "{:?}", action);
 984         }
 985     }
 986 
 987     #[test]
 988     fn a_floating_window_covers_the_destination_instead_of_swapping() {
 989         let mut c = grid_ctx();
 990         c.windows.push(win(1, 200.0, 300.0, 90.0, 90.0)); // floating, focused
 991         c.windows.push(tiled_at(2, 3.0, 3.0)); // tiled, directly to the right
 992         c.windows.push(win(3, 300.0, 300.0, 90.0, 90.0)); // floating, same cell
 993         c.focused = Some(wid(1));
 994 
 995         // Floating windows overlap freely: only the focused one moves, and
 996         // neither occupant — tiled or floating — is displaced.
 997         let cmds = dispatch(&c, Action::MoveWindowRight);
 998         assert_eq!(cmds, vec![Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 }, Command::Relayout]);
 999     }
1000 
1001     #[test]
1002     fn only_tiled_and_floating_windows_step() {
1003         let mut c = grid_ctx();
1004         // Fullscreen (and the internal roles) have no position of their own.
1005         let mut fs = win(1, 200.0, 300.0, 90.0, 90.0);
1006         fs.mode = TilingMode::Fullscreen;
1007         fs.resolved_mode = TilingMode::Fullscreen;
1008         c.windows.push(fs);
1009         c.focused = Some(wid(1));
1010         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1011 
1012         // Nothing focused at all.
1013         c.windows[0].mode = TilingMode::Tiled;
1014         c.windows[0].resolved_mode = TilingMode::Tiled;
1015         c.focused = None;
1016         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1017 
1018         // A degenerate grid: the step goes nowhere, for either kind.
1019         c.focused = Some(wid(1));
1020         c.grid_period_x = 0.0;
1021         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1022         c.windows[0].mode = TilingMode::Floating;
1023         c.windows[0].resolved_mode = TilingMode::Floating;
1024         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1025     }
1026 
1027     #[test]
1028     fn a_floating_window_in_the_way_is_not_swapped_with() {
1029         let mut c = grid_ctx();
1030         c.windows.push(tiled_at(1, 2.0, 3.0));
1031         c.windows.push(win(2, 300.0, 300.0, 90.0, 90.0)); // floating, in the destination
1032         c.focused = Some(wid(1));
1033         // The step happens anyway: only tiled windows hold cells.
1034         let cmds = dispatch(&c, Action::MoveWindowRight);
1035         assert_eq!(cmds.len(), 2);
1036         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
1037     }
1038 
1039     #[test]
1040     fn an_ambiguous_swap_is_declined() {
1041         let mut c = grid_ctx();
1042         // A two-cell-tall window stepping right onto TWO stacked neighbours:
1043         // "swap with it" names neither, so nothing happens.
1044         let mut tall = tiled_at(1, 2.0, 3.0);
1045         tall.h = 190.0;
1046         c.windows.push(tall);
1047         c.windows.push(tiled_at(2, 3.0, 3.0));
1048         c.windows.push(tiled_at(3, 3.0, 4.0));
1049         c.focused = Some(wid(1));
1050         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1051     }
1052 
1053     #[test]
1054     fn merely_touching_the_neighbour_is_not_occupying() {
1055         // Zero gap and zero inset: cells abut exactly, so a window's right
1056         // edge sits on its neighbour's left edge. Stepping AWAY from it must
1057         // not read that shared edge as an overlap.
1058         let mut c = grid_ctx();
1059         let mut a = tiled_at(1, 2.0, 3.0);
1060         a.w = 100.0;
1061         let mut b = tiled_at(2, 3.0, 3.0);
1062         b.w = 100.0;
1063         c.windows.push(a);
1064         c.windows.push(b);
1065         c.focused = Some(wid(1));
1066         let cmds = dispatch(&c, Action::MoveWindowLeft);
1067         assert_eq!(cmds.len(), 2, "stepping left should be a plain move");
1068         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
1069     }
1070 
1071     #[test]
1072     fn media_keys_spawn_stock_or_overridden_command() {
1073         let c = ctx();
1074         // Every media action is claimed and spawns its stock command.
1075         for action in [
1076             Action::VolumeUp, Action::VolumeDown, Action::VolumeMute,
1077             Action::MicMute, Action::BrightnessUp, Action::BrightnessDown,
1078         ] {
1079             assert_eq!(
1080                 dispatch(&c, action),
1081                 vec![Command::Spawn(media_command(action).to_string())],
1082                 "{}", action.name()
1083             );
1084         }
1085         assert_eq!(
1086             dispatch(&c, Action::VolumeMute),
1087             vec![Command::Spawn("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle".to_string())]
1088         );
1089         // A binding's command= property replaces the stock command.
1090         assert_eq!(
1091             DefaultPolicy.action(&c, Action::BrightnessUp, Some("brightnessctl set 10%+")),
1092             vec![Command::Spawn("brightnessctl set 10%+".to_string())]
1093         );
1094     }
1095 }