1use std::time::Duration;
9
10use chrono::Utc;
11
12use crate::runtime::HeartbeatStatus;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TrayVariant {
17 Idle,
18 Busy,
19 Disconnected,
20}
21
22impl TrayVariant {
23 pub fn rgba_16(self) -> Vec<u8> {
27 const SIZE: usize = 16;
28 let (r, g, b) = match self {
29 TrayVariant::Idle => (0x6B, 0xCE, 0x6B), TrayVariant::Busy => (0xE8, 0xA8, 0x38), TrayVariant::Disconnected => (0xD0, 0x60, 0x60), };
33 let cx = (SIZE as f32 - 1.0) / 2.0;
34 let cy = cx;
35 let radius = 6.5;
36 let mut buf = vec![0u8; SIZE * SIZE * 4];
37 for y in 0..SIZE {
38 for x in 0..SIZE {
39 let dx = x as f32 - cx;
40 let dy = y as f32 - cy;
41 let dist = (dx * dx + dy * dy).sqrt();
42 let i = (y * SIZE + x) * 4;
43 if dist <= radius {
44 buf[i] = r;
45 buf[i + 1] = g;
46 buf[i + 2] = b;
47 buf[i + 3] = 0xFF;
48 }
49 }
50 }
51 buf
52 }
53
54 pub fn tooltip(self) -> &'static str {
55 match self {
56 TrayVariant::Idle => "studio-worker — idle",
57 TrayVariant::Busy => "studio-worker — running a job",
58 TrayVariant::Disconnected => "studio-worker — disconnected",
59 }
60 }
61}
62
63pub fn rgba_to_argb32(rgba: &[u8]) -> Vec<u8> {
69 let mut out = rgba.to_vec();
70 for px in out.chunks_exact_mut(4) {
71 px.rotate_right(1);
72 }
73 out
74}
75
76pub fn derive_variant(
80 busy: bool,
81 last_heartbeat: Option<&HeartbeatStatus>,
82 heartbeat_interval: Duration,
83) -> TrayVariant {
84 if busy {
85 return TrayVariant::Busy;
86 }
87 match last_heartbeat {
88 None => TrayVariant::Disconnected,
89 Some(hb) => {
90 let age = Utc::now().signed_duration_since(hb.last_attempt_at);
91 let stale =
92 age.num_milliseconds() as u128 > (heartbeat_interval.as_millis().saturating_mul(3));
93 if stale || !matches!(hb.outcome, crate::runtime::HeartbeatOutcome::Ok) {
94 TrayVariant::Disconnected
95 } else {
96 TrayVariant::Idle
97 }
98 }
99 }
100}
101
102pub mod menu_ids {
105 pub const OPEN_WINDOW: &str = "studio-worker.open-window";
106 pub const TOGGLE_AUTO: &str = "studio-worker.toggle-auto";
107 pub const QUIT: &str = "studio-worker.quit";
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct MenuLabels {
114 pub open_window: &'static str,
115 pub toggle_auto: String,
116 pub quit: &'static str,
117}
118
119pub fn menu_labels(auto_enabled: bool) -> MenuLabels {
120 MenuLabels {
121 open_window: "Open Window",
122 toggle_auto: if auto_enabled {
123 "Pause claiming".into()
124 } else {
125 "Resume claiming".into()
126 },
127 quit: "Quit",
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::runtime::HeartbeatOutcome;
135 use chrono::Duration as ChronoDuration;
136
137 #[test]
138 fn variant_is_busy_when_busy_regardless_of_heartbeat() {
139 let hb = HeartbeatStatus {
140 last_attempt_at: Utc::now(),
141 outcome: HeartbeatOutcome::Err { reason: "x".into() },
142 };
143 assert_eq!(
144 derive_variant(true, Some(&hb), Duration::from_secs(5)),
145 TrayVariant::Busy
146 );
147 }
148
149 #[test]
150 fn variant_is_disconnected_without_any_heartbeat() {
151 assert_eq!(
152 derive_variant(false, None, Duration::from_secs(5)),
153 TrayVariant::Disconnected
154 );
155 }
156
157 #[test]
158 fn variant_is_idle_when_heartbeat_recent_and_ok() {
159 let hb = HeartbeatStatus {
160 last_attempt_at: Utc::now() - ChronoDuration::seconds(1),
161 outcome: HeartbeatOutcome::Ok,
162 };
163 assert_eq!(
164 derive_variant(false, Some(&hb), Duration::from_secs(5)),
165 TrayVariant::Idle
166 );
167 }
168
169 #[test]
170 fn variant_is_disconnected_when_heartbeat_failed() {
171 let hb = HeartbeatStatus {
172 last_attempt_at: Utc::now(),
173 outcome: HeartbeatOutcome::Err {
174 reason: "5xx".into(),
175 },
176 };
177 assert_eq!(
178 derive_variant(false, Some(&hb), Duration::from_secs(5)),
179 TrayVariant::Disconnected
180 );
181 }
182
183 #[test]
184 fn variant_is_disconnected_when_heartbeat_stale() {
185 let hb = HeartbeatStatus {
187 last_attempt_at: Utc::now() - ChronoDuration::seconds(30),
188 outcome: HeartbeatOutcome::Ok,
189 };
190 assert_eq!(
191 derive_variant(false, Some(&hb), Duration::from_secs(5)),
192 TrayVariant::Disconnected
193 );
194 }
195
196 #[test]
197 fn rgba_16_is_correct_size() {
198 assert_eq!(TrayVariant::Idle.rgba_16().len(), 16 * 16 * 4);
199 assert_eq!(TrayVariant::Busy.rgba_16().len(), 16 * 16 * 4);
200 assert_eq!(TrayVariant::Disconnected.rgba_16().len(), 16 * 16 * 4);
201 }
202
203 #[test]
204 fn rgba_to_argb32_rotates_each_pixel_and_preserves_length() {
205 let rgba = vec![0x11, 0x22, 0x33, 0xFF];
207 assert_eq!(rgba_to_argb32(&rgba), vec![0xFF, 0x11, 0x22, 0x33]);
208 let clear = vec![0x40, 0x50, 0x60, 0x00];
210 assert_eq!(rgba_to_argb32(&clear), vec![0x00, 0x40, 0x50, 0x60]);
211 assert_eq!(
213 rgba_to_argb32(&TrayVariant::Idle.rgba_16()).len(),
214 16 * 16 * 4
215 );
216 }
217
218 #[test]
219 fn rgba_16_disk_has_opaque_centre_and_clear_corners() {
220 let buf = TrayVariant::Idle.rgba_16();
221 let centre = (8 * 16 + 8) * 4;
223 assert_eq!(buf[centre + 3], 0xFF);
224 assert_eq!(buf[3], 0);
226 }
227
228 #[test]
229 fn menu_labels_flip_with_auto_enabled() {
230 assert_eq!(menu_labels(true).toggle_auto, "Pause claiming");
231 assert_eq!(menu_labels(false).toggle_auto, "Resume claiming");
232 assert_eq!(menu_labels(true).open_window, "Open Window");
233 assert_eq!(menu_labels(true).quit, "Quit");
234 }
235}