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-window overlay backend

+195 -77
+2 -47
src/main.rs
··· 189 189 tray.set_tooltip(&format!("ioma — {}", sched.label)); 190 190 191 191 if overlay.is_none() { 192 - match OverlayManager::new(is_dark) { 193 - Ok(mgr) => { 194 - let tx = cmd_tx.clone(); 195 - mgr.window().on_snooze_clicked(move || { 196 - let _ = tx.send(TimerCommand::BreakEnded { snoozed: true }); 197 - }); 198 - // Unlock button: show password prompt in enforced, dismiss in reminder. 199 - let tx = cmd_tx.clone(); 200 - let win_unlock = mgr.window().clone_strong(); 201 - let cfg_arc_unlock = cfg_arc.clone(); 202 - mgr.window().on_unlock_clicked(move || { 203 - let enforced = active_profile(&cfg_arc_unlock.lock().unwrap()).mode == BreakMode::Enforced; 204 - if enforced { 205 - win_unlock.set_unlock_input_visible(true); 206 - win_unlock.set_unlock_error("".into()); 207 - } else { 208 - let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 209 - } 210 - }); 211 - // Password submitted: verify; dismiss on success, show error on failure. 212 - let tx = cmd_tx.clone(); 213 - let win_submit = mgr.window().clone_strong(); 214 - let cfg_arc_ov = cfg_arc.clone(); 215 - mgr.window().on_unlock_submit_clicked(move |password| { 216 - let cfg = cfg_arc_ov.lock().unwrap(); 217 - let hash = cfg.enforced.password_hash.clone(); 218 - drop(cfg); 219 - if overlay::password::verify_password(password.as_str(), &hash) { 220 - win_submit.set_unlock_input_visible(false); 221 - win_submit.set_unlock_error("".into()); 222 - let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 223 - } else { 224 - win_submit.set_unlock_error("Incorrect password.".into()); 225 - } 226 - }); 227 - // Block OS close (Alt+F4) in enforced mode. 228 - let cfg_arc_close = cfg_arc.clone(); 229 - mgr.window().window().on_close_requested(move || { 230 - let enforced = active_profile(&cfg_arc_close.lock().unwrap()).mode == BreakMode::Enforced; 231 - if enforced { 232 - slint::CloseRequestResponse::KeepWindowShown 233 - } else { 234 - slint::CloseRequestResponse::HideWindow 235 - } 236 - }); 237 - overlay = Some(mgr); 238 - } 192 + match OverlayManager::new(is_dark, cmd_tx.clone(), cfg_arc.clone()) { 193 + Ok(mgr) => overlay = Some(mgr), 239 194 Err(e) => log::warn!("Failed to create overlay: {e}"), 240 195 } 241 196 }
+49 -30
src/overlay/mod.rs
··· 1 1 pub mod monitors; 2 + pub mod multi_slint; 2 3 pub mod password; 3 4 pub mod session; 4 5 6 + use std::sync::{Arc, Mutex}; 5 7 use std::time::Duration; 6 8 7 - use slint::ComponentHandle; 9 + use crate::config::AppConfig; 10 + use crate::timer::{ScheduledBreak, TimerCommand}; 11 + use crate::overlay::session::SessionType; 8 12 9 - use crate::generated::OverlayWindow; 10 - use crate::timer::ScheduledBreak; 13 + // ── Backend trait ──────────────────────────────────────────────────────────── 14 + 15 + pub trait OverlayBackend { 16 + fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool); 17 + fn hide(&self); 18 + fn update_countdown(&self, elapsed: Duration, total: Duration); 19 + fn reset_unlock_state(&self); 20 + } 21 + 22 + // ── OverlayManager ─────────────────────────────────────────────────────────── 11 23 12 24 pub struct OverlayManager { 13 - window: OverlayWindow, 25 + backend: Box<dyn OverlayBackend>, 14 26 } 15 27 16 28 impl OverlayManager { 17 - pub fn new(is_dark: bool) -> anyhow::Result<Self> { 18 - let w = OverlayWindow::new()?; 19 - w.set_is_dark(is_dark); 20 - w.hide().unwrap_or_default(); 21 - Ok(Self { window: w }) 29 + pub fn new( 30 + is_dark: bool, 31 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 32 + cfg_arc: Arc<Mutex<AppConfig>>, 33 + ) -> anyhow::Result<Self> { 34 + let session = session::detect(); 35 + let all_monitors = monitors::enumerate(); 36 + 37 + let monitors: &[monitors::MonitorInfo] = match session { 38 + SessionType::Wayland => { 39 + // wlr-layer-shell not yet implemented; cover primary monitor only. 40 + log::info!( 41 + "Wayland session detected — multi-monitor overlay requires wlr-layer-shell \ 42 + (Phase 6D). Using primary monitor only." 43 + ); 44 + &all_monitors[..1] 45 + } 46 + SessionType::X11 | SessionType::Windows => &all_monitors, 47 + }; 48 + 49 + let backend = Box::new(multi_slint::MultiSlintBackend::new( 50 + is_dark, monitors, cmd_tx, cfg_arc, 51 + )?); 52 + 53 + Ok(Self { backend }) 22 54 } 23 55 24 56 pub fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 25 - self.window.set_break_label(sched.label.as_str().into()); 26 - self.window.set_snooze_visible(!is_enforced && !snooze_used); 27 - self.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 28 - self.window.set_progress(0.0); 29 - // show() first so the compositor maps the window, then request fullscreen. 30 - self.window.show().unwrap_or_default(); 31 - self.window.window().set_fullscreen(true); 32 - } 33 - 34 - pub fn reset_unlock_state(&self) { 35 - self.window.set_unlock_input_visible(false); 36 - self.window.set_unlock_error("".into()); 57 + self.backend.show_break(sched, is_enforced, snooze_used); 37 58 } 38 59 39 60 pub fn hide(&self) { 40 - self.window.window().set_fullscreen(false); 41 - self.window.hide().unwrap_or_default(); 61 + self.backend.hide(); 42 62 } 43 63 44 64 pub fn update_countdown(&self, elapsed: Duration, total: Duration) { 45 - let remaining = total.saturating_sub(elapsed); 46 - let progress = elapsed.as_secs_f32() / total.as_secs_f32().max(1.0); 47 - self.window.set_countdown_text(fmt_dur(remaining).as_str().into()); 48 - self.window.set_progress(progress); 65 + self.backend.update_countdown(elapsed, total); 49 66 } 50 67 51 - pub fn window(&self) -> &OverlayWindow { 52 - &self.window 68 + pub fn reset_unlock_state(&self) { 69 + self.backend.reset_unlock_state(); 53 70 } 54 71 } 55 72 56 - pub fn fmt_dur(d: Duration) -> String { 73 + // ── Shared helpers ─────────────────────────────────────────────────────────── 74 + 75 + pub(crate) fn fmt_dur(d: Duration) -> String { 57 76 let s = d.as_secs(); 58 77 if s < 60 { 59 78 format!("0:{:02}", s)
+144
src/overlay/multi_slint.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + use std::time::Duration; 3 + 4 + use slint::ComponentHandle; 5 + 6 + use crate::app::active_profile; 7 + use crate::config::AppConfig; 8 + use crate::generated::OverlayWindow; 9 + use crate::timer::{BreakMode, ScheduledBreak, TimerCommand}; 10 + use crate::overlay::fmt_dur; 11 + use crate::overlay::monitors::MonitorInfo; 12 + use crate::overlay::OverlayBackend; 13 + 14 + pub struct MultiSlintBackend { 15 + windows: Vec<OverlayWindow>, 16 + } 17 + 18 + impl MultiSlintBackend { 19 + pub fn new( 20 + is_dark: bool, 21 + monitors: &[MonitorInfo], 22 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 23 + cfg_arc: Arc<Mutex<AppConfig>>, 24 + ) -> anyhow::Result<Self> { 25 + let mut windows = Vec::with_capacity(monitors.len()); 26 + 27 + for monitor in monitors { 28 + let w = OverlayWindow::new()?; 29 + w.set_is_dark(is_dark); 30 + 31 + // Position on the correct monitor before fullscreen so the WM places it there. 32 + w.window().set_position(slint::WindowPosition::Physical( 33 + slint::PhysicalPosition { x: monitor.x, y: monitor.y }, 34 + )); 35 + 36 + wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 37 + w.hide().unwrap_or_default(); 38 + windows.push(w); 39 + } 40 + 41 + Ok(Self { windows }) 42 + } 43 + } 44 + 45 + impl OverlayBackend for MultiSlintBackend { 46 + fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 47 + for w in &self.windows { 48 + w.set_break_label(sched.label.as_str().into()); 49 + w.set_snooze_visible(!is_enforced && !snooze_used); 50 + w.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 51 + w.set_progress(0.0); 52 + w.show().unwrap_or_default(); 53 + w.window().set_fullscreen(true); 54 + } 55 + } 56 + 57 + fn hide(&self) { 58 + for w in &self.windows { 59 + w.window().set_fullscreen(false); 60 + w.hide().unwrap_or_default(); 61 + } 62 + } 63 + 64 + fn update_countdown(&self, elapsed: Duration, total: Duration) { 65 + let remaining = total.saturating_sub(elapsed); 66 + let progress = elapsed.as_secs_f32() / total.as_secs_f32().max(1.0); 67 + for w in &self.windows { 68 + w.set_countdown_text(fmt_dur(remaining).as_str().into()); 69 + w.set_progress(progress); 70 + } 71 + } 72 + 73 + fn reset_unlock_state(&self) { 74 + for w in &self.windows { 75 + w.set_unlock_input_visible(false); 76 + w.set_unlock_error("".into()); 77 + } 78 + } 79 + } 80 + 81 + /// Wire all interaction callbacks onto a single window. Called once per window 82 + /// at construction so every monitor's overlay is independently interactive. 83 + fn wire_callbacks( 84 + w: &OverlayWindow, 85 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 86 + cfg_arc: Arc<Mutex<AppConfig>>, 87 + ) { 88 + // Snooze 89 + { 90 + let tx = cmd_tx.clone(); 91 + w.on_snooze_clicked(move || { 92 + let _ = tx.send(TimerCommand::BreakEnded { snoozed: true }); 93 + }); 94 + } 95 + 96 + // Unlock button: in enforced mode reveal the password prompt on THIS window; 97 + // in reminder mode dismiss the break immediately. 98 + { 99 + let tx = cmd_tx.clone(); 100 + let cfg = cfg_arc.clone(); 101 + let win = w.clone_strong(); 102 + w.on_unlock_clicked(move || { 103 + let enforced = 104 + active_profile(&cfg.lock().unwrap()).mode == BreakMode::Enforced; 105 + if enforced { 106 + win.set_unlock_input_visible(true); 107 + win.set_unlock_error("".into()); 108 + } else { 109 + let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 110 + } 111 + }); 112 + } 113 + 114 + // Password submitted: verify hash; dismiss on success, show inline error on failure. 115 + { 116 + let tx = cmd_tx.clone(); 117 + let cfg = cfg_arc.clone(); 118 + let win = w.clone_strong(); 119 + w.on_unlock_submit_clicked(move |password| { 120 + let hash = cfg.lock().unwrap().enforced.password_hash.clone(); 121 + if crate::overlay::password::verify_password(password.as_str(), &hash) { 122 + win.set_unlock_input_visible(false); 123 + win.set_unlock_error("".into()); 124 + let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 125 + } else { 126 + win.set_unlock_error("Incorrect password.".into()); 127 + } 128 + }); 129 + } 130 + 131 + // Block OS close (Alt+F4) in enforced mode on every window. 132 + { 133 + let cfg = cfg_arc.clone(); 134 + w.window().on_close_requested(move || { 135 + let enforced = 136 + active_profile(&cfg.lock().unwrap()).mode == BreakMode::Enforced; 137 + if enforced { 138 + slint::CloseRequestResponse::KeepWindowShown 139 + } else { 140 + slint::CloseRequestResponse::HideWindow 141 + } 142 + }); 143 + } 144 + }