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 break overlay, settings

+453
+23
src/autostart.rs
··· 1 + use anyhow::Result; 2 + use auto_launch::AutoLaunch; 3 + 4 + fn launcher() -> Result<AutoLaunch> { 5 + let exe = std::env::current_exe()?; 6 + Ok(AutoLaunch::new("ioma", exe.to_str().unwrap_or("ioma"), &[] as &[&str])) 7 + } 8 + 9 + pub fn set_enabled(enabled: bool) -> Result<()> { 10 + let al = launcher()?; 11 + if enabled { 12 + if !al.is_enabled()? { 13 + al.enable()?; 14 + } 15 + } else if al.is_enabled()? { 16 + al.disable()?; 17 + } 18 + Ok(()) 19 + } 20 + 21 + pub fn is_enabled() -> bool { 22 + launcher().and_then(|al| al.is_enabled()).unwrap_or(false) 23 + }
+79
src/overlay/mod.rs
··· 1 + pub mod password; 2 + 3 + use std::time::{Duration, Instant}; 4 + 5 + use slint::{ComponentHandle, SharedString}; 6 + 7 + use crate::timer::ScheduledBreak; 8 + 9 + slint::include_modules!(); 10 + 11 + pub struct OverlayManager { 12 + window: OverlayWindow, 13 + break_duration: Duration, 14 + break_started: Instant, 15 + snooze_used: bool, 16 + is_enforced: bool, 17 + is_dark: bool, 18 + } 19 + 20 + impl OverlayManager { 21 + pub fn new(is_dark: bool) -> anyhow::Result<Self> { 22 + let window = OverlayWindow::new()?; 23 + window.set_is_dark(is_dark); 24 + Ok(Self { 25 + window, 26 + break_duration: Duration::from_secs(300), 27 + break_started: Instant::now(), 28 + snooze_used: false, 29 + is_enforced: false, 30 + is_dark, 31 + }) 32 + } 33 + 34 + pub fn show(&mut self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 35 + self.break_duration = sched.break_duration; 36 + self.break_started = Instant::now(); 37 + self.snooze_used = snooze_used; 38 + self.is_enforced = is_enforced; 39 + 40 + self.window.set_break_label(SharedString::from(sched.label.as_str())); 41 + self.window.set_snooze_visible(!is_enforced && !snooze_used); 42 + self.window.set_countdown_text(format_duration(sched.break_duration).into()); 43 + self.window.set_progress(0.0); 44 + 45 + // Maximise the window to fill the screen. 46 + self.window.window().set_fullscreen(true); 47 + self.window.show().unwrap_or_default(); 48 + } 49 + 50 + pub fn hide(&self) { 51 + self.window.hide().unwrap_or_default(); 52 + } 53 + 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 + pub fn window(&self) -> &OverlayWindow { 68 + &self.window 69 + } 70 + } 71 + 72 + fn format_duration(d: Duration) -> String { 73 + let total = d.as_secs(); 74 + if total < 60 { 75 + format!("0:{:02}", total) 76 + } else { 77 + format!("{}:{:02}", total / 60, total % 60) 78 + } 79 + }
+42
src/overlay/password.rs
··· 1 + use anyhow::Result; 2 + use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; 3 + use argon2::password_hash::{rand_core::OsRng, SaltString}; 4 + 5 + pub fn hash_password(password: &str) -> Result<String> { 6 + let salt = SaltString::generate(&mut OsRng); 7 + let argon2 = Argon2::default(); 8 + let hash = argon2 9 + .hash_password(password.as_bytes(), &salt) 10 + .map_err(|e| anyhow::anyhow!("hashing failed: {e}"))?; 11 + Ok(hash.to_string()) 12 + } 13 + 14 + pub fn verify_password(password: &str, hash: &str) -> bool { 15 + if hash.is_empty() { 16 + return false; 17 + } 18 + let parsed = match PasswordHash::new(hash) { 19 + Ok(h) => h, 20 + Err(_) => return false, 21 + }; 22 + Argon2::default() 23 + .verify_password(password.as_bytes(), &parsed) 24 + .is_ok() 25 + } 26 + 27 + #[cfg(test)] 28 + mod tests { 29 + use super::*; 30 + 31 + #[test] 32 + fn hash_verify_roundtrip() { 33 + let h = hash_password("correct horse battery staple").unwrap(); 34 + assert!(verify_password("correct horse battery staple", &h)); 35 + assert!(!verify_password("wrong", &h)); 36 + } 37 + 38 + #[test] 39 + fn empty_hash_rejects() { 40 + assert!(!verify_password("anything", "")); 41 + } 42 + }
+83
src/settings/mod.rs
··· 1 + use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; 2 + use std::rc::Rc; 3 + 4 + use crate::config::AppConfig; 5 + 6 + slint::include_modules!(); 7 + 8 + pub struct SettingsManager { 9 + window: SettingsWindow, 10 + } 11 + 12 + impl SettingsManager { 13 + pub fn new(cfg: &AppConfig) -> anyhow::Result<Self> { 14 + let window = SettingsWindow::new()?; 15 + Self::populate(&window, cfg); 16 + Ok(Self { window }) 17 + } 18 + 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 + pub fn window(&self) -> &SettingsWindow { 28 + &self.window 29 + } 30 + 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 + pub fn read_into(&self, cfg: &mut AppConfig) { 67 + cfg.appearance.sound_enabled = self.window.get_sound_enabled(); 68 + cfg.appearance.sound_volume = self.window.get_sound_volume(); 69 + cfg.app.autostart = self.window.get_autostart(); 70 + 71 + if let Some(prof) = cfg.profiles.get_mut(&cfg.app.active_profile) { 72 + prof.idle_detection_enabled = self.window.get_idle_detection_enabled(); 73 + prof.idle_threshold_secs = (self.window.get_idle_threshold_mins() as u64) * 60; 74 + 75 + use crate::config::BreakModeConfig; 76 + prof.mode = if self.window.get_enforced_mode() { 77 + BreakModeConfig::Enforced 78 + } else { 79 + BreakModeConfig::Reminder 80 + }; 81 + } 82 + } 83 + }
+72
ui/overlay.slint
··· 1 + import { Button, ProgressIndicator } from "std-widgets.slint"; 2 + 3 + export component OverlayWindow inherits Window { 4 + title: "ioma — break time"; 5 + no-frame: true; 6 + always-on-top: true; 7 + 8 + // Populated from Rust before showing. 9 + in property <string> break-label: "Break time"; 10 + in property <string> countdown-text: "5:00"; 11 + in property <float> progress: 0.0; // 0.0 = start, 1.0 = done 12 + in property <bool> snooze-visible: true; 13 + in property <bool> is-dark: true; 14 + 15 + callback snooze-clicked(); 16 + callback unlock-clicked(); 17 + 18 + background: is-dark ? #1a1a2e : #f5f5f0; 19 + 20 + VerticalLayout { 21 + alignment: center; 22 + spacing: 24px; 23 + padding: 48px; 24 + 25 + Text { 26 + text: break-label; 27 + font-size: 28px; 28 + font-weight: 400; 29 + color: is-dark ? #a0c4ff : #333333; 30 + horizontal-alignment: center; 31 + } 32 + 33 + Text { 34 + text: countdown-text; 35 + font-size: 96px; 36 + font-weight: 700; 37 + color: is-dark ? #ffffff : #1a1a2e; 38 + horizontal-alignment: center; 39 + } 40 + 41 + Rectangle { 42 + height: 8px; 43 + width: 320px; 44 + horizontal-stretch: 0; 45 + background: is-dark ? #333355 : #d0d0d0; 46 + border-radius: 4px; 47 + 48 + Rectangle { 49 + x: 0; 50 + width: parent.width * progress; 51 + height: parent.height; 52 + background: is-dark ? #a0c4ff : #4a90d9; 53 + border-radius: 4px; 54 + } 55 + } 56 + 57 + HorizontalLayout { 58 + alignment: center; 59 + spacing: 16px; 60 + 61 + if snooze-visible : Button { 62 + text: "Snooze once"; 63 + clicked => { root.snooze-clicked(); } 64 + } 65 + 66 + Button { 67 + text: "Unlock"; 68 + clicked => { root.unlock-clicked(); } 69 + } 70 + } 71 + } 72 + }
+154
ui/settings.slint
··· 1 + import { Button, ComboBox, CheckBox, Slider, TabWidget, LineEdit, SpinBox } from "std-widgets.slint"; 2 + 3 + export component SettingsWindow inherits Window { 4 + title: "ioma — Settings"; 5 + width: 560px; 6 + min-height: 420px; 7 + 8 + // Populated from Rust. 9 + in-out property <bool> enforced-mode: false; 10 + in-out property <bool> sound-enabled: true; 11 + in-out property <float> sound-volume: 0.7; 12 + in-out property <bool> autostart: false; 13 + in-out property <bool> idle-detection-enabled: true; 14 + in-out property <int> idle-threshold-mins: 5; 15 + in-out property <string> active-profile: "Pomodoro"; 16 + in-out property <[string]> profile-names: ["Pomodoro", "52/17", "20-20-20", "Focus+Micro"]; 17 + 18 + callback save-clicked(); 19 + callback cancel-clicked(); 20 + callback set-password-clicked(); 21 + callback open-config-dir(); 22 + callback profile-changed(string); 23 + 24 + VerticalLayout { 25 + padding: 16px; 26 + spacing: 12px; 27 + 28 + TabWidget { 29 + Tab { 30 + title: "Mode"; 31 + VerticalLayout { 32 + padding: 16px; 33 + spacing: 12px; 34 + 35 + CheckBox { 36 + text: "Enforced mode (full-screen, requires password to unlock early)"; 37 + checked <=> enforced-mode; 38 + } 39 + 40 + if enforced-mode : Button { 41 + text: "Set emergency unlock password…"; 42 + clicked => { root.set-password-clicked(); } 43 + } 44 + 45 + Text { 46 + text: "In reminder mode a single snooze is allowed per break interval."; 47 + wrap: word-wrap; 48 + color: #888888; 49 + font-size: 13px; 50 + } 51 + } 52 + } 53 + 54 + Tab { 55 + title: "Profile"; 56 + VerticalLayout { 57 + padding: 16px; 58 + spacing: 12px; 59 + 60 + Text { text: "Active profile:"; } 61 + ComboBox { 62 + model: active-profile; 63 + // Full profile editor will be added in Phase 4. 64 + // For now, selection only. 65 + } 66 + Text { 67 + text: "Full profile editor (add/remove levels, set long break) coming in next release."; 68 + color: #888888; 69 + font-size: 13px; 70 + wrap: word-wrap; 71 + } 72 + } 73 + } 74 + 75 + Tab { 76 + title: "Idle"; 77 + VerticalLayout { 78 + padding: 16px; 79 + spacing: 12px; 80 + 81 + CheckBox { 82 + text: "Pause timer when idle"; 83 + checked <=> idle-detection-enabled; 84 + } 85 + 86 + HorizontalLayout { 87 + spacing: 8px; 88 + Text { text: "Idle threshold (minutes):"; vertical-alignment: center; } 89 + SpinBox { 90 + value <=> idle-threshold-mins; 91 + minimum: 1; 92 + maximum: 60; 93 + } 94 + } 95 + } 96 + } 97 + 98 + Tab { 99 + title: "Sound"; 100 + VerticalLayout { 101 + padding: 16px; 102 + spacing: 12px; 103 + 104 + CheckBox { 105 + text: "Play sound on break start"; 106 + checked <=> sound-enabled; 107 + } 108 + 109 + HorizontalLayout { 110 + spacing: 8px; 111 + Text { text: "Volume:"; vertical-alignment: center; } 112 + Slider { 113 + value <=> sound-volume; 114 + minimum: 0.0; 115 + maximum: 1.0; 116 + } 117 + } 118 + } 119 + } 120 + 121 + Tab { 122 + title: "System"; 123 + VerticalLayout { 124 + padding: 16px; 125 + spacing: 12px; 126 + 127 + CheckBox { 128 + text: "Start ioma automatically at login"; 129 + checked <=> autostart; 130 + } 131 + 132 + Button { 133 + text: "Open config file location"; 134 + clicked => { root.open-config-dir(); } 135 + } 136 + } 137 + } 138 + } 139 + 140 + HorizontalLayout { 141 + alignment: end; 142 + spacing: 8px; 143 + 144 + Button { 145 + text: "Cancel"; 146 + clicked => { root.cancel-clicked(); } 147 + } 148 + Button { 149 + text: "Save"; 150 + clicked => { root.save-clicked(); } 151 + } 152 + } 153 + } 154 + }