Skip to main content

entracte_lib/scheduler/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Which kind of break a scheduled event represents.
4///
5/// `Micro` is the short eye-rest prompt, `Long` is the longer movement
6/// break, `Sleep` is the bedtime reminder fired inside the configured
7/// nighttime window.
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "lowercase")]
10pub enum BreakKind {
11    Micro,
12    Long,
13    Sleep,
14}
15
16/// How a break surfaces to the user. Driven by per-kind settings
17/// (`micro_break_mode` / `long_break_mode`).
18///
19/// - `Overlay`: full-screen overlay that the user cannot click past.
20/// - `Windowed`: same overlay sized to 80% of the monitor, desktop stays clickable.
21/// - `Notification`: system notification only; no overlay, no countdown.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum BreakDelivery {
24    Overlay,
25    Windowed,
26    Notification,
27}
28
29/// Payload emitted to the renderer when a break starts.
30///
31/// Captures everything the overlay needs to render itself without
32/// re-querying the backend: duration, whether the user can dismiss or
33/// postpone, the hint pool, and the "health intensity" used for the
34/// skip-vignette effect.
35#[derive(Debug, Clone, Serialize)]
36pub struct BreakEvent {
37    pub kind: BreakKind,
38    pub duration_secs: u64,
39    pub enforceable: bool,
40    pub manual_finish: bool,
41    pub postpone_available: bool,
42    pub hints: Vec<String>,
43    pub hint_rotate_seconds: u64,
44    pub health_intensity: f32,
45}
46
47/// The most recently skipped or postponed break, or `None` if none yet
48/// in this session. Powers the tray's "Resume last skipped break" item.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct LastBreakInfo {
51    pub kind: Option<BreakKind>,
52}
53
54/// Pixel rectangle for a monitor in the desktop's coordinate space.
55/// Used to position overlay windows. Origin can be negative on
56/// multi-monitor setups where the primary is not the top-left display.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct MonitorRect {
59    pub x: i32,
60    pub y: i32,
61    pub width: u32,
62    pub height: u32,
63}
64
65/// Which auto-suppression rule is currently silencing breaks, exposed
66/// to the tray so the user can tell why the icon is inactive.
67///
68/// Set by `run_loop` whenever a guard branch fires (DND / camera /
69/// video / app-pause / outside work-window); cleared at the top of
70/// every tick before the guards re-evaluate. `None` (encoded as 0)
71/// means "not auto-suppressed".
72///
73/// Idle isn't tracked here because it can be partial (only one of
74/// micro/long suppressed at a time) and the user isn't watching the
75/// tray when idle anyway. Explicit user pause goes through
76/// `PauseState`, not this enum.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum SuppressReason {
79    WorkWindow,
80    Dnd,
81    Camera,
82    Video,
83    AppPause,
84}
85
86impl SuppressReason {
87    /// Stable u8 encoding for the `AtomicU8` round-trip. `0` is reserved
88    /// for "not suppressed" — the inverse of `from_u8`.
89    pub const fn as_u8(self) -> u8 {
90        match self {
91            Self::WorkWindow => 1,
92            Self::Dnd => 2,
93            Self::Camera => 3,
94            Self::Video => 4,
95            Self::AppPause => 5,
96        }
97    }
98
99    /// Decode from the `AtomicU8`. Anything outside the encoded range
100    /// (including `0`) returns `None` — treat as "not suppressed".
101    pub fn from_u8(b: u8) -> Option<Self> {
102        match b {
103            1 => Some(Self::WorkWindow),
104            2 => Some(Self::Dnd),
105            3 => Some(Self::Camera),
106            4 => Some(Self::Video),
107            5 => Some(Self::AppPause),
108            _ => None,
109        }
110    }
111
112    /// Short label for the always-visible tray title (macOS / Linux).
113    /// Kept under ~12 chars so the menu-bar doesn't blow out.
114    pub fn short_label(self) -> &'static str {
115        match self {
116            Self::WorkWindow => "off-hours",
117            Self::Dnd => "DND",
118            Self::Camera => "camera",
119            Self::Video => "video",
120            Self::AppPause => "app paused",
121        }
122    }
123
124    /// Full sentence for tooltips. Explains both *what* and *which
125    /// setting* turns it off, so the user knows where to look.
126    pub fn human(self) -> &'static str {
127        match self {
128            Self::WorkWindow => "Outside work hours (Schedule → Work window)",
129            Self::Dnd => "Do Not Disturb is on (Quiet → Pause during DND)",
130            Self::Camera => "Camera in use (Quiet → Pause during camera)",
131            Self::Video => "Video keeping the display awake (Quiet → Pause during video)",
132            Self::AppPause => "A paused app is running (Quiet → App pause list)",
133        }
134    }
135}
136
137/// Per-break postpone budget exposed to the renderer.
138///
139/// `count` is how many times the active break has been postponed so far,
140/// `max` is the configured cap (or `u32::MAX` when escalation is off),
141/// `remaining` is `max - count` saturated at zero.
142#[derive(Debug, Clone, Serialize)]
143pub struct PostponeState {
144    pub count: u32,
145    pub max: u32,
146    pub remaining: u32,
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    const ALL_REASONS: [SuppressReason; 5] = [
154        SuppressReason::WorkWindow,
155        SuppressReason::Dnd,
156        SuppressReason::Camera,
157        SuppressReason::Video,
158        SuppressReason::AppPause,
159    ];
160
161    #[test]
162    fn suppress_reason_as_u8_round_trips_through_from_u8() {
163        // The AtomicU8 path depends on these two functions being exact
164        // inverses. A typo in either direction would silently mislabel
165        // tooltips ("camera" while DND is the real cause).
166        for r in ALL_REASONS {
167            assert_eq!(
168                SuppressReason::from_u8(r.as_u8()),
169                Some(r),
170                "{r:?} must round-trip",
171            );
172        }
173    }
174
175    #[test]
176    fn suppress_reason_zero_is_reserved_for_not_suppressed() {
177        // `0` must never decode to a real reason — it's the
178        // "everything's fine" sentinel for `auto_suppress_reason`.
179        assert_eq!(SuppressReason::from_u8(0), None);
180        for r in ALL_REASONS {
181            assert_ne!(r.as_u8(), 0, "{r:?} encoded as 0 collides with sentinel");
182        }
183    }
184
185    #[test]
186    fn suppress_reason_from_u8_rejects_out_of_range() {
187        // Anything past the highest assigned value should be `None`
188        // so a corrupted load doesn't crash or pick a random reason.
189        assert_eq!(SuppressReason::from_u8(99), None);
190        assert_eq!(SuppressReason::from_u8(u8::MAX), None);
191    }
192
193    #[test]
194    fn suppress_reason_short_label_is_compact() {
195        // Tray title space is tight on macOS; keep short labels under
196        // ~12 chars so the menu bar doesn't get truncated.
197        for r in ALL_REASONS {
198            let label = r.short_label();
199            assert!(label.len() <= 12, "{r:?} short_label {label:?} is too long",);
200            assert!(!label.is_empty());
201        }
202    }
203
204    #[test]
205    fn suppress_reason_human_strings_are_non_empty() {
206        // Tooltip lines — must always say something so a hover gives
207        // the user actionable info.
208        for r in ALL_REASONS {
209            assert!(!r.human().is_empty(), "{r:?} has empty human() string");
210        }
211    }
212}