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: add time to next breaks shown in tray

+ reorganize tray menu items

+379 -50
+152 -14
src/main.rs
··· 26 26 use generated::PasswordSetupWindow; 27 27 use overlay::OverlayManager; 28 28 use settings::{SettingsManager, read_settings_into}; 29 - use timer::{BreakMode, TimerCommand, TimerEvent}; 29 + use timer::{BreakMode, LevelBreakStatus, LongBreakStatus, TimerCommand, TimerEvent}; 30 30 use tray::AppTray; 31 31 32 32 fn main() -> anyhow::Result<()> { ··· 56 56 } 57 57 } 58 58 59 + fn fmt_tray_duration(secs: u64) -> String { 60 + match secs { 61 + 0..=59 => format!("{secs}s"), 62 + _ if secs % 3600 == 0 => { 63 + let hours = secs / 3600; 64 + format!("{hours} hour{}", if hours == 1 { "" } else { "s" }) 65 + } 66 + _ if secs % 60 == 0 => { 67 + let mins = secs / 60; 68 + format!("{mins} minute{}", if mins == 1 { "" } else { "s" }) 69 + } 70 + _ => format!("{}m {}s", secs / 60, secs % 60), 71 + } 72 + } 73 + 74 + fn fmt_tray_minutes(secs: u64) -> String { 75 + if secs < 60 { 76 + "under 1 min".to_string() 77 + } else { 78 + let mins = secs / 60; 79 + if mins >= 120 && mins % 60 == 0 { 80 + format!("{} hr", mins / 60) 81 + } else if mins >= 60 { 82 + format!("{} hr {} min", mins / 60, mins % 60) 83 + } else { 84 + format!("{mins} min") 85 + } 86 + } 87 + } 88 + 89 + fn tray_long_rest_line(profile_name: &str, status: Option<&LongBreakStatus>) -> String { 90 + match status { 91 + Some(status) if status.remaining_cycles == 0 => { 92 + format!("{} follows this cycle", status.label) 93 + } 94 + Some(status) if status.remaining_cycles == 1 => { 95 + format!("{} after 1 more round", status.label) 96 + } 97 + Some(status) => format!( 98 + "{} after {} more rounds", 99 + status.label, status.remaining_cycles 100 + ), 101 + None => format!("Rhythm: {profile_name}"), 102 + } 103 + } 104 + 105 + fn build_working_tracker_lines( 106 + profile_name: &str, 107 + level_break_statuses: &[LevelBreakStatus], 108 + long_break_status: Option<&LongBreakStatus>, 109 + ) -> Vec<String> { 110 + let mut lines = Vec::with_capacity(level_break_statuses.len() + 1); 111 + 112 + if let Some(first) = level_break_statuses.first() { 113 + lines.push(format!( 114 + "Next {} in {}", 115 + first.label, 116 + fmt_tray_minutes(first.remaining_secs) 117 + )); 118 + for status in level_break_statuses.iter().skip(1) { 119 + lines.push(format!( 120 + "{} in {}", 121 + status.label, 122 + fmt_tray_minutes(status.remaining_secs) 123 + )); 124 + } 125 + } 126 + 127 + if let Some(status) = long_break_status { 128 + lines.push(tray_long_rest_line(profile_name, Some(status))); 129 + } else if lines.len() < 2 { 130 + lines.push(format!("Rhythm: {profile_name}")); 131 + } 132 + 133 + if lines.is_empty() { 134 + lines.push("Settling into your rhythm".to_string()); 135 + lines.push(format!("Rhythm: {profile_name}")); 136 + } else if lines.len() == 1 { 137 + lines.push(format!("Rhythm: {profile_name}")); 138 + } 139 + 140 + lines 141 + } 142 + 59 143 fn run(cfg: AppConfig) -> anyhow::Result<()> { 60 144 let (state, event_rx) = AppState::new(cfg.clone()); 61 145 ··· 73 157 let sound_enabled = cfg.appearance.sound_enabled; 74 158 let sound_volume = cfg.appearance.sound_volume; 75 159 let active_profile_name = active_profile(&cfg).name.clone(); 160 + tray.set_tracker_lines(&[ 161 + "Settling into your rhythm".to_string(), 162 + format!("Rhythm: {active_profile_name}"), 163 + ]); 76 164 77 165 // Poll timer — owns all mutable state; no Rc<RefCell> on the hot path. 78 166 let poll_timer = slint::Timer::default(); ··· 262 350 if sound_enabled { 263 351 sound::play_chime(sound_volume); 264 352 } 265 - tray.set_paused(true); 353 + tray.set_breaking(); 354 + tray.set_tracker_lines(&[ 355 + format!("Now: {}", sched.label), 356 + format!("Back in {}", fmt_tray_duration(dur.as_secs())), 357 + ]); 266 358 tray.set_tooltip(&format!("ioma — {}", sched.label)); 267 359 268 360 if overlay.is_none() { ··· 282 374 mgr.show_break(&sched, is_enforced, snooze_used); 283 375 } 284 376 } 285 - TimerEvent::Resumed => { 377 + TimerEvent::Resumed { 378 + secs_until_break, 379 + level_break_statuses, 380 + long_break_status, 381 + } => { 382 + let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 286 383 snooze_used = false; 287 384 break_active = None; 288 - tray.set_paused(false); 289 - tray.set_tooltip("ioma"); 385 + tray.set_working(); 386 + tray.set_tracker_lines(&build_working_tracker_lines( 387 + &profile_name, 388 + &level_break_statuses, 389 + long_break_status.as_ref(), 390 + )); 391 + tray.set_tooltip(&format!( 392 + "ioma — next pause in {}", 393 + fmt_countdown(secs_until_break) 394 + )); 290 395 if let Some(ref mgr) = overlay { 291 396 mgr.reset_unlock_state(); 292 397 mgr.hide(); 293 398 } 294 399 } 295 - TimerEvent::SnoozePeriodStarting { remaining_secs } => { 400 + TimerEvent::SnoozePeriodStarting { 401 + remaining_secs, 402 + level_break_statuses, 403 + long_break_status, 404 + } => { 405 + let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 296 406 snooze_used = true; 297 407 break_active = None; 298 - tray.set_paused(false); 408 + tray.set_working(); 409 + let mut lines = build_working_tracker_lines( 410 + &profile_name, 411 + &level_break_statuses, 412 + long_break_status.as_ref(), 413 + ); 414 + lines[0] = format!( 415 + "Next pause returns in {}", 416 + fmt_tray_minutes(remaining_secs) 417 + ); 418 + tray.set_tracker_lines(&lines); 299 419 tray.set_tooltip(&format!( 300 - "ioma — snoozed, break in {}", 420 + "ioma — snoozed, pause in {}", 301 421 fmt_countdown(remaining_secs) 302 422 )); 303 423 if let Some(ref mgr) = overlay { 304 424 mgr.hide(); 305 425 } 306 426 } 307 - TimerEvent::WorkTick { secs_until_break } => { 308 - tray.set_paused(false); 427 + TimerEvent::WorkTick { 428 + secs_until_break, 429 + level_break_statuses, 430 + long_break_status, 431 + } => { 432 + let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 433 + tray.set_working(); 434 + tray.set_tracker_lines(&build_working_tracker_lines( 435 + &profile_name, 436 + &level_break_statuses, 437 + long_break_status.as_ref(), 438 + )); 309 439 tray.set_tooltip(&format!( 310 - "ioma — {} — next break in {}", 311 - active_profile_name, 440 + "ioma — next pause in {}", 312 441 fmt_countdown(secs_until_break) 313 442 )); 314 443 } 315 - TimerEvent::Paused => { 316 - tray.set_paused(true); 444 + TimerEvent::Paused { remaining_secs } => { 445 + tray.set_paused(); 446 + tray.set_tracker_lines(&[ 447 + "Reminders are paused".to_string(), 448 + match remaining_secs { 449 + Some(secs) => { 450 + format!("Ready again in {}", fmt_tray_minutes(secs)) 451 + } 452 + None => "Reset pauses when you're ready".to_string(), 453 + }, 454 + ]); 317 455 tray.set_tooltip("ioma — paused"); 318 456 } 319 457 }
+52 -12
src/timer/mod.rs
··· 2 2 pub mod scheduler; 3 3 4 4 pub use profile::{BreakMode, Profile}; 5 - pub use scheduler::{ScheduledBreak, Scheduler}; 5 + pub use scheduler::{LevelBreakStatus, LongBreakStatus, ScheduledBreak, Scheduler}; 6 6 7 7 use std::sync::mpsc as std_mpsc; 8 8 use std::time::{Duration, Instant}; ··· 38 38 // A break is starting; show the overlay. 39 39 BreakStarting(ScheduledBreak), 40 40 // Snooze period starting; overlay hides temporarily. 41 - SnoozePeriodStarting { remaining_secs: u64 }, 41 + SnoozePeriodStarting { 42 + remaining_secs: u64, 43 + level_break_statuses: Vec<LevelBreakStatus>, 44 + long_break_status: Option<LongBreakStatus>, 45 + }, 42 46 // Work phase: tick with current countdown to next break. 43 - WorkTick { secs_until_break: u64 }, 47 + WorkTick { 48 + secs_until_break: u64, 49 + level_break_statuses: Vec<LevelBreakStatus>, 50 + long_break_status: Option<LongBreakStatus>, 51 + }, 44 52 // Timer is paused (manual or idle). 45 - Paused, 53 + Paused { 54 + remaining_secs: Option<u64>, 55 + }, 46 56 // Timer resumed. 47 - Resumed, 57 + Resumed { 58 + secs_until_break: u64, 59 + level_break_statuses: Vec<LevelBreakStatus>, 60 + long_break_status: Option<LongBreakStatus>, 61 + }, 48 62 } 49 63 50 64 #[derive(Debug, Clone)] ··· 147 161 TimerState::ManualPause { until: Some(until) } => { 148 162 if now >= *until { 149 163 self.state = TimerState::Working; 150 - let _ = self.event_tx.send(TimerEvent::Resumed); 164 + let _ = self.event_tx.send(TimerEvent::Resumed { 165 + secs_until_break: self.scheduler.secs_until_next_break(), 166 + level_break_statuses: self.scheduler.level_break_statuses(), 167 + long_break_status: self.scheduler.long_break_status(), 168 + }); 151 169 } else { 152 - let _ = self.event_tx.send(TimerEvent::Paused); 170 + let _ = self.event_tx.send(TimerEvent::Paused { 171 + remaining_secs: Some((*until).duration_since(now).as_secs()), 172 + }); 153 173 } 154 174 } 155 175 TimerState::ManualPause { until: None } | TimerState::IdlePaused => { 156 - let _ = self.event_tx.send(TimerEvent::Paused); 176 + let _ = self.event_tx.send(TimerEvent::Paused { 177 + remaining_secs: None, 178 + }); 157 179 } 158 180 TimerState::Snoozed { until } => { 159 181 if now >= *until { ··· 170 192 let remaining = (*until).duration_since(now).as_secs(); 171 193 let _ = self.event_tx.send(TimerEvent::SnoozePeriodStarting { 172 194 remaining_secs: remaining, 195 + level_break_statuses: self.scheduler.level_break_statuses(), 196 + long_break_status: self.scheduler.long_break_status(), 173 197 }); 174 198 } 175 199 } ··· 190 214 let secs = self.scheduler.secs_until_next_break(); 191 215 let _ = self.event_tx.send(TimerEvent::WorkTick { 192 216 secs_until_break: secs, 217 + level_break_statuses: self.scheduler.level_break_statuses(), 218 + long_break_status: self.scheduler.long_break_status(), 193 219 }); 194 220 } 195 221 } ··· 201 227 TimerCommand::PauseFor(duration) => { 202 228 let until = Instant::now() + duration; 203 229 self.state = TimerState::ManualPause { until: Some(until) }; 204 - let _ = self.event_tx.send(TimerEvent::Paused); 230 + let _ = self.event_tx.send(TimerEvent::Paused { 231 + remaining_secs: Some(duration.as_secs()), 232 + }); 205 233 } 206 234 TimerCommand::Resume => { 207 235 self.state = TimerState::Working; 208 - let _ = self.event_tx.send(TimerEvent::Resumed); 236 + let _ = self.event_tx.send(TimerEvent::Resumed { 237 + secs_until_break: self.scheduler.secs_until_next_break(), 238 + level_break_statuses: self.scheduler.level_break_statuses(), 239 + long_break_status: self.scheduler.long_break_status(), 240 + }); 209 241 } 210 242 TimerCommand::SkipToNextBreak => { 211 243 if let Some(b) = self.scheduler.skip_to_next_break() { ··· 226 258 self.state = TimerState::Snoozed { until }; 227 259 let _ = self.event_tx.send(TimerEvent::SnoozePeriodStarting { 228 260 remaining_secs: snooze_dur.as_secs(), 261 + level_break_statuses: self.scheduler.level_break_statuses(), 262 + long_break_status: self.scheduler.long_break_status(), 229 263 }); 230 264 } else if let TimerState::Breaking { 231 265 is_long, ··· 235 269 { 236 270 self.scheduler.record_break_completed(fired_index, is_long); 237 271 self.state = TimerState::Working; 238 - let _ = self.event_tx.send(TimerEvent::Resumed); 272 + let _ = self.event_tx.send(TimerEvent::Resumed { 273 + secs_until_break: self.scheduler.secs_until_next_break(), 274 + level_break_statuses: self.scheduler.level_break_statuses(), 275 + long_break_status: self.scheduler.long_break_status(), 276 + }); 239 277 } 240 278 } 241 279 TimerCommand::IdleDetected { duration } => { 242 280 if matches!(self.state, TimerState::Working) { 243 281 self.scheduler.record_idle_break(duration); 244 282 self.state = TimerState::IdlePaused; 245 - let _ = self.event_tx.send(TimerEvent::Paused); 283 + let _ = self.event_tx.send(TimerEvent::Paused { 284 + remaining_secs: None, 285 + }); 246 286 } 247 287 } 248 288 TimerCommand::SetProfile(profile) => {
+34
src/timer/scheduler.rs
··· 11 11 pub level_index: usize, 12 12 } 13 13 14 + #[derive(Debug, Clone)] 15 + pub struct LongBreakStatus { 16 + pub label: String, 17 + pub remaining_cycles: u32, 18 + } 19 + 20 + #[derive(Debug, Clone)] 21 + pub struct LevelBreakStatus { 22 + pub label: String, 23 + pub remaining_secs: u64, 24 + } 25 + 14 26 // Tracks elapsed work time and cycle counts to determine the next break. 15 27 // 16 28 // Each break level has its own independent elapsed counter so that a ··· 155 167 return due.saturating_sub(self.level_elapsed[idx]).as_secs(); 156 168 } 157 169 0 170 + } 171 + 172 + pub fn long_break_status(&self) -> Option<LongBreakStatus> { 173 + self.profile.long_break.as_ref().map(|lb| LongBreakStatus { 174 + label: lb.label.clone(), 175 + remaining_cycles: lb.after_cycles.saturating_sub(self.primary_cycle_count), 176 + }) 177 + } 178 + 179 + pub fn level_break_statuses(&self) -> Vec<LevelBreakStatus> { 180 + self.profile 181 + .levels 182 + .iter() 183 + .enumerate() 184 + .map(|(idx, level)| LevelBreakStatus { 185 + label: level.label.clone(), 186 + remaining_secs: level 187 + .work_duration 188 + .saturating_sub(self.level_elapsed[idx]) 189 + .as_secs(), 190 + }) 191 + .collect() 158 192 } 159 193 160 194 // --- Private helpers ---
+141 -24
src/tray.rs
··· 13 13 ]; 14 14 15 15 pub struct AppTray { 16 + menu: Menu, 16 17 _tray: TrayIcon, 17 18 icon_active: tray_icon::Icon, 18 19 icon_paused: tray_icon::Icon, 20 + item_status_primary: MenuItem, 21 + item_status_secondary: MenuItem, 19 22 item_skip: MenuItem, 20 - item_resume: MenuItem, 23 + item_reset: MenuItem, 24 + pause_sub: Submenu, 21 25 pause_items: Vec<(MenuItem, u64)>, 22 26 item_settings: MenuItem, 23 27 item_quit: MenuItem, 24 - last_paused: Cell<Option<bool>>, 25 28 last_tooltip: RefCell<String>, 29 + last_status: RefCell<(String, String)>, 30 + last_tracker_lines: RefCell<Vec<String>>, 31 + tracker_items: RefCell<Vec<MenuItem>>, 32 + mode: Cell<TrayMode>, 33 + } 34 + 35 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 36 + enum TrayMode { 37 + Working, 38 + Breaking, 39 + Paused, 26 40 } 27 41 28 42 impl AppTray { ··· 32 46 33 47 let menu = Menu::new(); 34 48 35 - let item_skip = MenuItem::new("Skip to next break", true, None); 36 - menu.append(&item_skip)?; 37 - let item_resume = MenuItem::new("Resume breaks", false, None); 38 - menu.append(&item_resume)?; 49 + let item_status_primary = MenuItem::new("Settling into your rhythm", false, None); 50 + menu.append(&item_status_primary)?; 51 + let item_status_secondary = MenuItem::new("The next pause will appear here", false, None); 52 + menu.append(&item_status_secondary)?; 39 53 menu.append(&PredefinedMenuItem::separator())?; 40 54 41 - let pause_sub = Submenu::new("Pause breaks for", true); 55 + let item_skip = MenuItem::new("Skip to the next pause", true, None); 56 + menu.append(&item_skip)?; 57 + 58 + let pause_sub = Submenu::new("Hold pauses for..", true); 42 59 let mut pause_items = Vec::new(); 43 60 for (label, secs) in PAUSE_DURATIONS { 44 - let item = MenuItem::new(*label, true, None); 61 + let item = MenuItem::new(format!("For {label}"), true, None); 45 62 pause_sub.append(&item)?; 46 63 pause_items.push((item, *secs)); 47 64 } 65 + pause_sub.append(&PredefinedMenuItem::separator())?; 66 + let item_reset = MenuItem::new("Reset pauses", false, None); 67 + pause_sub.append(&item_reset)?; 48 68 menu.append(&pause_sub)?; 49 69 menu.append(&PredefinedMenuItem::separator())?; 50 70 51 - let item_settings = MenuItem::new("Settings", true, None); 71 + let item_settings = MenuItem::new("Open settings", true, None); 52 72 menu.append(&item_settings)?; 53 73 menu.append(&PredefinedMenuItem::separator())?; 54 74 55 - let item_quit = MenuItem::new("Quit", true, None); 75 + let item_quit = MenuItem::new("Quit ioma", true, None); 56 76 menu.append(&item_quit)?; 57 77 58 78 let tray = TrayIconBuilder::new() 59 - .with_menu(Box::new(menu)) 79 + .with_menu(Box::new(menu.clone())) 60 80 .with_tooltip("ioma — break reminder") 61 81 .with_icon(icon_active.clone()) 62 82 .build()?; 63 83 64 84 Ok(Self { 85 + menu, 65 86 _tray: tray, 66 87 icon_active, 67 88 icon_paused, 89 + item_status_primary, 90 + item_status_secondary, 68 91 item_skip, 69 - item_resume, 92 + item_reset, 93 + pause_sub, 70 94 pause_items, 71 95 item_settings, 72 96 item_quit, 73 - last_paused: Cell::new(None), 74 97 last_tooltip: RefCell::new(String::new()), 98 + last_status: RefCell::new(( 99 + "Settling into your rhythm".to_owned(), 100 + "The next pause will appear here".to_owned(), 101 + )), 102 + last_tracker_lines: RefCell::new(Vec::new()), 103 + tracker_items: RefCell::new(Vec::new()), 104 + mode: Cell::new(TrayMode::Working), 75 105 }) 76 106 } 77 107 78 - // Set icon and menu state to reflect paused state. No-op if state unchanged. 79 - pub fn set_paused(&self, paused: bool) { 80 - if self.last_paused.get() == Some(paused) { 108 + pub fn set_working(&self) { 109 + self.set_mode(TrayMode::Working); 110 + } 111 + 112 + pub fn set_breaking(&self) { 113 + self.set_mode(TrayMode::Breaking); 114 + } 115 + 116 + pub fn set_paused(&self) { 117 + self.set_mode(TrayMode::Paused); 118 + } 119 + 120 + pub fn set_status_lines(&self, primary: &str, secondary: &str) { 121 + let mut last_status = self.last_status.borrow_mut(); 122 + if last_status.0 == primary && last_status.1 == secondary { 81 123 return; 82 124 } 83 - self.last_paused.set(Some(paused)); 84 - let _ = self._tray.set_icon(Some(if paused { 85 - self.icon_paused.clone() 86 - } else { 87 - self.icon_active.clone() 88 - })); 89 - self.item_resume.set_enabled(paused); 125 + self.item_status_primary.set_text(primary); 126 + self.item_status_secondary.set_text(secondary); 127 + *last_status = (primary.to_owned(), secondary.to_owned()); 128 + } 129 + 130 + pub fn set_tracker_lines(&self, lines: &[String]) { 131 + { 132 + let last_tracker_lines = self.last_tracker_lines.borrow(); 133 + if last_tracker_lines.as_slice() == lines { 134 + return; 135 + } 136 + } 137 + 138 + let primary = lines 139 + .first() 140 + .map(String::as_str) 141 + .unwrap_or("Settling into your rhythm"); 142 + let secondary = lines 143 + .get(1) 144 + .map(String::as_str) 145 + .unwrap_or("The next pause will appear here"); 146 + self.set_status_lines(primary, secondary); 147 + 148 + let extra_lines = &lines[2..]; 149 + let mut tracker_items = self.tracker_items.borrow_mut(); 150 + 151 + while tracker_items.len() > extra_lines.len() { 152 + if let Some(item) = tracker_items.pop() { 153 + let _ = self.menu.remove(&item); 154 + } 155 + } 156 + 157 + while tracker_items.len() < extra_lines.len() { 158 + let item = MenuItem::new("", false, None); 159 + let insert_at = 2 + tracker_items.len(); 160 + let _ = self.menu.insert(&item, insert_at); 161 + tracker_items.push(item); 162 + } 163 + 164 + for (item, line) in tracker_items.iter().zip(extra_lines.iter()) { 165 + if item.text() != *line { 166 + item.set_text(line); 167 + } 168 + } 169 + 170 + *self.last_tracker_lines.borrow_mut() = lines.to_vec(); 90 171 } 91 172 92 173 // Update tray tooltip. No-op if text unchanged. ··· 116 197 if event.id() == self.item_skip.id() { 117 198 let _ = cmd_tx.send(TimerCommand::SkipToNextBreak); 118 199 } 119 - if event.id() == self.item_resume.id() { 200 + if event.id() == self.item_reset.id() { 120 201 let _ = cmd_tx.send(TimerCommand::Resume); 121 202 } 122 203 if event.id() == self.item_settings.id() { ··· 129 210 } 130 211 } 131 212 false 213 + } 214 + 215 + fn set_mode(&self, mode: TrayMode) { 216 + if self.mode.get() == mode { 217 + return; 218 + } 219 + self.mode.set(mode); 220 + match mode { 221 + TrayMode::Working => { 222 + let _ = self._tray.set_icon(Some(self.icon_active.clone())); 223 + self.item_skip.set_enabled(true); 224 + for (item, _) in &self.pause_items { 225 + item.set_enabled(true); 226 + } 227 + self.item_reset.set_enabled(false); 228 + self.pause_sub.set_enabled(true); 229 + } 230 + TrayMode::Breaking => { 231 + let _ = self._tray.set_icon(Some(self.icon_paused.clone())); 232 + self.item_skip.set_enabled(false); 233 + for (item, _) in &self.pause_items { 234 + item.set_enabled(false); 235 + } 236 + self.item_reset.set_enabled(false); 237 + self.pause_sub.set_enabled(false); 238 + } 239 + TrayMode::Paused => { 240 + let _ = self._tray.set_icon(Some(self.icon_paused.clone())); 241 + self.item_skip.set_enabled(false); 242 + for (item, _) in &self.pause_items { 243 + item.set_enabled(false); 244 + } 245 + self.item_reset.set_enabled(true); 246 + self.pause_sub.set_enabled(true); 247 + } 248 + } 132 249 } 133 250 } 134 251