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.

refctor: extract tray formatting

and other cleanup

+382 -277
+8
src/config/mod.rs
··· 33 33 Ok(cfg) 34 34 } 35 35 36 + pub fn resolve_is_dark(theme: &OverlayTheme, system_dark: bool) -> bool { 37 + match theme { 38 + OverlayTheme::Dark => true, 39 + OverlayTheme::Light => false, 40 + OverlayTheme::System => system_dark, 41 + } 42 + } 43 + 36 44 pub fn save(cfg: &AppConfig) -> Result<()> { 37 45 let path = config_path(); 38 46 if let Some(parent) = path.parent() {
+47 -242
src/main.rs
··· 9 9 mod system; 10 10 mod timer; 11 11 mod tray; 12 + mod tray_format; 12 13 13 14 use std::rc::Rc; 14 15 use std::sync::atomic::Ordering; ··· 21 22 gtk::init().expect("GTK init failed"); 22 23 } 23 24 25 + use std::sync::{Arc, Mutex}; 26 + use std::sync::atomic::AtomicBool; 27 + 24 28 use app::{AppState, active_profile, spawn_idle_poller}; 25 29 use config::AppConfig; 26 - use generated::PasswordSetupWindow; 27 30 use overlay::OverlayManager; 28 - use settings::{SettingsManager, read_settings_into}; 29 - use timer::{BreakMode, LevelBreakStatus, LongBreakStatus, TimerCommand, TimerEvent}; 31 + use settings::SettingsManager; 32 + use timer::{BreakMode, TimerCommand, TimerEvent}; 30 33 use tray::AppTray; 34 + use tray_format::{build_working_tracker_lines, fmt_countdown, fmt_tray_duration, fmt_tray_minutes}; 31 35 32 36 fn main() -> anyhow::Result<()> { 33 37 env_logger::init(); ··· 48 52 run(cfg) 49 53 } 50 54 51 - fn resolve_is_dark(theme: &config::OverlayTheme, system_dark: bool) -> bool { 52 - match theme { 53 - config::OverlayTheme::Dark => true, 54 - config::OverlayTheme::Light => false, 55 - config::OverlayTheme::System => system_dark, 56 - } 55 + fn current_dark(cfg_arc: &Arc<Mutex<AppConfig>>, system_dark: &Arc<AtomicBool>) -> bool { 56 + let theme = cfg_arc.lock().unwrap().appearance.overlay_theme.clone(); 57 + config::resolve_is_dark(&theme, system_dark.load(Ordering::Relaxed)) 57 58 } 58 59 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 60 + fn set_tray_working( 61 + tray: &AppTray, 62 + cfg_arc: &Arc<Mutex<AppConfig>>, 63 + level_break_statuses: &[timer::LevelBreakStatus], 64 + long_break_status: Option<&timer::LongBreakStatus>, 65 + secs_until_break: u64, 66 + ) { 67 + let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 68 + tray.set_working(); 69 + tray.set_tracker_lines(&build_working_tracker_lines( 70 + &profile_name, 71 + level_break_statuses, 72 + long_break_status, 73 + )); 74 + tray.set_tooltip(&format!( 75 + "ioma — next pause in {}", 76 + fmt_countdown(secs_until_break) 77 + )); 141 78 } 142 79 143 80 fn run(cfg: AppConfig) -> anyhow::Result<()> { ··· 205 142 206 143 if open_settings { 207 144 if settings_mgr.is_none() { 208 - // First open: create window, wire callbacks, then show. 145 + // First open: create window lazily, wire callbacks, then show. 209 146 let cfg_snap = cfg_arc.lock().unwrap().clone(); 210 147 match SettingsManager::new(&cfg_snap, cfg_arc.clone()) { 211 148 Ok(mgr) => { 212 - // Set initial dark mode 213 - let init_dark = resolve_is_dark( 214 - &cfg_snap.appearance.overlay_theme, 215 - system_dark.load(Ordering::Relaxed), 149 + mgr.wire_and_show( 150 + current_dark(&cfg_arc, &system_dark), 151 + cfg_arc.clone(), 152 + system_dark.clone(), 153 + cmd_tx_settings.clone(), 216 154 ); 217 - mgr.window().set_is_dark(init_dark); 218 - 219 - // Cancel 220 - { 221 - let win = mgr.window().clone_strong(); 222 - mgr.window().on_cancel_clicked(move || { 223 - win.hide().unwrap_or_default(); 224 - }); 225 - } 226 - // Save 227 - { 228 - let win = mgr.window().clone_strong(); 229 - let lm = mgr.levels_model(); 230 - let cfg_arc2 = cfg_arc.clone(); 231 - let cmd_tx_save = cmd_tx_settings.clone(); 232 - let system_dark2 = system_dark.clone(); 233 - mgr.window().on_save_clicked(move || { 234 - let mut cfg = cfg_arc2.lock().unwrap().clone(); 235 - read_settings_into(&win, &lm, &mut cfg); 236 - *cfg_arc2.lock().unwrap() = cfg.clone(); 237 - if let Err(e) = autostart::set_enabled(cfg.app.autostart) { 238 - log::warn!("Autostart toggle failed: {e}"); 239 - } 240 - // Apply new theme immediately so window shows correctly on next open 241 - let new_dark = resolve_is_dark( 242 - &cfg.appearance.overlay_theme, 243 - system_dark2.load(Ordering::Relaxed), 244 - ); 245 - win.set_is_dark(new_dark); 246 - win.hide().unwrap_or_default(); 247 - config::save(&cfg) 248 - .unwrap_or_else(|e| log::warn!("save failed: {e}")); 249 - let profile = active_profile(&cfg); 250 - let _ = cmd_tx_save.send(TimerCommand::SetProfile(profile)); 251 - }); 252 - } 253 - // Theme mode changed — instant preview 254 - { 255 - let win = mgr.window().clone_strong(); 256 - let system_dark3 = system_dark.clone(); 257 - mgr.window().on_theme_mode_changed(move |mode| { 258 - let theme = match mode { 259 - 1 => config::OverlayTheme::Light, 260 - 2 => config::OverlayTheme::Dark, 261 - _ => config::OverlayTheme::System, 262 - }; 263 - win.set_is_dark(resolve_is_dark( 264 - &theme, 265 - system_dark3.load(Ordering::Relaxed), 266 - )); 267 - }); 268 - } 269 - // Open config dir 270 - mgr.window().on_open_config_dir(|| { 271 - if let Some(dir) = config::config_path().parent() { 272 - let _ = open::that(dir); 273 - } 274 - }); 275 - // Password setup wizard — created fresh on each click. 276 - { 277 - let cfg_arc_pw = cfg_arc.clone(); 278 - mgr.window().on_set_password_clicked(move || { 279 - match PasswordSetupWindow::new() { 280 - Ok(pw) => { 281 - let pw_cancel = pw.clone_strong(); 282 - pw.on_cancel_clicked(move || { 283 - pw_cancel.hide().unwrap_or_default(); 284 - }); 285 - let pw_submit = pw.clone_strong(); 286 - let cfg_arc_submit = cfg_arc_pw.clone(); 287 - pw.on_submit_clicked(move |password| { 288 - match overlay::password::hash_password( 289 - password.as_str(), 290 - ) { 291 - Ok(hash) => { 292 - cfg_arc_submit 293 - .lock() 294 - .unwrap() 295 - .enforced 296 - .password_hash = hash.clone(); 297 - let snap = cfg_arc_submit 298 - .lock() 299 - .unwrap() 300 - .clone(); 301 - config::save(&snap).unwrap_or_else( 302 - |e| log::warn!("save failed: {e}"), 303 - ); 304 - } 305 - Err(e) => { 306 - log::warn!("hash_password failed: {e}") 307 - } 308 - } 309 - pw_submit.hide().unwrap_or_default(); 310 - }); 311 - pw.show().unwrap_or_default(); 312 - } 313 - Err(e) => { 314 - log::warn!("Failed to create password window: {e}") 315 - } 316 - } 317 - }); 318 - } 319 - 320 - mgr.window().show().unwrap_or_default(); 321 - // Force the window to its intended size after the first 322 - // show(). On Linux/X11 the WM may not honour with_inner_size 323 - // during initial window creation; an explicit set_size after 324 - // mapping ensures the layout is computed at the right height. 325 - mgr.window() 326 - .window() 327 - .set_size(slint::LogicalSize::new(760.0, 680.0)); 328 155 settings_mgr = Some(mgr); 329 156 } 330 157 Err(e) => log::warn!("Failed to create settings window: {e}"), 331 158 } 332 159 } else if let Some(ref mgr) = settings_mgr { 333 160 // Refresh theme in case system preference changed while hidden 334 - let dark = resolve_is_dark( 335 - &cfg_arc.lock().unwrap().appearance.overlay_theme, 336 - system_dark.load(Ordering::Relaxed), 337 - ); 161 + let dark = current_dark(&cfg_arc, &system_dark); 338 162 mgr.window().set_is_dark(dark); 339 163 mgr.window().show().unwrap_or_default(); 340 164 } ··· 358 182 tray.set_tooltip(&format!("ioma — {}", sched.label)); 359 183 360 184 if overlay.is_none() { 361 - let dark = resolve_is_dark( 362 - &cfg_arc.lock().unwrap().appearance.overlay_theme, 363 - system_dark.load(Ordering::Relaxed), 364 - ); 185 + let dark = current_dark(&cfg_arc, &system_dark); 365 186 match OverlayManager::new(dark, cmd_tx.clone(), cfg_arc.clone()) { 366 187 Ok(mgr) => overlay = Some(mgr), 367 188 Err(e) => log::warn!("Failed to create overlay: {e}"), ··· 379 200 level_break_statuses, 380 201 long_break_status, 381 202 } => { 382 - let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 383 203 snooze_used = false; 384 204 break_active = None; 385 - tray.set_working(); 386 - tray.set_tracker_lines(&build_working_tracker_lines( 387 - &profile_name, 205 + set_tray_working( 206 + &tray, 207 + &cfg_arc, 388 208 &level_break_statuses, 389 209 long_break_status.as_ref(), 390 - )); 391 - tray.set_tooltip(&format!( 392 - "ioma — next pause in {}", 393 - fmt_countdown(secs_until_break) 394 - )); 210 + secs_until_break, 211 + ); 395 212 if let Some(ref mgr) = overlay { 396 213 mgr.reset_unlock_state(); 397 214 mgr.hide(); ··· 429 246 level_break_statuses, 430 247 long_break_status, 431 248 } => { 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, 249 + set_tray_working( 250 + &tray, 251 + &cfg_arc, 436 252 &level_break_statuses, 437 253 long_break_status.as_ref(), 438 - )); 439 - tray.set_tooltip(&format!( 440 - "ioma — next pause in {}", 441 - fmt_countdown(secs_until_break) 442 - )); 254 + secs_until_break, 255 + ); 443 256 } 444 257 TimerEvent::Paused { remaining_secs } => { 445 258 tray.set_paused(); ··· 480 293 dark_check_tick += 1; 481 294 if dark_check_tick >= 50 || last_dark.is_none() { 482 295 dark_check_tick = 0; 483 - let theme = cfg_arc.lock().unwrap().appearance.overlay_theme.clone(); 484 - let dark = resolve_is_dark(&theme, system_dark.load(Ordering::Relaxed)); 296 + let dark = current_dark(&cfg_arc, &system_dark); 485 297 if last_dark != Some(dark) { 486 298 last_dark = Some(dark); 487 299 if let Some(ref mgr) = settings_mgr { ··· 528 340 Ok(()) 529 341 } 530 342 531 - fn fmt_countdown(secs: u64) -> String { 532 - if secs < 60 { 533 - format!("{secs}s") 534 - } else { 535 - format!("{}m {}s", secs / 60, secs % 60) 536 - } 537 - }
+135 -26
src/settings/mod.rs
··· 1 1 use std::rc::Rc; 2 + use std::sync::atomic::{AtomicBool, Ordering}; 2 3 use std::sync::{Arc, Mutex}; 3 4 4 5 use fontdue::Font; 5 6 use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; 6 7 8 + use crate::app::active_profile; 7 9 use crate::autostart; 8 - use crate::config::{AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig, OverlayTheme}; 9 - use crate::generated::{LevelEntry, SettingsWindow}; 10 + use crate::config::{ 11 + self, AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig, OverlayTheme, 12 + }; 13 + use crate::generated::{LevelEntry, PasswordSetupWindow, SettingsWindow}; 14 + use crate::overlay::password; 15 + use crate::timer::TimerCommand; 10 16 11 17 pub struct SettingsManager { 12 18 window: SettingsWindow, ··· 71 77 pub fn levels_model(&self) -> Rc<VecModel<LevelEntry>> { 72 78 Rc::clone(&self.levels_model) 73 79 } 80 + 81 + // Wire all window callbacks and show the window at the correct size. 82 + pub fn wire_and_show( 83 + &self, 84 + is_dark: bool, 85 + cfg_arc: Arc<Mutex<AppConfig>>, 86 + system_dark: Arc<AtomicBool>, 87 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 88 + ) { 89 + self.window.set_is_dark(is_dark); 90 + 91 + // Cancel 92 + { 93 + let win = self.window.clone_strong(); 94 + self.window.on_cancel_clicked(move || { 95 + win.hide().unwrap_or_default(); 96 + }); 97 + } 98 + 99 + // Save 100 + { 101 + let win = self.window.clone_strong(); 102 + let lm = self.levels_model(); 103 + let cfg_arc2 = cfg_arc.clone(); 104 + let system_dark2 = system_dark.clone(); 105 + self.window.on_save_clicked(move || { 106 + let mut cfg = cfg_arc2.lock().unwrap().clone(); 107 + read_settings_into(&win, &lm, &mut cfg); 108 + *cfg_arc2.lock().unwrap() = cfg.clone(); 109 + if let Err(e) = autostart::set_enabled(cfg.app.autostart) { 110 + log::warn!("Autostart toggle failed: {e}"); 111 + } 112 + let new_dark = config::resolve_is_dark( 113 + &cfg.appearance.overlay_theme, 114 + system_dark2.load(Ordering::Relaxed), 115 + ); 116 + win.set_is_dark(new_dark); 117 + win.hide().unwrap_or_default(); 118 + config::save(&cfg).unwrap_or_else(|e| log::warn!("save failed: {e}")); 119 + let profile = active_profile(&cfg); 120 + let _ = cmd_tx.send(TimerCommand::SetProfile(profile)); 121 + }); 122 + } 123 + 124 + // Theme mode changed — instant preview 125 + { 126 + let win = self.window.clone_strong(); 127 + let system_dark3 = system_dark.clone(); 128 + self.window.on_theme_mode_changed(move |mode| { 129 + let theme = match mode { 130 + 1 => OverlayTheme::Light, 131 + 2 => OverlayTheme::Dark, 132 + _ => OverlayTheme::System, 133 + }; 134 + win.set_is_dark(config::resolve_is_dark( 135 + &theme, 136 + system_dark3.load(Ordering::Relaxed), 137 + )); 138 + }); 139 + } 140 + 141 + // Open config dir 142 + self.window.on_open_config_dir(|| { 143 + if let Some(dir) = config::config_path().parent() { 144 + let _ = open::that(dir); 145 + } 146 + }); 147 + 148 + // Password setup wizard — created fresh on each click. 149 + { 150 + let cfg_arc_pw = cfg_arc.clone(); 151 + self.window 152 + .on_set_password_clicked(move || match PasswordSetupWindow::new() { 153 + Ok(pw) => { 154 + let pw_cancel = pw.clone_strong(); 155 + pw.on_cancel_clicked(move || { 156 + pw_cancel.hide().unwrap_or_default(); 157 + }); 158 + let pw_submit = pw.clone_strong(); 159 + let cfg_arc_submit = cfg_arc_pw.clone(); 160 + pw.on_submit_clicked(move |password| { 161 + match password::hash_password(password.as_str()) { 162 + Ok(hash) => { 163 + cfg_arc_submit.lock().unwrap().enforced.password_hash = 164 + hash.clone(); 165 + let snap = cfg_arc_submit.lock().unwrap().clone(); 166 + config::save(&snap) 167 + .unwrap_or_else(|e| log::warn!("save failed: {e}")); 168 + } 169 + Err(e) => log::warn!("hash_password failed: {e}"), 170 + } 171 + pw_submit.hide().unwrap_or_default(); 172 + }); 173 + pw.show().unwrap_or_default(); 174 + } 175 + Err(e) => log::warn!("Failed to create password window: {e}"), 176 + }); 177 + } 178 + 179 + self.window.show().unwrap_or_default(); 180 + // Force the window to its intended size after the first show(). On 181 + // Linux/X11 the WM may not honour with_inner_size during initial window 182 + // creation; an explicit set_size after mapping ensures the correct height. 183 + self.window 184 + .window() 185 + .set_size(slint::LogicalSize::new(760.0, 680.0)); 186 + } 74 187 } 75 188 76 189 // Reads the settings window state back into `cfg`. Also usable from callbacks ··· 132 245 cfg.appearance.text_size_mode = window.get_text_size_mode() as u32; 133 246 } 134 247 248 + fn measure_name_widths(names: &[SharedString]) -> Vec<i32> { 249 + let font_bytes = include_bytes!(concat!( 250 + env!("CARGO_MANIFEST_DIR"), 251 + "/assets/fonts/Nunito-Regular.ttf" 252 + )); 253 + let font = Font::from_bytes(font_bytes.as_ref(), fontdue::FontSettings::default()).unwrap(); 254 + let font_size = 16.0; 255 + names 256 + .iter() 257 + .map(|s| { 258 + let mut w = 0.0_f32; 259 + for ch in s.as_str().chars() { 260 + let m = font.metrics(ch, font_size); 261 + w += m.advance_width; 262 + } 263 + w.round() as i32 264 + }) 265 + .collect() 266 + } 267 + 135 268 fn populate(window: &SettingsWindow, levels_model: &VecModel<LevelEntry>, cfg: &AppConfig) { 136 269 let active_name = cfg 137 270 .profiles ··· 148 281 .collect(); 149 282 names.sort(); 150 283 window.set_profile_names(ModelRc::new(VecModel::from(names.clone()))); 151 - 152 - // Measure profile name widths (pixels) using bundled Nunito Regular at 12px 153 - fn measure_name_widths(names: &[SharedString]) -> Vec<i32> { 154 - // include_bytes with concat! so path is resolved from manifest dir at compile time 155 - let font_bytes = include_bytes!(concat!( 156 - env!("CARGO_MANIFEST_DIR"), 157 - "/assets/fonts/Nunito-Regular.ttf" 158 - )); 159 - let font = Font::from_bytes(font_bytes.as_ref(), fontdue::FontSettings::default()).unwrap(); 160 - // measure at an accessible base size (16px) 161 - let font_size = 16.0; 162 - names 163 - .iter() 164 - .map(|s| { 165 - let mut w = 0.0_f32; 166 - for ch in s.as_str().chars() { 167 - let m = font.metrics(ch, font_size); 168 - w += m.advance_width; 169 - } 170 - // round and ensure at least 16px (defensive) 171 - w.round() as i32 172 - }) 173 - .collect() 174 - } 175 284 176 285 let name_widths = measure_name_widths(&names); 177 286 // set as a VecModel of ints for the Slint property
+9 -9
src/tray.rs
··· 212 212 false 213 213 } 214 214 215 + fn set_pause_items_enabled(&self, enabled: bool) { 216 + for (item, _) in &self.pause_items { 217 + item.set_enabled(enabled); 218 + } 219 + } 220 + 215 221 fn set_mode(&self, mode: TrayMode) { 216 222 if self.mode.get() == mode { 217 223 return; ··· 221 227 TrayMode::Working => { 222 228 let _ = self._tray.set_icon(Some(self.icon_active.clone())); 223 229 self.item_skip.set_enabled(true); 224 - for (item, _) in &self.pause_items { 225 - item.set_enabled(true); 226 - } 230 + self.set_pause_items_enabled(true); 227 231 self.item_reset.set_enabled(false); 228 232 self.pause_sub.set_enabled(true); 229 233 } 230 234 TrayMode::Breaking => { 231 235 let _ = self._tray.set_icon(Some(self.icon_paused.clone())); 232 236 self.item_skip.set_enabled(false); 233 - for (item, _) in &self.pause_items { 234 - item.set_enabled(false); 235 - } 237 + self.set_pause_items_enabled(false); 236 238 self.item_reset.set_enabled(false); 237 239 self.pause_sub.set_enabled(false); 238 240 } 239 241 TrayMode::Paused => { 240 242 let _ = self._tray.set_icon(Some(self.icon_paused.clone())); 241 243 self.item_skip.set_enabled(false); 242 - for (item, _) in &self.pause_items { 243 - item.set_enabled(false); 244 - } 244 + self.set_pause_items_enabled(false); 245 245 self.item_reset.set_enabled(true); 246 246 self.pause_sub.set_enabled(true); 247 247 }
+183
src/tray_format.rs
··· 1 + use crate::timer::{LevelBreakStatus, LongBreakStatus}; 2 + 3 + pub fn fmt_countdown(secs: u64) -> String { 4 + if secs < 60 { 5 + format!("{secs}s") 6 + } else { 7 + format!("{}m {}s", secs / 60, secs % 60) 8 + } 9 + } 10 + 11 + pub fn fmt_tray_duration(secs: u64) -> String { 12 + match secs { 13 + 0..=59 => format!("{secs}s"), 14 + _ if secs % 3600 == 0 => { 15 + let hours = secs / 3600; 16 + format!("{hours} hour{}", if hours == 1 { "" } else { "s" }) 17 + } 18 + _ if secs % 60 == 0 => { 19 + let mins = secs / 60; 20 + format!("{mins} minute{}", if mins == 1 { "" } else { "s" }) 21 + } 22 + _ => format!("{}m {}s", secs / 60, secs % 60), 23 + } 24 + } 25 + 26 + pub fn fmt_tray_minutes(secs: u64) -> String { 27 + if secs < 60 { 28 + "under 1 min".to_string() 29 + } else { 30 + let mins = secs / 60; 31 + if mins >= 120 && mins % 60 == 0 { 32 + format!("{} hr", mins / 60) 33 + } else if mins >= 60 { 34 + format!("{} hr {} min", mins / 60, mins % 60) 35 + } else { 36 + format!("{mins} min") 37 + } 38 + } 39 + } 40 + 41 + pub fn tray_long_rest_line(profile_name: &str, status: Option<&LongBreakStatus>) -> String { 42 + match status { 43 + Some(status) if status.remaining_cycles == 0 => { 44 + format!("{} follows this cycle", status.label) 45 + } 46 + Some(status) if status.remaining_cycles == 1 => { 47 + format!("{} after 1 more round", status.label) 48 + } 49 + Some(status) => format!( 50 + "{} after {} more rounds", 51 + status.label, status.remaining_cycles 52 + ), 53 + None => format!("Rhythm: {profile_name}"), 54 + } 55 + } 56 + 57 + pub fn build_working_tracker_lines( 58 + profile_name: &str, 59 + level_break_statuses: &[LevelBreakStatus], 60 + long_break_status: Option<&LongBreakStatus>, 61 + ) -> Vec<String> { 62 + let mut lines = Vec::with_capacity(level_break_statuses.len() + 1); 63 + 64 + if let Some(first) = level_break_statuses.first() { 65 + lines.push(format!( 66 + "Next {} in {}", 67 + first.label, 68 + fmt_tray_minutes(first.remaining_secs) 69 + )); 70 + for status in level_break_statuses.iter().skip(1) { 71 + lines.push(format!( 72 + "{} in {}", 73 + status.label, 74 + fmt_tray_minutes(status.remaining_secs) 75 + )); 76 + } 77 + } 78 + 79 + if let Some(status) = long_break_status { 80 + lines.push(tray_long_rest_line(profile_name, Some(status))); 81 + } else if !lines.is_empty() && lines.len() < 2 { 82 + lines.push(format!("Rhythm: {profile_name}")); 83 + } 84 + 85 + if lines.is_empty() { 86 + lines.push("Settling into your rhythm".to_string()); 87 + lines.push(format!("Rhythm: {profile_name}")); 88 + } else if lines.len() == 1 { 89 + lines.push(format!("Rhythm: {profile_name}")); 90 + } 91 + 92 + lines 93 + } 94 + 95 + #[cfg(test)] 96 + mod tests { 97 + use super::*; 98 + 99 + #[test] 100 + fn fmt_countdown_under_60s() { 101 + assert_eq!(fmt_countdown(0), "0s"); 102 + assert_eq!(fmt_countdown(59), "59s"); 103 + } 104 + 105 + #[test] 106 + fn fmt_countdown_over_60s() { 107 + assert_eq!(fmt_countdown(90), "1m 30s"); 108 + assert_eq!(fmt_countdown(3600), "60m 0s"); 109 + } 110 + 111 + #[test] 112 + fn fmt_tray_duration_seconds() { 113 + assert_eq!(fmt_tray_duration(30), "30s"); 114 + assert_eq!(fmt_tray_duration(59), "59s"); 115 + } 116 + 117 + #[test] 118 + fn fmt_tray_duration_exact_hours() { 119 + assert_eq!(fmt_tray_duration(3600), "1 hour"); 120 + assert_eq!(fmt_tray_duration(7200), "2 hours"); 121 + } 122 + 123 + #[test] 124 + fn fmt_tray_duration_exact_minutes() { 125 + assert_eq!(fmt_tray_duration(60), "1 minute"); 126 + assert_eq!(fmt_tray_duration(120), "2 minutes"); 127 + } 128 + 129 + #[test] 130 + fn fmt_tray_duration_mixed() { 131 + assert_eq!(fmt_tray_duration(90), "1m 30s"); 132 + } 133 + 134 + #[test] 135 + fn fmt_tray_minutes_under_60s() { 136 + assert_eq!(fmt_tray_minutes(0), "under 1 min"); 137 + assert_eq!(fmt_tray_minutes(59), "under 1 min"); 138 + } 139 + 140 + #[test] 141 + fn fmt_tray_minutes_minutes() { 142 + assert_eq!(fmt_tray_minutes(60), "1 min"); 143 + assert_eq!(fmt_tray_minutes(3540), "59 min"); 144 + } 145 + 146 + #[test] 147 + fn fmt_tray_minutes_hours() { 148 + assert_eq!(fmt_tray_minutes(3600), "1 hr 0 min"); 149 + assert_eq!(fmt_tray_minutes(7200), "2 hr"); 150 + } 151 + 152 + #[test] 153 + fn build_working_tracker_lines_empty() { 154 + let lines = build_working_tracker_lines("Focus", &[], None); 155 + assert_eq!(lines, vec!["Settling into your rhythm", "Rhythm: Focus"]); 156 + } 157 + 158 + #[test] 159 + fn build_working_tracker_lines_one_level() { 160 + let statuses = vec![LevelBreakStatus { 161 + label: "Break".to_string(), 162 + remaining_secs: 300, 163 + }]; 164 + let lines = build_working_tracker_lines("Focus", &statuses, None); 165 + assert_eq!(lines[0], "Next Break in 5 min"); 166 + assert_eq!(lines[1], "Rhythm: Focus"); 167 + } 168 + 169 + #[test] 170 + fn build_working_tracker_lines_with_long_break() { 171 + let statuses = vec![LevelBreakStatus { 172 + label: "Break".to_string(), 173 + remaining_secs: 300, 174 + }]; 175 + let lb = LongBreakStatus { 176 + label: "Long rest".to_string(), 177 + remaining_cycles: 2, 178 + }; 179 + let lines = build_working_tracker_lines("Focus", &statuses, Some(&lb)); 180 + assert_eq!(lines[0], "Next Break in 5 min"); 181 + assert_eq!(lines[1], "Long rest after 2 more rounds"); 182 + } 183 + }