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
9pub 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
27pub 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
47pub 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#[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#[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 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 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}