Skip to main content

entracte_lib/scheduler/
overlay.rs

1use std::sync::Arc;
2
3use tauri::{AppHandle, Emitter, Manager, Runtime};
4#[cfg(not(test))]
5use tauri_plugin_notification::NotificationExt;
6
7use super::overlay_watchdog;
8use super::settings::MonitorPlacement;
9use super::types::{BreakDelivery, BreakEvent, BreakKind, MonitorRect};
10
11/// Index of the monitor that contains `(cursor_x, cursor_y)`, or
12/// `None` if the cursor sits outside every rect. Used by
13/// `MonitorPlacement::Active` to decide which display the overlay
14/// should pop on.
15pub fn pick_active_monitor(
16    cursor_x: f64,
17    cursor_y: f64,
18    monitors: &[MonitorRect],
19) -> Option<usize> {
20    monitors.iter().position(|m| {
21        let mx = m.x as f64;
22        let my = m.y as f64;
23        let mw = m.width as f64;
24        let mh = m.height as f64;
25        cursor_x >= mx && cursor_x < mx + mw && cursor_y >= my && cursor_y < my + mh
26    })
27}
28
29/// Shrink `monitor` to `fraction` of its size and centre it inside
30/// the original. `fraction` is clamped to `[0.1, 1.0]`. Used to size
31/// the `BreakDelivery::Windowed` overlay so the desktop stays
32/// clickable around it.
33pub fn centered_windowed_rect(monitor: MonitorRect, fraction: f64) -> MonitorRect {
34    let fraction = fraction.clamp(0.1, 1.0);
35    let width = ((monitor.width as f64) * fraction).round() as u32;
36    let height = ((monitor.height as f64) * fraction).round() as u32;
37    let width = width.max(1).min(monitor.width);
38    let height = height.max(1).min(monitor.height);
39    let x = monitor.x + ((monitor.width.saturating_sub(width)) / 2) as i32;
40    let y = monitor.y + ((monitor.height.saturating_sub(height)) / 2) as i32;
41    MonitorRect {
42        x,
43        y,
44        width,
45        height,
46    }
47}
48
49/// Pure Wayland-session test over the two relevant env signals, split out
50/// so the decision is unit-testable without mutating process env.
51fn wayland_session_from_env(session_type: Option<&str>, wayland_display: bool) -> bool {
52    session_type.is_some_and(|s| s.eq_ignore_ascii_case("wayland")) || wayland_display
53}
54
55/// Whether this is a Wayland session. Used to decide if the overlay
56/// geometry needs the HiDPI scale correction below (#67). Mirrors the
57/// probe in `video.rs`; kept local so the overlay path stays
58/// self-contained.
59fn is_wayland_session() -> bool {
60    wayland_session_from_env(
61        std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
62        std::env::var("WAYLAND_DISPLAY").is_ok(),
63    )
64}
65
66/// Correct a monitor's reported geometry for the GNOME/Wayland HiDPI
67/// quirk where `tao` returns `monitor.size()` and `position()` already
68/// multiplied by the scale factor. Feeding those straight into
69/// `set_size`/`set_position` (which divide by the window's scale factor
70/// again) builds an overlay `scale`× too large in each axis — it spills
71/// onto the neighbouring monitor and pushes the hint and Skip controls
72/// off the bottom of the screen (#67, Steffi's 2×4K @ 200% report). On
73/// Wayland with a >1 scale we divide back out to the true physical
74/// geometry; on X11 and macOS `monitor.size()` is already true physical,
75/// so it's a no-op. Pure so the correction is unit-testable without a
76/// windowing system.
77///
78/// Assumes a uniform scale across monitors: each rect's *position* is
79/// divided by that monitor's own scale, which only stays globally
80/// coherent when every monitor shares one factor (the common case). A
81/// mixed-DPI Wayland layout would need a shared coordinate basis — not
82/// handled here, since the whole correction is a workaround for the tao
83/// reporting quirk rather than a general geometry layer.
84fn scale_corrected_rect(rect: MonitorRect, scale: f64, wayland: bool) -> MonitorRect {
85    if !wayland || scale <= 1.0 {
86        return rect;
87    }
88    let div_i = |v: i32| (v as f64 / scale).round() as i32;
89    let div_u = |v: u32| ((v as f64 / scale).round() as u32).max(1);
90    MonitorRect {
91        x: div_i(rect.x),
92        y: div_i(rect.y),
93        width: div_u(rect.width),
94        height: div_u(rect.height),
95    }
96}
97
98/// Human-friendly break duration for notifications (e.g. `"20 seconds"`,
99/// `"5 minutes"`, `"1m 30s"`). Drops the seconds part when the
100/// duration is a whole-minute multiple.
101pub fn format_break_duration(secs: u64) -> String {
102    if secs >= 60 && secs.is_multiple_of(60) {
103        let mins = secs / 60;
104        if mins == 1 {
105            "1 minute".to_string()
106        } else {
107            format!("{mins} minutes")
108        }
109    } else if secs >= 60 {
110        let mins = secs / 60;
111        let rem = secs % 60;
112        format!("{mins}m {rem}s")
113    } else if secs == 1 {
114        "1 second".to_string()
115    } else {
116        format!("{secs} seconds")
117    }
118}
119
120pub(super) fn notify_break_now<R: Runtime>(
121    app: &AppHandle<R>,
122    kind: BreakKind,
123    duration_secs: u64,
124) {
125    let title = match kind {
126        BreakKind::Micro => "Micro break",
127        BreakKind::Long => "Long break",
128        BreakKind::Sleep => "Bedtime reminder",
129    };
130    let body = format!("Take a {} break.", format_break_duration(duration_secs));
131    post_notification(app, title, body);
132}
133
134/// Post a desktop notification. Split on `cfg(test)` so the OS-posting body
135/// is compiled out of the test/coverage build: the scheduler's delivery
136/// tests drive the routing glue end to end, and without this a real
137/// `tauri_plugin_notification` would post an actual macOS notification on
138/// every `cargo test` run — attributed to the terminal, since a test binary
139/// is not an app bundle. The routing the tests assert runs before this call,
140/// so no meaningful coverage is lost.
141#[cfg(not(test))]
142pub(super) fn post_notification<R: Runtime>(app: &AppHandle<R>, title: &str, body: String) {
143    let _ = app.notification().builder().title(title).body(body).show();
144}
145
146#[cfg(test)]
147pub(super) fn post_notification<R: Runtime>(_app: &AppHandle<R>, _title: &str, _body: String) {}
148
149fn ensure_overlay<R: Runtime>(app: &AppHandle<R>, idx: usize) -> Option<tauri::WebviewWindow<R>> {
150    let label = format!("overlay-{idx}");
151    if let Some(w) = app.get_webview_window(&label) {
152        return Some(w);
153    }
154    match tauri::WebviewWindowBuilder::new(
155        app,
156        &label,
157        tauri::WebviewUrl::App("index.html?window=overlay".into()),
158    )
159    .title("Entracte Break")
160    .decorations(false)
161    .always_on_top(true)
162    .skip_taskbar(true)
163    .transparent(true)
164    .resizable(false)
165    .visible(false)
166    .focused(false)
167    .build()
168    {
169        Ok(w) => {
170            log::debug!("overlay: created break window '{label}'");
171            Some(w)
172        }
173        // Don't swallow this silently: a failed build means the break is
174        // completely invisible (no overlay, no preview, no test break) with
175        // no other symptom. On some Linux setups the windowing system
176        // rejects a transparent always-on-top surface, which used to look
177        // like "breaks just don't fire". Logging it gives users and bug
178        // reports something to go on. See issue #67.
179        Err(e) => {
180            log::error!("overlay: failed to create break window '{label}': {e}");
181            None
182        }
183    }
184}
185
186/// Monitor *indices* to cover for the `Primary` placement. When the
187/// windowing system names a primary monitor we cover exactly it. When it
188/// can't (Wayland has no "primary" concept) we cover *every* monitor
189/// instead of just the first: "the primary screen" is meaningless there,
190/// and leaving the other monitors uncovered lets the user dodge an
191/// enforceable break by glancing at the next screen (#67, Steffi's
192/// dual-monitor setup). Pure so the fallback is unit-testable without a
193/// windowing system.
194fn primary_or_all_indices(primary: Option<usize>, monitor_count: usize) -> Vec<usize> {
195    match primary {
196        Some(i) if i < monitor_count => vec![i],
197        _ => (0..monitor_count).collect(),
198    }
199}
200
201/// Monitor index to cover for the `Active` placement: whichever monitor
202/// holds the cursor, else the reported primary, else the first available.
203/// Pure so the fallback chain is unit-testable. Returns an empty list only
204/// when there are no monitors at all.
205fn active_indices(
206    active: Option<usize>,
207    primary: Option<usize>,
208    monitor_count: usize,
209) -> Vec<usize> {
210    if monitor_count == 0 {
211        return Vec::new();
212    }
213    let chosen = active
214        .filter(|&i| i < monitor_count)
215        .or(primary.filter(|&i| i < monitor_count))
216        .unwrap_or(0);
217    vec![chosen]
218}
219
220/// Resolve a placement to the set of monitor indices that should each get
221/// an overlay window, given what the windowing system could report.
222///
223/// On macOS / Windows / X11 every returned index is pinned to its physical
224/// monitor via `set_position`, so the indices map one-to-one onto screens.
225///
226/// On Wayland the result is collapsed to a single index. The compositor
227/// owns window placement: an app cannot move a surface to an absolute
228/// `(x, y)` or target a specific output, and `set_position` is a no-op
229/// there (tauri #6394 / tao). Building one overlay per monitor — as we do
230/// elsewhere — therefore does NOT spread the overlays across screens; the
231/// compositor stacks every one of them onto the same physical output,
232/// which is exactly Steffi's #67 report (two overlays, both on the
233/// secondary monitor, each showing a different hint). We cannot honour
234/// "active" / "primary" / "all" by output on Wayland, so we build exactly
235/// one overlay and fullscreen it; the compositor decides which monitor.
236/// Collapsing to one window also removes the duplicate-overlay symptom.
237/// Pure so every branch is unit-testable without a windowing system.
238fn resolve_overlay_indices(
239    placement: MonitorPlacement,
240    primary: Option<usize>,
241    active: Option<usize>,
242    monitor_count: usize,
243    wayland: bool,
244) -> Vec<usize> {
245    if monitor_count == 0 {
246        return Vec::new();
247    }
248    if wayland {
249        let preferred = match placement {
250            MonitorPlacement::Active => active.or(primary),
251            MonitorPlacement::Primary | MonitorPlacement::All => primary,
252        };
253        return vec![preferred.filter(|&i| i < monitor_count).unwrap_or(0)];
254    }
255    match placement {
256        MonitorPlacement::All => (0..monitor_count).collect(),
257        MonitorPlacement::Primary => primary_or_all_indices(primary, monitor_count),
258        MonitorPlacement::Active => active_indices(active, primary, monitor_count),
259    }
260}
261
262/// Locate `needle` in `rects` by identity-ish geometry match on position
263/// and size. `available_monitors` and `primary_monitor` return independent
264/// `Monitor` values, so the only stable cross-reference is their reported
265/// rect. Pure so the lookup is unit-testable.
266fn monitor_index_by_rect(needle: &MonitorRect, rects: &[MonitorRect]) -> Option<usize> {
267    rects.iter().position(|r| r == needle)
268}
269
270fn monitor_rect(m: &tauri::Monitor) -> MonitorRect {
271    MonitorRect {
272        x: m.position().x,
273        y: m.position().y,
274        width: m.size().width,
275        height: m.size().height,
276    }
277}
278
279/// Read the display layout **on the main thread** and hand back plain data.
280///
281/// Every caller of [`fire_break`] runs on a tokio worker (the scheduler run
282/// loop, the IPC handler, and the tray menu all go through
283/// `async_runtime::spawn`), which is exactly the thread the monitor getters
284/// must not be called from — see [`crate::display::on_main_thread`] for the
285/// mechanism, the `create_webview` exclusion that keeps [`ensure_overlay`] off
286/// the main thread, and the degrade-to-`None` behaviour (here: no overlay this
287/// time, rather than risking a hang).
288///
289/// Both reads share a single hop so the returned monitor list and primary come
290/// from one consistent snapshot. Resolving the primary's *index* happens inside
291/// the hop too, so the caller receives only plain data and never re-touches a
292/// live handle.
293#[cfg(not(test))]
294fn read_display_geometry<R: Runtime>(
295    app: &AppHandle<R>,
296) -> Option<(Vec<tauri::Monitor>, Option<usize>)> {
297    crate::display::on_main_thread(app, "overlay monitors", |handle| {
298        let all = handle.available_monitors().unwrap_or_default();
299        let rects: Vec<MonitorRect> = all.iter().map(monitor_rect).collect();
300        let primary = handle
301            .primary_monitor()
302            .ok()
303            .flatten()
304            .and_then(|p| monitor_index_by_rect(&monitor_rect(&p), &rects));
305        (all, primary)
306    })
307}
308
309fn select_overlay_monitors<R: Runtime>(
310    app: &AppHandle<R>,
311    placement: MonitorPlacement,
312) -> Vec<tauri::Monitor> {
313    // In unit tests the mock runtime's available_monitors() is unimplemented
314    // and panics; return empty so fire_break can be called in tests without
315    // opening windows. NOTE: under test `all` is always empty, so the
316    // monitor-selection logic below (rects/active/pick) is not exercised by
317    // unit tests — it relies on the e2e smoke run for coverage.
318    // Treat edits below this line as unit-uncovered by design.
319    #[cfg(test)]
320    let (all, primary): (Vec<tauri::Monitor>, Option<usize>) = (Vec::new(), None);
321    #[cfg(not(test))]
322    let (all, primary) = read_display_geometry(app).unwrap_or_default();
323    if all.is_empty() {
324        return Vec::new();
325    }
326    let rects: Vec<MonitorRect> = all.iter().map(monitor_rect).collect();
327
328    let active = match placement {
329        MonitorPlacement::Active => match app.cursor_position() {
330            Ok(p) => pick_active_monitor(p.x, p.y, &rects),
331            Err(_) => None,
332        },
333        _ => None,
334    };
335
336    resolve_overlay_indices(placement, primary, active, all.len(), is_wayland_session())
337        .into_iter()
338        .map(|i| all[i].clone())
339        .collect()
340}
341
342/// Surface a break through whichever channel the active settings ask
343/// for: a system notification or the overlay (full-screen or windowed).
344/// `Notification` delivery short-circuits the overlay path entirely.
345///
346/// `event` carries the break content (kind, duration, hints, …); the
347/// `delivery` and `placement` decide *how* and *where* it surfaces.
348pub fn deliver_break<R: Runtime>(
349    app: &AppHandle<R>,
350    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
351    event: BreakEvent,
352    delivery: BreakDelivery,
353    placement: MonitorPlacement,
354    windowed_fraction: f64,
355) {
356    match delivery {
357        BreakDelivery::Notification => notify_break_now(app, event.kind, event.duration_secs),
358        BreakDelivery::Overlay | BreakDelivery::Windowed => fire_break(
359            app,
360            current_break,
361            event,
362            placement,
363            matches!(delivery, BreakDelivery::Windowed),
364            windowed_fraction,
365        ),
366    }
367}
368
369/// Stash the break `event` in `current_break`, position an overlay window
370/// on each selected monitor, and emit `break:start` to the renderer. Used
371/// directly for sleep/resume-last paths; normal scheduled breaks go
372/// through `deliver_break` instead.
373///
374/// `postpone_available` is forced off for enforceable breaks here, so
375/// callers can pass the user's raw intent without re-deriving it.
376pub fn fire_break<R: Runtime>(
377    app: &AppHandle<R>,
378    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
379    event: BreakEvent,
380    placement: MonitorPlacement,
381    windowed: bool,
382    windowed_fraction: f64,
383) {
384    let mut payload = event;
385    payload.postpone_available = payload.postpone_available && !payload.enforceable;
386    payload.skip_available = payload.skip_available && !payload.enforceable;
387    *super::lock_current_break(current_break) = Some(payload.clone());
388
389    // Quiet any playing media for the duration of the break (#77). No-op
390    // unless the user enabled it; `end_break` resumes. Only the overlay
391    // path reaches here — notification-only breaks don't block the screen.
392    crate::media::on_break_start();
393
394    let monitors = select_overlay_monitors(app, placement);
395    let count = monitors.len().max(1);
396    let mut shown = 0usize;
397    let wayland = is_wayland_session();
398
399    for (idx, monitor) in monitors.iter().enumerate() {
400        if let Some(window) = ensure_overlay(app, idx) {
401            let scale = monitor.scale_factor();
402            let reported = monitor_rect(monitor);
403            let monitor_rect = scale_corrected_rect(reported, scale, wayland);
404            let rect = if windowed {
405                centered_windowed_rect(monitor_rect, windowed_fraction)
406            } else {
407                monitor_rect
408            };
409            // The geometry, scale, and Wayland flag in one line so a
410            // diagnostics report can confirm the overlay was sized to the
411            // monitor (and flag the inverse of #67 — a too-small overlay
412            // if some compositor reports true physical despite scaling).
413            log::debug!(
414                "overlay-{idx}: wayland={wayland} scale={scale:.2} reported={rw}x{rh}@({rx},{ry}) \
415                 -> set {w}x{h}@({x},{y})",
416                rw = reported.width,
417                rh = reported.height,
418                rx = reported.x,
419                ry = reported.y,
420                w = rect.width,
421                h = rect.height,
422                x = rect.x,
423                y = rect.y,
424            );
425            let _ = window.set_position(tauri::PhysicalPosition::new(rect.x, rect.y));
426            let _ = window.set_size(tauri::PhysicalSize::new(rect.width, rect.height));
427            let _ = window.set_always_on_top(true);
428            // On Wayland the compositor ignores `set_position`/`set_size`,
429            // so a full-screen overlay positioned by rect would just sit at
430            // its default size on whatever output has focus. Ask the
431            // compositor to fullscreen the surface instead — that fills the
432            // focused output edge-to-edge regardless of the ignored rect.
433            // We still cannot choose *which* output (see
434            // `resolve_overlay_indices`), so monitor placement can't be
435            // honoured here; this only guarantees the overlay covers a whole
436            // screen rather than appearing as a small floating window (#67).
437            // Windowed mode stays non-fullscreen so the desktop is reachable.
438            let _ = window.set_fullscreen(wayland && !windowed);
439            let _ = window.show();
440            let _ = window.set_focus();
441            shown += 1;
442        }
443    }
444
445    // Logged to the rotating log file (not just the stats event log) so a
446    // diagnostics report's log tail shows the break actually firing — and
447    // flags the Linux case where no overlay could be built (shown == 0).
448    if shown == 0 {
449        log::error!(
450            "scheduler: break kind={kind:?} fired but NO overlay window could be shown \
451             ({count} monitor(s) targeted) — the break is invisible",
452            kind = payload.kind
453        );
454    } else {
455        log::info!(
456            "scheduler: break kind={kind:?} shown on {shown}/{count} monitor(s)",
457            kind = payload.kind
458        );
459    }
460
461    // Close (not just hide) any overlays for monitors that disconnected since
462    // last break — `hide()` left the webview process holding the slot, which
463    // leaked memory on every monitor unplug cycle.
464    for (label, window) in app.webview_windows() {
465        if let Some(suffix) = label.strip_prefix("overlay-") {
466            if let Ok(idx) = suffix.parse::<usize>() {
467                if idx >= count {
468                    let _ = window.close();
469                }
470            }
471        }
472    }
473
474    // Emit `break:start` immediately. Already-mounted overlay windows hear it
475    // through their `listen("break:start")` subscription; freshly-created
476    // ones rehydrate via the `get_current_break` call in their mount effect.
477    // The payload was already stashed in `current_break` above, so the cold-
478    // mount path returns the correct data without any handshake.
479    let _ = app.emit("break:start", &payload);
480
481    // Arm the render-readiness watchdog (#196 / #226). The overlay acks via
482    // `notify_overlay_rendered` once it paints; if no ack lands within the
483    // grace period the break is torn down, so a dead webview can't leave the
484    // desktop frozen behind an invisible, focus-grabbing, media-pausing
485    // overlay. This also covers the `shown == 0` case logged above: nothing
486    // rendered, so the watchdog resumes media and clears the break rather than
487    // leaving it stranded.
488    let epoch = overlay_watchdog::OVERLAY_ACK.arm();
489    spawn_render_watchdog(app, current_break, epoch);
490}
491
492/// Hide every break overlay window. Shared by the break-teardown paths
493/// (`end_break`, `postpone_break`, and the render watchdog) so the
494/// `"overlay-"` label convention lives in exactly one place.
495pub(crate) fn hide_overlay_windows<R: Runtime>(app: &AppHandle<R>) {
496    for (label, window) in app.webview_windows() {
497        if label.starts_with("overlay-") {
498            let _ = window.hide();
499        }
500    }
501}
502
503/// Tear down a break whose overlay never reported rendering — the #196/#226
504/// watchdog firing, or the `shown == 0` no-overlay case. Mirrors the cleanup
505/// half of `end_break` (clear the current break, resume any paused media, hide
506/// every overlay window, emit `break:end`) but records **no** stats: an
507/// invisible break was never taken or dismissed. Scheduler-free so the
508/// watchdog task can run it from cloned `Arc`s without holding `&Scheduler`.
509///
510/// On the Windows test build the only callers — the `#[cfg(not(test))]`
511/// watchdog spawn and the rig test below (excluded on Windows, where the
512/// `tauri` test feature can't boot a mock app) — are both compiled out, so it
513/// reads as dead there only; the real build keeps it live.
514#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
515pub(crate) fn abort_stranded_break<R: Runtime>(
516    app: &AppHandle<R>,
517    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
518) {
519    log::warn!(
520        "overlay: break overlay never reported rendering within {}s — tearing the break down to \
521         release the desktop (media + focus). See #196 (macOS WKWebView) / #226 (Linux).",
522        overlay_watchdog::RENDER_GRACE_SECS
523    );
524    *super::lock_current_break(current_break) = None;
525    crate::media::on_break_end();
526    hide_overlay_windows(app);
527    let _ = app.emit("break:end", ());
528}
529
530/// Spawn the grace-period task that aborts a never-rendered break. Captures
531/// cloned `Arc`s + `AppHandle` so it outlives `fire_break`'s borrows.
532///
533/// The teardown is marshalled onto the main thread: `abort_stranded_break`
534/// iterates and hides webview windows, and on X11/GTK those calls must not run
535/// off the GUI thread (a worker-thread window op trips
536/// `xcb_xlib_threads_sequence_lost`). `run_on_main_thread` serialises it onto
537/// the event loop, mirroring the tray's status-title update.
538#[cfg(not(test))]
539fn spawn_render_watchdog<R: Runtime>(
540    app: &AppHandle<R>,
541    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
542    epoch: u64,
543) {
544    let app = app.clone();
545    let current_break = current_break.clone();
546    tauri::async_runtime::spawn(async move {
547        tokio::time::sleep(std::time::Duration::from_secs(
548            overlay_watchdog::RENDER_GRACE_SECS,
549        ))
550        .await;
551        if overlay_watchdog::OVERLAY_ACK.is_stranded(epoch) {
552            let app_main = app.clone();
553            let _ = app.run_on_main_thread(move || {
554                abort_stranded_break(&app_main, &current_break);
555            });
556        }
557    });
558}
559
560// Tests drive `fire_break` synchronously and assert on its immediate effects;
561// a real multi-second watchdog task would outlive the test and fire against a
562// torn-down mock app. `arm()` above still runs (a cheap atomic bump), so the
563// epoch bookkeeping is exercised; only the timer task is suppressed here. The
564// teardown itself is covered directly via `abort_stranded_break`.
565#[cfg(test)]
566fn spawn_render_watchdog<R: Runtime>(
567    _app: &AppHandle<R>,
568    _current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
569    _epoch: u64,
570) {
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn rect(x: i32, y: i32, w: u32, h: u32) -> MonitorRect {
578        MonitorRect {
579            x,
580            y,
581            width: w,
582            height: h,
583        }
584    }
585
586    #[test]
587    fn pick_active_monitor_returns_containing_index() {
588        let monitors = vec![
589            rect(0, 0, 1920, 1080),
590            rect(1920, 0, 2560, 1440),
591            rect(0, 1080, 1920, 1080),
592        ];
593        assert_eq!(pick_active_monitor(100.0, 100.0, &monitors), Some(0));
594        assert_eq!(pick_active_monitor(3000.0, 500.0, &monitors), Some(1));
595        assert_eq!(pick_active_monitor(500.0, 1500.0, &monitors), Some(2));
596    }
597
598    #[test]
599    fn pick_active_monitor_returns_none_when_outside() {
600        let monitors = vec![rect(0, 0, 1920, 1080)];
601        assert_eq!(pick_active_monitor(-10.0, 50.0, &monitors), None);
602        assert_eq!(pick_active_monitor(50.0, 2000.0, &monitors), None);
603    }
604
605    #[test]
606    fn pick_active_monitor_handles_negative_origin() {
607        let monitors = vec![rect(-1920, 0, 1920, 1080), rect(0, 0, 1920, 1080)];
608        assert_eq!(pick_active_monitor(-500.0, 200.0, &monitors), Some(0));
609        assert_eq!(pick_active_monitor(500.0, 200.0, &monitors), Some(1));
610    }
611
612    #[test]
613    fn pick_active_monitor_returns_none_for_empty_list() {
614        assert_eq!(pick_active_monitor(0.0, 0.0, &[]), None);
615    }
616
617    #[test]
618    fn primary_or_all_indices_uses_primary_when_present() {
619        // A named primary is honoured exactly — never widened.
620        assert_eq!(primary_or_all_indices(Some(1), 3), vec![1]);
621    }
622
623    #[test]
624    fn primary_or_all_indices_covers_every_monitor_without_primary() {
625        // No primary (Wayland off-path / X11): cover all monitors so a
626        // break can't be dodged on the second screen (#67).
627        assert_eq!(primary_or_all_indices(None, 2), vec![0, 1]);
628    }
629
630    #[test]
631    fn primary_or_all_indices_covers_all_when_primary_out_of_range() {
632        // A stale / mismatched primary index must not silently target the
633        // wrong screen — fall back to covering everything.
634        assert_eq!(primary_or_all_indices(Some(5), 2), vec![0, 1]);
635    }
636
637    #[test]
638    fn primary_or_all_indices_empty_when_no_monitors_at_all() {
639        assert!(primary_or_all_indices(None, 0).is_empty());
640        assert!(primary_or_all_indices(Some(0), 0).is_empty());
641    }
642
643    #[test]
644    fn active_indices_prefers_cursor_monitor() {
645        assert_eq!(active_indices(Some(2), Some(0), 3), vec![2]);
646    }
647
648    #[test]
649    fn active_indices_falls_back_to_primary_then_first() {
650        assert_eq!(active_indices(None, Some(1), 3), vec![1]);
651        assert_eq!(active_indices(None, None, 3), vec![0]);
652    }
653
654    #[test]
655    fn active_indices_ignores_out_of_range_inputs() {
656        assert_eq!(active_indices(Some(9), Some(9), 2), vec![0]);
657        assert_eq!(active_indices(Some(9), Some(1), 2), vec![1]);
658    }
659
660    #[test]
661    fn active_indices_empty_without_monitors() {
662        assert!(active_indices(Some(0), Some(0), 0).is_empty());
663    }
664
665    #[test]
666    fn resolve_overlay_indices_off_wayland_matches_placement() {
667        // active: cursor monitor; primary: the named primary; all: everyone.
668        assert_eq!(
669            resolve_overlay_indices(MonitorPlacement::Active, Some(0), Some(1), 2, false),
670            vec![1]
671        );
672        assert_eq!(
673            resolve_overlay_indices(MonitorPlacement::Primary, Some(1), None, 2, false),
674            vec![1]
675        );
676        assert_eq!(
677            resolve_overlay_indices(MonitorPlacement::All, Some(0), None, 2, false),
678            vec![0, 1]
679        );
680    }
681
682    #[test]
683    fn resolve_overlay_indices_primary_without_named_primary_covers_all() {
684        // X11 with no reported primary: every monitor, never just the first.
685        assert_eq!(
686            resolve_overlay_indices(MonitorPlacement::Primary, None, None, 2, false),
687            vec![0, 1]
688        );
689    }
690
691    #[test]
692    fn resolve_overlay_indices_on_wayland_collapses_to_single_overlay() {
693        // The core #67 fix: Wayland cannot place windows per-output, so
694        // EVERY placement resolves to exactly one overlay — never two on the
695        // same physical monitor.
696        for placement in [
697            MonitorPlacement::Active,
698            MonitorPlacement::Primary,
699            MonitorPlacement::All,
700        ] {
701            let got = resolve_overlay_indices(placement, Some(0), Some(1), 2, true);
702            assert_eq!(got.len(), 1, "{placement:?} must yield one overlay");
703        }
704    }
705
706    #[test]
707    fn resolve_overlay_indices_on_wayland_prefers_active_then_primary() {
708        assert_eq!(
709            resolve_overlay_indices(MonitorPlacement::Active, Some(0), Some(1), 2, true),
710            vec![1]
711        );
712        assert_eq!(
713            resolve_overlay_indices(MonitorPlacement::Active, Some(1), None, 2, true),
714            vec![1]
715        );
716        assert_eq!(
717            resolve_overlay_indices(MonitorPlacement::Primary, Some(1), Some(0), 2, true),
718            vec![1]
719        );
720        assert_eq!(
721            resolve_overlay_indices(MonitorPlacement::All, Some(1), None, 2, true),
722            vec![1]
723        );
724    }
725
726    #[test]
727    fn resolve_overlay_indices_on_wayland_defaults_to_first_without_hints() {
728        // No primary, no cursor hit (Wayland reports neither): still build
729        // one overlay rather than none, so the break stays visible (#67).
730        assert_eq!(
731            resolve_overlay_indices(MonitorPlacement::Primary, None, None, 2, true),
732            vec![0]
733        );
734    }
735
736    #[test]
737    fn resolve_overlay_indices_empty_without_monitors() {
738        assert!(resolve_overlay_indices(MonitorPlacement::All, None, None, 0, false).is_empty());
739        assert!(resolve_overlay_indices(MonitorPlacement::Active, None, None, 0, true).is_empty());
740    }
741
742    #[test]
743    fn monitor_index_by_rect_matches_on_geometry() {
744        let rects = vec![rect(0, 0, 1920, 1080), rect(1920, 0, 2560, 1440)];
745        assert_eq!(
746            monitor_index_by_rect(&rect(1920, 0, 2560, 1440), &rects),
747            Some(1)
748        );
749        assert_eq!(monitor_index_by_rect(&rect(0, 0, 1280, 720), &rects), None);
750    }
751
752    #[test]
753    fn wayland_session_from_env_detects_session_type_and_display() {
754        assert!(wayland_session_from_env(Some("wayland"), false));
755        assert!(wayland_session_from_env(Some("WAYLAND"), false));
756        assert!(wayland_session_from_env(None, true));
757        assert!(!wayland_session_from_env(Some("x11"), false));
758        assert!(!wayland_session_from_env(None, false));
759    }
760
761    #[test]
762    fn scale_corrected_rect_divides_out_doubled_wayland_geometry() {
763        // Steffi's #67 case: a 4K panel at 200% is reported as 7680×4320
764        // (physical × scale). Dividing by the scale recovers true physical
765        // 3840×2160, so the overlay covers exactly one monitor instead of
766        // 2× spilling onto the neighbour.
767        let reported = rect(7680, 0, 7680, 4320);
768        let r = scale_corrected_rect(reported, 2.0, true);
769        assert_eq!(r, rect(3840, 0, 3840, 2160));
770    }
771
772    #[test]
773    fn scale_corrected_rect_noop_off_wayland() {
774        // X11 / macOS report true physical already — never halve them.
775        let reported = rect(0, 0, 3840, 2160);
776        assert_eq!(scale_corrected_rect(reported, 2.0, false), reported);
777    }
778
779    #[test]
780    fn scale_corrected_rect_noop_at_unity_scale() {
781        // Wayland without HiDPI scaling: nothing to correct.
782        let reported = rect(1920, 0, 1920, 1080);
783        assert_eq!(scale_corrected_rect(reported, 1.0, true), reported);
784    }
785
786    #[test]
787    fn scale_corrected_rect_handles_fractional_scale() {
788        // 150% scaling rounds to the nearest physical pixel and never
789        // collapses a dimension to zero.
790        let r = scale_corrected_rect(rect(0, 0, 2880, 1620), 1.5, true);
791        assert_eq!(r, rect(0, 0, 1920, 1080));
792    }
793
794    #[test]
795    fn centered_windowed_rect_returns_eighty_percent_centered() {
796        let monitor = rect(0, 0, 1000, 1000);
797        let r = centered_windowed_rect(monitor, 0.8);
798        assert_eq!(r.width, 800);
799        assert_eq!(r.height, 800);
800        assert_eq!(r.x, 100);
801        assert_eq!(r.y, 100);
802    }
803
804    #[test]
805    fn centered_windowed_rect_respects_monitor_origin() {
806        let monitor = rect(1920, 100, 2560, 1440);
807        let r = centered_windowed_rect(monitor, 0.8);
808        assert_eq!(r.width, 2048);
809        assert_eq!(r.height, 1152);
810        assert_eq!(r.x, 1920 + (2560 - 2048) / 2);
811        assert_eq!(r.y, 100 + (1440 - 1152) / 2);
812    }
813
814    #[test]
815    fn centered_windowed_rect_clamps_fraction() {
816        let monitor = rect(0, 0, 1000, 1000);
817        let full = centered_windowed_rect(monitor, 2.0);
818        assert_eq!(full.width, 1000);
819        assert_eq!(full.height, 1000);
820        let tiny = centered_windowed_rect(monitor, 0.0);
821        assert_eq!(tiny.width, 100);
822        assert_eq!(tiny.height, 100);
823    }
824
825    #[test]
826    fn format_break_duration_uses_friendly_units() {
827        assert_eq!(format_break_duration(20), "20 seconds");
828        assert_eq!(format_break_duration(1), "1 second");
829        assert_eq!(format_break_duration(60), "1 minute");
830        assert_eq!(format_break_duration(120), "2 minutes");
831        assert_eq!(format_break_duration(300), "5 minutes");
832        assert_eq!(format_break_duration(90), "1m 30s");
833    }
834}