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