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(mod): enable theme toggling

+252 -5
+1
Cargo.toml
··· 54 54 "Win32_UI_Input_KeyboardAndMouse", 55 55 "Win32_Foundation", 56 56 "Win32_System_SystemInformation", 57 + "Win32_System_Registry", 57 58 ] } 58 59 59 60 [build-dependencies]
+2 -2
src/config/types.rs
··· 106 106 impl Default for AppearanceConfig { 107 107 fn default() -> Self { 108 108 Self { 109 - overlay_theme: OverlayTheme::Dark, 109 + overlay_theme: OverlayTheme::System, 110 110 sound_enabled: true, 111 111 sound_volume: 0.7, 112 112 font_size: 48, ··· 116 116 117 117 impl Default for OverlayTheme { 118 118 fn default() -> Self { 119 - OverlayTheme::Dark 119 + OverlayTheme::System 120 120 } 121 121 } 122 122
+71 -2
src/main.rs
··· 6 6 mod overlay; 7 7 mod settings; 8 8 mod sound; 9 + mod system; 9 10 mod timer; 10 11 mod tray; 11 12 12 13 use std::rc::Rc; 14 + use std::sync::atomic::Ordering; 13 15 use std::time::{Duration, Instant}; 14 16 15 17 use slint::ComponentHandle; ··· 44 46 45 47 let _guard = rt.enter(); 46 48 run(cfg) 49 + } 50 + 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 + } 47 57 } 48 58 49 59 fn run(cfg: AppConfig) -> anyhow::Result<()> { ··· 59 69 } 60 70 61 71 let tray = Rc::new(AppTray::new()?); 62 - let is_dark = cfg.appearance.overlay_theme == config::OverlayTheme::Dark; 72 + let system_dark = system::start_watcher(); 63 73 let sound_enabled = cfg.appearance.sound_enabled; 64 74 let sound_volume = cfg.appearance.sound_volume; 65 75 let active_profile_name = active_profile(&cfg).name.clone(); ··· 78 88 let mut overlay: Option<OverlayManager> = None; 79 89 let mut break_active: Option<(Instant, Duration)> = None; 80 90 let mut snooze_used = false; 91 + let mut dark_check_tick: u32 = 0; 92 + let mut last_dark: Option<bool> = None; 81 93 82 94 poll_timer.start( 83 95 slint::TimerMode::Repeated, ··· 102 114 let cfg_snap = cfg_arc.lock().unwrap().clone(); 103 115 match SettingsManager::new(&cfg_snap, cfg_arc.clone()) { 104 116 Ok(mgr) => { 117 + // Set initial dark mode 118 + let init_dark = resolve_is_dark( 119 + &cfg_snap.appearance.overlay_theme, 120 + system_dark.load(Ordering::Relaxed), 121 + ); 122 + mgr.window().set_is_dark(init_dark); 123 + 105 124 // Cancel 106 125 { 107 126 let win = mgr.window().clone_strong(); ··· 115 134 let lm = mgr.levels_model(); 116 135 let cfg_arc2 = cfg_arc.clone(); 117 136 let cmd_tx_save = cmd_tx_settings.clone(); 137 + let system_dark2 = system_dark.clone(); 118 138 mgr.window().on_save_clicked(move || { 119 139 let mut cfg = cfg_arc2.lock().unwrap().clone(); 120 140 read_settings_into(&win, &lm, &mut cfg); ··· 122 142 if let Err(e) = autostart::set_enabled(cfg.app.autostart) { 123 143 log::warn!("Autostart toggle failed: {e}"); 124 144 } 145 + // Apply new theme immediately so window shows correctly on next open 146 + let new_dark = resolve_is_dark( 147 + &cfg.appearance.overlay_theme, 148 + system_dark2.load(Ordering::Relaxed), 149 + ); 150 + win.set_is_dark(new_dark); 125 151 win.hide().unwrap_or_default(); 126 152 config::save(&cfg) 127 153 .unwrap_or_else(|e| log::warn!("save failed: {e}")); ··· 129 155 let _ = cmd_tx_save.send(TimerCommand::SetProfile(profile)); 130 156 }); 131 157 } 158 + // Theme mode changed — instant preview 159 + { 160 + let win = mgr.window().clone_strong(); 161 + let system_dark3 = system_dark.clone(); 162 + mgr.window().on_theme_mode_changed(move |mode| { 163 + let theme = match mode { 164 + 1 => config::OverlayTheme::Light, 165 + 2 => config::OverlayTheme::Dark, 166 + _ => config::OverlayTheme::System, 167 + }; 168 + win.set_is_dark(resolve_is_dark( 169 + &theme, 170 + system_dark3.load(Ordering::Relaxed), 171 + )); 172 + }); 173 + } 132 174 // Open config dir 133 175 mgr.window().on_open_config_dir(|| { 134 176 if let Some(dir) = config::config_path().parent() { ··· 171 213 Err(e) => log::warn!("Failed to create settings window: {e}"), 172 214 } 173 215 } else if let Some(ref mgr) = settings_mgr { 216 + // Refresh theme in case system preference changed while hidden 217 + let dark = resolve_is_dark( 218 + &cfg_arc.lock().unwrap().appearance.overlay_theme, 219 + system_dark.load(Ordering::Relaxed), 220 + ); 221 + mgr.window().set_is_dark(dark); 174 222 mgr.window().show().unwrap_or_default(); 175 223 } 176 224 } ··· 189 237 tray.set_tooltip(&format!("ioma — {}", sched.label)); 190 238 191 239 if overlay.is_none() { 192 - match OverlayManager::new(is_dark, cmd_tx.clone(), cfg_arc.clone()) { 240 + let dark = resolve_is_dark( 241 + &cfg_arc.lock().unwrap().appearance.overlay_theme, 242 + system_dark.load(Ordering::Relaxed), 243 + ); 244 + match OverlayManager::new(dark, cmd_tx.clone(), cfg_arc.clone()) { 193 245 Ok(mgr) => overlay = Some(mgr), 194 246 Err(e) => log::warn!("Failed to create overlay: {e}"), 195 247 } ··· 254 306 mgr.hide(); 255 307 } 256 308 let _ = cmd_tx.send(TimerCommand::BreakEnded { snoozed: false }); 309 + } 310 + 311 + // --- Dark mode polling (every ~5s) --- 312 + dark_check_tick += 1; 313 + if dark_check_tick >= 50 || last_dark.is_none() { 314 + dark_check_tick = 0; 315 + let theme = cfg_arc.lock().unwrap().appearance.overlay_theme.clone(); 316 + let dark = resolve_is_dark(&theme, system_dark.load(Ordering::Relaxed)); 317 + if last_dark != Some(dark) { 318 + last_dark = Some(dark); 319 + if let Some(ref mgr) = settings_mgr { 320 + mgr.window().set_is_dark(dark); 321 + } 322 + if let Some(ref mut mgr) = overlay { 323 + mgr.set_is_dark(dark); 324 + } 325 + } 257 326 } 258 327 }, 259 328 );
+6
src/overlay/mod.rs
··· 21 21 fn hide(&self); 22 22 fn update_countdown(&self, elapsed: Duration, total: Duration); 23 23 fn reset_unlock_state(&self); 24 + fn set_is_dark(&self, dark: bool); 24 25 } 25 26 26 27 // ── OverlayManager ─────────────────────────────────────────────────────────── ··· 84 85 85 86 pub fn reset_unlock_state(&self) { 86 87 self.backend.reset_unlock_state(); 88 + } 89 + 90 + pub fn set_is_dark(&mut self, dark: bool) { 91 + self.is_dark = dark; 92 + self.backend.set_is_dark(dark); 87 93 } 88 94 } 89 95
+6
src/overlay/multi_slint.rs
··· 165 165 e.window.set_unlock_error("".into()); 166 166 } 167 167 } 168 + 169 + fn set_is_dark(&self, dark: bool) { 170 + for e in &self.entries { 171 + e.window.set_is_dark(dark); 172 + } 173 + } 168 174 } 169 175 170 176 /// Wire all interaction callbacks onto a single window. Called once per window
+11 -1
src/settings/mod.rs
··· 4 4 use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; 5 5 6 6 use crate::autostart; 7 - use crate::config::{AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig}; 7 + use crate::config::{AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig, OverlayTheme}; 8 8 use crate::generated::{LevelEntry, SettingsWindow}; 9 9 10 10 pub struct SettingsManager { ··· 121 121 cfg.appearance.sound_enabled = window.get_sound_enabled(); 122 122 cfg.appearance.sound_volume = window.get_sound_volume(); 123 123 cfg.app.autostart = window.get_autostart(); 124 + cfg.appearance.overlay_theme = match window.get_theme_mode() { 125 + 1 => OverlayTheme::Light, 126 + 2 => OverlayTheme::Dark, 127 + _ => OverlayTheme::System, 128 + }; 124 129 } 125 130 126 131 fn populate(window: &SettingsWindow, levels_model: &VecModel<LevelEntry>, cfg: &AppConfig) { ··· 143 148 window.set_sound_enabled(cfg.appearance.sound_enabled); 144 149 window.set_sound_volume(cfg.appearance.sound_volume); 145 150 window.set_autostart(autostart::is_enabled()); 151 + window.set_theme_mode(match cfg.appearance.overlay_theme { 152 + OverlayTheme::System => 0, 153 + OverlayTheme::Light => 1, 154 + OverlayTheme::Dark => 2, 155 + }); 146 156 147 157 populate_for_profile(window, levels_model, cfg, &active_name); 148 158 }
+83
src/system.rs
··· 1 + use std::sync::Arc; 2 + use std::sync::atomic::{AtomicBool, Ordering}; 3 + 4 + /// Starts a background thread that polls the OS dark-mode preference every 5s. 5 + /// Returns an Arc<AtomicBool> that the main thread can read at any time. 6 + pub fn start_watcher() -> Arc<AtomicBool> { 7 + let dark = Arc::new(AtomicBool::new(query())); 8 + let bg = Arc::clone(&dark); 9 + std::thread::Builder::new() 10 + .name("system-theme".into()) 11 + .spawn(move || loop { 12 + std::thread::sleep(std::time::Duration::from_secs(5)); 13 + bg.store(query(), Ordering::Relaxed); 14 + }) 15 + .ok(); 16 + dark 17 + } 18 + 19 + fn query() -> bool { 20 + platform::is_dark() 21 + } 22 + 23 + #[cfg(target_os = "linux")] 24 + mod platform { 25 + pub fn is_dark() -> bool { 26 + use std::process::Command; 27 + // Try GNOME/freedesktop color-scheme gsettings key first 28 + if let Ok(out) = Command::new("gsettings") 29 + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) 30 + .output() 31 + { 32 + let s = String::from_utf8_lossy(&out.stdout); 33 + if s.contains("prefer-dark") { 34 + return true; 35 + } 36 + if s.contains("prefer-light") || s.contains("'default'") { 37 + return false; 38 + } 39 + } 40 + // Fallback: GTK_THEME env var (used by some DEs/compositors) 41 + if let Ok(t) = std::env::var("GTK_THEME") { 42 + let t = t.to_lowercase(); 43 + if t.ends_with(":dark") || t.contains("-dark") { 44 + return true; 45 + } 46 + } 47 + false 48 + } 49 + } 50 + 51 + #[cfg(target_os = "windows")] 52 + mod platform { 53 + pub fn is_dark() -> bool { 54 + use windows::{ 55 + Win32::Foundation::HKEY_CURRENT_USER, 56 + Win32::System::Registry::{RegGetValueW, RRF_RT_REG_DWORD}, 57 + }; 58 + let mut val: u32 = 1; 59 + let mut sz = std::mem::size_of::<u32>() as u32; 60 + unsafe { 61 + RegGetValueW( 62 + HKEY_CURRENT_USER, 63 + windows::core::w!( 64 + "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize" 65 + ), 66 + windows::core::w!("AppsUseLightTheme"), 67 + RRF_RT_REG_DWORD, 68 + None, 69 + Some((&mut val as *mut u32).cast()), 70 + Some(&mut sz), 71 + ) 72 + .is_ok() 73 + && val == 0 // AppsUseLightTheme=0 means dark mode 74 + } 75 + } 76 + } 77 + 78 + #[cfg(not(any(target_os = "linux", target_os = "windows")))] 79 + mod platform { 80 + pub fn is_dark() -> bool { 81 + false 82 + } 83 + }
+72
ui/settings.slint
··· 534 534 in-out property <int> long-break-gap-mins: 30; 535 535 in-out property <string> long-break-label: "Long rest"; 536 536 537 + // 0 = system, 1 = light, 2 = dark 538 + in-out property <int> theme-mode: 0; 539 + 537 540 // ── Callbacks (same contract as before) ────────────────────────────────── 538 541 callback save-clicked(); 539 542 callback cancel-clicked(); 543 + callback theme-mode-changed(int); 540 544 callback set-password-clicked(); 541 545 callback open-config-dir(); 542 546 callback profile-changed(string); ··· 979 983 preferred-width: 180px; 980 984 clicked => { 981 985 root.open-config-dir(); 986 + } 987 + } 988 + } 989 + 990 + PaperDivider { } 991 + 992 + // Appearance 993 + HorizontalLayout { 994 + padding-top: 12px; 995 + padding-bottom: 10px; 996 + spacing: 24px; 997 + 998 + VerticalLayout { 999 + alignment: center; 1000 + spacing: 3px; 1001 + Text { 1002 + text: "Appearance"; 1003 + font-size: 13px; 1004 + font-weight: 500; 1005 + color: Theme.ink; 1006 + } 1007 + Text { 1008 + text: "Follows system by default."; 1009 + font-size: 11.5px; 1010 + color: Theme.ink-mid; 1011 + } 1012 + } 1013 + 1014 + Rectangle { 1015 + horizontal-stretch: 1; 1016 + } 1017 + 1018 + HorizontalLayout { 1019 + spacing: 6px; 1020 + alignment: center; 1021 + 1022 + for label[i] in ["System", "Light", "Dark"]: theme-chip-ta := TouchArea { 1023 + height: 32px; 1024 + min-width: 48px; 1025 + clicked => { 1026 + root.theme-mode = i; 1027 + root.theme-mode-changed(i); 1028 + } 1029 + 1030 + Rectangle { 1031 + border-radius: 6px; 1032 + background: i == root.theme-mode 1033 + ? Theme.btn-bg 1034 + : (theme-chip-ta.has-hover ? Theme.surface-hov : Theme.surface); 1035 + border-width: 1px; 1036 + border-color: i == root.theme-mode ? transparent : Theme.line; 1037 + animate background { duration: 120ms; } 1038 + 1039 + HorizontalLayout { 1040 + padding-left: 12px; 1041 + padding-right: 12px; 1042 + alignment: center; 1043 + 1044 + Text { 1045 + text: label; 1046 + font-size: 12px; 1047 + font-weight: i == root.theme-mode ? 600 : 400; 1048 + color: i == root.theme-mode ? Theme.btn-fg : Theme.ink; 1049 + vertical-alignment: center; 1050 + animate color { duration: 120ms; } 1051 + } 1052 + } 1053 + } 982 1054 } 983 1055 } 984 1056 }