Skip to main content

studio_worker/ui/
tray.rs

1//! Tray icon state + menu factory (pure data).  The per-OS tray
2//! construction lives in [`super::tray_host`] (`ksni` on Linux,
3//! `tray-icon` on macOS / Windows); this module keeps the logic that
4//! decides *what* the tray looks like (icon variant, menu labels,
5//! ARGB byte order) free of any platform types so it stays
6//! unit-testable.
7
8use std::time::Duration;
9
10use chrono::Utc;
11
12use crate::runtime::HeartbeatStatus;
13
14/// What the tray icon currently advertises about the worker.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TrayVariant {
17    Idle,
18    Busy,
19    Disconnected,
20}
21
22impl TrayVariant {
23    /// 16x16 RGBA bytes for the icon.  Each variant is a solid
24    /// coloured disk so users distinguish state at a glance without
25    /// shipping bespoke art for v1.
26    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),         // green
30            TrayVariant::Busy => (0xE8, 0xA8, 0x38),         // amber
31            TrayVariant::Disconnected => (0xD0, 0x60, 0x60), // red
32        };
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
63/// Convert an RGBA byte buffer (what [`TrayVariant::rgba_16`] produces,
64/// the format `tray-icon` wants) into the ARGB32 network-byte-order
65/// layout `ksni` expects for its `icon_pixmap`.  Each 4-byte
66/// `[R, G, B, A]` group is rotated right by one to `[A, R, G, B]`.
67/// Pure so the byte-order contract is unit-tested without a live tray.
68pub 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
76/// Derive the tray variant from live state.  A heartbeat that's
77/// missing or older than `disconnect_threshold` flips the variant
78/// to `Disconnected`.
79pub 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
102/// Stable IDs used both as `MenuId`s on the muda side and to match
103/// menu events back to actions.
104pub 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/// Menu labels the tray exposes given current state.  Pure data so
111/// it's snapshot-testable.
112#[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        // Heartbeat 30s ago with 5s interval → 6x older than interval.
186        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        // One opaque pixel [R, G, B, A] -> [A, R, G, B].
206        let rgba = vec![0x11, 0x22, 0x33, 0xFF];
207        assert_eq!(rgba_to_argb32(&rgba), vec![0xFF, 0x11, 0x22, 0x33]);
208        // A transparent pixel keeps its zero alpha at the front.
209        let clear = vec![0x40, 0x50, 0x60, 0x00];
210        assert_eq!(rgba_to_argb32(&clear), vec![0x00, 0x40, 0x50, 0x60]);
211        // Length is preserved for the real 16x16 icon buffer.
212        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        // Centre pixel (8, 8): alpha = 0xFF
222        let centre = (8 * 16 + 8) * 4;
223        assert_eq!(buf[centre + 3], 0xFF);
224        // Corner pixel (0, 0): alpha = 0
225        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}