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}