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/// How long to wait for the main thread to answer a geometry read before
280/// giving up. Generous enough to absorb a busy event loop, short enough
281/// that a wedged main thread degrades to "no overlay" rather than
282/// stalling the break loop indefinitely.
283#[cfg(not(test))]
284const GEOMETRY_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
285
286/// Read the display layout **on the main thread** and hand back plain data.
287///
288/// `WryHandle::available_monitors` and `primary_monitor` reach straight into
289/// the event loop's `window_target` with no dispatch, and the `unsafe impl
290/// Send` that lets that type cross threads states its own precondition:
291/// "we ensure this type is only used on the main thread". Every caller of
292/// [`fire_break`] runs on a tokio worker (the scheduler run loop, the IPC
293/// handler, and the tray menu all go through `async_runtime::spawn`), so
294/// calling these directly races the GTK main loop on one X connection and
295/// aborts inside libxcb (#333).
296///
297/// Both reads share a single hop so the returned monitor list and primary
298/// come from one consistent snapshot.
299///
300/// Deliberately **not** extended to cover window creation: tauri-runtime-wry
301/// documents that `create_webview` "must be called from a separate thread,
302/// otherwise the channel will introduce a deadlock", so [`ensure_overlay`]
303/// must keep running on the caller's thread. `cursor_position` is left alone
304/// too — it already dispatches through the event loop.
305///
306/// Returns `None` if the main thread is unreachable or too slow, which
307/// degrades to "no overlay this time" rather than risking a hang.
308/// Resolving the primary's *index* happens inside the hop too, so the
309/// caller receives only plain data and never re-touches a live handle.
310#[cfg(not(test))]
311fn read_display_geometry<R: Runtime>(
312    app: &AppHandle<R>,
313) -> Option<(Vec<tauri::Monitor>, Option<usize>)> {
314    let (tx, rx) = std::sync::mpsc::channel();
315    let handle = app.clone();
316    if let Err(e) = app.run_on_main_thread(move || {
317        let all = handle.available_monitors().unwrap_or_default();
318        let rects: Vec<MonitorRect> = all.iter().map(monitor_rect).collect();
319        let primary = handle
320            .primary_monitor()
321            .ok()
322            .flatten()
323            .and_then(|p| monitor_index_by_rect(&monitor_rect(&p), &rects));
324        let _ = tx.send((all, primary));
325    }) {
326        log::warn!("overlay: could not reach the main thread to read monitors: {e}");
327        return None;
328    }
329    match rx.recv_timeout(GEOMETRY_READ_TIMEOUT) {
330        Ok(snapshot) => Some(snapshot),
331        Err(e) => {
332            log::warn!("overlay: timed out reading monitors on the main thread: {e}");
333            None
334        }
335    }
336}
337
338fn select_overlay_monitors<R: Runtime>(
339    app: &AppHandle<R>,
340    placement: MonitorPlacement,
341) -> Vec<tauri::Monitor> {
342    // In unit tests the mock runtime's available_monitors() is unimplemented
343    // and panics; return empty so fire_break can be called in tests without
344    // opening windows. NOTE: under test `all` is always empty, so the
345    // monitor-selection logic below (rects/active/pick) is not exercised by
346    // unit tests — it relies on the e2e smoke run for coverage.
347    // Treat edits below this line as unit-uncovered by design.
348    #[cfg(test)]
349    let (all, primary): (Vec<tauri::Monitor>, Option<usize>) = (Vec::new(), None);
350    #[cfg(not(test))]
351    let (all, primary) = read_display_geometry(app).unwrap_or_default();
352    if all.is_empty() {
353        return Vec::new();
354    }
355    let rects: Vec<MonitorRect> = all.iter().map(monitor_rect).collect();
356
357    let active = match placement {
358        MonitorPlacement::Active => match app.cursor_position() {
359            Ok(p) => pick_active_monitor(p.x, p.y, &rects),
360            Err(_) => None,
361        },
362        _ => None,
363    };
364
365    resolve_overlay_indices(placement, primary, active, all.len(), is_wayland_session())
366        .into_iter()
367        .map(|i| all[i].clone())
368        .collect()
369}
370
371/// Surface a break through whichever channel the active settings ask
372/// for: a system notification or the overlay (full-screen or windowed).
373/// `Notification` delivery short-circuits the overlay path entirely.
374///
375/// `event` carries the break content (kind, duration, hints, …); the
376/// `delivery` and `placement` decide *how* and *where* it surfaces.
377pub fn deliver_break<R: Runtime>(
378    app: &AppHandle<R>,
379    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
380    event: BreakEvent,
381    delivery: BreakDelivery,
382    placement: MonitorPlacement,
383    windowed_fraction: f64,
384) {
385    match delivery {
386        BreakDelivery::Notification => notify_break_now(app, event.kind, event.duration_secs),
387        BreakDelivery::Overlay | BreakDelivery::Windowed => fire_break(
388            app,
389            current_break,
390            event,
391            placement,
392            matches!(delivery, BreakDelivery::Windowed),
393            windowed_fraction,
394        ),
395    }
396}
397
398/// Stash the break `event` in `current_break`, position an overlay window
399/// on each selected monitor, and emit `break:start` to the renderer. Used
400/// directly for sleep/resume-last paths; normal scheduled breaks go
401/// through `deliver_break` instead.
402///
403/// `postpone_available` is forced off for enforceable breaks here, so
404/// callers can pass the user's raw intent without re-deriving it.
405pub fn fire_break<R: Runtime>(
406    app: &AppHandle<R>,
407    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
408    event: BreakEvent,
409    placement: MonitorPlacement,
410    windowed: bool,
411    windowed_fraction: f64,
412) {
413    let mut payload = event;
414    payload.postpone_available = payload.postpone_available && !payload.enforceable;
415    payload.skip_available = payload.skip_available && !payload.enforceable;
416    *super::lock_current_break(current_break) = Some(payload.clone());
417
418    // Quiet any playing media for the duration of the break (#77). No-op
419    // unless the user enabled it; `end_break` resumes. Only the overlay
420    // path reaches here — notification-only breaks don't block the screen.
421    crate::media::on_break_start();
422
423    let monitors = select_overlay_monitors(app, placement);
424    let count = monitors.len().max(1);
425    let mut shown = 0usize;
426    let wayland = is_wayland_session();
427
428    for (idx, monitor) in monitors.iter().enumerate() {
429        if let Some(window) = ensure_overlay(app, idx) {
430            let scale = monitor.scale_factor();
431            let reported = monitor_rect(monitor);
432            let monitor_rect = scale_corrected_rect(reported, scale, wayland);
433            let rect = if windowed {
434                centered_windowed_rect(monitor_rect, windowed_fraction)
435            } else {
436                monitor_rect
437            };
438            // The geometry, scale, and Wayland flag in one line so a
439            // diagnostics report can confirm the overlay was sized to the
440            // monitor (and flag the inverse of #67 — a too-small overlay
441            // if some compositor reports true physical despite scaling).
442            log::debug!(
443                "overlay-{idx}: wayland={wayland} scale={scale:.2} reported={rw}x{rh}@({rx},{ry}) \
444                 -> set {w}x{h}@({x},{y})",
445                rw = reported.width,
446                rh = reported.height,
447                rx = reported.x,
448                ry = reported.y,
449                w = rect.width,
450                h = rect.height,
451                x = rect.x,
452                y = rect.y,
453            );
454            let _ = window.set_position(tauri::PhysicalPosition::new(rect.x, rect.y));
455            let _ = window.set_size(tauri::PhysicalSize::new(rect.width, rect.height));
456            let _ = window.set_always_on_top(true);
457            // On Wayland the compositor ignores `set_position`/`set_size`,
458            // so a full-screen overlay positioned by rect would just sit at
459            // its default size on whatever output has focus. Ask the
460            // compositor to fullscreen the surface instead — that fills the
461            // focused output edge-to-edge regardless of the ignored rect.
462            // We still cannot choose *which* output (see
463            // `resolve_overlay_indices`), so monitor placement can't be
464            // honoured here; this only guarantees the overlay covers a whole
465            // screen rather than appearing as a small floating window (#67).
466            // Windowed mode stays non-fullscreen so the desktop is reachable.
467            let _ = window.set_fullscreen(wayland && !windowed);
468            let _ = window.show();
469            let _ = window.set_focus();
470            shown += 1;
471        }
472    }
473
474    // Logged to the rotating log file (not just the stats event log) so a
475    // diagnostics report's log tail shows the break actually firing — and
476    // flags the Linux case where no overlay could be built (shown == 0).
477    if shown == 0 {
478        log::error!(
479            "scheduler: break kind={kind:?} fired but NO overlay window could be shown \
480             ({count} monitor(s) targeted) — the break is invisible",
481            kind = payload.kind
482        );
483    } else {
484        log::info!(
485            "scheduler: break kind={kind:?} shown on {shown}/{count} monitor(s)",
486            kind = payload.kind
487        );
488    }
489
490    // Close (not just hide) any overlays for monitors that disconnected since
491    // last break — `hide()` left the webview process holding the slot, which
492    // leaked memory on every monitor unplug cycle.
493    for (label, window) in app.webview_windows() {
494        if let Some(suffix) = label.strip_prefix("overlay-") {
495            if let Ok(idx) = suffix.parse::<usize>() {
496                if idx >= count {
497                    let _ = window.close();
498                }
499            }
500        }
501    }
502
503    // Emit `break:start` immediately. Already-mounted overlay windows hear it
504    // through their `listen("break:start")` subscription; freshly-created
505    // ones rehydrate via the `get_current_break` call in their mount effect.
506    // The payload was already stashed in `current_break` above, so the cold-
507    // mount path returns the correct data without any handshake.
508    let _ = app.emit("break:start", &payload);
509
510    // Arm the render-readiness watchdog (#196 / #226). The overlay acks via
511    // `notify_overlay_rendered` once it paints; if no ack lands within the
512    // grace period the break is torn down, so a dead webview can't leave the
513    // desktop frozen behind an invisible, focus-grabbing, media-pausing
514    // overlay. This also covers the `shown == 0` case logged above: nothing
515    // rendered, so the watchdog resumes media and clears the break rather than
516    // leaving it stranded.
517    let epoch = overlay_watchdog::OVERLAY_ACK.arm();
518    spawn_render_watchdog(app, current_break, epoch);
519}
520
521/// Hide every break overlay window. Shared by the break-teardown paths
522/// (`end_break`, `postpone_break`, and the render watchdog) so the
523/// `"overlay-"` label convention lives in exactly one place.
524pub(crate) fn hide_overlay_windows<R: Runtime>(app: &AppHandle<R>) {
525    for (label, window) in app.webview_windows() {
526        if label.starts_with("overlay-") {
527            let _ = window.hide();
528        }
529    }
530}
531
532/// Tear down a break whose overlay never reported rendering — the #196/#226
533/// watchdog firing, or the `shown == 0` no-overlay case. Mirrors the cleanup
534/// half of `end_break` (clear the current break, resume any paused media, hide
535/// every overlay window, emit `break:end`) but records **no** stats: an
536/// invisible break was never taken or dismissed. Scheduler-free so the
537/// watchdog task can run it from cloned `Arc`s without holding `&Scheduler`.
538///
539/// On the Windows test build the only callers — the `#[cfg(not(test))]`
540/// watchdog spawn and the rig test below (excluded on Windows, where the
541/// `tauri` test feature can't boot a mock app) — are both compiled out, so it
542/// reads as dead there only; the real build keeps it live.
543#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
544pub(crate) fn abort_stranded_break<R: Runtime>(
545    app: &AppHandle<R>,
546    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
547) {
548    log::warn!(
549        "overlay: break overlay never reported rendering within {}s — tearing the break down to \
550         release the desktop (media + focus). See #196 (macOS WKWebView) / #226 (Linux).",
551        overlay_watchdog::RENDER_GRACE_SECS
552    );
553    *super::lock_current_break(current_break) = None;
554    crate::media::on_break_end();
555    hide_overlay_windows(app);
556    let _ = app.emit("break:end", ());
557}
558
559/// Spawn the grace-period task that aborts a never-rendered break. Captures
560/// cloned `Arc`s + `AppHandle` so it outlives `fire_break`'s borrows.
561///
562/// The teardown is marshalled onto the main thread: `abort_stranded_break`
563/// iterates and hides webview windows, and on X11/GTK those calls must not run
564/// off the GUI thread (a worker-thread window op trips
565/// `xcb_xlib_threads_sequence_lost`). `run_on_main_thread` serialises it onto
566/// the event loop, mirroring the tray's status-title update.
567#[cfg(not(test))]
568fn spawn_render_watchdog<R: Runtime>(
569    app: &AppHandle<R>,
570    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
571    epoch: u64,
572) {
573    let app = app.clone();
574    let current_break = current_break.clone();
575    tauri::async_runtime::spawn(async move {
576        tokio::time::sleep(std::time::Duration::from_secs(
577            overlay_watchdog::RENDER_GRACE_SECS,
578        ))
579        .await;
580        if overlay_watchdog::OVERLAY_ACK.is_stranded(epoch) {
581            let app_main = app.clone();
582            let _ = app.run_on_main_thread(move || {
583                abort_stranded_break(&app_main, &current_break);
584            });
585        }
586    });
587}
588
589// Tests drive `fire_break` synchronously and assert on its immediate effects;
590// a real multi-second watchdog task would outlive the test and fire against a
591// torn-down mock app. `arm()` above still runs (a cheap atomic bump), so the
592// epoch bookkeeping is exercised; only the timer task is suppressed here. The
593// teardown itself is covered directly via `abort_stranded_break`.
594#[cfg(test)]
595fn spawn_render_watchdog<R: Runtime>(
596    _app: &AppHandle<R>,
597    _current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
598    _epoch: u64,
599) {
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn rect(x: i32, y: i32, w: u32, h: u32) -> MonitorRect {
607        MonitorRect {
608            x,
609            y,
610            width: w,
611            height: h,
612        }
613    }
614
615    #[test]
616    fn pick_active_monitor_returns_containing_index() {
617        let monitors = vec![
618            rect(0, 0, 1920, 1080),
619            rect(1920, 0, 2560, 1440),
620            rect(0, 1080, 1920, 1080),
621        ];
622        assert_eq!(pick_active_monitor(100.0, 100.0, &monitors), Some(0));
623        assert_eq!(pick_active_monitor(3000.0, 500.0, &monitors), Some(1));
624        assert_eq!(pick_active_monitor(500.0, 1500.0, &monitors), Some(2));
625    }
626
627    #[test]
628    fn pick_active_monitor_returns_none_when_outside() {
629        let monitors = vec![rect(0, 0, 1920, 1080)];
630        assert_eq!(pick_active_monitor(-10.0, 50.0, &monitors), None);
631        assert_eq!(pick_active_monitor(50.0, 2000.0, &monitors), None);
632    }
633
634    #[test]
635    fn pick_active_monitor_handles_negative_origin() {
636        let monitors = vec![rect(-1920, 0, 1920, 1080), rect(0, 0, 1920, 1080)];
637        assert_eq!(pick_active_monitor(-500.0, 200.0, &monitors), Some(0));
638        assert_eq!(pick_active_monitor(500.0, 200.0, &monitors), Some(1));
639    }
640
641    #[test]
642    fn pick_active_monitor_returns_none_for_empty_list() {
643        assert_eq!(pick_active_monitor(0.0, 0.0, &[]), None);
644    }
645
646    #[test]
647    fn primary_or_all_indices_uses_primary_when_present() {
648        // A named primary is honoured exactly — never widened.
649        assert_eq!(primary_or_all_indices(Some(1), 3), vec![1]);
650    }
651
652    #[test]
653    fn primary_or_all_indices_covers_every_monitor_without_primary() {
654        // No primary (Wayland off-path / X11): cover all monitors so a
655        // break can't be dodged on the second screen (#67).
656        assert_eq!(primary_or_all_indices(None, 2), vec![0, 1]);
657    }
658
659    #[test]
660    fn primary_or_all_indices_covers_all_when_primary_out_of_range() {
661        // A stale / mismatched primary index must not silently target the
662        // wrong screen — fall back to covering everything.
663        assert_eq!(primary_or_all_indices(Some(5), 2), vec![0, 1]);
664    }
665
666    #[test]
667    fn primary_or_all_indices_empty_when_no_monitors_at_all() {
668        assert!(primary_or_all_indices(None, 0).is_empty());
669        assert!(primary_or_all_indices(Some(0), 0).is_empty());
670    }
671
672    #[test]
673    fn active_indices_prefers_cursor_monitor() {
674        assert_eq!(active_indices(Some(2), Some(0), 3), vec![2]);
675    }
676
677    #[test]
678    fn active_indices_falls_back_to_primary_then_first() {
679        assert_eq!(active_indices(None, Some(1), 3), vec![1]);
680        assert_eq!(active_indices(None, None, 3), vec![0]);
681    }
682
683    #[test]
684    fn active_indices_ignores_out_of_range_inputs() {
685        assert_eq!(active_indices(Some(9), Some(9), 2), vec![0]);
686        assert_eq!(active_indices(Some(9), Some(1), 2), vec![1]);
687    }
688
689    #[test]
690    fn active_indices_empty_without_monitors() {
691        assert!(active_indices(Some(0), Some(0), 0).is_empty());
692    }
693
694    #[test]
695    fn resolve_overlay_indices_off_wayland_matches_placement() {
696        // active: cursor monitor; primary: the named primary; all: everyone.
697        assert_eq!(
698            resolve_overlay_indices(MonitorPlacement::Active, Some(0), Some(1), 2, false),
699            vec![1]
700        );
701        assert_eq!(
702            resolve_overlay_indices(MonitorPlacement::Primary, Some(1), None, 2, false),
703            vec![1]
704        );
705        assert_eq!(
706            resolve_overlay_indices(MonitorPlacement::All, Some(0), None, 2, false),
707            vec![0, 1]
708        );
709    }
710
711    #[test]
712    fn resolve_overlay_indices_primary_without_named_primary_covers_all() {
713        // X11 with no reported primary: every monitor, never just the first.
714        assert_eq!(
715            resolve_overlay_indices(MonitorPlacement::Primary, None, None, 2, false),
716            vec![0, 1]
717        );
718    }
719
720    #[test]
721    fn resolve_overlay_indices_on_wayland_collapses_to_single_overlay() {
722        // The core #67 fix: Wayland cannot place windows per-output, so
723        // EVERY placement resolves to exactly one overlay — never two on the
724        // same physical monitor.
725        for placement in [
726            MonitorPlacement::Active,
727            MonitorPlacement::Primary,
728            MonitorPlacement::All,
729        ] {
730            let got = resolve_overlay_indices(placement, Some(0), Some(1), 2, true);
731            assert_eq!(got.len(), 1, "{placement:?} must yield one overlay");
732        }
733    }
734
735    #[test]
736    fn resolve_overlay_indices_on_wayland_prefers_active_then_primary() {
737        assert_eq!(
738            resolve_overlay_indices(MonitorPlacement::Active, Some(0), Some(1), 2, true),
739            vec![1]
740        );
741        assert_eq!(
742            resolve_overlay_indices(MonitorPlacement::Active, Some(1), None, 2, true),
743            vec![1]
744        );
745        assert_eq!(
746            resolve_overlay_indices(MonitorPlacement::Primary, Some(1), Some(0), 2, true),
747            vec![1]
748        );
749        assert_eq!(
750            resolve_overlay_indices(MonitorPlacement::All, Some(1), None, 2, true),
751            vec![1]
752        );
753    }
754
755    #[test]
756    fn resolve_overlay_indices_on_wayland_defaults_to_first_without_hints() {
757        // No primary, no cursor hit (Wayland reports neither): still build
758        // one overlay rather than none, so the break stays visible (#67).
759        assert_eq!(
760            resolve_overlay_indices(MonitorPlacement::Primary, None, None, 2, true),
761            vec![0]
762        );
763    }
764
765    #[test]
766    fn resolve_overlay_indices_empty_without_monitors() {
767        assert!(resolve_overlay_indices(MonitorPlacement::All, None, None, 0, false).is_empty());
768        assert!(resolve_overlay_indices(MonitorPlacement::Active, None, None, 0, true).is_empty());
769    }
770
771    #[test]
772    fn monitor_index_by_rect_matches_on_geometry() {
773        let rects = vec![rect(0, 0, 1920, 1080), rect(1920, 0, 2560, 1440)];
774        assert_eq!(
775            monitor_index_by_rect(&rect(1920, 0, 2560, 1440), &rects),
776            Some(1)
777        );
778        assert_eq!(monitor_index_by_rect(&rect(0, 0, 1280, 720), &rects), None);
779    }
780
781    #[test]
782    fn wayland_session_from_env_detects_session_type_and_display() {
783        assert!(wayland_session_from_env(Some("wayland"), false));
784        assert!(wayland_session_from_env(Some("WAYLAND"), false));
785        assert!(wayland_session_from_env(None, true));
786        assert!(!wayland_session_from_env(Some("x11"), false));
787        assert!(!wayland_session_from_env(None, false));
788    }
789
790    #[test]
791    fn scale_corrected_rect_divides_out_doubled_wayland_geometry() {
792        // Steffi's #67 case: a 4K panel at 200% is reported as 7680×4320
793        // (physical × scale). Dividing by the scale recovers true physical
794        // 3840×2160, so the overlay covers exactly one monitor instead of
795        // 2× spilling onto the neighbour.
796        let reported = rect(7680, 0, 7680, 4320);
797        let r = scale_corrected_rect(reported, 2.0, true);
798        assert_eq!(r, rect(3840, 0, 3840, 2160));
799    }
800
801    #[test]
802    fn scale_corrected_rect_noop_off_wayland() {
803        // X11 / macOS report true physical already — never halve them.
804        let reported = rect(0, 0, 3840, 2160);
805        assert_eq!(scale_corrected_rect(reported, 2.0, false), reported);
806    }
807
808    #[test]
809    fn scale_corrected_rect_noop_at_unity_scale() {
810        // Wayland without HiDPI scaling: nothing to correct.
811        let reported = rect(1920, 0, 1920, 1080);
812        assert_eq!(scale_corrected_rect(reported, 1.0, true), reported);
813    }
814
815    #[test]
816    fn scale_corrected_rect_handles_fractional_scale() {
817        // 150% scaling rounds to the nearest physical pixel and never
818        // collapses a dimension to zero.
819        let r = scale_corrected_rect(rect(0, 0, 2880, 1620), 1.5, true);
820        assert_eq!(r, rect(0, 0, 1920, 1080));
821    }
822
823    #[test]
824    fn centered_windowed_rect_returns_eighty_percent_centered() {
825        let monitor = rect(0, 0, 1000, 1000);
826        let r = centered_windowed_rect(monitor, 0.8);
827        assert_eq!(r.width, 800);
828        assert_eq!(r.height, 800);
829        assert_eq!(r.x, 100);
830        assert_eq!(r.y, 100);
831    }
832
833    #[test]
834    fn centered_windowed_rect_respects_monitor_origin() {
835        let monitor = rect(1920, 100, 2560, 1440);
836        let r = centered_windowed_rect(monitor, 0.8);
837        assert_eq!(r.width, 2048);
838        assert_eq!(r.height, 1152);
839        assert_eq!(r.x, 1920 + (2560 - 2048) / 2);
840        assert_eq!(r.y, 100 + (1440 - 1152) / 2);
841    }
842
843    #[test]
844    fn centered_windowed_rect_clamps_fraction() {
845        let monitor = rect(0, 0, 1000, 1000);
846        let full = centered_windowed_rect(monitor, 2.0);
847        assert_eq!(full.width, 1000);
848        assert_eq!(full.height, 1000);
849        let tiny = centered_windowed_rect(monitor, 0.0);
850        assert_eq!(tiny.width, 100);
851        assert_eq!(tiny.height, 100);
852    }
853
854    #[test]
855    fn format_break_duration_uses_friendly_units() {
856        assert_eq!(format_break_duration(20), "20 seconds");
857        assert_eq!(format_break_duration(1), "1 second");
858        assert_eq!(format_break_duration(60), "1 minute");
859        assert_eq!(format_break_duration(120), "2 minutes");
860        assert_eq!(format_break_duration(300), "5 minutes");
861        assert_eq!(format_break_duration(90), "1m 30s");
862    }
863}