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 }