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: overlay expiry, snooze state, was_long tracking, resume breaks

+59 -106
+15 -11
src/main.rs
··· 135 135 TimerEvent::BreakStarting(sched) => { 136 136 let dur = sched.break_duration; 137 137 break_active = Some((Instant::now(), dur)); 138 - snooze_used = false; 138 + // snooze_used carries over — only reset when break fully ends (Resumed) 139 139 140 140 if sound_enabled { 141 141 sound::play_chime(sound_volume); ··· 143 143 tray.set_paused(true); 144 144 tray.set_tooltip(&format!("ioma — {}", sched.label)); 145 145 146 - // Create overlay windows lazily so they don't appear 147 - // in the taskbar until a break actually starts. 146 + // Create overlay lazily so it doesn't appear in taskbar at startup. 148 147 if overlay.is_none() { 149 148 match OverlayManager::new(is_dark) { 150 149 Ok(mgr) => { 151 150 let tx = cmd_tx.clone(); 152 - mgr.primary_window().on_snooze_clicked(move || { 151 + mgr.window().on_snooze_clicked(move || { 153 152 let _ = tx.send(TimerCommand::BreakEnded { snoozed: true }); 154 153 }); 155 154 let tx = cmd_tx.clone(); 156 - mgr.primary_window().on_unlock_clicked(move || { 155 + mgr.window().on_unlock_clicked(move || { 157 156 let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 158 157 }); 159 158 overlay = Some(mgr); ··· 162 161 } 163 162 } 164 163 165 - if let Some(ref mut mgr) = overlay { 164 + if let Some(ref mgr) = overlay { 166 165 mgr.show_break(&sched, is_enforced, snooze_used); 167 166 } 168 167 } 169 168 TimerEvent::Resumed => { 169 + snooze_used = false; 170 170 break_active = None; 171 171 tray.set_paused(false); 172 172 tray.set_tooltip("ioma"); 173 173 if let Some(ref mgr) = overlay { 174 - mgr.hide_all(); 174 + mgr.hide(); 175 175 } 176 176 } 177 177 TimerEvent::SnoozePeriodStarting { remaining_secs } => { 178 178 snooze_used = true; 179 179 break_active = None; 180 + tray.set_paused(false); 180 181 tray.set_tooltip(&format!( 181 182 "ioma — snoozed, break in {}", 182 183 fmt_countdown(remaining_secs) 183 184 )); 184 185 if let Some(ref mgr) = overlay { 185 - mgr.hide_all(); 186 + mgr.hide(); 186 187 } 187 188 } 188 189 TimerEvent::WorkTick { secs_until_break } => { ··· 201 202 } 202 203 203 204 // --- Overlay countdown tick --- 204 - // Split the check and the mutation to avoid any borrow conflicts. 205 205 let expired = match break_active { 206 206 Some((start, dur)) if start.elapsed() < dur => { 207 207 if let Some(ref mgr) = overlay { 208 - mgr.update_countdown(); 208 + mgr.update_countdown(start.elapsed(), dur); 209 209 } 210 210 false 211 211 } 212 - Some(_) => true, // break duration elapsed 212 + Some(_) => true, 213 213 None => false, 214 214 }; 215 215 if expired { 216 216 break_active = None; 217 + if let Some(ref mgr) = overlay { 218 + mgr.hide(); 219 + } 220 + let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 217 221 } 218 222 }, 219 223 );
+27 -86
src/overlay/mod.rs
··· 2 2 3 3 use std::time::{Duration, Instant}; 4 4 5 - use slint::{ComponentHandle, PhysicalPosition, PhysicalSize}; 5 + use slint::ComponentHandle; 6 6 7 7 use crate::generated::OverlayWindow; 8 8 use crate::timer::ScheduledBreak; 9 9 10 10 pub struct OverlayManager { 11 - windows: Vec<OverlayWindow>, 11 + window: OverlayWindow, 12 12 break_duration: Duration, 13 13 break_started: Instant, 14 14 } 15 15 16 16 impl OverlayManager { 17 17 pub fn new(is_dark: bool) -> anyhow::Result<Self> { 18 - let monitors = get_monitors(); 19 - let windows = if monitors.is_empty() { 20 - vec![make_window(is_dark, None)?] 21 - } else { 22 - monitors 23 - .iter() 24 - .map(|m| make_window(is_dark, Some(m))) 25 - .collect::<anyhow::Result<Vec<_>>>()? 26 - }; 27 - 28 - // Ensure all overlay windows start hidden — Slint shows windows 29 - // automatically when the event loop runs unless we hide them first. 30 - for w in &windows { 31 - w.hide().unwrap_or_default(); 32 - } 33 - 18 + let w = OverlayWindow::new()?; 19 + w.set_is_dark(is_dark); 20 + // Start hidden — shown only when a break fires. 21 + w.hide().unwrap_or_default(); 34 22 Ok(Self { 35 - windows, 23 + window: w, 36 24 break_duration: Duration::from_secs(300), 37 25 break_started: Instant::now(), 38 26 }) 39 27 } 40 28 41 - pub fn show_break(&mut self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 42 - self.break_duration = sched.break_duration; 43 - self.break_started = Instant::now(); 44 - 45 - for w in &self.windows { 46 - w.set_break_label(sched.label.as_str().into()); 47 - w.set_snooze_visible(!is_enforced && !snooze_used); 48 - w.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 49 - w.set_progress(0.0); 50 - // show() must come before set_fullscreen(true): the compositor needs 51 - // the window mapped before it can honour a fullscreen request. 52 - w.show().unwrap_or_default(); 53 - w.window().set_fullscreen(true); 54 - } 29 + pub fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 30 + self.window.set_break_label(sched.label.as_str().into()); 31 + self.window.set_snooze_visible(!is_enforced && !snooze_used); 32 + self.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 33 + self.window.set_progress(0.0); 34 + // show() first so the compositor maps the window, then request fullscreen. 35 + self.window.show().unwrap_or_default(); 36 + self.window.window().set_fullscreen(true); 55 37 } 56 38 57 - pub fn hide_all(&self) { 58 - for w in &self.windows { 59 - w.window().set_fullscreen(false); 60 - w.hide().unwrap_or_default(); 61 - } 39 + pub fn update_snooze_visible(&self, visible: bool) { 40 + self.window.set_snooze_visible(visible); 62 41 } 63 42 64 - pub fn update_countdown(&self) { 65 - let elapsed = self.break_started.elapsed(); 66 - if elapsed >= self.break_duration { 67 - return; 68 - } 69 - let remaining = self.break_duration - elapsed; 70 - let progress = elapsed.as_secs_f32() / self.break_duration.as_secs_f32(); 71 - let text = fmt_dur(remaining); 72 - for w in &self.windows { 73 - w.set_countdown_text(text.as_str().into()); 74 - w.set_progress(progress); 75 - } 43 + pub fn hide(&self) { 44 + self.window.window().set_fullscreen(false); 45 + self.window.hide().unwrap_or_default(); 76 46 } 77 47 78 - /// The first window — used to attach snooze/unlock callbacks. 79 - pub fn primary_window(&self) -> &OverlayWindow { 80 - &self.windows[0] 48 + pub fn update_countdown(&self, elapsed: Duration, total: Duration) { 49 + let remaining = total.saturating_sub(elapsed); 50 + let progress = elapsed.as_secs_f32() / total.as_secs_f32().max(1.0); 51 + self.window.set_countdown_text(fmt_dur(remaining).as_str().into()); 52 + self.window.set_progress(progress); 81 53 } 82 - } 83 54 84 - struct MonitorRect { 85 - x: i32, 86 - y: i32, 87 - width: u32, 88 - height: u32, 89 - } 90 - 91 - fn get_monitors() -> Vec<MonitorRect> { 92 - display_info::DisplayInfo::all() 93 - .unwrap_or_default() 94 - .into_iter() 95 - .map(|m| MonitorRect { 96 - x: m.x, 97 - y: m.y, 98 - width: m.width, 99 - height: m.height, 100 - }) 101 - .collect() 102 - } 103 - 104 - fn make_window(is_dark: bool, monitor: Option<&MonitorRect>) -> anyhow::Result<OverlayWindow> { 105 - let w = OverlayWindow::new()?; 106 - w.set_is_dark(is_dark); 107 - 108 - if let Some(m) = monitor { 109 - // Pre-position the window on this monitor. On X11 this takes effect; 110 - // on Wayland set_position is ignored and fullscreen covers the monitor 111 - // the window was most recently visible on (handled in show_break). 112 - w.window().set_position(PhysicalPosition::new(m.x, m.y)); 113 - w.window().set_size(PhysicalSize::new(m.width, m.height)); 55 + pub fn window(&self) -> &OverlayWindow { 56 + &self.window 114 57 } 115 - 116 - Ok(w) 117 58 } 118 59 119 60 pub fn fmt_dur(d: Duration) -> String {
+8 -8
src/timer/mod.rs
··· 50 50 #[derive(Debug, Clone, PartialEq)] 51 51 enum TimerState { 52 52 Working, 53 - Breaking { snooze_used: bool }, 53 + Breaking { snooze_used: bool, is_long: bool }, 54 54 Snoozed { until: Instant }, 55 55 ManualPause { until: Option<Instant> }, 56 56 IdlePaused, ··· 119 119 } 120 120 TimerState::Snoozed { until } => { 121 121 if now >= *until { 122 - self.state = TimerState::Breaking { snooze_used: true }; 123 122 let sched = self.current_break_or_next(); 123 + let is_long = sched.is_long_break; 124 + self.state = TimerState::Breaking { snooze_used: true, is_long }; 124 125 let _ = self.event_tx.send(TimerEvent::BreakStarting(sched)); 125 126 } else { 126 127 let remaining = (*until).duration_since(now).as_secs(); ··· 132 133 } 133 134 TimerState::Working => { 134 135 if let Some(break_due) = self.scheduler.tick(delta) { 135 - self.state = TimerState::Breaking { snooze_used: false }; 136 + let is_long = break_due.is_long_break; 137 + self.state = TimerState::Breaking { snooze_used: false, is_long }; 136 138 let _ = self.event_tx.send(TimerEvent::BreakStarting(break_due)); 137 139 } else { 138 140 let secs = self.scheduler.secs_until_next_break(); ··· 155 157 } 156 158 TimerCommand::SkipToNextBreak => { 157 159 if let Some(b) = self.scheduler.skip_to_next_break() { 158 - self.state = TimerState::Breaking { snooze_used: false }; 160 + let is_long = b.is_long_break; 161 + self.state = TimerState::Breaking { snooze_used: false, is_long }; 159 162 let _ = self.event_tx.send(TimerEvent::BreakStarting(b)); 160 163 } 161 164 } ··· 168 171 remaining_secs: snooze_dur.as_secs(), 169 172 }); 170 173 } else { 171 - let was_long = matches!( 172 - &self.state, 173 - TimerState::Breaking { .. } 174 - ) && self.scheduler.primary_cycle_count() == 0; 174 + let was_long = matches!(&self.state, TimerState::Breaking { is_long: true, .. }); 175 175 self.scheduler.record_break_completed(was_long); 176 176 self.state = TimerState::Working; 177 177 let _ = self.event_tx.send(TimerEvent::Resumed);
+9 -1
src/tray.rs
··· 16 16 icon_active: tray_icon::Icon, 17 17 icon_paused: tray_icon::Icon, 18 18 item_skip: MenuItem, 19 + item_resume: MenuItem, 19 20 pause_items: Vec<(MenuItem, u64)>, 20 21 item_settings: MenuItem, 21 22 item_quit: MenuItem, ··· 30 31 31 32 let item_skip = MenuItem::new("Skip to next break", true, None); 32 33 menu.append(&item_skip)?; 34 + let item_resume = MenuItem::new("Resume breaks", false, None); 35 + menu.append(&item_resume)?; 33 36 menu.append(&PredefinedMenuItem::separator())?; 34 37 35 38 let pause_sub = Submenu::new("Pause for", true); ··· 60 63 icon_active, 61 64 icon_paused, 62 65 item_skip, 66 + item_resume, 63 67 pause_items, 64 68 item_settings, 65 69 item_quit, 66 70 }) 67 71 } 68 72 69 - /// Set icon to reflect paused state. 73 + /// Set icon and menu state to reflect paused state. 70 74 pub fn set_paused(&self, paused: bool) { 71 75 let _ = self._tray.set_icon(Some(if paused { 72 76 self.icon_paused.clone() 73 77 } else { 74 78 self.icon_active.clone() 75 79 })); 80 + let _ = self.item_resume.set_enabled(paused); 76 81 } 77 82 78 83 pub fn set_tooltip(&self, text: &str) { ··· 104 109 } 105 110 if event.id() == self.item_skip.id() { 106 111 let _ = cmd_tx.send(TimerCommand::SkipToNextBreak); 112 + } 113 + if event.id() == self.item_resume.id() { 114 + let _ = cmd_tx.send(TimerCommand::Resume); 107 115 } 108 116 if event.id() == self.item_settings.id() { 109 117 *open_settings = true;