Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 63 additions & 10 deletions crates/scheduler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -1251,21 +1270,55 @@ 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 {
panic!("expected an overlay");
};
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]
Expand Down
15 changes: 15 additions & 0 deletions crates/wayland-overlay/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down