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: remove crash and improve overlay

- eliminate RefCell double-borrow crash
- show() before set_fullscreen()
- reset snooze_used per break
- center progres bar in overlay
- lazy overlay creation

+87 -68
+57 -51
src/main.rs
··· 9 9 mod timer; 10 10 mod tray; 11 11 12 - use std::cell::RefCell; 13 12 use std::rc::Rc; 14 13 use std::time::{Duration, Instant}; 15 14 ··· 30 29 fn main() -> anyhow::Result<()> { 31 30 env_logger::init(); 32 31 33 - // GTK must be initialized on the main thread before any GTK/tray operations. 34 32 #[cfg(target_os = "linux")] 35 33 init_gtk(); 36 34 ··· 43 41 .enable_all() 44 42 .build()?; 45 43 46 - // Enter the tokio runtime context so tokio::spawn works inside run(). 47 44 let _guard = rt.enter(); 48 45 run(cfg) 49 46 } ··· 67 64 let sound_volume = cfg.appearance.sound_volume; 68 65 let active_profile_name = active_profile(&cfg).name.clone(); 69 66 70 - let overlay = Rc::new(RefCell::new(OverlayManager::new(is_dark)?)); 71 67 let settings_mgr = Rc::new(SettingsManager::new(&cfg)?); 72 68 73 - // Overlay callbacks (attached to primary/first monitor window). 74 - { 75 - let cmd_tx = state.cmd_tx.clone(); 76 - overlay.borrow().primary_window().on_snooze_clicked(move || { 77 - let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: true }); 78 - }); 79 - } 80 - { 81 - let cmd_tx = state.cmd_tx.clone(); 82 - overlay.borrow().primary_window().on_unlock_clicked(move || { 83 - // Phase 3: enforced mode will prompt for password first. 84 - let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 85 - }); 86 - } 87 - 88 69 // Settings callbacks. 89 70 { 90 71 let win = settings_mgr.window().clone_strong(); ··· 96 77 let settings_ref = Rc::clone(&settings_mgr); 97 78 settings_mgr.window().on_save_clicked(move || { 98 79 let mut cfg = cfg_arc.lock().unwrap().clone(); 99 - // Pull widget state back into config before saving. 100 80 settings_ref.read_into(&mut cfg); 101 81 *cfg_arc.lock().unwrap() = cfg.clone(); 102 - 103 - // Apply autostart immediately. 104 82 if let Err(e) = autostart::set_enabled(cfg.app.autostart) { 105 83 log::warn!("Autostart toggle failed: {e}"); 106 84 } 107 - 108 85 win.hide().unwrap_or_default(); 109 86 config::save(&cfg).unwrap_or_else(|e| log::warn!("save failed: {e}")); 110 87 }); ··· 119 96 }); 120 97 settings_mgr.window().on_profile_changed(|_name| {}); 121 98 122 - // Break state tracked on the main thread. 123 - let break_start: Rc<RefCell<Option<(Instant, Duration)>>> = Rc::new(RefCell::new(None)); 124 - 125 - // Shared event_rx wrapped for polling from Slint timer. 126 - let event_rx = Rc::new(RefCell::new(event_rx)); 127 - 128 - // Main event polling timer: drains timer events + tray events every 100 ms. 99 + // Poll timer — owns overlay, break state, and event_rx directly. 100 + // No Rc<RefCell> needed: this closure is the sole accessor of these values. 129 101 let poll_timer = slint::Timer::default(); 130 102 { 131 - let overlay = overlay.clone(); 132 103 let tray = tray.clone(); 133 104 let settings_handle = settings_mgr.window().clone_strong(); 134 105 let cmd_tx = state.cmd_tx.clone(); 135 - let event_rx = event_rx.clone(); 136 - let break_start = break_start.clone(); 106 + 107 + // These are owned by the closure — no RefCell, no double-borrow risk. 108 + let mut overlay: Option<OverlayManager> = None; 109 + let mut break_active: Option<(Instant, Duration)> = None; 110 + let mut snooze_used = false; 137 111 138 112 poll_timer.start( 139 113 slint::TimerMode::Repeated, 140 114 Duration::from_millis(100), 141 115 move || { 142 - // Pump GTK events so the tray icon/menu stays responsive on Linux. 116 + // Pump GTK events so the tray menu stays responsive on Linux. 143 117 #[cfg(target_os = "linux")] 144 118 while gtk::events_pending() { 145 119 gtk::main_iteration_do(false); 146 120 } 147 121 148 - // --- Tray events --- 122 + // --- Tray --- 149 123 let mut open_settings = false; 150 124 if tray.process_events(&cmd_tx, &mut open_settings) { 151 125 slint::quit_event_loop().unwrap_or_default(); ··· 156 130 } 157 131 158 132 // --- Timer events --- 159 - let rx = event_rx.borrow(); 160 - while let Ok(event) = rx.try_recv() { 133 + while let Ok(event) = event_rx.try_recv() { 161 134 match event { 162 135 TimerEvent::BreakStarting(sched) => { 163 136 let dur = sched.break_duration; 164 - *break_start.borrow_mut() = Some((Instant::now(), dur)); 137 + break_active = Some((Instant::now(), dur)); 138 + snooze_used = false; 165 139 166 140 if sound_enabled { 167 141 sound::play_chime(sound_volume); 168 142 } 169 - 143 + tray.set_paused(true); 170 144 tray.set_tooltip(&format!("ioma — {}", sched.label)); 171 145 172 - overlay.borrow_mut().show_break(&sched, is_enforced, false); 146 + // Create overlay windows lazily so they don't appear 147 + // in the taskbar until a break actually starts. 148 + if overlay.is_none() { 149 + match OverlayManager::new(is_dark) { 150 + Ok(mgr) => { 151 + let tx = cmd_tx.clone(); 152 + mgr.primary_window().on_snooze_clicked(move || { 153 + let _ = tx.send(TimerCommand::BreakEnded { snoozed: true }); 154 + }); 155 + let tx = cmd_tx.clone(); 156 + mgr.primary_window().on_unlock_clicked(move || { 157 + let _ = tx.send(TimerCommand::BreakEnded { snoozed: false }); 158 + }); 159 + overlay = Some(mgr); 160 + } 161 + Err(e) => log::warn!("Failed to create overlay: {e}"), 162 + } 163 + } 164 + 165 + if let Some(ref mut mgr) = overlay { 166 + mgr.show_break(&sched, is_enforced, snooze_used); 167 + } 173 168 } 174 169 TimerEvent::Resumed => { 175 - *break_start.borrow_mut() = None; 170 + break_active = None; 176 171 tray.set_paused(false); 177 172 tray.set_tooltip("ioma"); 178 - overlay.borrow().hide_all(); 173 + if let Some(ref mgr) = overlay { 174 + mgr.hide_all(); 175 + } 179 176 } 180 177 TimerEvent::SnoozePeriodStarting { remaining_secs } => { 181 - *break_start.borrow_mut() = None; 178 + snooze_used = true; 179 + break_active = None; 182 180 tray.set_tooltip(&format!( 183 181 "ioma — snoozed, break in {}", 184 182 fmt_countdown(remaining_secs) 185 183 )); 186 - overlay.borrow().hide_all(); 184 + if let Some(ref mgr) = overlay { 185 + mgr.hide_all(); 186 + } 187 187 } 188 188 TimerEvent::WorkTick { secs_until_break } => { 189 189 tray.set_paused(false); ··· 200 200 } 201 201 } 202 202 203 - // --- Update overlay countdown if break is active --- 204 - if let Some((start, dur)) = *break_start.borrow() { 205 - let elapsed = start.elapsed(); 206 - if elapsed < dur { 207 - overlay.borrow().update_countdown(); 208 - } else { 209 - *break_start.borrow_mut() = None; 203 + // --- Overlay countdown tick --- 204 + // Split the check and the mutation to avoid any borrow conflicts. 205 + let expired = match break_active { 206 + Some((start, dur)) if start.elapsed() < dur => { 207 + if let Some(ref mgr) = overlay { 208 + mgr.update_countdown(); 209 + } 210 + false 210 211 } 212 + Some(_) => true, // break duration elapsed 213 + None => false, 214 + }; 215 + if expired { 216 + break_active = None; 211 217 } 212 218 }, 213 219 );
+14 -6
src/overlay/mod.rs
··· 11 11 windows: Vec<OverlayWindow>, 12 12 break_duration: Duration, 13 13 break_started: Instant, 14 - is_dark: bool, 15 14 } 16 15 17 16 impl OverlayManager { 18 17 pub fn new(is_dark: bool) -> anyhow::Result<Self> { 19 18 let monitors = get_monitors(); 20 19 let windows = if monitors.is_empty() { 21 - // Fallback: single fullscreen window. 22 20 vec![make_window(is_dark, None)?] 23 21 } else { 24 22 monitors ··· 27 25 .collect::<anyhow::Result<Vec<_>>>()? 28 26 }; 29 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 + 30 34 Ok(Self { 31 35 windows, 32 36 break_duration: Duration::from_secs(300), 33 37 break_started: Instant::now(), 34 - is_dark, 35 38 }) 36 39 } 37 40 ··· 44 47 w.set_snooze_visible(!is_enforced && !snooze_used); 45 48 w.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 46 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. 47 52 w.show().unwrap_or_default(); 53 + w.window().set_fullscreen(true); 48 54 } 49 55 } 50 56 51 57 pub fn hide_all(&self) { 52 58 for w in &self.windows { 59 + w.window().set_fullscreen(false); 53 60 w.hide().unwrap_or_default(); 54 61 } 55 62 } ··· 68 75 } 69 76 } 70 77 71 - /// Returns the first window (primary monitor) for attaching callbacks. 78 + /// The first window — used to attach snooze/unlock callbacks. 72 79 pub fn primary_window(&self) -> &OverlayWindow { 73 80 &self.windows[0] 74 81 } ··· 99 106 w.set_is_dark(is_dark); 100 107 101 108 if let Some(m) = monitor { 102 - // Use physical pixel coordinates from display-info. 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). 103 112 w.window().set_position(PhysicalPosition::new(m.x, m.y)); 104 113 w.window().set_size(PhysicalSize::new(m.width, m.height)); 105 114 } 106 - // no-frame is set in overlay.slint; we size manually per monitor. 107 115 108 116 Ok(w) 109 117 }
+3 -1
src/settings/mod.rs
··· 1 - use slint::{ModelRc, SharedString, VecModel}; 1 + use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; 2 2 3 3 use crate::autostart; 4 4 use crate::config::{AppConfig, BreakModeConfig}; ··· 12 12 pub fn new(cfg: &AppConfig) -> anyhow::Result<Self> { 13 13 let window = SettingsWindow::new()?; 14 14 populate(&window, cfg); 15 + // Hide immediately — shown only when user opens settings from tray. 16 + window.hide().unwrap_or_default(); 15 17 Ok(Self { window }) 16 18 } 17 19
+13 -10
ui/overlay.slint
··· 38 38 horizontal-alignment: center; 39 39 } 40 40 41 - Rectangle { 42 - height: 8px; 43 - width: 320px; 44 - horizontal-stretch: 0; 45 - background: is-dark ? #333355 : #d0d0d0; 46 - border-radius: 4px; 41 + HorizontalLayout { 42 + alignment: center; 47 43 48 44 Rectangle { 49 - x: 0; 50 - width: parent.width * progress; 51 - height: parent.height; 52 - background: is-dark ? #a0c4ff : #4a90d9; 45 + height: 8px; 46 + width: 320px; 47 + background: is-dark ? #333355 : #d0d0d0; 53 48 border-radius: 4px; 49 + 50 + Rectangle { 51 + x: 0; 52 + width: parent.width * progress; 53 + height: parent.height; 54 + background: is-dark ? #a0c4ff : #4a90d9; 55 + border-radius: 4px; 56 + } 54 57 } 55 58 } 56 59