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
11pub 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
29pub 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
49fn 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
55fn 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
66fn 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
98pub 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#[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 Err(e) => {
180 log::error!("overlay: failed to create break window '{label}': {e}");
181 None
182 }
183 }
184}
185
186fn 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
201fn 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
220fn 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
262fn 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#[cfg(not(test))]
284const GEOMETRY_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
285
286#[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 #[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
371pub 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
398pub 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 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 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 let _ = window.set_fullscreen(wayland && !windowed);
468 let _ = window.show();
469 let _ = window.set_focus();
470 shown += 1;
471 }
472 }
473
474 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 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 let _ = app.emit("break:start", &payload);
509
510 let epoch = overlay_watchdog::OVERLAY_ACK.arm();
518 spawn_render_watchdog(app, current_break, epoch);
519}
520
521pub(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#[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#[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, ¤t_break);
584 });
585 }
586 });
587}
588
589#[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 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 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 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 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 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 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 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 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 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 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 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}