diff --git a/README.md b/README.md index f73759c..015ef22 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ systemctl --user restart breakd.service Set `strict.mode` to one of these values: - `off`: skip and postpone are available immediately. -- `delay`: controls unlock after `strict.minimum_visible`. -- `entire`: the break cannot be skipped. +- `delay`: skip and postpone are still available immediately; `strict.minimum_visible` only delays the pause and reset loopholes. +- `entire`: skip and postpone are locked for the whole break (`allow_postpone_during_lockout` still permits postponing). Set `strict.inhibit_shortcuts = true` to request standard Wayland shortcut inhibition while the overlay is active. With `hyprland.submap_fallback = true`, the daemon also enters a temporary Hyprland `breakd` submap because layer surfaces do not reliably suppress compositor bindings on every Hyprland version. The submap is registered at runtime, checked throughout the break, and reset when the break or daemon exits. diff --git a/config.example.toml b/config.example.toml index a7e38a9..2356b5b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -51,7 +51,7 @@ duration = "10m" [strict] mode = "delay" # off | delay | entire -minimum_visible = "10s" +minimum_visible = "10s" # delay: how long pause/reset stay locked; skip/postpone are always immediate allow_postpone_during_lockout = false inhibit_shortcuts = true diff --git a/crates/scheduler/src/lib.rs b/crates/scheduler/src/lib.rs index 66aa7a6..e0aca01 100644 --- a/crates/scheduler/src/lib.rs +++ b/crates/scheduler/src/lib.rs @@ -377,8 +377,7 @@ impl Scheduler { can_skip: !paused && !awaiting_resume && active.is_some_and(|active| { - self.skip_available(active.due.kind) - && now.boottime_ms >= active.strict_until_boot_ms + self.skip_available(active.due.kind) && !self.dismissal_locked(active, now) }), can_postpone: !paused && !awaiting_resume @@ -625,7 +624,15 @@ impl Scheduler { .active_break() .cloned() .ok_or(SchedulerError::NoActiveBreak)?; - self.ensure_dismissal_allowed(now)?; + if awaiting_manual_resume(&active, now) { + return Err(SchedulerError::AwaitingResume); + } + if !self.skip_available(active.due.kind) { + return Err(SchedulerError::SkipDisabled); + } + if self.dismissal_locked(&active, now) { + return Err(SchedulerError::StrictMode); + } Ok(self.finish_active(active, now)) } @@ -671,8 +678,7 @@ impl Scheduler { if !rule.enabled { return Err(SchedulerError::PostponeDisabled); } - if now.boottime_ms < active.strict_until_boot_ms - && !self.config.strict.allow_postpone_during_lockout + if self.dismissal_locked(&active, now) && !self.config.strict.allow_postpone_during_lockout { return Err(SchedulerError::StrictMode); } @@ -857,7 +863,11 @@ impl Scheduler { kind: active.due.kind, duration: DurationMs::from_millis(active.ends_boot_ms.saturating_sub(now.boottime_ms)), strict_remaining: DurationMs::from_millis( - active.strict_until_boot_ms.saturating_sub(now.boottime_ms), + if self.config.strict.mode == StrictMode::Entire { + active.strict_until_boot_ms.saturating_sub(now.boottime_ms) + } else { + 0 + }, ), can_skip: self.skip_available(active.due.kind), can_postpone: self.postpone_available(active), @@ -877,10 +887,19 @@ impl Scheduler { fn postpone_allowed(&self, active: &ActiveBreak, now: ClockSample) -> bool { self.postpone_available(active) - && (now.boottime_ms >= active.strict_until_boot_ms + && (!self.dismissal_locked(active, now) || self.config.strict.allow_postpone_during_lockout) } + /// Whether strict mode currently blocks dismissing the break with skip or + /// postpone. Only `StrictMode::Entire` holds those controls for the whole + /// break; the `Delay` mode's minimum-visible window intentionally does not + /// gate skip or postpone, so the user can act on the break immediately. + fn dismissal_locked(&self, active: &ActiveBreak, now: ClockSample) -> bool { + self.config.strict.mode == StrictMode::Entire + && now.boottime_ms < active.strict_until_boot_ms + } + fn postpone_available(&self, active: &ActiveBreak) -> bool { let rule = match active.due.kind { BreakKind::Mini => &self.config.postpone.mini, @@ -1251,7 +1270,7 @@ mod tests { } #[test] - fn strict_mode_rejects_early_skip() { + fn delay_mode_allows_immediate_skip_and_postpone() { let mut scheduler = test_scheduler(); let effects = scheduler.handle_command(&Command::Mini, clock(0)).unwrap(); let Effect::StartOverlay(spec) = &effects[0] else { @@ -1259,13 +1278,47 @@ mod tests { }; assert!(spec.can_skip); assert!(spec.can_postpone); + // The minimum-visible delay no longer gates skip or postpone, so the + // overlay enables both controls from the first frame. + assert_eq!(spec.strict_remaining, DurationMs::from_millis(0)); + assert!(scheduler.status(clock(1)).can_skip); + assert!(scheduler.status(clock(1)).can_postpone); + assert!( + scheduler + .handle_command(&Command::Postpone, clock(1)) + .is_ok() + ); + } + + #[test] + fn entire_strict_mode_locks_skip_and_postpone_for_the_whole_break() { + let mut scheduler = test_scheduler(); + scheduler.config.strict.mode = StrictMode::Entire; + let effects = scheduler.handle_command(&Command::Long, clock(0)).unwrap(); + let Effect::StartOverlay(spec) = &effects[0] else { + panic!("expected an overlay"); + }; + // Entire mode keeps the controls locked for the whole break. + assert_eq!(spec.strict_remaining, spec.duration); + assert!(!scheduler.status(clock(10)).can_skip); assert!(!scheduler.status(clock(10)).can_postpone); assert_eq!( scheduler.handle_command(&Command::Skip, clock(10)), Err(SchedulerError::StrictMode) ); - assert!(scheduler.status(clock(20)).can_postpone); - assert!(scheduler.handle_command(&Command::Skip, clock(20)).is_ok()); + assert_eq!( + scheduler.handle_command(&Command::Postpone, clock(10)), + Err(SchedulerError::StrictMode) + ); + + // The postpone-during-lockout escape hatch still applies in Entire mode. + scheduler.config.strict.allow_postpone_during_lockout = true; + assert!(scheduler.status(clock(10)).can_postpone); + assert!( + scheduler + .handle_command(&Command::Postpone, clock(10)) + .is_ok() + ); } #[test] diff --git a/crates/wayland-overlay/src/lib.rs b/crates/wayland-overlay/src/lib.rs index 51be5a1..1f8488a 100644 --- a/crates/wayland-overlay/src/lib.rs +++ b/crates/wayland-overlay/src/lib.rs @@ -32,6 +32,18 @@ pub fn run(spec: OverlaySpec, config: AppConfig) -> Result<(), String> { return; } + // Keep the overlay process alive for the whole break even if every + // window is torn down. When the compositor removes or powers off the + // outputs the overlay is anchored to (for example display power + // management after the user steps away during a long break), GTK closes + // those layer-shell windows; without an explicit hold the application + // would quit as soon as the last window closes, leaving the break still + // counting down in the daemon with nothing on screen. The hold is + // released when the countdown ends (the timer below calls `quit`) or + // when the daemon stops the overlay. Surfaces are recreated by + // `reconcile` when the outputs come back. + let hold = application.hold(); + install_css(&config); let manager = Rc::new(RefCell::new(OverlayManager::new( application.clone(), @@ -50,6 +62,9 @@ pub fn run(spec: OverlaySpec, config: AppConfig) -> Result<(), String> { let manager_for_timer = manager.clone(); glib::timeout_add_local(Duration::from_millis(200), move || { + // Own the application hold for the lifetime of the countdown so the + // process survives losing all of its windows mid-break. + let _hold = &hold; let mut manager = manager_for_timer.borrow_mut(); if manager.update_countdown() { glib::ControlFlow::Continue