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}