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.

feat: multi-monitor overlay

+150 -41
+63
Cargo.lock
··· 1375 1375 ] 1376 1376 1377 1377 [[package]] 1378 + name = "display-info" 1379 + version = "0.5.9" 1380 + source = "registry+https://github.com/rust-lang/crates.io-index" 1381 + checksum = "9e0aca670967c2528799e316f9f97913efcc034867614d55681dd41a1c2f7830" 1382 + dependencies = [ 1383 + "fxhash", 1384 + "log", 1385 + "objc2 0.6.4", 1386 + "objc2-app-kit 0.3.2", 1387 + "objc2-core-foundation", 1388 + "objc2-core-graphics", 1389 + "objc2-foundation 0.3.2", 1390 + "scopeguard", 1391 + "smithay-client-toolkit 0.20.0", 1392 + "thiserror 2.0.18", 1393 + "widestring", 1394 + "windows 0.62.2", 1395 + "xcb", 1396 + ] 1397 + 1398 + [[package]] 1378 1399 name = "displaydoc" 1379 1400 version = "0.2.5" 1380 1401 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1886 1907 ] 1887 1908 1888 1909 [[package]] 1910 + name = "fxhash" 1911 + version = "0.2.1" 1912 + source = "registry+https://github.com/rust-lang/crates.io-index" 1913 + checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" 1914 + dependencies = [ 1915 + "byteorder", 1916 + ] 1917 + 1918 + [[package]] 1889 1919 name = "gbm" 1890 1920 version = "0.18.0" 1891 1921 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2943 2973 "argon2", 2944 2974 "auto-launch", 2945 2975 "dirs 5.0.1", 2976 + "display-info", 2946 2977 "env_logger", 2947 2978 "gtk", 2948 2979 "image", ··· 3839 3870 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" 3840 3871 dependencies = [ 3841 3872 "bitflags 2.11.1", 3873 + "block2 0.6.2", 3842 3874 "dispatch2", 3875 + "libc", 3843 3876 "objc2 0.6.4", 3844 3877 ] 3845 3878 ··· 3850 3883 checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" 3851 3884 dependencies = [ 3852 3885 "bitflags 2.11.1", 3886 + "block2 0.6.2", 3853 3887 "dispatch2", 3888 + "libc", 3854 3889 "objc2 0.6.4", 3855 3890 "objc2-core-foundation", 3856 3891 "objc2-io-surface", 3892 + "objc2-metal 0.3.2", 3857 3893 ] 3858 3894 3859 3895 [[package]] ··· 3942 3978 dependencies = [ 3943 3979 "bitflags 2.11.1", 3944 3980 "block2 0.6.2", 3981 + "libc", 3945 3982 "objc2 0.6.4", 3946 3983 "objc2-core-foundation", 3947 3984 ] ··· 4555 4592 version = "2.0.1" 4556 4593 source = "registry+https://github.com/rust-lang/crates.io-index" 4557 4594 checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 4595 + 4596 + [[package]] 4597 + name = "quick-xml" 4598 + version = "0.30.0" 4599 + source = "registry+https://github.com/rust-lang/crates.io-index" 4600 + checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" 4601 + dependencies = [ 4602 + "memchr", 4603 + ] 4558 4604 4559 4605 [[package]] 4560 4606 name = "quick-xml" ··· 6465 6511 checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" 6466 6512 6467 6513 [[package]] 6514 + name = "widestring" 6515 + version = "1.2.1" 6516 + source = "registry+https://github.com/rust-lang/crates.io-index" 6517 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 6518 + 6519 + [[package]] 6468 6520 name = "winapi" 6469 6521 version = "0.3.9" 6470 6522 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7339 7391 dependencies = [ 7340 7392 "libc", 7341 7393 "rustix 1.1.4", 7394 + ] 7395 + 7396 + [[package]] 7397 + name = "xcb" 7398 + version = "1.7.0" 7399 + source = "registry+https://github.com/rust-lang/crates.io-index" 7400 + checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" 7401 + dependencies = [ 7402 + "bitflags 1.3.2", 7403 + "libc", 7404 + "quick-xml 0.30.0", 7342 7405 ] 7343 7406 7344 7407 [[package]]
+1
Cargo.toml
··· 41 41 thiserror = "1" 42 42 anyhow = "1" 43 43 open = "5" 44 + display-info = "0.5" 44 45 45 46 [target.'cfg(target_os = "linux")'.dependencies] 46 47 zbus = { version = "4", default-features = false, features = ["tokio"] }
+7 -23
src/main.rs
··· 70 70 let overlay = Rc::new(RefCell::new(OverlayManager::new(is_dark)?)); 71 71 let settings_mgr = Rc::new(SettingsManager::new(&cfg)?); 72 72 73 - // Overlay callbacks. 73 + // Overlay callbacks (attached to primary/first monitor window). 74 74 { 75 75 let cmd_tx = state.cmd_tx.clone(); 76 - overlay.borrow().window().on_snooze_clicked(move || { 76 + overlay.borrow().primary_window().on_snooze_clicked(move || { 77 77 let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: true }); 78 78 }); 79 79 } 80 80 { 81 81 let cmd_tx = state.cmd_tx.clone(); 82 - overlay.borrow().window().on_unlock_clicked(move || { 82 + overlay.borrow().primary_window().on_unlock_clicked(move || { 83 83 // Phase 3: enforced mode will prompt for password first. 84 84 let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 85 85 }); ··· 169 169 170 170 tray.set_tooltip(&format!("ioma — {}", sched.label)); 171 171 172 - let ov = overlay.borrow(); 173 - ov.window().set_break_label(sched.label.as_str().into()); 174 - ov.window().set_snooze_visible(!is_enforced); 175 - ov.window().set_countdown_text(overlay::fmt_dur(dur).as_str().into()); 176 - ov.window().set_progress(0.0); 177 - ov.window().window().set_fullscreen(true); 178 - ov.window().show().unwrap_or_default(); 172 + overlay.borrow_mut().show_break(&sched, is_enforced, false); 179 173 } 180 174 TimerEvent::Resumed => { 181 175 *break_start.borrow_mut() = None; 182 176 tray.set_paused(false); 183 177 tray.set_tooltip("ioma"); 184 - let ov = overlay.borrow(); 185 - ov.window().window().set_fullscreen(false); 186 - ov.window().hide().unwrap_or_default(); 178 + overlay.borrow().hide_all(); 187 179 } 188 180 TimerEvent::SnoozePeriodStarting { remaining_secs } => { 189 181 *break_start.borrow_mut() = None; ··· 191 183 "ioma — snoozed, break in {}", 192 184 fmt_countdown(remaining_secs) 193 185 )); 194 - let ov = overlay.borrow(); 195 - ov.window().window().set_fullscreen(false); 196 - ov.window().hide().unwrap_or_default(); 186 + overlay.borrow().hide_all(); 197 187 } 198 188 TimerEvent::WorkTick { secs_until_break } => { 199 189 tray.set_paused(false); ··· 214 204 if let Some((start, dur)) = *break_start.borrow() { 215 205 let elapsed = start.elapsed(); 216 206 if elapsed < dur { 217 - let remaining = dur - elapsed; 218 - let progress = elapsed.as_secs_f32() / dur.as_secs_f32(); 219 - let ov = overlay.borrow(); 220 - ov.window().set_countdown_text(overlay::fmt_dur(remaining).as_str().into()); 221 - ov.window().set_progress(progress); 207 + overlay.borrow().update_countdown(); 222 208 } else { 223 - // Break expired naturally — the timer should have sent Resumed by now, 224 - // but ensure overlay is hidden if it somehow wasn't. 225 209 *break_start.borrow_mut() = None; 226 210 } 227 211 }
+79 -18
src/overlay/mod.rs
··· 2 2 3 3 use std::time::{Duration, Instant}; 4 4 5 - use slint::ComponentHandle; 5 + use slint::{ComponentHandle, PhysicalPosition, PhysicalSize}; 6 6 7 7 use crate::generated::OverlayWindow; 8 8 use crate::timer::ScheduledBreak; 9 9 10 10 pub struct OverlayManager { 11 - window: OverlayWindow, 11 + windows: Vec<OverlayWindow>, 12 12 break_duration: Duration, 13 13 break_started: Instant, 14 + is_dark: bool, 14 15 } 15 16 16 17 impl OverlayManager { 17 18 pub fn new(is_dark: bool) -> anyhow::Result<Self> { 18 - let window = OverlayWindow::new()?; 19 - window.set_is_dark(is_dark); 19 + let monitors = get_monitors(); 20 + let windows = if monitors.is_empty() { 21 + // Fallback: single fullscreen window. 22 + vec![make_window(is_dark, None)?] 23 + } else { 24 + monitors 25 + .iter() 26 + .map(|m| make_window(is_dark, Some(m))) 27 + .collect::<anyhow::Result<Vec<_>>>()? 28 + }; 29 + 20 30 Ok(Self { 21 - window, 31 + windows, 22 32 break_duration: Duration::from_secs(300), 23 33 break_started: Instant::now(), 34 + is_dark, 24 35 }) 25 36 } 26 37 27 - #[allow(dead_code)] 28 - pub fn show(&mut self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 38 + pub fn show_break(&mut self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 29 39 self.break_duration = sched.break_duration; 30 40 self.break_started = Instant::now(); 31 41 32 - self.window.set_break_label(sched.label.as_str().into()); 33 - self.window.set_snooze_visible(!is_enforced && !snooze_used); 34 - self.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 35 - self.window.set_progress(0.0); 36 - self.window.window().set_fullscreen(true); 37 - self.window.show().unwrap_or_default(); 42 + for w in &self.windows { 43 + w.set_break_label(sched.label.as_str().into()); 44 + w.set_snooze_visible(!is_enforced && !snooze_used); 45 + w.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 46 + w.set_progress(0.0); 47 + w.show().unwrap_or_default(); 48 + } 49 + } 50 + 51 + pub fn hide_all(&self) { 52 + for w in &self.windows { 53 + w.hide().unwrap_or_default(); 54 + } 55 + } 56 + 57 + pub fn update_countdown(&self) { 58 + let elapsed = self.break_started.elapsed(); 59 + if elapsed >= self.break_duration { 60 + return; 61 + } 62 + let remaining = self.break_duration - elapsed; 63 + let progress = elapsed.as_secs_f32() / self.break_duration.as_secs_f32(); 64 + let text = fmt_dur(remaining); 65 + for w in &self.windows { 66 + w.set_countdown_text(text.as_str().into()); 67 + w.set_progress(progress); 68 + } 38 69 } 39 70 40 - #[allow(dead_code)] 41 - pub fn hide(&self) { 42 - self.window.hide().unwrap_or_default(); 71 + /// Returns the first window (primary monitor) for attaching callbacks. 72 + pub fn primary_window(&self) -> &OverlayWindow { 73 + &self.windows[0] 43 74 } 75 + } 44 76 45 - pub fn window(&self) -> &OverlayWindow { 46 - &self.window 77 + struct MonitorRect { 78 + x: i32, 79 + y: i32, 80 + width: u32, 81 + height: u32, 82 + } 83 + 84 + fn get_monitors() -> Vec<MonitorRect> { 85 + display_info::DisplayInfo::all() 86 + .unwrap_or_default() 87 + .into_iter() 88 + .map(|m| MonitorRect { 89 + x: m.x, 90 + y: m.y, 91 + width: m.width, 92 + height: m.height, 93 + }) 94 + .collect() 95 + } 96 + 97 + fn make_window(is_dark: bool, monitor: Option<&MonitorRect>) -> anyhow::Result<OverlayWindow> { 98 + let w = OverlayWindow::new()?; 99 + w.set_is_dark(is_dark); 100 + 101 + if let Some(m) = monitor { 102 + // Use physical pixel coordinates from display-info. 103 + w.window().set_position(PhysicalPosition::new(m.x, m.y)); 104 + w.window().set_size(PhysicalSize::new(m.width, m.height)); 47 105 } 106 + // no-frame is set in overlay.slint; we size manually per monitor. 107 + 108 + Ok(w) 48 109 } 49 110 50 111 pub fn fmt_dur(d: Duration) -> String {