a lightweight, interval-based utility to combat digital strain through "Ma" (intentional pauses) for the eyes and body.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: ensure overlay is applied to all monitors correctly

+212 -64
+7
src/overlay/mod.rs
··· 41 41 ) -> anyhow::Result<Self> { 42 42 let session = session::detect(); 43 43 let all_monitors = monitors::enumerate(); 44 + log::info!( 45 + "overlay: session={:?}, {} monitor(s): {}", 46 + session, 47 + all_monitors.len(), 48 + all_monitors.iter().map(|m| format!("{}x{}@({},{})", m.width, m.height, m.x, m.y)) 49 + .collect::<Vec<_>>().join(", ") 50 + ); 44 51 45 52 #[cfg(target_os = "linux")] 46 53 let layer_barrier: Option<LayerShellBarrier> = if session == SessionType::Wayland {
+205 -64
src/overlay/multi_slint.rs
··· 18 18 window: OverlayWindow, 19 19 pos: slint::LogicalPosition, 20 20 size: slint::LogicalSize, 21 + /// Index of this window in the list of monitors sorted by (x, y). Used as 22 + /// a fallback when position-based monitor matching fails. 23 + monitor_index: usize, 21 24 } 22 25 23 26 pub struct MultiSlintBackend { ··· 35 38 ) -> anyhow::Result<Self> { 36 39 let mut entries = Vec::new(); 37 40 41 + // Sort monitors by (x, y) so the index-based fallback in show_break() 42 + // matches the same order winit uses when we sort available_monitors() by x. 43 + let mut sorted_monitors: Vec<(usize, &MonitorInfo)> = monitors.iter().enumerate().collect(); 44 + sorted_monitors.sort_by_key(|(_, m)| (m.x, m.y)); 45 + 38 46 if session == SessionType::Wayland { 39 - // On Wayland, create one window per monitor. We set position before 40 - // show() as a hint to nudge the compositor toward the right output, 41 - // then call set_fullscreen(true) so it fills that output. 42 - log::info!( 43 - "MultiSlintBackend: Wayland — {} compositor-placed overlay window(s)", 44 - monitors.len() 45 - ); 46 - for monitor in monitors { 47 - log::info!( 48 - " monitor: {}x{} at ({},{}), primary={}", 49 - monitor.width, monitor.height, monitor.x, monitor.y, monitor.is_primary 47 + for (sort_rank, monitor) in &sorted_monitors { 48 + log::debug!( 49 + "overlay window[{}]: {}x{} at ({},{})", 50 + sort_rank, monitor.width, monitor.height, monitor.x, monitor.y 50 51 ); 51 52 let pos = slint::LogicalPosition { x: monitor.x as f32, y: monitor.y as f32 }; 52 53 let size = slint::LogicalSize { ··· 57 58 w.set_is_dark(is_dark); 58 59 wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 59 60 w.hide().unwrap_or_default(); 60 - entries.push(WindowEntry { window: w, pos, size }); 61 + entries.push(WindowEntry { window: w, pos, size, monitor_index: *sort_rank }); 61 62 } 62 63 } else { 63 - // X11 / Windows: one window per monitor, positioned and sized explicitly. 64 - log::info!("MultiSlintBackend: creating overlays for {} monitor(s)", monitors.len()); 65 - for monitor in monitors { 66 - log::info!( 67 - " monitor: {}x{} at ({},{}), primary={}", 68 - monitor.width, monitor.height, monitor.x, monitor.y, monitor.is_primary 64 + for (sort_rank, monitor) in &sorted_monitors { 65 + log::debug!( 66 + "overlay window[{}]: {}x{} at ({},{})", 67 + sort_rank, monitor.width, monitor.height, monitor.x, monitor.y 69 68 ); 70 69 let pos = slint::LogicalPosition { x: monitor.x as f32, y: monitor.y as f32 }; 71 70 let size = slint::LogicalSize { ··· 76 75 w.set_is_dark(is_dark); 77 76 wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 78 77 w.hide().unwrap_or_default(); 79 - entries.push(WindowEntry { window: w, pos, size }); 78 + entries.push(WindowEntry { window: w, pos, size, monitor_index: *sort_rank }); 80 79 } 81 80 } 82 81 ··· 84 83 } 85 84 } 86 85 86 + /// Given the logical positions of winit monitors (sorted by physical x), find 87 + /// the index of the one that best matches `target`. 88 + /// 89 + /// First tries an exact position match within `tolerance` logical pixels. 90 + /// If nothing matches, falls back to `fallback_index` in the sorted list 91 + /// (guaranteed to pick *some* monitor rather than leaving the window on 92 + /// whatever output the compositor chooses by default). 93 + /// 94 + /// Returns `None` only when `monitors` is empty. 95 + pub(crate) fn find_monitor_index( 96 + monitors: &[(f32, f32)], 97 + target: (f32, f32), 98 + tolerance: f32, 99 + fallback_index: usize, 100 + ) -> Option<usize> { 101 + if monitors.is_empty() { 102 + return None; 103 + } 104 + // Position match 105 + if let Some(idx) = monitors.iter().position(|&(lx, ly)| { 106 + (lx - target.0).abs() < tolerance && (ly - target.1).abs() < tolerance 107 + }) { 108 + return Some(idx); 109 + } 110 + // Index fallback: clamp so we never go out of bounds 111 + Some(fallback_index.min(monitors.len() - 1)) 112 + } 113 + 87 114 impl OverlayBackend for MultiSlintBackend { 88 115 fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 89 116 for e in &self.entries { ··· 91 118 e.window.set_snooze_visible(!is_enforced && !snooze_used); 92 119 e.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 93 120 e.window.set_progress(0.0); 94 - if self.session == SessionType::Wayland { 95 - #[cfg(target_os = "linux")] 96 - { 97 - // show() queues window creation but the winit Window isn't yet 98 - // available synchronously. spawn_local runs after the event loop 99 - // processes show(), at which point winit_window().await resolves 100 - // and we can call set_fullscreen with the specific wl_output — 101 - // the only reliable way to target a specific output on GNOME Wayland. 102 - let target_pos = e.pos; 103 - let win_weak = e.window.as_weak(); 104 - e.window.show().unwrap_or_default(); 105 - slint::spawn_local(async move { 106 - let Some(overlay) = win_weak.upgrade() else { return }; 107 - let winit_win = match overlay.window().winit_window().await { 108 - Ok(w) => w, 109 - Err(_) => { 110 - log::warn!("winit_window() unavailable for output at ({},{})", target_pos.x, target_pos.y); 111 - return; 112 - } 113 - }; 114 - let handle = winit_win.available_monitors().find(|m| { 115 - let sf = m.scale_factor(); 116 - let phys = m.position(); 117 - let lx = (phys.x as f64 / sf) as f32; 118 - let ly = (phys.y as f64 / sf) as f32; 119 - (lx - target_pos.x).abs() < 2.0 && (ly - target_pos.y).abs() < 2.0 120 - }); 121 - if handle.is_some() { 122 - log::info!("Wayland: fullscreen on output at ({},{})", target_pos.x, target_pos.y); 123 - } else { 124 - log::warn!("Wayland: no output matched ({},{}), compositor will choose", target_pos.x, target_pos.y); 121 + 122 + #[cfg(target_os = "linux")] 123 + { 124 + // On both X11 and Wayland, use winit's monitor-specific fullscreen: 125 + // X11: _NET_WM_FULLSCREEN_MONITORS via RandR 126 + // Wayland: xdg_toplevel.set_fullscreen(wl_output) 127 + // Setting position/size before show() is unreliable on both backends; 128 + // this async approach waits until the window handle is live, then 129 + // targets the correct output by matching our enumerated positions. 130 + let target_pos = e.pos; 131 + let monitor_idx = e.monitor_index; 132 + let win_weak = e.window.as_weak(); 133 + e.window.show().unwrap_or_default(); 134 + slint::spawn_local(async move { 135 + let Some(overlay) = win_weak.upgrade() else { return }; 136 + let winit_win = match overlay.window().winit_window().await { 137 + Ok(w) => w, 138 + Err(_) => { 139 + log::warn!( 140 + "overlay: winit_window() unavailable for ({},{}) — falling back", 141 + target_pos.x, target_pos.y 142 + ); 143 + overlay.window().set_fullscreen(true); 144 + return; 125 145 } 126 - winit_win.set_fullscreen(Some(winit::window::Fullscreen::Borderless(handle))); 127 - }).ok(); 128 - } 129 - #[cfg(not(target_os = "linux"))] 130 - { 131 - e.window.show().unwrap_or_default(); 132 - e.window.window().set_fullscreen(true); 133 - } 134 - } else { 135 - // On X11/Windows, explicit logical position+size is reliable and 136 - // avoids WM-dependent fullscreen-monitor selection behaviour. 146 + }; 147 + 148 + let mut all_monitors: Vec<_> = winit_win.available_monitors().collect(); 149 + all_monitors.sort_by_key(|m| m.position().x); 150 + 151 + let candidates: Vec<(f32, f32)> = all_monitors.iter().map(|m| { 152 + let sf = m.scale_factor(); 153 + let phys = m.position(); 154 + let lx = (phys.x as f64 / sf) as f32; 155 + let ly = (phys.y as f64 / sf) as f32; 156 + log::debug!( 157 + "overlay: monitor {:?} phys=({},{}) scale={:.2} logical=({},{})", 158 + m.name().as_deref().unwrap_or("?"), phys.x, phys.y, sf, lx, ly 159 + ); 160 + (lx, ly) 161 + }).collect(); 162 + 163 + let chosen_idx = find_monitor_index( 164 + &candidates, 165 + (target_pos.x, target_pos.y), 166 + 32.0, 167 + monitor_idx, 168 + ); 169 + let handle = chosen_idx.and_then(|i| all_monitors.into_iter().nth(i)); 170 + 171 + match &handle { 172 + Some(h) => log::info!( 173 + "overlay: fullscreen on {:?} for ({},{})", 174 + h.name().as_deref().unwrap_or("?"), target_pos.x, target_pos.y 175 + ), 176 + None => log::warn!( 177 + "overlay: no monitors available for ({},{}), compositor will choose", 178 + target_pos.x, target_pos.y 179 + ), 180 + } 181 + 182 + winit_win.set_fullscreen(Some(winit::window::Fullscreen::Borderless(handle))); 183 + }).ok(); 184 + } 185 + 186 + #[cfg(not(target_os = "linux"))] 187 + { 188 + // Windows: explicit position+size is reliable. 137 189 e.window.window().set_position(slint::WindowPosition::Logical(e.pos)); 138 190 e.window.window().set_size(slint::WindowSize::Logical(e.size)); 139 191 e.window.show().unwrap_or_default(); ··· 143 195 144 196 fn hide(&self) { 145 197 for e in &self.entries { 146 - if self.session == SessionType::Wayland { 147 - e.window.window().set_fullscreen(false); 148 - } 198 + // Always exit fullscreen before hiding so the window doesn't 199 + // re-appear in fullscreen state on the next show(). 200 + e.window.window().set_fullscreen(false); 149 201 e.window.hide().unwrap_or_default(); 150 202 } 151 203 } ··· 237 289 }); 238 290 } 239 291 } 292 + 293 + #[cfg(test)] 294 + mod tests { 295 + use super::*; 296 + 297 + // ── find_monitor_index ──────────────────────────────────────────────────── 298 + 299 + #[test] 300 + fn exact_match_first_monitor() { 301 + let monitors = vec![(0.0f32, 0.0f32), (1920.0, 0.0)]; 302 + assert_eq!(find_monitor_index(&monitors, (0.0, 0.0), 32.0, 0), Some(0)); 303 + } 304 + 305 + #[test] 306 + fn exact_match_second_monitor() { 307 + let monitors = vec![(0.0f32, 0.0f32), (1920.0, 0.0)]; 308 + assert_eq!(find_monitor_index(&monitors, (1920.0, 0.0), 32.0, 1), Some(1)); 309 + } 310 + 311 + #[test] 312 + fn match_within_tolerance() { 313 + let monitors = vec![(0.0f32, 0.0f32), (1920.0, 0.0)]; 314 + // Winit rounding may yield 1919.5 instead of 1920.0 315 + assert_eq!(find_monitor_index(&monitors, (1920.0, 0.0), 32.0, 1), Some(1)); 316 + assert_eq!(find_monitor_index(&monitors, (1910.0, 0.0), 32.0, 1), Some(1)); 317 + } 318 + 319 + #[test] 320 + fn outside_tolerance_uses_fallback() { 321 + let monitors = vec![(0.0f32, 0.0f32), (1920.0, 0.0)]; 322 + // Target at 960: not within 32px of either monitor, fallback_index=1 323 + assert_eq!(find_monitor_index(&monitors, (960.0, 0.0), 32.0, 1), Some(1)); 324 + } 325 + 326 + #[test] 327 + fn fallback_index_clamped_to_last() { 328 + let monitors = vec![(0.0f32, 0.0f32), (1920.0, 0.0)]; 329 + // fallback_index=5 is out of bounds, should clamp to last (1) 330 + assert_eq!(find_monitor_index(&monitors, (9999.0, 0.0), 32.0, 5), Some(1)); 331 + } 332 + 333 + #[test] 334 + fn empty_monitors_returns_none() { 335 + assert_eq!(find_monitor_index(&[], (0.0, 0.0), 32.0, 0), None); 336 + } 337 + 338 + #[test] 339 + fn single_monitor_always_matches() { 340 + let monitors = vec![(0.0f32, 0.0f32)]; 341 + assert_eq!(find_monitor_index(&monitors, (0.0, 0.0), 32.0, 0), Some(0)); 342 + // Even with a wrong target — only one option 343 + assert_eq!(find_monitor_index(&monitors, (9999.0, 0.0), 32.0, 0), Some(0)); 344 + } 345 + 346 + #[test] 347 + fn triple_monitor_matches_correct_index() { 348 + let monitors = vec![(-1920.0f32, 0.0), (0.0, 0.0), (1920.0, 0.0)]; 349 + assert_eq!(find_monitor_index(&monitors, (-1920.0, 0.0), 32.0, 0), Some(0)); 350 + assert_eq!(find_monitor_index(&monitors, (0.0, 0.0), 32.0, 1), Some(1)); 351 + assert_eq!(find_monitor_index(&monitors, (1920.0, 0.0), 32.0, 2), Some(2)); 352 + } 353 + 354 + #[test] 355 + fn y_coordinate_used_for_vertical_stack() { 356 + let monitors = vec![(0.0f32, 0.0), (0.0, 1080.0)]; 357 + assert_eq!(find_monitor_index(&monitors, (0.0, 0.0), 32.0, 0), Some(0)); 358 + assert_eq!(find_monitor_index(&monitors, (0.0, 1080.0), 32.0, 1), Some(1)); 359 + } 360 + 361 + // ── manual integration stubs (run with `cargo test -- --ignored`) ───────── 362 + 363 + /// Trigger a break with two external 1080p monitors connected. Confirm both 364 + /// monitors are covered and the log shows two distinct winit monitor handles. 365 + #[test] 366 + #[ignore] 367 + fn manual_dual_external_both_covered() {} 368 + 369 + /// Trigger a break on a single laptop display. Confirm the overlay fills the 370 + /// entire screen and the log shows exactly one winit monitor. 371 + #[test] 372 + #[ignore] 373 + fn manual_laptop_single_monitor_full_coverage() {} 374 + 375 + /// After closing the laptop lid with two external monitors connected, 376 + /// trigger a break. Confirm both external monitors are covered. 377 + #[test] 378 + #[ignore] 379 + fn manual_clamshell_dual_external_both_covered() {} 380 + }