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.

refactor: overlay owns monitor state, create apploop for mutable states

+330 -240
+262 -237
src/main.rs
··· 12 12 mod tray_format; 13 13 14 14 use std::rc::Rc; 15 - use std::sync::atomic::Ordering; 15 + use std::sync::atomic::{AtomicBool, Ordering}; 16 + use std::sync::{Arc, Mutex, mpsc as std_mpsc}; 16 17 use std::time::{Duration, Instant}; 17 18 18 19 use slint::ComponentHandle; ··· 21 22 fn init_gtk() { 22 23 gtk::init().expect("GTK init failed"); 23 24 } 24 - 25 - use std::sync::{Arc, Mutex}; 26 - use std::sync::atomic::AtomicBool; 27 25 28 26 use app::{AppState, spawn_idle_poller}; 29 27 use config::AppConfig; ··· 77 75 )); 78 76 } 79 77 78 + // ── AppLoop ─────────────────────────────────────────────────────────────────── 79 + // 80 + // Owns all mutable state that the 100 ms poll timer needs. Each concern has 81 + // its own method so the hot path (`tick`) reads as a flat list of steps. 82 + 83 + struct AppLoop { 84 + tray: Rc<AppTray>, 85 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 86 + cfg_arc: Arc<Mutex<AppConfig>>, 87 + event_rx: std_mpsc::Receiver<TimerEvent>, 88 + system_dark: Arc<AtomicBool>, 89 + sound_enabled: bool, 90 + sound_volume: f32, 91 + settings_mgr: Option<SettingsManager>, 92 + overlay: Option<OverlayManager>, 93 + break_active: Option<(Instant, Duration)>, 94 + snooze_used: bool, 95 + dark_check_tick: u32, 96 + last_dark: Option<bool>, 97 + } 98 + 99 + impl AppLoop { 100 + fn new( 101 + tray: Rc<AppTray>, 102 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 103 + cfg_arc: Arc<Mutex<AppConfig>>, 104 + event_rx: std_mpsc::Receiver<TimerEvent>, 105 + system_dark: Arc<AtomicBool>, 106 + sound_enabled: bool, 107 + sound_volume: f32, 108 + ) -> Self { 109 + Self { 110 + tray, 111 + cmd_tx, 112 + cfg_arc, 113 + event_rx, 114 + system_dark, 115 + sound_enabled, 116 + sound_volume, 117 + settings_mgr: None, 118 + overlay: None, 119 + break_active: None, 120 + snooze_used: false, 121 + dark_check_tick: 0, 122 + last_dark: None, 123 + } 124 + } 125 + 126 + // Returns true if the event loop should quit. 127 + fn tick(&mut self) -> bool { 128 + let mut open_settings = false; 129 + if self.tray.process_events(&self.cmd_tx, &mut open_settings) { 130 + return true; 131 + } 132 + if open_settings { 133 + self.try_open_settings(); 134 + } 135 + self.dispatch_timer_events(); 136 + self.tick_break_countdown(); 137 + self.poll_dark_mode(); 138 + if let Some(ref mut mgr) = self.overlay { 139 + mgr.poll_monitors(self.break_active.is_some()); 140 + } 141 + false 142 + } 143 + 144 + fn try_open_settings(&mut self) { 145 + if self.settings_mgr.is_none() { 146 + // First open: create window lazily, wire callbacks, then show. 147 + let cfg_snap = self.cfg_arc.lock().unwrap().clone(); 148 + match SettingsManager::new(&cfg_snap, self.cfg_arc.clone()) { 149 + Ok(mgr) => { 150 + mgr.wire_and_show( 151 + current_dark(&self.cfg_arc, &self.system_dark), 152 + self.cfg_arc.clone(), 153 + self.system_dark.clone(), 154 + self.cmd_tx.clone(), 155 + ); 156 + self.settings_mgr = Some(mgr); 157 + } 158 + Err(e) => log::warn!("Failed to create settings window: {e}"), 159 + } 160 + } else if let Some(ref mgr) = self.settings_mgr { 161 + // Refresh theme in case system preference changed while hidden. 162 + let dark = current_dark(&self.cfg_arc, &self.system_dark); 163 + mgr.window().set_is_dark(dark); 164 + mgr.window().show().unwrap_or_default(); 165 + } 166 + } 167 + 168 + fn dispatch_timer_events(&mut self) { 169 + while let Ok(event) = self.event_rx.try_recv() { 170 + match event { 171 + TimerEvent::BreakStarting(sched) => { 172 + let dur = sched.break_duration; 173 + self.break_active = Some((Instant::now(), dur)); 174 + 175 + if self.sound_enabled { 176 + sound::play_chime(self.sound_volume); 177 + } 178 + self.tray.set_breaking(); 179 + self.tray.set_tracker_lines(&[ 180 + format!("Now: {}", sched.label), 181 + format!("Back in {}", fmt_tray_duration(dur.as_secs())), 182 + ]); 183 + self.tray.set_tooltip(&format!("ioma — {}", sched.label)); 184 + 185 + if self.overlay.is_none() { 186 + let dark = current_dark(&self.cfg_arc, &self.system_dark); 187 + match OverlayManager::new(dark, self.cmd_tx.clone(), self.cfg_arc.clone()) { 188 + Ok(mgr) => self.overlay = Some(mgr), 189 + Err(e) => log::warn!("Failed to create overlay: {e}"), 190 + } 191 + } 192 + if let Some(ref mgr) = self.overlay { 193 + let is_enforced = active_profile(&self.cfg_arc.lock().unwrap()).mode 194 + == BreakMode::Enforced; 195 + mgr.show_break(&sched, is_enforced, self.snooze_used); 196 + } 197 + } 198 + TimerEvent::Resumed { 199 + secs_until_break, 200 + level_break_statuses, 201 + long_break_status, 202 + } => { 203 + self.snooze_used = false; 204 + self.break_active = None; 205 + set_tray_working( 206 + &self.tray, 207 + &self.cfg_arc, 208 + &level_break_statuses, 209 + long_break_status.as_ref(), 210 + secs_until_break, 211 + ); 212 + if let Some(ref mgr) = self.overlay { 213 + mgr.reset_unlock_state(); 214 + mgr.hide(); 215 + } 216 + } 217 + TimerEvent::SnoozePeriodStarting { 218 + remaining_secs, 219 + level_break_statuses, 220 + long_break_status, 221 + } => { 222 + let profile_name = active_profile(&self.cfg_arc.lock().unwrap()).name; 223 + self.snooze_used = true; 224 + self.break_active = None; 225 + self.tray.set_working(); 226 + let mut lines = build_working_tracker_lines( 227 + &profile_name, 228 + &level_break_statuses, 229 + long_break_status.as_ref(), 230 + ); 231 + lines[0] = format!( 232 + "Next pause returns in {}", 233 + fmt_tray_minutes(remaining_secs) 234 + ); 235 + self.tray.set_tracker_lines(&lines); 236 + self.tray.set_tooltip(&format!( 237 + "ioma — snoozed, pause in {}", 238 + fmt_countdown(remaining_secs) 239 + )); 240 + if let Some(ref mgr) = self.overlay { 241 + mgr.hide(); 242 + } 243 + } 244 + TimerEvent::WorkTick { 245 + secs_until_break, 246 + level_break_statuses, 247 + long_break_status, 248 + } => { 249 + set_tray_working( 250 + &self.tray, 251 + &self.cfg_arc, 252 + &level_break_statuses, 253 + long_break_status.as_ref(), 254 + secs_until_break, 255 + ); 256 + } 257 + TimerEvent::Paused { remaining_secs } => { 258 + self.tray.set_paused(); 259 + self.tray.set_tracker_lines(&[ 260 + "Reminders are paused".to_string(), 261 + match remaining_secs { 262 + Some(secs) => { 263 + format!("Ready again in {}", fmt_tray_minutes(secs)) 264 + } 265 + None => "Reset pauses when you're ready".to_string(), 266 + }, 267 + ]); 268 + self.tray.set_tooltip("ioma — paused"); 269 + } 270 + } 271 + } 272 + } 273 + 274 + fn tick_break_countdown(&mut self) { 275 + let expired = match self.break_active { 276 + Some((start, dur)) if start.elapsed() < dur => { 277 + if let Some(ref mgr) = self.overlay { 278 + mgr.update_countdown(start.elapsed(), dur); 279 + } 280 + false 281 + } 282 + Some(_) => true, 283 + None => false, 284 + }; 285 + if expired { 286 + self.break_active = None; 287 + if let Some(ref mgr) = self.overlay { 288 + mgr.hide(); 289 + } 290 + let _ = self.cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 291 + } 292 + } 293 + 294 + fn poll_dark_mode(&mut self) { 295 + self.dark_check_tick += 1; 296 + if self.dark_check_tick < 50 && self.last_dark.is_some() { 297 + return; 298 + } 299 + self.dark_check_tick = 0; 300 + let dark = current_dark(&self.cfg_arc, &self.system_dark); 301 + if self.last_dark != Some(dark) { 302 + self.last_dark = Some(dark); 303 + if let Some(ref mgr) = self.settings_mgr { 304 + mgr.window().set_is_dark(dark); 305 + } 306 + if let Some(ref mut mgr) = self.overlay { 307 + mgr.set_is_dark(dark); 308 + } 309 + } 310 + } 311 + } 312 + 313 + // ── run ─────────────────────────────────────────────────────────────────────── 314 + 80 315 fn run(cfg: AppConfig) -> anyhow::Result<()> { 81 316 let (state, event_rx) = AppState::new(cfg.clone()); 82 317 ··· 91 326 92 327 let tray = Rc::new(AppTray::new()?); 93 328 let system_dark = system::start_watcher(); 94 - let sound_enabled = cfg.appearance.sound_enabled; 95 - let sound_volume = cfg.appearance.sound_volume; 96 329 let active_profile_name = active_profile(&cfg).name.clone(); 97 330 tray.set_tracker_lines(&[ 98 331 "Settling into your rhythm".to_string(), 99 332 format!("Rhythm: {active_profile_name}"), 100 333 ]); 101 334 102 - // Poll timer — owns all mutable state; no Rc<RefCell> on the hot path. 335 + let mut app_loop = AppLoop::new( 336 + tray, 337 + state.cmd_tx.clone(), 338 + state.cfg.clone(), 339 + event_rx, 340 + system_dark, 341 + cfg.appearance.sound_enabled, 342 + cfg.appearance.sound_volume, 343 + ); 344 + 103 345 let poll_timer = slint::Timer::default(); 104 - { 105 - let tray = tray.clone(); 106 - let cmd_tx = state.cmd_tx.clone(); 107 - let cfg_arc = state.cfg.clone(); 108 - let cmd_tx_settings = state.cmd_tx.clone(); 346 + poll_timer.start( 347 + slint::TimerMode::Repeated, 348 + Duration::from_millis(100), 349 + move || { 350 + #[cfg(target_os = "linux")] 351 + while gtk::events_pending() { 352 + gtk::main_iteration_do(false); 353 + } 109 354 110 - // Settings window is created lazily on first open to avoid appearing 111 - // in the taskbar/dock at startup before the user opens it. 112 - let mut settings_mgr: Option<SettingsManager> = None; 113 - let mut overlay: Option<OverlayManager> = None; 114 - let mut break_active: Option<(Instant, Duration)> = None; 115 - let mut snooze_used = false; 116 - let mut dark_check_tick: u32 = 0; 117 - let mut last_dark: Option<bool> = None; 118 - let mut monitor_check_tick: u32 = 0; 119 - let mut known_monitors: Vec<overlay::monitors::MonitorInfo> = { 120 - let mut v = overlay::monitors::enumerate(); 121 - v.sort_by_key(|m| (m.x, m.y)); 122 - v 123 - }; 124 - let mut overlay_needs_rebuild = false; 125 - 126 - poll_timer.start( 127 - slint::TimerMode::Repeated, 128 - Duration::from_millis(100), 129 - move || { 130 - // Pump GTK events so the tray menu stays responsive on Linux. 131 - #[cfg(target_os = "linux")] 132 - while gtk::events_pending() { 133 - gtk::main_iteration_do(false); 134 - } 135 - 136 - // --- Tray --- 137 - let mut open_settings = false; 138 - if tray.process_events(&cmd_tx, &mut open_settings) { 139 - slint::quit_event_loop().unwrap_or_default(); 140 - return; 141 - } 142 - 143 - if open_settings { 144 - if settings_mgr.is_none() { 145 - // First open: create window lazily, wire callbacks, then show. 146 - let cfg_snap = cfg_arc.lock().unwrap().clone(); 147 - match SettingsManager::new(&cfg_snap, cfg_arc.clone()) { 148 - Ok(mgr) => { 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(), 154 - ); 155 - settings_mgr = Some(mgr); 156 - } 157 - Err(e) => log::warn!("Failed to create settings window: {e}"), 158 - } 159 - } else if let Some(ref mgr) = settings_mgr { 160 - // Refresh theme in case system preference changed while hidden 161 - let dark = current_dark(&cfg_arc, &system_dark); 162 - mgr.window().set_is_dark(dark); 163 - mgr.window().show().unwrap_or_default(); 164 - } 165 - } 166 - 167 - // --- Timer events --- 168 - while let Ok(event) = event_rx.try_recv() { 169 - match event { 170 - TimerEvent::BreakStarting(sched) => { 171 - let dur = sched.break_duration; 172 - break_active = Some((Instant::now(), dur)); 173 - 174 - if sound_enabled { 175 - sound::play_chime(sound_volume); 176 - } 177 - tray.set_breaking(); 178 - tray.set_tracker_lines(&[ 179 - format!("Now: {}", sched.label), 180 - format!("Back in {}", fmt_tray_duration(dur.as_secs())), 181 - ]); 182 - tray.set_tooltip(&format!("ioma — {}", sched.label)); 183 - 184 - if overlay.is_none() { 185 - let dark = current_dark(&cfg_arc, &system_dark); 186 - match OverlayManager::new(dark, cmd_tx.clone(), cfg_arc.clone()) { 187 - Ok(mgr) => overlay = Some(mgr), 188 - Err(e) => log::warn!("Failed to create overlay: {e}"), 189 - } 190 - } 191 - 192 - if let Some(ref mgr) = overlay { 193 - let is_enforced = active_profile(&cfg_arc.lock().unwrap()).mode 194 - == BreakMode::Enforced; 195 - mgr.show_break(&sched, is_enforced, snooze_used); 196 - } 197 - } 198 - TimerEvent::Resumed { 199 - secs_until_break, 200 - level_break_statuses, 201 - long_break_status, 202 - } => { 203 - snooze_used = false; 204 - break_active = None; 205 - set_tray_working( 206 - &tray, 207 - &cfg_arc, 208 - &level_break_statuses, 209 - long_break_status.as_ref(), 210 - secs_until_break, 211 - ); 212 - if let Some(ref mgr) = overlay { 213 - mgr.reset_unlock_state(); 214 - mgr.hide(); 215 - } 216 - } 217 - TimerEvent::SnoozePeriodStarting { 218 - remaining_secs, 219 - level_break_statuses, 220 - long_break_status, 221 - } => { 222 - let profile_name = active_profile(&cfg_arc.lock().unwrap()).name; 223 - snooze_used = true; 224 - break_active = None; 225 - tray.set_working(); 226 - let mut lines = build_working_tracker_lines( 227 - &profile_name, 228 - &level_break_statuses, 229 - long_break_status.as_ref(), 230 - ); 231 - lines[0] = format!( 232 - "Next pause returns in {}", 233 - fmt_tray_minutes(remaining_secs) 234 - ); 235 - tray.set_tracker_lines(&lines); 236 - tray.set_tooltip(&format!( 237 - "ioma — snoozed, pause in {}", 238 - fmt_countdown(remaining_secs) 239 - )); 240 - if let Some(ref mgr) = overlay { 241 - mgr.hide(); 242 - } 243 - } 244 - TimerEvent::WorkTick { 245 - secs_until_break, 246 - level_break_statuses, 247 - long_break_status, 248 - } => { 249 - set_tray_working( 250 - &tray, 251 - &cfg_arc, 252 - &level_break_statuses, 253 - long_break_status.as_ref(), 254 - secs_until_break, 255 - ); 256 - } 257 - TimerEvent::Paused { remaining_secs } => { 258 - tray.set_paused(); 259 - tray.set_tracker_lines(&[ 260 - "Reminders are paused".to_string(), 261 - match remaining_secs { 262 - Some(secs) => { 263 - format!("Ready again in {}", fmt_tray_minutes(secs)) 264 - } 265 - None => "Reset pauses when you're ready".to_string(), 266 - }, 267 - ]); 268 - tray.set_tooltip("ioma — paused"); 269 - } 270 - } 271 - } 272 - 273 - // --- Overlay countdown tick --- 274 - let expired = match break_active { 275 - Some((start, dur)) if start.elapsed() < dur => { 276 - if let Some(ref mgr) = overlay { 277 - mgr.update_countdown(start.elapsed(), dur); 278 - } 279 - false 280 - } 281 - Some(_) => true, 282 - None => false, 283 - }; 284 - if expired { 285 - break_active = None; 286 - if let Some(ref mgr) = overlay { 287 - mgr.hide(); 288 - } 289 - let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 290 - } 291 - 292 - // --- Dark mode polling (every ~5s) --- 293 - dark_check_tick += 1; 294 - if dark_check_tick >= 50 || last_dark.is_none() { 295 - dark_check_tick = 0; 296 - let dark = current_dark(&cfg_arc, &system_dark); 297 - if last_dark != Some(dark) { 298 - last_dark = Some(dark); 299 - if let Some(ref mgr) = settings_mgr { 300 - mgr.window().set_is_dark(dark); 301 - } 302 - if let Some(ref mut mgr) = overlay { 303 - mgr.set_is_dark(dark); 304 - } 305 - } 306 - } 307 - 308 - // --- Monitor hotplug detection (every ~5s) --- 309 - monitor_check_tick += 1; 310 - if monitor_check_tick >= 50 { 311 - monitor_check_tick = 0; 312 - let mut current = overlay::monitors::enumerate(); 313 - current.sort_by_key(|m| (m.x, m.y)); 314 - if current != known_monitors { 315 - log::info!( 316 - "overlay: monitor config changed ({} → {} monitor(s)), queuing rebuild", 317 - known_monitors.len(), 318 - current.len() 319 - ); 320 - known_monitors = current; 321 - overlay_needs_rebuild = true; 322 - } 323 - } 324 - // Apply rebuild between breaks so the next break uses the 325 - // current monitor layout. During an active break we leave the 326 - // existing overlay in place and rebuild when the break ends. 327 - if overlay_needs_rebuild && break_active.is_none() { 328 - overlay = None; 329 - overlay_needs_rebuild = false; 330 - } 331 - }, 332 - ); 333 - } 355 + if app_loop.tick() { 356 + slint::quit_event_loop().unwrap_or_default(); 357 + } 358 + }, 359 + ); 334 360 335 361 slint::run_event_loop_until_quit()?; 336 362 drop(poll_timer); ··· 339 365 state.save_config(); 340 366 Ok(()) 341 367 } 342 -
+68 -3
src/overlay/mod.rs
··· 31 31 pub struct OverlayManager { 32 32 backend: Box<dyn OverlayBackend>, 33 33 is_dark: bool, 34 + cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 35 + cfg_arc: Arc<Mutex<AppConfig>>, 36 + known_monitors: Vec<monitors::MonitorInfo>, 37 + monitor_tick: u32, 38 + needs_rebuild: bool, 34 39 #[cfg(target_os = "linux")] 35 40 layer_barrier: Option<LayerShellBarrier>, 36 41 } ··· 42 47 cfg_arc: Arc<Mutex<AppConfig>>, 43 48 ) -> anyhow::Result<Self> { 44 49 let session = session::detect(); 45 - let all_monitors = monitors::enumerate(); 50 + let mut all_monitors = monitors::enumerate(); 51 + all_monitors.sort_by_key(|m| (m.x, m.y)); 46 52 log::info!( 47 53 "overlay: session={:?}, {} monitor(s): {}", 48 54 session, ··· 66 72 let backend = Box::new(multi_slint::MultiSlintBackend::new( 67 73 is_dark, 68 74 &all_monitors, 69 - cmd_tx, 70 - cfg_arc, 75 + cmd_tx.clone(), 76 + cfg_arc.clone(), 71 77 )?); 72 78 73 79 Ok(Self { 74 80 backend, 75 81 is_dark, 82 + cmd_tx, 83 + cfg_arc, 84 + known_monitors: all_monitors, 85 + monitor_tick: 0, 86 + needs_rebuild: false, 76 87 #[cfg(target_os = "linux")] 77 88 layer_barrier, 78 89 }) ··· 105 116 pub fn set_is_dark(&mut self, dark: bool) { 106 117 self.is_dark = dark; 107 118 self.backend.set_is_dark(dark); 119 + } 120 + 121 + // Checks for monitor changes every ~5 s. Rebuilds the backend between 122 + // breaks when a hotplug event is detected. 123 + pub fn poll_monitors(&mut self, break_active: bool) { 124 + self.monitor_tick += 1; 125 + if self.monitor_tick < 50 { 126 + return; 127 + } 128 + self.monitor_tick = 0; 129 + 130 + let mut current = monitors::enumerate(); 131 + current.sort_by_key(|m| (m.x, m.y)); 132 + if current != self.known_monitors { 133 + log::info!( 134 + "overlay: monitor config changed ({} → {} monitor(s)), queuing rebuild", 135 + self.known_monitors.len(), 136 + current.len() 137 + ); 138 + self.known_monitors = current; 139 + self.needs_rebuild = true; 140 + } 141 + 142 + if self.needs_rebuild && !break_active { 143 + self.rebuild(); 144 + } 145 + } 146 + 147 + fn rebuild(&mut self) { 148 + #[cfg(target_os = "linux")] 149 + { 150 + let session = session::detect(); 151 + self.layer_barrier = if session == SessionType::Wayland { 152 + let primary = self.known_monitors.iter().find(|m| m.is_primary); 153 + let (px, py) = primary.map(|m| (m.x, m.y)).unwrap_or((0, 0)); 154 + LayerShellBarrier::try_new(px, py) 155 + } else { 156 + None 157 + }; 158 + } 159 + 160 + match multi_slint::MultiSlintBackend::new( 161 + self.is_dark, 162 + &self.known_monitors, 163 + self.cmd_tx.clone(), 164 + self.cfg_arc.clone(), 165 + ) { 166 + Ok(backend) => { 167 + self.backend = Box::new(backend); 168 + self.needs_rebuild = false; 169 + log::info!("overlay: rebuilt for {} monitor(s)", self.known_monitors.len()); 170 + } 171 + Err(e) => log::warn!("overlay: monitor rebuild failed: {e}"), 172 + } 108 173 } 109 174 } 110 175