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

src/actions.rs (44.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 /// Focus toward a free direction `v` (virtual units, y down) — the
 417 /// three-finger swipe's path, where the fingers give a vector rather than
 418 /// one of four directions. See `focus::vector_focus`; `cone_deg` is how far
 419 /// off the ray a window center may lie. Empty when there is no ray (nothing
 420 /// focused) or nothing within the cone, which the caller reads as "no
 421 /// focus change" — or, with nothing focused, falls back to the four-way
 422 /// action for its entry rule.
 423 pub fn focus_toward(ctx: &ActionCtx, v: (f64, f64), cone_deg: f64) -> Vec<Command> {
 424     let ring = focus_ring(ctx);
 425     let rects: Vec<focus::Rect> = ring
 426         .iter()
 427         .map(|w| focus::Rect::new(w.x, w.y, w.w * w.scale, w.h * w.scale))
 428         .collect();
 429     let focused_idx = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
 430     let Some(target) = focus::vector_focus(&rects, focused_idx, v, cone_deg) else {
 431         return Vec::new();
 432     };
 433     let id = ring[target].id;
 434     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 435 }
 436 
 437 /// Toggle fullscreen on the focused window. Leaving fullscreen puts the
 438 /// window back the way the toggle found it — mode AND lock, so a window
 439 /// tiled by hand stays tiled (the mechanism records both when it applies
 440 /// the Fullscreen `SetWindowMode`). Without that record it unlocks to the
 441 /// viewport-resolved mode (Floating if that resolution is itself
 442 /// Fullscreen). Restoring matters beyond the mode itself: Tiled tells the
 443 /// client it is maximized, and Chromium-family clients drop their
 444 /// client-side shadow band only then.
 445 fn fullscreen(ctx: &ActionCtx) -> Vec<Command> {
 446     let Some(id) = ctx.focused else { return Vec::new() };
 447     let Some(win) = window(ctx, id) else { return Vec::new() };
 448     let cmd = if win.mode == TilingMode::Fullscreen {
 449         let (mode, locked) = match win.pre_fullscreen {
 450             Some((mode, locked)) if mode != TilingMode::Fullscreen => (mode, locked),
 451             _ => {
 452                 let target = if win.resolved_mode == TilingMode::Fullscreen {
 453                     TilingMode::Floating
 454                 } else {
 455                     win.resolved_mode
 456                 };
 457                 (target, false)
 458             }
 459         };
 460         Command::SetWindowMode { id, mode, locked }
 461     } else {
 462         Command::SetWindowMode { id, mode: TilingMode::Fullscreen, locked: true }
 463     };
 464     vec![cmd, Command::Relayout]
 465 }
 466 
 467 /// The keyed mode cycle.
 468 fn next_mode(current: TilingMode) -> TilingMode {
 469     let cycle = [TilingMode::Floating, TilingMode::Fullscreen];
 470     cycle
 471         .iter()
 472         .position(|m| *m == current)
 473         .map(|i| cycle[(i + 1) % cycle.len()])
 474         .unwrap_or(TilingMode::Floating)
 475 }
 476 
 477 /// Cycle the focused window's mode.
 478 fn mode_next(ctx: &ActionCtx) -> Vec<Command> {
 479     let Some(id) = ctx.focused else { return Vec::new() };
 480     let Some(win) = window(ctx, id) else { return Vec::new() };
 481     vec![
 482         Command::SetWindowMode { id, mode: next_mode(win.mode), locked: true },
 483         Command::Relayout,
 484     ]
 485 }
 486 
 487 /// Cycle every visible window that shares the focused window's mode.
 488 fn mode_next_shared(ctx: &ActionCtx) -> Vec<Command> {
 489     let Some(id) = ctx.focused else { return Vec::new() };
 490     let Some(win) = window(ctx, id) else { return Vec::new() };
 491     let current = win.mode;
 492     let next = next_mode(current);
 493     let mut cmds: Vec<Command> = ctx
 494         .windows
 495         .iter()
 496         .filter(|w| w.visible && w.mode == current)
 497         .map(|w| Command::SetWindowMode { id: w.id, mode: next, locked: true })
 498         .collect();
 499     cmds.push(Command::Relayout);
 500     cmds
 501 }
 502 
 503 /// Stock command line for a media-key action (PipeWire's wpctl for audio,
 504 /// brightnessctl for the backlight). A binding's `command="..."` property
 505 /// overrides this wholesale — that's where a custom step size or a different
 506 /// mixer goes.
 507 fn media_command(action: Action) -> &'static str {
 508     match action {
 509         Action::VolumeUp => "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+",
 510         Action::VolumeDown => "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
 511         Action::VolumeMute => "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
 512         Action::MicMute => "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle",
 513         Action::BrightnessUp => "brightnessctl set 5%+",
 514         Action::BrightnessDown => "brightnessctl set 5%-",
 515         _ => "",
 516     }
 517 }
 518 
 519 #[cfg(test)]
 520 mod tests {
 521     use super::*;
 522     use crate::api::{ActionWindow, Rect};
 523     use crate::slotmap::Key;
 524 
 525     fn wid(index: u32) -> WindowId {
 526         WindowId(Key { generation: 0, index })
 527     }
 528 
 529     /// A plain visible, cyclable, overview-eligible Floating window.
 530     fn win(index: u32, x: f64, y: f64, w: f64, h: f64) -> ActionWindow {
 531         ActionWindow {
 532             id: wid(index),
 533             app_id: None,
 534             title: None,
 535             mapped: true,
 536             x,
 537             y,
 538             w,
 539             h,
 540             scale: 1.0,
 541             mode: TilingMode::Floating,
 542             resolved_mode: TilingMode::Floating,
 543             pre_fullscreen: None,
 544             visible: true,
 545             focus_cyclable: true,
 546             overview_eligible: true,
 547         }
 548     }
 549 
 550     fn ctx() -> ActionCtx {
 551         ActionCtx {
 552             camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
 553             overview: false,
 554             pan_target_x: None,
 555             pan_target_y: None,
 556             viewport_w: 1920.0,
 557             viewport_h: 1080.0,
 558             cursor_viewport: Rect { x: 0, y: 0, width: 1920, height: 1080 },
 559             has_cursor: true,
 560             cursor_x: 960.0,
 561             cursor_y: 540.0,
 562             hovered: None,
 563             focused: None,
 564             grid_period_x: 512.0,
 565             grid_period_y: 512.0,
 566             windows: Vec::new(),
 567         }
 568     }
 569 
 570     fn dispatch(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 571         DefaultPolicy.action(ctx, action, None)
 572     }
 573 
 574     #[test]
 575     fn toggle_spawns_or_closes_by_program_match() {
 576         let mut c = ctx();
 577         // No arg: not claimed. No match: spawn.
 578         assert!(dispatch(&c, Action::Toggle).is_empty());
 579         assert_eq!(
 580             DefaultPolicy.action(&c, Action::Toggle, Some("firefox --new-window")),
 581             vec![Command::Spawn("firefox --new-window".to_string())]
 582         );
 583         // App-id containment match (either direction, case-insensitive)
 584         // closes; the focused match also refocuses.
 585         let mut w = win(1, 0.0, 0.0, 100.0, 100.0);
 586         w.app_id = Some("org.mozilla.Firefox".to_string());
 587         c.windows.push(w);
 588         assert_eq!(
 589             DefaultPolicy.action(&c, Action::Toggle, Some("/usr/bin/firefox -P")),
 590             vec![Command::CloseWindow(wid(1)), Command::Relayout]
 591         );
 592         c.focused = Some(wid(1));
 593         assert_eq!(
 594             DefaultPolicy.action(&c, Action::Toggle, Some("firefox")),
 595             vec![Command::CloseWindow(wid(1)), Command::FocusNextVisible, Command::Relayout]
 596         );
 597         // A window without an app_id falls back to title-contains; an
 598         // unmapped window never matches.
 599         let mut t = win(2, 0.0, 0.0, 100.0, 100.0);
 600         t.title = Some("Alacritty scratchpad".to_string());
 601         c.windows.push(t);
 602         assert_eq!(
 603             DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
 604             vec![Command::CloseWindow(wid(2)), Command::Relayout]
 605         );
 606         c.windows[1].mapped = false;
 607         assert_eq!(
 608             DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
 609             vec![Command::Spawn("alacritty".to_string())]
 610         );
 611     }
 612 
 613     #[test]
 614     fn program_name_takes_first_token_basename() {
 615         assert_eq!(program_name("/usr/bin/firefox --new-window"), "firefox");
 616         assert_eq!(program_name("  alacritty -e htop "), "alacritty");
 617         assert_eq!(program_name(""), "");
 618     }
 619 
 620     #[test]
 621     fn unclaimed_actions_return_empty() {
 622         assert!(dispatch(&ctx(), Action::Spawn).is_empty());
 623         assert!(dispatch(&ctx(), Action::Reload).is_empty());
 624         assert!(dispatch(&ctx(), Action::Exit).is_empty());
 625     }
 626 
 627     #[test]
 628     fn close_and_minimize_need_focus_and_refocus() {
 629         assert!(dispatch(&ctx(), Action::Close).is_empty());
 630         let mut c = ctx();
 631         c.focused = Some(wid(4));
 632         assert_eq!(
 633             dispatch(&c, Action::Close),
 634             vec![Command::CloseWindow(wid(4)), Command::FocusNextVisible, Command::Relayout]
 635         );
 636         assert_eq!(
 637             dispatch(&c, Action::Minimize),
 638             vec![
 639                 Command::SetMinimized { id: wid(4), minimized: true },
 640                 Command::FocusNextVisible,
 641                 Command::Relayout
 642             ]
 643         );
 644     }
 645 
 646     #[test]
 647     fn focus_cycle_walks_id_order_and_wraps() {
 648         let mut c = ctx();
 649         // Inserted out of id order; the ring sorts by id: 1, 5, 9.
 650         c.windows.push(win(9, 0.0, 0.0, 100.0, 100.0));
 651         c.windows.push(win(1, 200.0, 0.0, 100.0, 100.0));
 652         c.windows.push(win(5, 400.0, 0.0, 100.0, 100.0));
 653         c.focused = Some(wid(9));
 654         // Next from the last entry wraps to the first.
 655         assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
 656         // Prev from 9 goes to 5.
 657         assert_eq!(dispatch(&c, Action::FocusPrev)[0], Command::Focus(wid(5)));
 658         // No focus: Next starts at the ring's first entry.
 659         c.focused = None;
 660         assert_eq!(dispatch(&c, Action::FocusNext)[0], Command::Focus(wid(1)));
 661         // Non-cyclable windows are not in the ring.
 662         for w in c.windows.iter_mut() {
 663             w.focus_cyclable = false;
 664         }
 665         assert!(dispatch(&c, Action::FocusNext).is_empty());
 666     }
 667 
 668     #[test]
 669     fn focus_directional_picks_by_footprint() {
 670         let mut c = ctx();
 671         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 672         c.windows.push(win(2, 500.0, 0.0, 100.0, 100.0));
 673         c.focused = Some(wid(1));
 674         let cmds = dispatch(&c, Action::FocusRight);
 675         assert_eq!(cmds[0], Command::Focus(wid(2)));
 676         assert_eq!(cmds[1], Command::Raise(wid(2)));
 677         // Nothing to the left of window 1.
 678         assert!(dispatch(&c, Action::FocusLeft).is_empty());
 679         // The footprint is the scaled extent. A window starting at 80 is
 680         // past window 1's midpoint (50) at scale 1 and wins as the nearer
 681         // edge; at scale 2 window 1 spans 0..200, so it is stacked, not
 682         // ahead, and window 2 wins again.
 683         c.windows.push(win(3, 80.0, 0.0, 100.0, 100.0));
 684         assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(3)));
 685         c.windows[0].scale = 2.0;
 686         assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(2)));
 687     }
 688 
 689     #[test]
 690     fn fullscreen_toggles_and_unlocks_to_resolved_mode() {
 691         let mut c = ctx();
 692         c.focused = Some(wid(1));
 693         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 694         // Enter: lock to Fullscreen.
 695         assert_eq!(
 696             dispatch(&c, Action::Fullscreen)[0],
 697             Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true }
 698         );
 699         // Exit: unlock back to the resolved mode.
 700         c.windows[0].mode = TilingMode::Fullscreen;
 701         c.windows[0].resolved_mode = TilingMode::Tiled;
 702         assert_eq!(
 703             dispatch(&c, Action::Fullscreen)[0],
 704             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
 705         );
 706         // Exit when the viewport itself resolves Fullscreen: fall to Floating.
 707         c.windows[0].resolved_mode = TilingMode::Fullscreen;
 708         assert_eq!(
 709             dispatch(&c, Action::Fullscreen)[0],
 710             Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
 711         );
 712     }
 713 
 714     #[test]
 715     fn fullscreen_exit_restores_the_recorded_mode_and_lock() {
 716         let mut c = ctx();
 717         c.focused = Some(wid(1));
 718         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 719         c.windows[0].mode = TilingMode::Fullscreen;
 720         // A hand-tiled (locked) window comes back Tiled and locked, whatever
 721         // the viewport would resolve it to.
 722         c.windows[0].resolved_mode = TilingMode::Floating;
 723         c.windows[0].pre_fullscreen = Some((TilingMode::Tiled, true));
 724         assert_eq!(
 725             dispatch(&c, Action::Fullscreen)[0],
 726             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: true }
 727         );
 728         // An unlocked Floating window comes back unlocked.
 729         c.windows[0].pre_fullscreen = Some((TilingMode::Floating, false));
 730         assert_eq!(
 731             dispatch(&c, Action::Fullscreen)[0],
 732             Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
 733         );
 734         // A record that itself says Fullscreen is useless: resolved-mode fallback.
 735         c.windows[0].resolved_mode = TilingMode::Tiled;
 736         c.windows[0].pre_fullscreen = Some((TilingMode::Fullscreen, true));
 737         assert_eq!(
 738             dispatch(&c, Action::Fullscreen)[0],
 739             Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
 740         );
 741     }
 742 
 743     #[test]
 744     fn mode_next_cycles_and_shared_hits_all_matching() {
 745         let mut c = ctx();
 746         c.focused = Some(wid(1));
 747         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
 748         c.windows.push(win(2, 200.0, 0.0, 100.0, 100.0));
 749         let mut hidden = win(3, 400.0, 0.0, 100.0, 100.0);
 750         hidden.visible = false;
 751         c.windows.push(hidden);
 752         // Floating -> Fullscreen on the focused window only.
 753         assert_eq!(
 754             dispatch(&c, Action::ModeNext),
 755             vec![
 756                 Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
 757                 Command::Relayout
 758             ]
 759         );
 760         // Shared: every visible window in the focused window's mode cycles;
 761         // the invisible one is untouched.
 762         assert_eq!(
 763             dispatch(&c, Action::ModeNextShared),
 764             vec![
 765                 Command::SetWindowMode { id: wid(1), mode: TilingMode::Fullscreen, locked: true },
 766                 Command::SetWindowMode { id: wid(2), mode: TilingMode::Fullscreen, locked: true },
 767                 Command::Relayout
 768             ]
 769         );
 770     }
 771 
 772     #[test]
 773     fn zoom_in_sets_overview_and_relayouts() {
 774         let cmds = dispatch(&ctx(), Action::ZoomIn);
 775         assert_eq!(cmds.len(), 2);
 776         let Command::SetCamera { camera, overview, .. } = cmds[0] else { panic!() };
 777         assert!((camera.zoom - 1.1).abs() < 1e-9);
 778         assert_eq!(overview, Some(true));
 779         assert_eq!(cmds[1], Command::Relayout);
 780         // Reset from zoomed goes back to normal.
 781         let mut c = ctx();
 782         c.camera.zoom = 2.0;
 783         let Command::SetCamera { camera, overview, .. } = dispatch(&c, Action::ZoomReset)[0] else { panic!() };
 784         assert_eq!(camera.zoom, 1.0);
 785         assert_eq!(overview, Some(false));
 786     }
 787 
 788     #[test]
 789     fn pan_steps_one_aligned_cell_from_pending_target() {
 790         let Command::PanTo { x, y } = dispatch(&ctx(), Action::PanRight)[0] else { panic!() };
 791         assert_eq!((x, y), (Some(512.0), None));
 792         // A pending target queues the next cell from there.
 793         let mut c = ctx();
 794         c.pan_target_x = Some(512.0);
 795         let Command::PanTo { x, .. } = dispatch(&c, Action::PanRight)[0] else { panic!() };
 796         assert_eq!(x, Some(1024.0));
 797     }
 798 
 799     #[test]
 800     fn overview_enter_fits_eligible_windows_only() {
 801         let mut c = ctx();
 802         c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
 803         let mut ineligible = win(2, 5000.0, 0.0, 400.0, 300.0);
 804         ineligible.overview_eligible = false;
 805         c.windows.push(ineligible);
 806         let cmds = dispatch(&c, Action::Overview);
 807         let Command::SetCamera { camera, overview, .. } = cmds[0] else { panic!() };
 808         assert_eq!(overview, Some(true));
 809         // Only window 1 counts: 400x300 fits without zooming out.
 810         assert_eq!(camera.zoom, 1.0);
 811         assert_eq!(cmds[1], Command::RefreshCamera);
 812         // No eligible windows: not claimed, nothing happens.
 813         c.windows.clear();
 814         assert!(dispatch(&c, Action::Overview).is_empty());
 815     }
 816 
 817     #[test]
 818     fn overview_exit_prefers_the_hovered_window() {
 819         let mut c = ctx();
 820         c.overview = true;
 821         c.camera.zoom = 0.5;
 822         c.windows.push(win(3, 1000.0, 2000.0, 400.0, 300.0));
 823         c.hovered = Some(wid(3));
 824         let cmds = dispatch(&c, Action::Overview);
 825         assert_eq!(cmds[0], Command::Focus(wid(3)));
 826         assert_eq!(cmds[1], Command::StopPanAnimation);
 827         let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
 828         assert_eq!(overview, Some(false));
 829         assert_eq!(camera.zoom, 1.0);
 830         // Centered on the window's center (1200, 2150).
 831         assert_eq!(camera.pan_x, 1200.0 - 960.0);
 832         assert_eq!(camera.pan_y, 2150.0 - 540.0);
 833         // Without a hovered window, exit centers the point under the cursor.
 834         c.hovered = None;
 835         c.camera.pan_x = 100.0;
 836         let Command::SetCamera { camera, .. } = dispatch(&c, Action::Overview)[1] else { panic!() };
 837         // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
 838         assert_eq!(camera.pan_x, 2020.0 - 960.0);
 839     }
 840 
 841     #[test]
 842     fn one_way_overview_actions_only_fire_in_the_other_mode() {
 843         let mut c = ctx();
 844         c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
 845 
 846         // Normal mode: Enter does the same thing the toggle would, Exit is
 847         // a no-op (you are already where it would take you).
 848         assert_eq!(dispatch(&c, Action::OverviewEnter), dispatch(&c, Action::Overview));
 849         let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewEnter)[0] else {
 850             panic!()
 851         };
 852         assert_eq!(overview, Some(true));
 853         assert!(dispatch(&c, Action::OverviewExit).is_empty());
 854 
 855         // Overview mode: exactly the reverse.
 856         c.overview = true;
 857         c.camera.zoom = 0.5;
 858         assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
 859         let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewExit)[1] else {
 860             panic!()
 861         };
 862         assert_eq!(overview, Some(false));
 863         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
 864     }
 865 
 866     #[test]
 867     fn the_keyed_exit_lands_on_the_focused_window_not_the_hovered_one() {
 868         let mut c = ctx();
 869         c.overview = true;
 870         c.camera.zoom = 0.5;
 871         c.windows.push(win(1, 1000.0, 2000.0, 400.0, 300.0)); // focused
 872         c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0)); // under the pointer
 873         c.focused = Some(wid(1));
 874         c.hovered = Some(wid(2));
 875 
 876         // The pointer is over window 2, but a key press said nothing about
 877         // the pointer: window 1 wins, and gets (re)focused.
 878         let cmds = dispatch(&c, Action::OverviewExit);
 879         assert_eq!(cmds[0], Command::Focus(wid(1)));
 880         let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
 881         assert_eq!(overview, Some(false));
 882         assert_eq!(camera.zoom, 1.0);
 883         // Centered on window 1's center (1200, 2150).
 884         assert_eq!(camera.pan_x, 1200.0 - 960.0);
 885         assert_eq!(camera.pan_y, 2150.0 - 540.0);
 886 
 887         // The toggle is the cursor-driven path and still prefers the hover.
 888         assert_eq!(dispatch(&c, Action::Overview)[0], Command::Focus(wid(2)));
 889     }
 890 
 891     #[test]
 892     fn the_keyed_exit_falls_back_to_the_cursor_with_nothing_focused() {
 893         let mut c = ctx();
 894         c.overview = true;
 895         c.camera.zoom = 0.5;
 896         c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0));
 897         c.focused = None;
 898         c.hovered = Some(wid(2));
 899         assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
 900 
 901         // ...and with neither, onto the virtual point under the cursor.
 902         c.hovered = None;
 903         c.camera.pan_x = 100.0;
 904         let Command::SetCamera { camera, .. } = dispatch(&c, Action::OverviewExit)[1] else {
 905             panic!()
 906         };
 907         // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
 908         assert_eq!(camera.pan_x, 2020.0 - 960.0);
 909     }
 910 
 911     #[test]
 912     fn overview_enter_on_an_empty_desktop_is_not_claimed() {
 913         // Nothing to fit, so no camera to compute — same empty list the
 914         // toggle returns, and for the same reason.
 915         let c = ctx();
 916         assert!(c.windows.is_empty());
 917         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
 918     }
 919 
 920     /// A ctx on a 100px grid period, so cell (col,row) sits at (col*100,
 921     /// row*100) and the arithmetic in these tests reads directly.
 922     fn grid_ctx() -> ActionCtx {
 923         let mut c = ctx();
 924         c.grid_period_x = 100.0;
 925         c.grid_period_y = 100.0;
 926         c
 927     }
 928 
 929     /// A tiled window one cell wide/high at cell (col,row) on `grid_ctx`.
 930     fn tiled_at(index: u32, col: f64, row: f64) -> ActionWindow {
 931         let mut w = win(index, col * 100.0, row * 100.0, 90.0, 90.0);
 932         w.mode = TilingMode::Tiled;
 933         w.resolved_mode = TilingMode::Tiled;
 934         w
 935     }
 936 
 937     #[test]
 938     fn a_tiled_window_steps_one_cell_into_empty_space() {
 939         let mut c = grid_ctx();
 940         c.windows.push(tiled_at(1, 2.0, 3.0));
 941         c.focused = Some(wid(1));
 942 
 943         for (action, x, y) in [
 944             (Action::MoveWindowRight, 300.0, 300.0),
 945             (Action::MoveWindowLeft, 100.0, 300.0),
 946             (Action::MoveWindowDown, 200.0, 400.0),
 947             (Action::MoveWindowUp, 200.0, 200.0),
 948         ] {
 949             let cmds = dispatch(&c, action);
 950             assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x, y }, "{:?}", action);
 951             assert_eq!(cmds[1], Command::Relayout);
 952         }
 953     }
 954 
 955     #[test]
 956     fn stepping_onto_a_tiled_neighbour_swaps_the_two() {
 957         let mut c = grid_ctx();
 958         c.windows.push(tiled_at(1, 2.0, 3.0));
 959         c.windows.push(tiled_at(2, 3.0, 3.0)); // directly to the right
 960         c.focused = Some(wid(1));
 961 
 962         // Right: lands on window 2, so the two exchange origins.
 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         assert_eq!(cmds[2], Command::Relayout);
 967 
 968         // Left is still empty, so that direction is an ordinary move.
 969         let cmds = dispatch(&c, Action::MoveWindowLeft);
 970         assert_eq!(cmds.len(), 2);
 971         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
 972     }
 973 
 974     #[test]
 975     fn a_swap_keeps_each_window_its_own_size() {
 976         let mut c = grid_ctx();
 977         c.windows.push(tiled_at(1, 2.0, 3.0));
 978         let mut wide = tiled_at(2, 3.0, 3.0);
 979         wide.w = 190.0; // two cells wide
 980         c.windows.push(wide);
 981         c.focused = Some(wid(1));
 982 
 983         // Only origins move; neither command carries a size.
 984         let cmds = dispatch(&c, Action::MoveWindowRight);
 985         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
 986         assert_eq!(cmds[1], Command::MoveWindow { id: wid(2), x: 200.0, y: 300.0 });
 987     }
 988 
 989     #[test]
 990     fn a_floating_window_steps_one_period_too() {
 991         let mut c = grid_ctx();
 992         // Floating, and deliberately NOT cell-aligned: the step is a plain
 993         // offset by one period, no snapping to the grid.
 994         c.windows.push(win(1, 230.0, 310.0, 700.0, 666.0));
 995         c.focused = Some(wid(1));
 996 
 997         for (action, x, y) in [
 998             (Action::MoveWindowRight, 330.0, 310.0),
 999             (Action::MoveWindowLeft, 130.0, 310.0),
1000             (Action::MoveWindowDown, 230.0, 410.0),
1001             (Action::MoveWindowUp, 230.0, 210.0),
1002         ] {
1003             let cmds = dispatch(&c, action);
1004             assert_eq!(cmds, vec![Command::MoveWindow { id: wid(1), x, y }, Command::Relayout], "{:?}", action);
1005         }
1006     }
1007 
1008     #[test]
1009     fn a_floating_window_covers_the_destination_instead_of_swapping() {
1010         let mut c = grid_ctx();
1011         c.windows.push(win(1, 200.0, 300.0, 90.0, 90.0)); // floating, focused
1012         c.windows.push(tiled_at(2, 3.0, 3.0)); // tiled, directly to the right
1013         c.windows.push(win(3, 300.0, 300.0, 90.0, 90.0)); // floating, same cell
1014         c.focused = Some(wid(1));
1015 
1016         // Floating windows overlap freely: only the focused one moves, and
1017         // neither occupant — tiled or floating — is displaced.
1018         let cmds = dispatch(&c, Action::MoveWindowRight);
1019         assert_eq!(cmds, vec![Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 }, Command::Relayout]);
1020     }
1021 
1022     #[test]
1023     fn only_tiled_and_floating_windows_step() {
1024         let mut c = grid_ctx();
1025         // Fullscreen (and the internal roles) have no position of their own.
1026         let mut fs = win(1, 200.0, 300.0, 90.0, 90.0);
1027         fs.mode = TilingMode::Fullscreen;
1028         fs.resolved_mode = TilingMode::Fullscreen;
1029         c.windows.push(fs);
1030         c.focused = Some(wid(1));
1031         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1032 
1033         // Nothing focused at all.
1034         c.windows[0].mode = TilingMode::Tiled;
1035         c.windows[0].resolved_mode = TilingMode::Tiled;
1036         c.focused = None;
1037         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1038 
1039         // A degenerate grid: the step goes nowhere, for either kind.
1040         c.focused = Some(wid(1));
1041         c.grid_period_x = 0.0;
1042         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1043         c.windows[0].mode = TilingMode::Floating;
1044         c.windows[0].resolved_mode = TilingMode::Floating;
1045         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1046     }
1047 
1048     #[test]
1049     fn a_floating_window_in_the_way_is_not_swapped_with() {
1050         let mut c = grid_ctx();
1051         c.windows.push(tiled_at(1, 2.0, 3.0));
1052         c.windows.push(win(2, 300.0, 300.0, 90.0, 90.0)); // floating, in the destination
1053         c.focused = Some(wid(1));
1054         // The step happens anyway: only tiled windows hold cells.
1055         let cmds = dispatch(&c, Action::MoveWindowRight);
1056         assert_eq!(cmds.len(), 2);
1057         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
1058     }
1059 
1060     #[test]
1061     fn an_ambiguous_swap_is_declined() {
1062         let mut c = grid_ctx();
1063         // A two-cell-tall window stepping right onto TWO stacked neighbours:
1064         // "swap with it" names neither, so nothing happens.
1065         let mut tall = tiled_at(1, 2.0, 3.0);
1066         tall.h = 190.0;
1067         c.windows.push(tall);
1068         c.windows.push(tiled_at(2, 3.0, 3.0));
1069         c.windows.push(tiled_at(3, 3.0, 4.0));
1070         c.focused = Some(wid(1));
1071         assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
1072     }
1073 
1074     #[test]
1075     fn merely_touching_the_neighbour_is_not_occupying() {
1076         // Zero gap and zero inset: cells abut exactly, so a window's right
1077         // edge sits on its neighbour's left edge. Stepping AWAY from it must
1078         // not read that shared edge as an overlap.
1079         let mut c = grid_ctx();
1080         let mut a = tiled_at(1, 2.0, 3.0);
1081         a.w = 100.0;
1082         let mut b = tiled_at(2, 3.0, 3.0);
1083         b.w = 100.0;
1084         c.windows.push(a);
1085         c.windows.push(b);
1086         c.focused = Some(wid(1));
1087         let cmds = dispatch(&c, Action::MoveWindowLeft);
1088         assert_eq!(cmds.len(), 2, "stepping left should be a plain move");
1089         assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
1090     }
1091 
1092     #[test]
1093     fn media_keys_spawn_stock_or_overridden_command() {
1094         let c = ctx();
1095         // Every media action is claimed and spawns its stock command.
1096         for action in [
1097             Action::VolumeUp, Action::VolumeDown, Action::VolumeMute,
1098             Action::MicMute, Action::BrightnessUp, Action::BrightnessDown,
1099         ] {
1100             assert_eq!(
1101                 dispatch(&c, action),
1102                 vec![Command::Spawn(media_command(action).to_string())],
1103                 "{}", action.name()
1104             );
1105         }
1106         assert_eq!(
1107             dispatch(&c, Action::VolumeMute),
1108             vec![Command::Spawn("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle".to_string())]
1109         );
1110         // A binding's command= property replaces the stock command.
1111         assert_eq!(
1112             DefaultPolicy.action(&c, Action::BrightnessUp, Some("brightnessctl set 10%+")),
1113             vec![Command::Spawn("brightnessctl set 10%+".to_string())]
1114         );
1115     }
1116 }