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, Hash)]
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/// One step of a guided break routine: a short instruction the overlay
30/// shows for `seconds` before advancing to the next step. Part of the
31/// `break:start` wire payload; the routine library that produces these
32/// lives in [`super::routines`].
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct RoutineStep {
35    pub text: String,
36    pub seconds: u64,
37    /// Optional image shown alongside the instruction. In a plugin manifest
38    /// this is the pack-local asset id; on install it is rewritten to the
39    /// stored sidecar's absolute path, which is what reaches the overlay (the
40    /// frontend turns it into an `asset:` URL). `None` for the common
41    /// text-only step.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub asset: Option<String>,
44    /// Optional sound cue played when this step begins (e.g. a chime signalling
45    /// the next exercise). Like `asset`, a pack-local audio asset id rewritten
46    /// to the stored sidecar path on install. `None` for a silent step.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub sound: Option<String>,
49}
50
51/// How a routine's step durations relate to the break length. Sent in
52/// [`BreakEvent`] as the routine's own declared pacing (if any); the
53/// frontend falls back to the `routine_fill` global setting when this is
54/// absent.
55///
56/// - `hold` — authored `seconds` are absolute; hold the last step once
57///   the routine finishes, truncate if it overruns. This is the legacy
58///   behaviour and the default when no pacing is declared.
59/// - `fill` — authored `seconds` are relative weights; scale them so
60///   steps exactly fill the break duration.
61/// - `loop` — authored `seconds` are absolute; restart from step 0 when
62///   the routine is shorter than the break (used by repeating routines
63///   such as breathing cycles).
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "lowercase")]
66pub enum RoutinePacing {
67    Hold,
68    Fill,
69    Loop,
70}
71
72/// What a breathing routine does once its `cycles` cap is reached.
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "lowercase")]
75pub enum BreathThen {
76    /// Keep repeating the pattern until the break ends (the default).
77    Loop,
78    /// Stop guiding and hold a settled "rest" state for the remainder.
79    Rest,
80}
81
82/// Per-phase sound cues for a breathing pattern, so a user can follow the
83/// rhythm with their eyes closed. Each is an audio asset id (rewritten to the
84/// stored sidecar path on install); any phase may be silent.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
86pub struct BreathSounds {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub inhale: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub hold: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub exhale: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub hold_out: Option<String>,
95}
96
97/// A guided breathing pattern, animated on the countdown ring. Phase durations
98/// are **absolute seconds** — tempo is never scaled to the break length; the
99/// cycle simply repeats. `cycles` optionally caps the guided portion, after
100/// which `then` decides whether to loop or rest. A routine carrying a `breath`
101/// takes the place of step text on the overlay.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct BreathPattern {
104    /// Seconds breathing in (ring expands).
105    pub inhale: u64,
106    /// Seconds holding the breath in (ring full).
107    #[serde(default)]
108    pub hold: u64,
109    /// Seconds breathing out (ring contracts).
110    pub exhale: u64,
111    /// Seconds holding empty (ring settled).
112    #[serde(default)]
113    pub hold_out: u64,
114    /// Stop guiding after this many cycles. `None` loops for the whole break.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub cycles: Option<u64>,
117    /// What to do after `cycles`. `None` is treated as `loop`.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub then: Option<BreathThen>,
120    /// Optional per-phase sound cues. `None` for a silent pattern.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub sounds: Option<BreathSounds>,
123}
124
125/// Payload emitted to the renderer when a break starts.
126///
127/// Captures everything the overlay needs to render itself without
128/// re-querying the backend: duration, whether the user can dismiss or
129/// postpone, the hint pool, and the "health intensity" used for the
130/// skip-vignette effect.
131///
132/// `routine_steps` is the resolved guided-routine sequence for this break
133/// (empty when the user has not selected a routine for this kind, in which
134/// case the overlay falls back to plain hint rotation).
135/// `routine_pacing` carries the routine's own declared [`RoutinePacing`]
136/// when set; the renderer falls back to the global `routine_fill` setting
137/// when this is `None`.
138/// `routine_max_step_secs` caps individual step durations when
139/// `routine_pacing` is `fill` and scaling would exceed this limit (the
140/// overlay falls back to `loop` behaviour for the remainder in that case).
141/// `chore_prompt` is the day's user-entered chore the overlay nudges during
142/// a long break (`None` for micro / bedtime, and for long breaks when the
143/// list is empty); it occupies the wellness-hint space in place of a random
144/// tip.
145#[derive(Debug, Clone, Serialize)]
146pub struct BreakEvent {
147    pub kind: BreakKind,
148    pub duration_secs: u64,
149    pub enforceable: bool,
150    pub manual_finish: bool,
151    pub postpone_available: bool,
152    pub skip_available: bool,
153    pub hints: Vec<String>,
154    pub hint_rotate_seconds: u64,
155    pub health_intensity: f32,
156    pub routine_steps: Vec<RoutineStep>,
157    pub routine_pacing: Option<RoutinePacing>,
158    pub routine_max_step_secs: Option<u64>,
159    /// The resolved routine's breathing pattern, if it carries one. The
160    /// overlay animates the ring to it and shows phase labels in place of
161    /// step text. `None` for non-breathing routines.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub routine_breath: Option<BreathPattern>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub chore_prompt: Option<String>,
166}
167
168/// The most recently skipped or postponed break, or `None` if none yet
169/// in this session. Powers the tray's "Resume last skipped break" item.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct LastBreakInfo {
172    pub kind: Option<BreakKind>,
173}
174
175/// Pixel rectangle for a monitor in the desktop's coordinate space.
176/// Used to position overlay windows. Origin can be negative on
177/// multi-monitor setups where the primary is not the top-left display.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct MonitorRect {
180    pub x: i32,
181    pub y: i32,
182    pub width: u32,
183    pub height: u32,
184}
185
186/// Which auto-suppression rule is currently silencing breaks, exposed
187/// to the tray so the user can tell why the icon is inactive.
188///
189/// Set by `run_loop` whenever a guard branch fires (DND / camera /
190/// video / app-pause / outside work-window); cleared at the top of
191/// every tick before the guards re-evaluate. `None` (encoded as 0)
192/// means "not auto-suppressed".
193///
194/// Idle isn't tracked here because it can be partial (only one of
195/// micro/long suppressed at a time) and the user isn't watching the
196/// tray when idle anyway. Explicit user pause goes through
197/// `PauseState`, not this enum.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum SuppressReason {
200    WorkWindow,
201    Dnd,
202    Camera,
203    Video,
204    AppPause,
205    Plugin,
206}
207
208impl SuppressReason {
209    /// Stable u8 encoding for the `AtomicU8` round-trip. `0` is reserved
210    /// for "not suppressed" — the inverse of `from_u8`.
211    pub const fn as_u8(self) -> u8 {
212        match self {
213            Self::WorkWindow => 1,
214            Self::Dnd => 2,
215            Self::Camera => 3,
216            Self::Video => 4,
217            Self::AppPause => 5,
218            Self::Plugin => 6,
219        }
220    }
221
222    /// Decode from the `AtomicU8`. Anything outside the encoded range
223    /// (including `0`) returns `None` — treat as "not suppressed".
224    pub fn from_u8(b: u8) -> Option<Self> {
225        match b {
226            1 => Some(Self::WorkWindow),
227            2 => Some(Self::Dnd),
228            3 => Some(Self::Camera),
229            4 => Some(Self::Video),
230            5 => Some(Self::AppPause),
231            6 => Some(Self::Plugin),
232            _ => None,
233        }
234    }
235
236    /// Short label for the always-visible tray title (macOS / Linux).
237    /// Kept under ~12 chars so the menu-bar doesn't blow out.
238    pub fn short_label(self) -> &'static str {
239        match self {
240            Self::WorkWindow => "off-hours",
241            Self::Dnd => "DND",
242            Self::Camera => "camera",
243            Self::Video => "video",
244            Self::AppPause => "app paused",
245            Self::Plugin => "plugin",
246        }
247    }
248
249    /// Full sentence for tooltips. Explains both *what* and *which
250    /// setting* turns it off, so the user knows where to look.
251    pub fn human(self) -> &'static str {
252        match self {
253            Self::WorkWindow => "Outside work hours (Schedule → Work window)",
254            Self::Dnd => "Do Not Disturb is on (Quiet → Pause during DND)",
255            Self::Camera => "Camera in use (Quiet → Pause during camera)",
256            Self::Video => "Video keeping the display awake (Quiet → Pause during video)",
257            Self::AppPause => "A paused app is running (Quiet → App pause list)",
258            Self::Plugin => "A detector plugin is suppressing breaks (System → Plugins)",
259        }
260    }
261}
262
263/// Per-break postpone budget exposed to the renderer.
264///
265/// `count` is how many times the active break has been postponed so far,
266/// `max` is the configured cap (or `u32::MAX` when escalation is off),
267/// `remaining` is `max - count` saturated at zero.
268#[derive(Debug, Clone, Serialize)]
269pub struct PostponeState {
270    pub count: u32,
271    pub max: u32,
272    pub remaining: u32,
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    const ALL_REASONS: [SuppressReason; 6] = [
280        SuppressReason::WorkWindow,
281        SuppressReason::Dnd,
282        SuppressReason::Camera,
283        SuppressReason::Video,
284        SuppressReason::AppPause,
285        SuppressReason::Plugin,
286    ];
287
288    #[test]
289    fn suppress_reason_as_u8_round_trips_through_from_u8() {
290        // The AtomicU8 path depends on these two functions being exact
291        // inverses. A typo in either direction would silently mislabel
292        // tooltips ("camera" while DND is the real cause).
293        for r in ALL_REASONS {
294            assert_eq!(
295                SuppressReason::from_u8(r.as_u8()),
296                Some(r),
297                "{r:?} must round-trip",
298            );
299        }
300    }
301
302    #[test]
303    fn suppress_reason_zero_is_reserved_for_not_suppressed() {
304        // `0` must never decode to a real reason — it's the
305        // "everything's fine" sentinel for `auto_suppress_reason`.
306        assert_eq!(SuppressReason::from_u8(0), None);
307        for r in ALL_REASONS {
308            assert_ne!(r.as_u8(), 0, "{r:?} encoded as 0 collides with sentinel");
309        }
310    }
311
312    #[test]
313    fn suppress_reason_from_u8_rejects_out_of_range() {
314        // Anything past the highest assigned value should be `None`
315        // so a corrupted load doesn't crash or pick a random reason.
316        assert_eq!(SuppressReason::from_u8(99), None);
317        assert_eq!(SuppressReason::from_u8(u8::MAX), None);
318    }
319
320    #[test]
321    fn suppress_reason_short_label_is_compact() {
322        // Tray title space is tight on macOS; keep short labels under
323        // ~12 chars so the menu bar doesn't get truncated.
324        for r in ALL_REASONS {
325            let label = r.short_label();
326            assert!(label.len() <= 12, "{r:?} short_label {label:?} is too long",);
327            assert!(!label.is_empty());
328        }
329    }
330
331    #[test]
332    fn suppress_reason_human_strings_are_non_empty() {
333        // Tooltip lines — must always say something so a hover gives
334        // the user actionable info.
335        for r in ALL_REASONS {
336            assert!(!r.human().is_empty(), "{r:?} has empty human() string");
337        }
338    }
339}