From 9b012cc066b39bb6001e7a83dae1d3a945049171 Mon Sep 17 00:00:00 2001 From: Simon Winther Date: Wed, 15 Jul 2026 10:24:48 +0200 Subject: [PATCH 1/2] fix(wayland-overlay): keep the break overlay alive when outputs disappear The overlay is a separate GTK process, and GtkApplication quits as soon as its last window closes. During a break, when the compositor removes or powers off the outputs the overlay is anchored to (for example display power management after stepping away during a long break), GTK tears down the layer-shell windows and the process exits. The daemon keeps counting the break down in the tray, but nothing is left on screen and input is no longer blocked. Hold the application for the lifetime of the countdown so losing every window no longer terminates the process. The countdown timer keeps running and the existing reconcile() recreates the surfaces when the outputs return. The hold is released when the break ends or the daemon stops the overlay. --- crates/wayland-overlay/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 From c25956f8e59e7faf34658cd19f655213a44370c5 Mon Sep 17 00:00:00 2001 From: Simon Winther Date: Wed, 15 Jul 2026 10:24:48 +0200 Subject: [PATCH 2/2] fix(scheduler): allow skip and postpone immediately in delay mode The strict "delay" mode's minimum_visible window gated skip and postpone, so a break's controls sat disabled for the first several seconds. There is no reason to force a wait before postponing or skipping the break. Restrict the strict dismissal lockout to "entire" mode, which is the mode meant to hold the controls for the whole break. In "delay" (and "off") mode skip and postpone are now available from the first frame; minimum_visible continues to guard only the pause and reset loopholes. Update the overlay's strict_remaining, the status flags, and the docs to match. --- README.md | 4 +- config.example.toml | 2 +- crates/scheduler/src/lib.rs | 73 ++++++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 13 deletions(-) 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]