Skip to main content

entracte_lib/scheduler/
overlay.rs

1use std::sync::Arc;
2
3use tauri::{AppHandle, Emitter, Manager};
4use tauri_plugin_notification::NotificationExt;
5
6use super::settings::MonitorPlacement;
7use super::types::{BreakDelivery, BreakEvent, BreakKind, MonitorRect};
8
9/// Index of the monitor that contains `(cursor_x, cursor_y)`, or
10/// `None` if the cursor sits outside every rect. Used by
11/// `MonitorPlacement::Active` to decide which display the overlay
12/// should pop on.
13pub fn pick_active_monitor(
14    cursor_x: f64,
15    cursor_y: f64,
16    monitors: &[MonitorRect],
17) -> Option<usize> {
18    monitors.iter().position(|m| {
19        let mx = m.x as f64;
20        let my = m.y as f64;
21        let mw = m.width as f64;
22        let mh = m.height as f64;
23        cursor_x >= mx && cursor_x < mx + mw && cursor_y >= my && cursor_y < my + mh
24    })
25}
26
27/// Shrink `monitor` to `fraction` of its size and centre it inside
28/// the original. `fraction` is clamped to `[0.1, 1.0]`. Used to size
29/// the `BreakDelivery::Windowed` overlay so the desktop stays
30/// clickable around it.
31pub fn centered_windowed_rect(monitor: MonitorRect, fraction: f64) -> MonitorRect {
32    let fraction = fraction.clamp(0.1, 1.0);
33    let width = ((monitor.width as f64) * fraction).round() as u32;
34    let height = ((monitor.height as f64) * fraction).round() as u32;
35    let width = width.max(1).min(monitor.width);
36    let height = height.max(1).min(monitor.height);
37    let x = monitor.x + ((monitor.width.saturating_sub(width)) / 2) as i32;
38    let y = monitor.y + ((monitor.height.saturating_sub(height)) / 2) as i32;
39    MonitorRect {
40        x,
41        y,
42        width,
43        height,
44    }
45}
46
47/// Human-friendly break duration for notifications (e.g. `"20 seconds"`,
48/// `"5 minutes"`, `"1m 30s"`). Drops the seconds part when the
49/// duration is a whole-minute multiple.
50pub fn format_break_duration(secs: u64) -> String {
51    if secs >= 60 && secs.is_multiple_of(60) {
52        let mins = secs / 60;
53        if mins == 1 {
54            "1 minute".to_string()
55        } else {
56            format!("{mins} minutes")
57        }
58    } else if secs >= 60 {
59        let mins = secs / 60;
60        let rem = secs % 60;
61        format!("{mins}m {rem}s")
62    } else if secs == 1 {
63        "1 second".to_string()
64    } else {
65        format!("{secs} seconds")
66    }
67}
68
69pub(super) fn notify_break_now(app: &AppHandle, kind: BreakKind, duration_secs: u64) {
70    let title = match kind {
71        BreakKind::Micro => "Micro break",
72        BreakKind::Long => "Long break",
73        BreakKind::Sleep => "Bedtime reminder",
74    };
75    let body = format!("Take a {} break.", format_break_duration(duration_secs));
76    let _ = app.notification().builder().title(title).body(body).show();
77}
78
79fn ensure_overlay(app: &AppHandle, idx: usize) -> Option<tauri::WebviewWindow> {
80    let label = format!("overlay-{idx}");
81    if let Some(w) = app.get_webview_window(&label) {
82        return Some(w);
83    }
84    tauri::WebviewWindowBuilder::new(
85        app,
86        &label,
87        tauri::WebviewUrl::App("index.html?window=overlay".into()),
88    )
89    .title("Entracte Break")
90    .decorations(false)
91    .always_on_top(true)
92    .skip_taskbar(true)
93    .transparent(true)
94    .resizable(false)
95    .visible(false)
96    .focused(false)
97    .build()
98    .ok()
99}
100
101fn select_overlay_monitors(app: &AppHandle, placement: MonitorPlacement) -> Vec<tauri::Monitor> {
102    match placement {
103        MonitorPlacement::All => app.available_monitors().unwrap_or_default(),
104        MonitorPlacement::Primary => app
105            .primary_monitor()
106            .ok()
107            .flatten()
108            .into_iter()
109            .collect::<Vec<_>>(),
110        MonitorPlacement::Active => {
111            let all = app.available_monitors().unwrap_or_default();
112            if all.is_empty() {
113                return Vec::new();
114            }
115            let rects: Vec<MonitorRect> = all
116                .iter()
117                .map(|m| MonitorRect {
118                    x: m.position().x,
119                    y: m.position().y,
120                    width: m.size().width,
121                    height: m.size().height,
122                })
123                .collect();
124            let idx = match app.cursor_position() {
125                Ok(p) => pick_active_monitor(p.x, p.y, &rects),
126                Err(_) => None,
127            };
128            match idx {
129                Some(i) => vec![all[i].clone()],
130                None => app
131                    .primary_monitor()
132                    .ok()
133                    .flatten()
134                    .into_iter()
135                    .collect::<Vec<_>>(),
136            }
137        }
138    }
139}
140
141/// Surface a break through whichever channel the active settings ask
142/// for: a system notification or the overlay (full-screen or windowed).
143/// `Notification` delivery short-circuits the overlay path entirely.
144#[allow(clippy::too_many_arguments)]
145pub fn deliver_break(
146    app: &AppHandle,
147    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
148    delivery: BreakDelivery,
149    kind: BreakKind,
150    duration_secs: u64,
151    enforceable: bool,
152    placement: MonitorPlacement,
153    manual_finish: bool,
154    postpone_available: bool,
155    hints: Vec<String>,
156    hint_rotate_seconds: u64,
157    health_intensity: f32,
158) {
159    match delivery {
160        BreakDelivery::Notification => notify_break_now(app, kind, duration_secs),
161        BreakDelivery::Overlay | BreakDelivery::Windowed => fire_break(
162            app,
163            current_break,
164            kind,
165            duration_secs,
166            enforceable,
167            placement,
168            matches!(delivery, BreakDelivery::Windowed),
169            manual_finish,
170            postpone_available,
171            hints,
172            hint_rotate_seconds,
173            health_intensity,
174        ),
175    }
176}
177
178/// Build a `BreakEvent`, stash it in `current_break`, position an
179/// overlay window on each selected monitor, and emit `break:start` to
180/// the renderer. Used directly for sleep/resume-last paths; normal
181/// scheduled breaks go through `deliver_break` instead.
182#[allow(clippy::too_many_arguments)]
183pub fn fire_break(
184    app: &AppHandle,
185    current_break: &Arc<std::sync::Mutex<Option<BreakEvent>>>,
186    kind: BreakKind,
187    duration_secs: u64,
188    enforceable: bool,
189    placement: MonitorPlacement,
190    windowed: bool,
191    manual_finish: bool,
192    postpone_available: bool,
193    hints: Vec<String>,
194    hint_rotate_seconds: u64,
195    health_intensity: f32,
196) {
197    let payload = BreakEvent {
198        kind,
199        duration_secs,
200        enforceable,
201        manual_finish,
202        postpone_available: postpone_available && !enforceable,
203        hints,
204        hint_rotate_seconds,
205        health_intensity,
206    };
207    if let Ok(mut slot) = current_break.lock() {
208        *slot = Some(payload.clone());
209    }
210
211    let monitors = select_overlay_monitors(app, placement);
212    let count = monitors.len().max(1);
213
214    for (idx, monitor) in monitors.iter().enumerate() {
215        if let Some(window) = ensure_overlay(app, idx) {
216            let monitor_rect = MonitorRect {
217                x: monitor.position().x,
218                y: monitor.position().y,
219                width: monitor.size().width,
220                height: monitor.size().height,
221            };
222            let rect = if windowed {
223                centered_windowed_rect(monitor_rect, 0.8)
224            } else {
225                monitor_rect
226            };
227            let _ = window.set_position(tauri::PhysicalPosition::new(rect.x, rect.y));
228            let _ = window.set_size(tauri::PhysicalSize::new(rect.width, rect.height));
229            let _ = window.set_always_on_top(true);
230            let _ = window.set_fullscreen(false);
231            let _ = window.show();
232            let _ = window.set_focus();
233        }
234    }
235
236    // Close (not just hide) any overlays for monitors that disconnected since
237    // last break — `hide()` left the webview process holding the slot, which
238    // leaked memory on every monitor unplug cycle.
239    for (label, window) in app.webview_windows() {
240        if let Some(suffix) = label.strip_prefix("overlay-") {
241            if let Ok(idx) = suffix.parse::<usize>() {
242                if idx >= count {
243                    let _ = window.close();
244                }
245            }
246        }
247    }
248
249    // Emit `break:start` immediately. Already-mounted overlay windows hear it
250    // through their `listen("break:start")` subscription; freshly-created
251    // ones rehydrate via the `get_current_break` call in their mount effect.
252    // The payload was already stashed in `current_break` above, so the cold-
253    // mount path returns the correct data without any handshake.
254    let _ = app.emit("break:start", &payload);
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn rect(x: i32, y: i32, w: u32, h: u32) -> MonitorRect {
262        MonitorRect {
263            x,
264            y,
265            width: w,
266            height: h,
267        }
268    }
269
270    #[test]
271    fn pick_active_monitor_returns_containing_index() {
272        let monitors = vec![
273            rect(0, 0, 1920, 1080),
274            rect(1920, 0, 2560, 1440),
275            rect(0, 1080, 1920, 1080),
276        ];
277        assert_eq!(pick_active_monitor(100.0, 100.0, &monitors), Some(0));
278        assert_eq!(pick_active_monitor(3000.0, 500.0, &monitors), Some(1));
279        assert_eq!(pick_active_monitor(500.0, 1500.0, &monitors), Some(2));
280    }
281
282    #[test]
283    fn pick_active_monitor_returns_none_when_outside() {
284        let monitors = vec![rect(0, 0, 1920, 1080)];
285        assert_eq!(pick_active_monitor(-10.0, 50.0, &monitors), None);
286        assert_eq!(pick_active_monitor(50.0, 2000.0, &monitors), None);
287    }
288
289    #[test]
290    fn pick_active_monitor_handles_negative_origin() {
291        let monitors = vec![rect(-1920, 0, 1920, 1080), rect(0, 0, 1920, 1080)];
292        assert_eq!(pick_active_monitor(-500.0, 200.0, &monitors), Some(0));
293        assert_eq!(pick_active_monitor(500.0, 200.0, &monitors), Some(1));
294    }
295
296    #[test]
297    fn pick_active_monitor_returns_none_for_empty_list() {
298        assert_eq!(pick_active_monitor(0.0, 0.0, &[]), None);
299    }
300
301    #[test]
302    fn centered_windowed_rect_returns_eighty_percent_centered() {
303        let monitor = rect(0, 0, 1000, 1000);
304        let r = centered_windowed_rect(monitor, 0.8);
305        assert_eq!(r.width, 800);
306        assert_eq!(r.height, 800);
307        assert_eq!(r.x, 100);
308        assert_eq!(r.y, 100);
309    }
310
311    #[test]
312    fn centered_windowed_rect_respects_monitor_origin() {
313        let monitor = rect(1920, 100, 2560, 1440);
314        let r = centered_windowed_rect(monitor, 0.8);
315        assert_eq!(r.width, 2048);
316        assert_eq!(r.height, 1152);
317        assert_eq!(r.x, 1920 + (2560 - 2048) / 2);
318        assert_eq!(r.y, 100 + (1440 - 1152) / 2);
319    }
320
321    #[test]
322    fn centered_windowed_rect_clamps_fraction() {
323        let monitor = rect(0, 0, 1000, 1000);
324        let full = centered_windowed_rect(monitor, 2.0);
325        assert_eq!(full.width, 1000);
326        assert_eq!(full.height, 1000);
327        let tiny = centered_windowed_rect(monitor, 0.0);
328        assert_eq!(tiny.width, 100);
329        assert_eq!(tiny.height, 100);
330    }
331
332    #[test]
333    fn format_break_duration_uses_friendly_units() {
334        assert_eq!(format_break_duration(20), "20 seconds");
335        assert_eq!(format_break_duration(1), "1 second");
336        assert_eq!(format_break_duration(60), "1 minute");
337        assert_eq!(format_break_duration(120), "2 minutes");
338        assert_eq!(format_break_duration(300), "5 minutes");
339        assert_eq!(format_break_duration(90), "1m 30s");
340    }
341}