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))]
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 #[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
342pub 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
369pub 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 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 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 let _ = window.set_fullscreen(wayland && !windowed);
439 let _ = window.show();
440 let _ = window.set_focus();
441 shown += 1;
442 }
443 }
444
445 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 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 let _ = app.emit("break:start", &payload);
480
481 let epoch = overlay_watchdog::OVERLAY_ACK.arm();
489 spawn_render_watchdog(app, current_break, epoch);
490}
491
492pub(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#[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#[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, ¤t_break);
555 });
556 }
557 });
558}
559
560#[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 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 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 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 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 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 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 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 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 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 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 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}