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: setup app - wire features together

+331 -87
+1
Cargo.toml
··· 40 40 dirs = "5" 41 41 thiserror = "1" 42 42 anyhow = "1" 43 + open = "5" 43 44 44 45 [target.'cfg(target_os = "linux")'.dependencies] 45 46 zbus = { version = "4", default-features = false, features = ["tokio"] }
+1 -2
build.rs
··· 1 1 fn main() { 2 - slint_build::compile("ui/overlay.slint").unwrap(); 3 - slint_build::compile("ui/settings.slint").unwrap(); 2 + slint_build::compile("ui/app.slint").unwrap(); 4 3 }
+86
src/app.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + use std::time::Duration; 3 + use tokio::sync::mpsc; 4 + 5 + use crate::config::{self, AppConfig}; 6 + use crate::idle; 7 + use crate::timer::{Profile, TimerCommand, TimerEvent, TimerTask}; 8 + 9 + /// Shared app state accessed from multiple threads. 10 + pub struct AppState { 11 + pub cfg: Arc<Mutex<AppConfig>>, 12 + pub cmd_tx: mpsc::UnboundedSender<TimerCommand>, 13 + } 14 + 15 + impl AppState { 16 + pub fn new(cfg: AppConfig) -> (Self, mpsc::UnboundedReceiver<TimerEvent>) { 17 + let profile = active_profile(&cfg); 18 + let (cmd_tx, event_rx) = TimerTask::spawn(profile, &cfg); 19 + 20 + let state = Self { 21 + cfg: Arc::new(Mutex::new(cfg)), 22 + cmd_tx, 23 + }; 24 + (state, event_rx) 25 + } 26 + 27 + pub fn save_config(&self) { 28 + let cfg = self.cfg.lock().unwrap().clone(); 29 + if let Err(e) = config::save(&cfg) { 30 + log::warn!("Failed to save config: {e}"); 31 + } 32 + } 33 + } 34 + 35 + pub fn active_profile(cfg: &AppConfig) -> Profile { 36 + let id = &cfg.app.active_profile; 37 + let prof_cfg = cfg 38 + .profiles 39 + .get(id) 40 + .or_else(|| cfg.profiles.values().next()) 41 + .expect("config must have at least one profile"); 42 + Profile::from_config(id, prof_cfg) 43 + } 44 + 45 + /// Spawns the idle detection polling loop. 46 + pub fn spawn_idle_poller( 47 + cmd_tx: mpsc::UnboundedSender<TimerCommand>, 48 + threshold: Duration, 49 + enabled: bool, 50 + ) { 51 + if !enabled { 52 + return; 53 + } 54 + tokio::spawn(async move { 55 + let mut detector = idle::create(); 56 + let mut was_idle = false; 57 + let mut idle_start: Option<std::time::Instant> = None; 58 + 59 + loop { 60 + tokio::time::sleep(Duration::from_secs(5)).await; 61 + 62 + match detector.idle_duration() { 63 + Ok(Some(idle_dur)) if idle_dur >= threshold => { 64 + if !was_idle { 65 + idle_start = Some(std::time::Instant::now()); 66 + was_idle = true; 67 + let _ = cmd_tx.send(TimerCommand::IdleDetected { duration: idle_dur }); 68 + } 69 + } 70 + Ok(_) => { 71 + if was_idle { 72 + // User returned — tell the timer how long they were gone. 73 + let gone = idle_start 74 + .map(|t| t.elapsed()) 75 + .unwrap_or(threshold); 76 + was_idle = false; 77 + idle_start = None; 78 + let _ = cmd_tx.send(TimerCommand::Resume); 79 + drop(gone); // used in future for smarter cycle resets 80 + } 81 + } 82 + Err(e) => log::debug!("Idle detection error: {e}"), 83 + } 84 + } 85 + }); 86 + }
+1
src/generated.rs
··· 1 + slint::include_modules!();
+193 -2
src/main.rs
··· 1 - fn main() { 2 - println!("Hello, world!"); 1 + mod app; 2 + mod autostart; 3 + mod config; 4 + mod generated; 5 + mod idle; 6 + mod overlay; 7 + mod settings; 8 + mod timer; 9 + mod tray; 10 + 11 + use std::rc::Rc; 12 + use std::time::Duration; 13 + 14 + use slint::ComponentHandle; 15 + 16 + use app::{active_profile, spawn_idle_poller, AppState}; 17 + use config::AppConfig; 18 + use overlay::OverlayManager; 19 + use settings::SettingsManager; 20 + use timer::{BreakMode, TimerCommand, TimerEvent}; 21 + use tray::AppTray; 22 + 23 + fn main() -> anyhow::Result<()> { 24 + env_logger::init(); 25 + 26 + let cfg = config::load().unwrap_or_else(|e| { 27 + log::warn!("Could not load config ({e}), using defaults"); 28 + AppConfig::default() 29 + }); 30 + 31 + // tokio multi-thread runtime; Slint runs on the calling (main) thread. 32 + let rt = tokio::runtime::Builder::new_multi_thread() 33 + .enable_all() 34 + .build()?; 35 + 36 + rt.block_on(run(cfg)) 37 + } 38 + 39 + async fn run(cfg: AppConfig) -> anyhow::Result<()> { 40 + let (state, mut event_rx) = AppState::new(cfg.clone()); 41 + 42 + { 43 + let active = active_profile(&cfg); 44 + spawn_idle_poller( 45 + state.cmd_tx.clone(), 46 + active.idle_threshold, 47 + active.idle_detection_enabled, 48 + ); 49 + } 50 + 51 + // Tray must be created on the main thread; wrap in Rc for safe cloning into closures. 52 + let tray = Rc::new(AppTray::new()?); 53 + let is_dark = cfg.appearance.overlay_theme == config::OverlayTheme::Dark; 54 + 55 + let mut overlay = OverlayManager::new(is_dark)?; 56 + let settings_mgr = SettingsManager::new(&cfg)?; 57 + let is_enforced = active_profile(&cfg).mode == BreakMode::Enforced; 58 + 59 + // Overlay callbacks. 60 + { 61 + let cmd_tx = state.cmd_tx.clone(); 62 + overlay.window().on_snooze_clicked(move || { 63 + let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: true }); 64 + }); 65 + } 66 + { 67 + let cmd_tx = state.cmd_tx.clone(); 68 + overlay.window().on_unlock_clicked(move || { 69 + // Phase 3: enforced mode will prompt for password first. 70 + let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 71 + }); 72 + } 73 + 74 + // Settings callbacks. 75 + { 76 + let win = settings_mgr.window().clone_strong(); 77 + settings_mgr.window().on_cancel_clicked(move || win.hide().unwrap_or_default()); 78 + } 79 + { 80 + let win = settings_mgr.window().clone_strong(); 81 + let cfg_arc = state.cfg.clone(); 82 + settings_mgr.window().on_save_clicked(move || { 83 + let cfg = cfg_arc.lock().unwrap().clone(); 84 + win.hide().unwrap_or_default(); 85 + config::save(&cfg).unwrap_or_else(|e| log::warn!("save failed: {e}")); 86 + }); 87 + } 88 + settings_mgr.window().on_open_config_dir(|| { 89 + let path = config::config_path(); 90 + if let Some(dir) = path.parent() { 91 + let _ = open::that(dir); 92 + } 93 + }); 94 + settings_mgr.window().on_set_password_clicked(|| { 95 + log::info!("Password setup — Phase 3"); 96 + }); 97 + settings_mgr.window().on_profile_changed(|_name| {}); 98 + 99 + // Timer event loop → posts Slint UI updates from the tokio thread. 100 + { 101 + let overlay_handle = overlay.window().clone_strong(); 102 + tokio::spawn(async move { 103 + let mut in_break = false; 104 + let mut break_dur = Duration::ZERO; 105 + let mut break_start = std::time::Instant::now(); 106 + 107 + while let Some(event) = event_rx.recv().await { 108 + match event { 109 + TimerEvent::BreakStarting(sched) => { 110 + in_break = true; 111 + break_dur = sched.break_duration; 112 + break_start = std::time::Instant::now(); 113 + 114 + let label = sched.label.clone(); 115 + let dur = sched.break_duration; 116 + let h = overlay_handle.clone(); 117 + slint::invoke_from_event_loop(move || { 118 + h.set_break_label(label.as_str().into()); 119 + h.set_snooze_visible(!is_enforced); 120 + h.set_countdown_text(overlay::fmt_dur(dur).as_str().into()); 121 + h.set_progress(0.0); 122 + h.window().set_fullscreen(true); 123 + h.show().unwrap_or_default(); 124 + }) 125 + .unwrap_or_default(); 126 + } 127 + TimerEvent::Resumed => { 128 + in_break = false; 129 + let h = overlay_handle.clone(); 130 + slint::invoke_from_event_loop(move || { 131 + h.window().set_fullscreen(false); 132 + h.hide().unwrap_or_default(); 133 + }) 134 + .unwrap_or_default(); 135 + } 136 + TimerEvent::SnoozePeriodStarting { .. } => { 137 + in_break = false; 138 + let h = overlay_handle.clone(); 139 + slint::invoke_from_event_loop(move || { 140 + h.window().set_fullscreen(false); 141 + h.hide().unwrap_or_default(); 142 + }) 143 + .unwrap_or_default(); 144 + } 145 + TimerEvent::WorkTick { .. } | TimerEvent::Paused => {} 146 + } 147 + 148 + if in_break { 149 + let elapsed = break_start.elapsed(); 150 + if elapsed < break_dur { 151 + let remaining = break_dur - elapsed; 152 + let progress = elapsed.as_secs_f32() / break_dur.as_secs_f32(); 153 + let text = overlay::fmt_dur(remaining); 154 + let h = overlay_handle.clone(); 155 + slint::invoke_from_event_loop(move || { 156 + h.set_countdown_text(text.as_str().into()); 157 + h.set_progress(progress); 158 + }) 159 + .unwrap_or_default(); 160 + } 161 + } 162 + } 163 + }); 164 + } 165 + 166 + // Tray event polling via a Slint timer (50 ms, on main thread). 167 + { 168 + let cmd_tx = state.cmd_tx.clone(); 169 + let tray_poll = tray.clone(); 170 + let settings_handle = settings_mgr.window().clone_strong(); 171 + 172 + let poll_timer = slint::Timer::default(); 173 + poll_timer.start( 174 + slint::TimerMode::Repeated, 175 + Duration::from_millis(50), 176 + move || { 177 + let mut open_settings = false; 178 + if tray_poll.process_events(&cmd_tx, &mut open_settings) { 179 + slint::quit_event_loop().unwrap_or_default(); 180 + } 181 + if open_settings { 182 + settings_handle.show().unwrap_or_default(); 183 + } 184 + }, 185 + ); 186 + 187 + slint::run_event_loop_until_quit()?; 188 + drop(poll_timer); 189 + } 190 + 191 + let _ = state.cmd_tx.send(TimerCommand::Shutdown); 192 + state.save_config(); 193 + Ok(()) 3 194 }
+9 -33
src/overlay/mod.rs
··· 2 2 3 3 use std::time::{Duration, Instant}; 4 4 5 - use slint::{ComponentHandle, SharedString}; 5 + use slint::ComponentHandle; 6 6 7 + use crate::generated::OverlayWindow; 7 8 use crate::timer::ScheduledBreak; 8 9 9 - slint::include_modules!(); 10 - 11 10 pub struct OverlayManager { 12 11 window: OverlayWindow, 13 12 break_duration: Duration, 14 13 break_started: Instant, 15 - snooze_used: bool, 16 - is_enforced: bool, 17 - is_dark: bool, 18 14 } 19 15 20 16 impl OverlayManager { ··· 25 21 window, 26 22 break_duration: Duration::from_secs(300), 27 23 break_started: Instant::now(), 28 - snooze_used: false, 29 - is_enforced: false, 30 - is_dark, 31 24 }) 32 25 } 33 26 34 27 pub fn show(&mut self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 35 28 self.break_duration = sched.break_duration; 36 29 self.break_started = Instant::now(); 37 - self.snooze_used = snooze_used; 38 - self.is_enforced = is_enforced; 39 30 40 - self.window.set_break_label(SharedString::from(sched.label.as_str())); 31 + self.window.set_break_label(sched.label.as_str().into()); 41 32 self.window.set_snooze_visible(!is_enforced && !snooze_used); 42 - self.window.set_countdown_text(format_duration(sched.break_duration).into()); 33 + self.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 43 34 self.window.set_progress(0.0); 44 - 45 - // Maximise the window to fill the screen. 46 35 self.window.window().set_fullscreen(true); 47 36 self.window.show().unwrap_or_default(); 48 37 } ··· 51 40 self.window.hide().unwrap_or_default(); 52 41 } 53 42 54 - /// Tick the countdown. Returns Some(snoozed) when the break ends. 55 - pub fn tick(&self) -> Option<bool> { 56 - let elapsed = self.break_started.elapsed(); 57 - if elapsed >= self.break_duration { 58 - return Some(false); // break finished naturally 59 - } 60 - let remaining = self.break_duration - elapsed; 61 - let progress = elapsed.as_secs_f32() / self.break_duration.as_secs_f32(); 62 - self.window.set_countdown_text(format_duration(remaining).into()); 63 - self.window.set_progress(progress); 64 - None 65 - } 66 - 67 43 pub fn window(&self) -> &OverlayWindow { 68 44 &self.window 69 45 } 70 46 } 71 47 72 - fn format_duration(d: Duration) -> String { 73 - let total = d.as_secs(); 74 - if total < 60 { 75 - format!("0:{:02}", total) 48 + pub fn fmt_dur(d: Duration) -> String { 49 + let s = d.as_secs(); 50 + if s < 60 { 51 + format!("0:{:02}", s) 76 52 } else { 77 - format!("{}:{:02}", total / 60, total % 60) 53 + format!("{}:{:02}", s / 60, s % 60) 78 54 } 79 55 }
+35 -50
src/settings/mod.rs
··· 1 1 use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; 2 - use std::rc::Rc; 3 - 4 - use crate::config::AppConfig; 5 2 6 - slint::include_modules!(); 3 + use crate::config::{AppConfig, BreakModeConfig}; 4 + use crate::generated::SettingsWindow; 7 5 8 6 pub struct SettingsManager { 9 7 window: SettingsWindow, ··· 12 10 impl SettingsManager { 13 11 pub fn new(cfg: &AppConfig) -> anyhow::Result<Self> { 14 12 let window = SettingsWindow::new()?; 15 - Self::populate(&window, cfg); 13 + populate(&window, cfg); 16 14 Ok(Self { window }) 17 15 } 18 16 19 - pub fn show(&self) { 20 - self.window.show().unwrap_or_default(); 21 - } 22 - 23 - pub fn hide(&self) { 24 - self.window.hide().unwrap_or_default(); 25 - } 26 - 27 17 pub fn window(&self) -> &SettingsWindow { 28 18 &self.window 29 19 } 30 20 31 - fn populate(window: &SettingsWindow, cfg: &AppConfig) { 32 - let active = cfg 33 - .profiles 34 - .get(&cfg.app.active_profile) 35 - .map(|p| p.name.as_str()) 36 - .unwrap_or("") 37 - .to_string(); 38 - window.set_active_profile(SharedString::from(active.as_str())); 39 - 40 - let names: Vec<SharedString> = cfg 41 - .profiles 42 - .values() 43 - .map(|p| SharedString::from(p.name.as_str())) 44 - .collect(); 45 - window.set_profile_names(ModelRc::new(VecModel::from(names))); 46 - 47 - use crate::config::BreakModeConfig; 48 - let enforced = cfg 49 - .profiles 50 - .get(&cfg.app.active_profile) 51 - .map_or(false, |p| p.mode == BreakModeConfig::Enforced); 52 - window.set_enforced_mode(enforced); 53 - 54 - window.set_sound_enabled(cfg.appearance.sound_enabled); 55 - window.set_sound_volume(cfg.appearance.sound_volume); 56 - window.set_autostart(cfg.app.autostart); 57 - 58 - let idle_prof = cfg.profiles.get(&cfg.app.active_profile); 59 - window.set_idle_detection_enabled(idle_prof.map_or(true, |p| p.idle_detection_enabled)); 60 - window.set_idle_threshold_mins( 61 - idle_prof.map_or(5, |p| (p.idle_threshold_secs / 60).max(1) as i32), 62 - ); 63 - } 64 - 65 - /// Read current widget state back into config (call before saving). 66 21 pub fn read_into(&self, cfg: &mut AppConfig) { 67 22 cfg.appearance.sound_enabled = self.window.get_sound_enabled(); 68 23 cfg.appearance.sound_volume = self.window.get_sound_volume(); ··· 71 26 if let Some(prof) = cfg.profiles.get_mut(&cfg.app.active_profile) { 72 27 prof.idle_detection_enabled = self.window.get_idle_detection_enabled(); 73 28 prof.idle_threshold_secs = (self.window.get_idle_threshold_mins() as u64) * 60; 74 - 75 - use crate::config::BreakModeConfig; 76 29 prof.mode = if self.window.get_enforced_mode() { 77 30 BreakModeConfig::Enforced 78 31 } else { ··· 81 34 } 82 35 } 83 36 } 37 + 38 + fn populate(window: &SettingsWindow, cfg: &AppConfig) { 39 + let active = cfg 40 + .profiles 41 + .get(&cfg.app.active_profile) 42 + .map(|p| p.name.as_str()) 43 + .unwrap_or("") 44 + .to_string(); 45 + window.set_active_profile(SharedString::from(active.as_str())); 46 + 47 + let names: Vec<SharedString> = cfg 48 + .profiles 49 + .values() 50 + .map(|p| SharedString::from(p.name.as_str())) 51 + .collect(); 52 + window.set_profile_names(ModelRc::new(VecModel::from(names))); 53 + 54 + let enforced = cfg 55 + .profiles 56 + .get(&cfg.app.active_profile) 57 + .map_or(false, |p| p.mode == BreakModeConfig::Enforced); 58 + window.set_enforced_mode(enforced); 59 + window.set_sound_enabled(cfg.appearance.sound_enabled); 60 + window.set_sound_volume(cfg.appearance.sound_volume); 61 + window.set_autostart(cfg.app.autostart); 62 + 63 + let idle_prof = cfg.profiles.get(&cfg.app.active_profile); 64 + window.set_idle_detection_enabled(idle_prof.map_or(true, |p| p.idle_detection_enabled)); 65 + window.set_idle_threshold_mins( 66 + idle_prof.map_or(5, |p| (p.idle_threshold_secs / 60).max(1) as i32), 67 + ); 68 + }
+5
ui/app.slint
··· 1 + import { OverlayWindow } from "overlay.slint"; 2 + import { SettingsWindow } from "settings.slint"; 3 + 4 + // Re-export both windows from a single entry point so include_modules! works once. 5 + export { OverlayWindow, SettingsWindow }