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: switch enforced mode to per-interval with UI toggles

+ redesign 'during breaks' section

+230 -192
+29 -12
src/config/types.rs
··· 66 66 #[derive(Debug, Clone, Serialize, Deserialize)] 67 67 pub struct ProfileConfig { 68 68 pub name: String, 69 - pub mode: BreakModeConfig, 70 69 pub snooze_secs: u64, 71 70 pub idle_threshold_secs: u64, 72 71 pub idle_detection_enabled: bool, ··· 74 73 pub long_break: Option<LongBreakConfig>, 75 74 } 76 75 77 - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 78 - #[serde(rename_all = "lowercase")] 79 - pub enum BreakModeConfig { 80 - Reminder, 81 - Enforced, 82 - } 83 - 84 76 #[derive(Debug, Clone, Serialize, Deserialize)] 77 + #[serde(default)] 85 78 pub struct BreakLevelConfig { 86 79 pub work_secs: u64, 87 80 pub break_secs: u64, 88 81 pub label: String, 82 + pub enforced: bool, 83 + } 84 + 85 + impl Default for BreakLevelConfig { 86 + fn default() -> Self { 87 + Self { work_secs: 1200, break_secs: 300, label: String::new(), enforced: false } 88 + } 89 89 } 90 90 91 91 #[derive(Debug, Clone, Serialize, Deserialize)] 92 + #[serde(default)] 92 93 pub struct LongBreakConfig { 93 94 pub after_cycles: u32, 94 95 pub max_cycle_gap_secs: u64, 95 96 pub break_secs: u64, 96 97 pub label: String, 98 + pub enforced: bool, 99 + } 100 + 101 + impl Default for LongBreakConfig { 102 + fn default() -> Self { 103 + Self { 104 + after_cycles: 4, 105 + max_cycle_gap_secs: 1800, 106 + break_secs: 900, 107 + label: "Long break".to_string(), 108 + enforced: false, 109 + } 110 + } 97 111 } 98 112 99 113 // --- Defaults --- ··· 188 202 pub fn pomodoro() -> Self { 189 203 Self { 190 204 name: "Pomodoro".to_string(), 191 - mode: BreakModeConfig::Reminder, 192 205 snooze_secs: 5 * 60, 193 206 idle_threshold_secs: 5 * 60, 194 207 idle_detection_enabled: true, ··· 196 209 work_secs: 25 * 60, 197 210 break_secs: 5 * 60, 198 211 label: "Short break".to_string(), 212 + enforced: false, 199 213 }], 200 214 long_break: Some(LongBreakConfig { 201 215 after_cycles: 4, 202 216 max_cycle_gap_secs: 30 * 60, 203 217 break_secs: 15 * 60, 204 218 label: "Long break".to_string(), 219 + enforced: false, 205 220 }), 206 221 } 207 222 } ··· 209 224 pub fn fifty_two_seventeen() -> Self { 210 225 Self { 211 226 name: "52/17".to_string(), 212 - mode: BreakModeConfig::Reminder, 213 227 snooze_secs: 5 * 60, 214 228 idle_threshold_secs: 5 * 60, 215 229 idle_detection_enabled: true, ··· 217 231 work_secs: 52 * 60, 218 232 break_secs: 17 * 60, 219 233 label: "Recharge break".to_string(), 234 + enforced: false, 220 235 }], 221 236 long_break: None, 222 237 } ··· 225 240 pub fn twenty_twenty_twenty() -> Self { 226 241 Self { 227 242 name: "20-20-20".to_string(), 228 - mode: BreakModeConfig::Reminder, 229 243 snooze_secs: 2 * 60, 230 244 idle_threshold_secs: 3 * 60, 231 245 idle_detection_enabled: true, ··· 233 247 work_secs: 20 * 60, 234 248 break_secs: 20, 235 249 label: "Eye break — look 20 ft away".to_string(), 250 + enforced: false, 236 251 }], 237 252 long_break: None, 238 253 } ··· 241 256 pub fn custom_hierarchical() -> Self { 242 257 Self { 243 258 name: "Focus+Micro".to_string(), 244 - mode: BreakModeConfig::Reminder, 245 259 snooze_secs: 5 * 60, 246 260 idle_threshold_secs: 5 * 60, 247 261 idle_detection_enabled: true, ··· 250 264 work_secs: 10 * 60, 251 265 break_secs: 15, 252 266 label: "Micro-break".to_string(), 267 + enforced: false, 253 268 }, 254 269 BreakLevelConfig { 255 270 work_secs: 60 * 60, 256 271 break_secs: 10 * 60, 257 272 label: "Stretch break".to_string(), 273 + enforced: false, 258 274 }, 259 275 ], 260 276 long_break: Some(LongBreakConfig { ··· 262 278 max_cycle_gap_secs: 30 * 60, 263 279 break_secs: 30 * 60, 264 280 label: "Long rest".to_string(), 281 + enforced: false, 265 282 }), 266 283 } 267 284 }
+2 -4
src/main.rs
··· 27 27 use config::AppConfig; 28 28 use overlay::OverlayManager; 29 29 use settings::SettingsManager; 30 - use timer::{active_profile, BreakMode, TimerCommand, TimerEvent}; 30 + use timer::{active_profile, TimerCommand, TimerEvent}; 31 31 use tray::AppTray; 32 32 use tray_format::{build_working_tracker_lines, fmt_countdown, fmt_tray_duration, fmt_tray_minutes}; 33 33 ··· 190 190 } 191 191 } 192 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); 193 + mgr.show_break(&sched, sched.enforced, self.snooze_used); 196 194 } 197 195 } 198 196 TimerEvent::Resumed {
+15 -66
src/settings/mod.rs
··· 8 8 use crate::timer::active_profile; 9 9 use crate::autostart; 10 10 use crate::config::{ 11 - self, AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig, OverlayTheme, 11 + self, AppConfig, BreakLevelConfig, LongBreakConfig, OverlayTheme, 12 12 }; 13 13 use crate::generated::{LevelEntry, PasswordSetupWindow, SettingsWindow}; 14 14 use crate::overlay::password; ··· 50 50 break_mins: 1, 51 51 break_extra_secs: 0, 52 52 label: "Break".into(), 53 + enforced: false, 53 54 }); 54 55 }); 55 56 } ··· 175 176 }); 176 177 } 177 178 178 - // Enforced mode toggle — require password verification to turn off 179 - // when a password has been set. 180 - { 181 - let win_em = self.window.clone_strong(); 182 - let cfg_arc_em = cfg_arc.clone(); 183 - self.window.on_enforced_mode_toggled(move |_checked| { 184 - // The toggle was turned off with a password set. 185 - // Open the password window for verification. 186 - let hash = cfg_arc_em.lock().unwrap().enforced.password_hash.clone(); 187 - match PasswordSetupWindow::new() { 188 - Ok(pw) => { 189 - pw.set_verify_mode(true); 190 - let pw_cancel = pw.clone_strong(); 191 - pw.on_cancel_clicked(move || { 192 - pw_cancel.hide().unwrap_or_default(); 193 - }); 194 - let pw_submit = pw.clone_strong(); 195 - let hash_clone = hash.clone(); 196 - let win_disable = win_em.clone_strong(); 197 - pw.on_submit_clicked(move |password| { 198 - if password::verify_password(password.as_str(), &hash_clone) { 199 - win_disable.set_enforced_mode(false); 200 - pw_submit.hide().unwrap_or_default(); 201 - } else { 202 - pw_submit.set_error_text("Incorrect password.".into()); 203 - } 204 - }); 205 - pw.show().unwrap_or_default(); 206 - } 207 - Err(e) => log::warn!("Failed to create password window: {e}"), 208 - } 209 - }); 210 - } 211 - 212 179 self.window.show().unwrap_or_default(); 213 180 // Force the window to its intended size after the first show(). On 214 181 // Linux/X11 the WM may not honour with_inner_size during initial window ··· 221 188 222 189 pub(crate) struct SettingsFormData { 223 190 pub active_profile: String, 224 - pub enforced_mode: bool, 225 191 pub idle_detection_enabled: bool, 226 192 pub idle_threshold_mins: i32, 227 - pub levels: Vec<(i32, i32, i32, String)>, 193 + pub levels: Vec<(i32, i32, i32, String, bool)>, 228 194 pub long_break_enabled: bool, 229 195 pub long_break_after_cycles: i32, 230 196 pub long_break_duration_mins: i32, 231 197 pub long_break_gap_mins: i32, 232 198 pub long_break_label: String, 199 + pub long_break_enforced: bool, 233 200 pub sound_enabled: bool, 234 201 pub sound_volume: f32, 235 202 pub autostart: bool, ··· 240 207 fn collect_form_data(window: &SettingsWindow, levels_model: &VecModel<LevelEntry>) -> SettingsFormData { 241 208 SettingsFormData { 242 209 active_profile: window.get_active_profile().to_string(), 243 - enforced_mode: window.get_enforced_mode(), 244 210 idle_detection_enabled: window.get_idle_detection_enabled(), 245 211 idle_threshold_mins: window.get_idle_threshold_mins(), 246 212 levels: (0..levels_model.row_count()) 247 213 .filter_map(|i| levels_model.row_data(i)) 248 - .map(|e| (e.work_mins, e.break_mins, e.break_extra_secs, e.label.to_string())) 214 + .map(|e| (e.work_mins, e.break_mins, e.break_extra_secs, e.label.to_string(), e.enforced)) 249 215 .collect(), 250 216 long_break_enabled: window.get_long_break_enabled(), 251 217 long_break_after_cycles: window.get_long_break_after_cycles(), 252 218 long_break_duration_mins: window.get_long_break_duration_mins(), 253 219 long_break_gap_mins: window.get_long_break_gap_mins(), 254 220 long_break_label: window.get_long_break_label().to_string(), 221 + long_break_enforced: window.get_long_break_enforced(), 255 222 sound_enabled: window.get_sound_enabled(), 256 223 sound_volume: window.get_sound_volume(), 257 224 autostart: window.get_autostart(), ··· 274 241 prof.levels = data 275 242 .levels 276 243 .iter() 277 - .map(|(work_mins, break_mins, extra_secs, label)| BreakLevelConfig { 244 + .map(|(work_mins, break_mins, extra_secs, label, enforced)| BreakLevelConfig { 278 245 work_secs: *work_mins as u64 * 60, 279 246 break_secs: *break_mins as u64 * 60 + *extra_secs as u64, 280 247 label: label.clone(), 248 + enforced: *enforced, 281 249 }) 282 250 .collect(); 283 251 ··· 287 255 max_cycle_gap_secs: data.long_break_gap_mins as u64 * 60, 288 256 break_secs: data.long_break_duration_mins as u64 * 60, 289 257 label: data.long_break_label.clone(), 258 + enforced: data.long_break_enforced, 290 259 }) 291 260 } else { 292 261 None ··· 294 263 295 264 prof.idle_detection_enabled = data.idle_detection_enabled; 296 265 prof.idle_threshold_secs = data.idle_threshold_mins as u64 * 60; 297 - prof.mode = if data.enforced_mode { 298 - BreakModeConfig::Enforced 299 - } else { 300 - BreakModeConfig::Reminder 301 - }; 302 266 } 303 267 } 304 268 ··· 328 292 let level = &prof.levels[0]; 329 293 SettingsFormData { 330 294 active_profile: prof.name.clone(), 331 - enforced_mode: false, 332 295 idle_detection_enabled: true, 333 296 idle_threshold_mins: 5, 334 297 levels: vec![( ··· 336 299 (level.break_secs / 60) as i32, 337 300 (level.break_secs % 60) as i32, 338 301 level.label.clone(), 302 + false, 339 303 )], 340 304 long_break_enabled: true, 341 305 long_break_after_cycles: 4, 342 306 long_break_duration_mins: 15, 343 307 long_break_gap_mins: 30, 344 308 long_break_label: "Long break".to_string(), 309 + long_break_enforced: false, 345 310 sound_enabled: true, 346 311 sound_volume: 0.5, 347 312 autostart: false, ··· 359 324 } 360 325 361 326 #[test] 362 - fn apply_enforced_mode() { 363 - let mut cfg = AppConfig::default(); 364 - let mut data = pomodoro_form(&cfg); 365 - data.enforced_mode = true; 366 - apply_form_data(&data, &mut cfg); 367 - assert_eq!(cfg.profiles["pomodoro"].mode, BreakModeConfig::Enforced); 368 - } 369 - 370 - #[test] 371 - fn apply_reminder_mode() { 372 - let mut cfg = AppConfig::default(); 373 - let mut data = pomodoro_form(&cfg); 374 - data.enforced_mode = false; 375 - apply_form_data(&data, &mut cfg); 376 - assert_eq!(cfg.profiles["pomodoro"].mode, BreakModeConfig::Reminder); 377 - } 378 - 379 - #[test] 380 327 fn apply_long_break_enabled() { 381 328 let mut cfg = AppConfig::default(); 382 329 let mut data = pomodoro_form(&cfg); ··· 406 353 fn apply_levels_conversion() { 407 354 let mut cfg = AppConfig::default(); 408 355 let mut data = pomodoro_form(&cfg); 409 - data.levels = vec![(30, 5, 30, "Focus break".to_string())]; 356 + data.levels = vec![(30, 5, 30, "Focus break".to_string(), true)]; 410 357 apply_form_data(&data, &mut cfg); 411 358 let levels = &cfg.profiles["pomodoro"].levels; 412 359 assert_eq!(levels.len(), 1); 413 360 assert_eq!(levels[0].work_secs, 30 * 60); 414 361 assert_eq!(levels[0].break_secs, 5 * 60 + 30); 415 362 assert_eq!(levels[0].label, "Focus break"); 363 + assert!(levels[0].enforced); 416 364 } 417 365 418 366 #[test] ··· 505 453 ) { 506 454 let prof = cfg.profiles.values().find(|p| p.name == profile_name); 507 455 508 - window.set_enforced_mode(prof.is_some_and(|p| p.mode == BreakModeConfig::Enforced)); 509 456 window.set_idle_detection_enabled(prof.is_none_or(|p| p.idle_detection_enabled)); 510 457 window.set_idle_threshold_mins(prof.map_or(5, |p| (p.idle_threshold_secs / 60).max(1) as i32)); 511 458 ··· 518 465 break_mins: (l.break_secs / 60) as i32, 519 466 break_extra_secs: (l.break_secs % 60) as i32, 520 467 label: l.label.as_str().into(), 468 + enforced: l.enforced, 521 469 }) 522 470 .collect() 523 471 }) ··· 536 484 window.set_long_break_duration_mins(lb.map_or(15, |l| (l.break_secs / 60) as i32)); 537 485 window.set_long_break_gap_mins(lb.map_or(30, |l| (l.max_cycle_gap_secs / 60) as i32)); 538 486 window.set_long_break_label(lb.map_or("Long break", |l| l.label.as_str()).into()); 487 + window.set_long_break_enforced(lb.is_some_and(|l| l.enforced)); 539 488 }
+2 -1
src/timer/mod.rs
··· 2 2 pub mod scheduler; 3 3 pub mod types; 4 4 5 - pub use profile::{active_profile, BreakMode, Profile}; 5 + pub use profile::{active_profile, Profile}; 6 6 pub use scheduler::{LevelBreakStatus, LongBreakStatus, ScheduledBreak, Scheduler}; 7 7 pub use types::{TimerCommand, TimerEvent}; 8 8 ··· 212 212 break_duration: self.scheduler.profile().primary_level().break_duration, 213 213 label: self.scheduler.profile().primary_level().label.clone(), 214 214 is_long_break: false, 215 + enforced: self.scheduler.profile().primary_level().enforced, 215 216 level_index: self.scheduler.profile().levels.len().saturating_sub(1), 216 217 }) 217 218 }
+18 -24
src/timer/profile.rs
··· 1 1 use std::time::Duration; 2 2 3 - use crate::config::{AppConfig, BreakLevelConfig, BreakModeConfig, LongBreakConfig, ProfileConfig}; 3 + use crate::config::{AppConfig, BreakLevelConfig, LongBreakConfig, ProfileConfig}; 4 4 5 5 pub fn active_profile(cfg: &AppConfig) -> Profile { 6 6 let prof_cfg = cfg ··· 11 11 Profile::from_config(prof_cfg) 12 12 } 13 13 14 - #[derive(Debug, Clone, PartialEq)] 15 - pub enum BreakMode { 16 - Reminder, 17 - Enforced, 18 - } 19 - 20 14 #[derive(Debug, Clone)] 21 15 pub struct BreakLevel { 22 16 pub work_duration: Duration, 23 17 pub break_duration: Duration, 24 18 pub label: String, 19 + pub enforced: bool, 25 20 } 26 21 27 22 #[derive(Debug, Clone)] ··· 32 27 pub max_cycle_gap: Duration, 33 28 pub break_duration: Duration, 34 29 pub label: String, 30 + pub enforced: bool, 35 31 } 36 32 37 33 #[derive(Debug, Clone)] 38 34 pub struct Profile { 39 35 pub name: String, 40 - pub mode: BreakMode, 41 36 pub snooze_duration: Duration, 42 37 pub idle_threshold: Duration, 43 38 pub idle_detection_enabled: bool, ··· 56 51 work_duration: Duration::from_secs(l.work_secs), 57 52 break_duration: Duration::from_secs(l.break_secs), 58 53 label: l.label.clone(), 54 + enforced: l.enforced, 59 55 }) 60 56 .collect(); 61 57 // Ensure ascending order so scheduler can use index priority correctly. ··· 69 65 max_cycle_gap: Duration::from_secs(lb.max_cycle_gap_secs), 70 66 break_duration: Duration::from_secs(lb.break_secs), 71 67 label: lb.label.clone(), 68 + enforced: lb.enforced, 72 69 }); 73 70 74 71 Self { 75 72 name: cfg.name.clone(), 76 - mode: match cfg.mode { 77 - BreakModeConfig::Reminder => BreakMode::Reminder, 78 - BreakModeConfig::Enforced => BreakMode::Enforced, 79 - }, 80 73 snooze_duration: Duration::from_secs(cfg.snooze_secs), 81 74 idle_threshold: Duration::from_secs(cfg.idle_threshold_secs), 82 75 idle_detection_enabled: cfg.idle_detection_enabled, ··· 99 92 use crate::config::{AppearanceConfig, AppSettings, EnforcedConfig}; 100 93 use std::collections::HashMap; 101 94 102 - fn profile_cfg(name: &str, mode: BreakModeConfig) -> ProfileConfig { 95 + fn profile_cfg(name: &str) -> ProfileConfig { 103 96 ProfileConfig { 104 97 name: name.to_string(), 105 - mode, 106 98 snooze_secs: 300, 107 99 idle_threshold_secs: 300, 108 100 idle_detection_enabled: false, ··· 110 102 work_secs: 1500, 111 103 break_secs: 300, 112 104 label: "Short break".to_string(), 105 + enforced: false, 113 106 }], 114 107 long_break: None, 115 108 } ··· 118 111 fn make_config(active: &str, keys: &[&str]) -> AppConfig { 119 112 let mut profiles = HashMap::new(); 120 113 for &k in keys { 121 - profiles.insert(k.to_string(), profile_cfg(k, BreakModeConfig::Reminder)); 114 + profiles.insert(k.to_string(), profile_cfg(k)); 122 115 } 123 116 AppConfig { 124 117 app: AppSettings { active_profile: active.to_string(), autostart: false }, ··· 130 123 131 124 #[test] 132 125 fn from_config_single_level() { 133 - let cfg = profile_cfg("Test", BreakModeConfig::Reminder); 126 + let cfg = profile_cfg("Test"); 134 127 let p = Profile::from_config(&cfg); 135 128 assert_eq!(p.levels.len(), 1); 136 129 assert_eq!(p.levels[0].work_duration, Duration::from_secs(1500)); 137 130 assert_eq!(p.levels[0].break_duration, Duration::from_secs(300)); 138 - assert_eq!(p.mode, BreakMode::Reminder); 131 + assert!(!p.levels[0].enforced); 139 132 assert_eq!(p.snooze_duration, Duration::from_secs(300)); 140 133 assert!(p.long_break.is_none()); 141 134 } 142 135 143 136 #[test] 144 - fn from_config_enforced_mode() { 145 - let cfg = profile_cfg("Test", BreakModeConfig::Enforced); 137 + fn from_config_enforced_level() { 138 + let mut cfg = profile_cfg("Test"); 139 + cfg.levels[0].enforced = true; 146 140 let p = Profile::from_config(&cfg); 147 - assert_eq!(p.mode, BreakMode::Enforced); 141 + assert!(p.levels[0].enforced); 148 142 } 149 143 150 144 #[test] 151 145 fn from_config_levels_sorted_ascending() { 152 146 let cfg = ProfileConfig { 153 147 name: "Test".to_string(), 154 - mode: BreakModeConfig::Reminder, 155 148 snooze_secs: 300, 156 149 idle_threshold_secs: 300, 157 150 idle_detection_enabled: false, 158 151 levels: vec![ 159 - BreakLevelConfig { work_secs: 3600, break_secs: 600, label: "Long".to_string() }, 160 - BreakLevelConfig { work_secs: 600, break_secs: 60, label: "Short".to_string() }, 152 + BreakLevelConfig { work_secs: 3600, break_secs: 600, label: "Long".to_string(), enforced: false }, 153 + BreakLevelConfig { work_secs: 600, break_secs: 60, label: "Short".to_string(), enforced: false }, 161 154 ], 162 155 long_break: None, 163 156 }; ··· 170 163 fn from_config_long_break_mapped() { 171 164 let cfg = ProfileConfig { 172 165 name: "Test".to_string(), 173 - mode: BreakModeConfig::Reminder, 174 166 snooze_secs: 300, 175 167 idle_threshold_secs: 300, 176 168 idle_detection_enabled: false, ··· 178 170 work_secs: 1500, 179 171 break_secs: 300, 180 172 label: "Break".to_string(), 173 + enforced: false, 181 174 }], 182 175 long_break: Some(LongBreakConfig { 183 176 after_cycles: 4, 184 177 max_cycle_gap_secs: 1800, 185 178 break_secs: 900, 186 179 label: "Long break".to_string(), 180 + enforced: false, 187 181 }), 188 182 }; 189 183 let p = Profile::from_config(&cfg);
+6 -2
src/timer/scheduler.rs
··· 7 7 pub break_duration: Duration, 8 8 pub label: String, 9 9 pub is_long_break: bool, 10 + pub enforced: bool, 10 11 /// Which level index fired (passed back to record_break_completed). 11 12 pub level_index: usize, 12 13 } ··· 219 220 break_duration: lb.break_duration, 220 221 label: lb.label.clone(), 221 222 is_long_break: true, 223 + enforced: lb.enforced, 222 224 level_index: primary_idx, 223 225 }); 224 226 } ··· 237 239 break_duration: level.break_duration, 238 240 label: level.label.clone(), 239 241 is_long_break: false, 242 + enforced: level.enforced, 240 243 level_index: idx, 241 244 }); 242 245 } ··· 269 272 #[cfg(test)] 270 273 mod tests { 271 274 use super::*; 272 - use crate::timer::profile::{BreakLevel, BreakMode, LongBreakTrigger, Profile}; 275 + use crate::timer::profile::{BreakLevel, LongBreakTrigger, Profile}; 273 276 274 277 fn make_profile(levels: Vec<(u64, u64)>, long: Option<(u32, u64, u64)>) -> Profile { 275 278 Profile { 276 279 name: "Test".to_string(), 277 - mode: BreakMode::Reminder, 278 280 snooze_duration: Duration::from_secs(300), 279 281 idle_threshold: Duration::from_secs(300), 280 282 idle_detection_enabled: true, ··· 284 286 work_duration: Duration::from_secs(w), 285 287 break_duration: Duration::from_secs(b), 286 288 label: format!("{}s break", b), 289 + enforced: false, 287 290 }) 288 291 .collect(), 289 292 long_break: long.map(|(cycles, gap, dur)| LongBreakTrigger { ··· 291 294 max_cycle_gap: Duration::from_secs(gap), 292 295 break_duration: Duration::from_secs(dur), 293 296 label: "Long break".to_string(), 297 + enforced: false, 294 298 }), 295 299 } 296 300 }
+85
ui/components/interval_card.slint
··· 7 7 break-mins: int, 8 8 break-extra-secs: int, 9 9 label: string, 10 + enforced: bool, 10 11 } 11 12 12 13 export component IntervalCard { ··· 15 16 in property <int> break-mins; 16 17 in property <int> break-secs; 17 18 in property <string> label; 19 + in-out property <bool> enforced: false; 18 20 19 21 callback remove-clicked; 20 22 callback work-mins-changed(int); 21 23 callback break-mins-changed(int); 22 24 callback break-secs-changed(int); 23 25 callback label-changed(string); 26 + callback enforced-changed(bool); 24 27 25 28 // TouchArea dimensions (scale with font) 26 29 property <length> tw: 44px * Theme.font-scale; ··· 78 81 field-label: "break label"; 79 82 edited(v) => { 80 83 root.label-changed(v); 84 + } 85 + } 86 + 87 + // Enforced / Skippable chip 88 + enf-chip := Rectangle { 89 + width: 104px * Theme.font-scale; 90 + height: 24px * Theme.font-scale; 91 + border-radius: 5px * Theme.font-scale; 92 + background: enf-ta.has-hover ? Theme.surface-hov : Theme.surface; 93 + border-width: 1px; 94 + border-color: Theme.line-med; 95 + animate background { duration: 120ms; } 96 + 97 + enf-ta := TouchArea { 98 + accessible-role: button; 99 + accessible-label: root.enforced ? "Enforced — click to make skippable" : "Skippable — click to enforce"; 100 + clicked => { 101 + root.enforced = !root.enforced; 102 + root.enforced-changed(root.enforced); 103 + } 104 + } 105 + 106 + HorizontalLayout { 107 + alignment: center; 108 + spacing: 5px * Theme.font-scale; 109 + padding-left: 7px * Theme.font-scale; 110 + padding-right: 8px * Theme.font-scale; 111 + 112 + Text { 113 + text: root.enforced ? "🔒" : "🔓"; 114 + font-size: Theme.font_xsmall; 115 + vertical-alignment: center; 116 + } 117 + 118 + Text { 119 + text: root.enforced ? "Enforced" : "Skippable"; 120 + font-size: Theme.font_xsmall; 121 + color: Theme.ink-mid; 122 + vertical-alignment: center; 123 + } 81 124 } 82 125 } 83 126 ··· 191 234 in-out property <int> after-cycles: 2; 192 235 in-out property <int> duration-mins: 30; 193 236 in-out property <string> label: "Long rest"; 237 + in-out property <bool> enforced: false; 194 238 195 239 callback enabled-changed(bool); 196 240 callback after-cycles-changed(int); 197 241 callback duration-mins-changed(int); 198 242 callback label-changed(string); 243 + callback enforced-changed(bool); 199 244 200 245 // Same top padding as IntervalCard for visual alignment 201 246 property <length> tp: (44px * Theme.font-scale - Theme.font_medium * 1.364) / 2; ··· 244 289 field-label: "long rest label"; 245 290 edited(v) => { 246 291 root.label-changed(v); 292 + } 293 + } 294 + 295 + // Enforced / Skippable chip 296 + lr-enf-chip := Rectangle { 297 + width: 104px * Theme.font-scale; 298 + height: 24px * Theme.font-scale; 299 + border-radius: 5px * Theme.font-scale; 300 + background: lr-enf-ta.has-hover ? Theme.surface-hov : Theme.surface; 301 + border-width: 1px; 302 + border-color: Theme.line-med; 303 + animate background { duration: 120ms; } 304 + 305 + lr-enf-ta := TouchArea { 306 + accessible-role: button; 307 + accessible-label: root.enforced ? "Enforced — click to make skippable" : "Skippable — click to enforce"; 308 + clicked => { 309 + root.enforced = !root.enforced; 310 + root.enforced-changed(root.enforced); 311 + } 312 + } 313 + 314 + HorizontalLayout { 315 + alignment: center; 316 + spacing: 5px * Theme.font-scale; 317 + padding-left: 7px * Theme.font-scale; 318 + padding-right: 8px * Theme.font-scale; 319 + 320 + Text { 321 + text: root.enforced ? "🔒" : "🔓"; 322 + font-size: Theme.font_xsmall; 323 + vertical-alignment: center; 324 + } 325 + 326 + Text { 327 + text: root.enforced ? "Enforced" : "Skippable"; 328 + font-size: Theme.font_xsmall; 329 + color: Theme.ink-mid; 330 + vertical-alignment: center; 331 + } 247 332 } 248 333 } 249 334
+2 -6
ui/settings.slint
··· 33 33 Theme.font-scale = root.text-size-mode == 4 ? 1.3 : root.text-size-mode == 3 ? 1.15 : root.text-size-mode == 2 ? 1.0 : root.text-size-mode == 1 ? 0.9 : 0.8; 34 34 } 35 35 36 - in-out property <bool> enforced-mode: false; 37 36 in-out property <bool> sound-enabled: true; 38 37 in-out property <float> sound-volume: 0.7; 39 38 in-out property <bool> autostart: false; ··· 48 47 in-out property <int> long-break-duration-mins: 30; 49 48 in-out property <int> long-break-gap-mins: 30; 50 49 in-out property <string> long-break-label: "Long rest"; 50 + in-out property <bool> long-break-enforced: false; 51 51 in-out property <int> theme-mode: 0; 52 52 in-out property <int> text-size-mode: 0; 53 53 in-out property <bool> password-is-set: false; ··· 57 57 callback theme-mode-changed(int); 58 58 callback text-size-mode-changed(int); 59 59 callback set-password-clicked(); 60 - callback enforced-mode-toggled(bool); 61 60 callback open-config-dir(); 62 61 callback profile-changed(string); 63 62 callback level-changed(int, LevelEntry); ··· 118 117 long-break-after-cycles <=> root.long-break-after-cycles; 119 118 long-break-duration-mins <=> root.long-break-duration-mins; 120 119 long-break-label <=> root.long-break-label; 121 - enforced-mode <=> root.enforced-mode; 120 + long-break-enforced <=> root.long-break-enforced; 122 121 idle-detection-enabled <=> root.idle-detection-enabled; 123 122 idle-threshold-mins <=> root.idle-threshold-mins; 124 123 password-is-set <=> root.password-is-set; ··· 136 135 } 137 136 set-password-clicked => { 138 137 root.set-password-clicked(); 139 - } 140 - enforced-mode-toggled(checked) => { 141 - root.enforced-mode-toggled(checked); 142 138 } 143 139 } 144 140
+71 -77
ui/views/rhythm_tab.slint
··· 1 1 import { ScrollView } from "std-widgets.slint"; 2 2 import { Theme } from "../theme.slint"; 3 - import { SettingLabel, SectionHeading } from "../components/atoms.slint"; 3 + import { SettingLabel, SectionHeading, PaperDivider } from "../components/atoms.slint"; 4 4 import { PaperButton } from "../components/buttons.slint"; 5 5 import { PaperToggle } from "../components/toggles.slint"; 6 6 import { NumberField, PaperComboBox } from "../components/inputs.slint"; ··· 17 17 in-out property <int> long-break-after-cycles: 3; 18 18 in-out property <int> long-break-duration-mins: 30; 19 19 in-out property <string> long-break-label: "Long rest"; 20 - in-out property <bool> enforced-mode: false; 20 + in-out property <bool> long-break-enforced: false; 21 21 in-out property <bool> idle-detection-enabled: true; 22 22 in-out property <int> idle-threshold-mins: 5; 23 23 in-out property <bool> password-is-set: false; ··· 27 27 callback level-added; 28 28 callback profile-changed(string); 29 29 callback set-password-clicked; 30 - callback enforced-mode-toggled(bool); 31 30 32 31 private property <int> profile-index: 33 32 root.active-profile == root.profile-names[0] ? 0 : ··· 88 87 break-mins: level.break-mins; 89 88 break-secs: level.break-extra-secs; 90 89 label: level.label; 90 + enforced: level.enforced; 91 91 remove-clicked => { root.level-removed(i); } 92 92 work-mins-changed(v) => { 93 93 root.level-changed(i, { 94 94 work-mins: v, 95 95 break-mins: level.break-mins, 96 96 break-extra-secs: level.break-extra-secs, 97 - label: level.label 97 + label: level.label, 98 + enforced: level.enforced, 98 99 }); 99 100 } 100 101 break-mins-changed(v) => { ··· 102 103 work-mins: level.work-mins, 103 104 break-mins: v, 104 105 break-extra-secs: level.break-extra-secs, 105 - label: level.label 106 + label: level.label, 107 + enforced: level.enforced, 106 108 }); 107 109 } 108 110 break-secs-changed(v) => { ··· 110 112 work-mins: level.work-mins, 111 113 break-mins: level.break-mins, 112 114 break-extra-secs: v, 113 - label: level.label 115 + label: level.label, 116 + enforced: level.enforced, 114 117 }); 115 118 } 116 119 label-changed(v) => { ··· 118 121 work-mins: level.work-mins, 119 122 break-mins: level.break-mins, 120 123 break-extra-secs: level.break-extra-secs, 121 - label: v 124 + label: v, 125 + enforced: level.enforced, 126 + }); 127 + } 128 + enforced-changed(v) => { 129 + root.level-changed(i, { 130 + work-mins: level.work-mins, 131 + break-mins: level.break-mins, 132 + break-extra-secs: level.break-extra-secs, 133 + label: level.label, 134 + enforced: v, 122 135 }); 123 136 } 124 137 } ··· 132 145 after-cycles <=> root.long-break-after-cycles; 133 146 duration-mins <=> root.long-break-duration-mins; 134 147 label <=> root.long-break-label; 148 + enforced <=> root.long-break-enforced; 135 149 } 136 150 137 151 Rectangle { height: 10px * Theme.font-scale; } ··· 163 177 Rectangle { height: 28px * Theme.font-scale; } 164 178 165 179 SectionHeading { 166 - title: "Break behavior"; 180 + title: "During breaks"; 181 + description: "Rules for how breaks are handled when they start."; 167 182 } 168 183 169 - Rectangle { height: 12px * Theme.font-scale; } 170 - 171 - VerticalLayout { 172 - spacing: 0px; 173 - 174 - HorizontalLayout { 175 - padding-top: 12px * Theme.font-scale; 176 - padding-bottom: 4px * Theme.font-scale; 177 - spacing: 24px * Theme.font-scale; 184 + Rectangle { height: 14px * Theme.font-scale; } 178 185 179 - SettingLabel { 180 - title: "Enforced mode"; 181 - description: "Full-screen break. Emergency unlock requires a password."; 182 - } 186 + // Idle detection — toggle + inline controls on one row 187 + HorizontalLayout { 188 + spacing: 10px * Theme.font-scale; 189 + alignment: start; 183 190 184 - Rectangle { horizontal-stretch: 1; } 185 - 186 - PaperToggle { 187 - checked: root.enforced-mode; 188 - label: "Enforced mode"; 189 - toggled(checked) => { 190 - if !checked && root.password-is-set { 191 - root.enforced-mode-toggled(false); 192 - } else { 193 - root.enforced-mode = checked; 194 - } 195 - } 196 - } 191 + PaperToggle { 192 + checked <=> root.idle-detection-enabled; 193 + label: "Pause timer if idle"; 197 194 } 198 195 199 - if root.enforced-mode: HorizontalLayout { 200 - padding-bottom: 12px * Theme.font-scale; 196 + Text { 197 + text: "Pause timer if I'm idle for"; 198 + font-size: Theme.font_label; 199 + color: Theme.ink-mid; 200 + vertical-alignment: center; 201 + opacity: root.idle-detection-enabled ? 1.0 : 0.4; 202 + animate opacity { duration: 120ms; } 203 + } 201 204 202 - PaperButton { 203 - text: root.password-is-set ? "Change emergency password…" : "Set emergency unlock password…"; 204 - clicked => { root.set-password-clicked(); } 205 - } 205 + NumberField { 206 + width: 80px * Theme.font-scale; 207 + value <=> root.idle-threshold-mins; 208 + minimum: 1; 209 + maximum: 60; 210 + enabled: root.idle-detection-enabled; 211 + field-label: "Idle threshold minutes"; 206 212 } 207 213 208 - HorizontalLayout { 209 - padding-top: 12px * Theme.font-scale; 210 - padding-bottom: 4px * Theme.font-scale; 211 - spacing: 24px * Theme.font-scale; 214 + Text { 215 + text: "minutes."; 216 + font-size: Theme.font_label; 217 + color: Theme.ink-mid; 218 + vertical-alignment: center; 219 + opacity: root.idle-detection-enabled ? 1.0 : 0.4; 220 + animate opacity { duration: 120ms; } 221 + } 222 + } 212 223 213 - SettingLabel { 214 - title: "Pause on idle"; 215 - description: "If you've stepped away, ioma waits."; 216 - } 224 + Rectangle { height: 16px * Theme.font-scale; } 217 225 218 - Rectangle { horizontal-stretch: 1; } 226 + PaperDivider { } 219 227 220 - PaperToggle { 221 - checked <=> root.idle-detection-enabled; 222 - label: "Pause on idle"; 223 - } 224 - } 228 + Rectangle { height: 14px * Theme.font-scale; } 225 229 226 - if root.idle-detection-enabled: HorizontalLayout { 227 - padding-bottom: 12px * Theme.font-scale; 228 - spacing: 10px * Theme.font-scale; 229 - alignment: start; 230 + // Emergency unlock 231 + HorizontalLayout { 232 + spacing: 24px * Theme.font-scale; 230 233 231 - Text { 232 - text: "for"; 233 - font-size: Theme.font_label; 234 - color: Theme.ink-mid; 235 - vertical-alignment: center; 236 - } 234 + SettingLabel { 235 + title: "Emergency unlock"; 236 + description: "Set a password to skip enforced breaks."; 237 + } 237 238 238 - NumberField { 239 - width: 80px * Theme.font-scale; 240 - value <=> root.idle-threshold-mins; 241 - minimum: 1; 242 - maximum: 60; 243 - field-label: "Idle threshold minutes"; 244 - } 239 + Rectangle { horizontal-stretch: 1; } 245 240 246 - Text { 247 - text: "minutes before resetting"; 248 - font-size: Theme.font_label; 249 - color: Theme.ink-mid; 250 - vertical-alignment: center; 251 - } 241 + PaperButton { 242 + text: root.password-is-set ? "Change password…" : "Set password…"; 243 + clicked => { root.set-password-clicked(); } 252 244 } 253 245 } 246 + 247 + Rectangle { height: 8px * Theme.font-scale; } 254 248 255 249 Rectangle { vertical-stretch: 1; } 256 250 }