Skip to main content

entracte_lib/
tray.rs

1use std::sync::{Arc, Mutex};
2use std::time::{Duration, Instant};
3
4use chrono::{Local, Timelike};
5use tauri::{
6    image::Image,
7    menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
8    tray::{TrayIcon, TrayIconBuilder},
9    AppHandle, Emitter, Listener, Manager,
10};
11
12use crate::scheduler::{
13    format_countdown, BreakKind, LastBreakInfo, PauseState, Scheduler, TrayCountdownSnapshot,
14};
15
16const TRAY_ICON_BYTES: &[u8] = include_bytes!("../icons/trayIconTemplate.png");
17#[cfg_attr(target_os = "windows", allow(dead_code))]
18const TRAY_ICON_PAUSED_BYTES: &[u8] = include_bytes!("../icons/trayIconPausedTemplate.png");
19#[cfg_attr(target_os = "windows", allow(dead_code))]
20const TRAY_ICON_BEDTIME_BYTES: &[u8] = include_bytes!("../icons/trayIconBedtimeTemplate.png");
21// Distinct icon for the auto-suppressed state (DND / camera / video /
22// app-pause / idle / out-of-work-window). Previously this state shared
23// the Paused icon, which made every webcam call or video tab look like
24// the user had hit Pause — confusing diagnostic noise on the tray.
25#[cfg_attr(target_os = "windows", allow(dead_code))]
26const TRAY_ICON_INACTIVE_BYTES: &[u8] = include_bytes!("../icons/trayIconInactiveTemplate.png");
27
28#[cfg_attr(target_os = "windows", allow(dead_code))]
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum TrayIconKind {
31    Normal,
32    Paused,
33    Bedtime,
34    Inactive,
35}
36
37#[cfg_attr(target_os = "windows", allow(dead_code))]
38impl TrayIconKind {
39    fn bytes(self) -> &'static [u8] {
40        match self {
41            TrayIconKind::Normal => TRAY_ICON_BYTES,
42            TrayIconKind::Paused => TRAY_ICON_PAUSED_BYTES,
43            TrayIconKind::Bedtime => TRAY_ICON_BEDTIME_BYTES,
44            TrayIconKind::Inactive => TRAY_ICON_INACTIVE_BYTES,
45        }
46    }
47}
48
49pub fn seconds_until_tomorrow_morning() -> u64 {
50    let now = Local::now();
51    let target = (now + chrono::Duration::days(1))
52        .with_hour(6)
53        .and_then(|t| t.with_minute(0))
54        .and_then(|t| t.with_second(0))
55        .and_then(|t| t.with_nanosecond(0))
56        .unwrap_or(now);
57    ((target.timestamp() - now.timestamp()).max(60)) as u64
58}
59
60fn resume_break_label(kind: Option<BreakKind>) -> String {
61    match kind {
62        Some(BreakKind::Micro) => "Resume last skipped Micro break".to_string(),
63        Some(BreakKind::Long) => "Resume last skipped Long break".to_string(),
64        Some(BreakKind::Sleep) => "Resume last skipped Bedtime reminder".to_string(),
65        None => "Resume last skipped break".to_string(),
66    }
67}
68
69fn tooltip_for(profile: &str) -> String {
70    format!("Entracte · {profile}")
71}
72
73/// Tooltip that also explains the current visual state. When breaks
74/// are silently auto-suppressed (DND, camera, video, app-pause,
75/// off-hours) we append a "Why: …" line so a hover answers the
76/// "why is the icon dim?" question without opening Settings.
77fn tooltip_for_state(profile: &str, snapshot: &TrayCountdownSnapshot) -> String {
78    let base = tooltip_for(profile);
79    match snapshot {
80        TrayCountdownSnapshot::Suppressed(r) => format!("{base}\nInactive: {}", r.human()),
81        TrayCountdownSnapshot::Paused => format!("{base}\nPaused"),
82        TrayCountdownSnapshot::Bedtime => format!("{base}\nBedtime"),
83        TrayCountdownSnapshot::OnBreak => format!("{base}\nOn break"),
84        TrayCountdownSnapshot::Disabled
85        | TrayCountdownSnapshot::Idle
86        | TrayCountdownSnapshot::Running(_) => base,
87    }
88}
89
90fn profile_menu_id(name: &str) -> String {
91    format!("profile:{name}")
92}
93
94fn build_profile_submenu(
95    app: &AppHandle,
96    profiles: &[String],
97    active: &str,
98) -> tauri::Result<Submenu<tauri::Wry>> {
99    let mut items: Vec<CheckMenuItem<tauri::Wry>> = Vec::with_capacity(profiles.len());
100    for name in profiles {
101        let item = CheckMenuItem::with_id(
102            app,
103            profile_menu_id(name),
104            name,
105            true,
106            name == active,
107            None::<&str>,
108        )?;
109        items.push(item);
110    }
111    let item_refs: Vec<&dyn tauri::menu::IsMenuItem<tauri::Wry>> = items
112        .iter()
113        .map(|i| i as &dyn tauri::menu::IsMenuItem<tauri::Wry>)
114        .collect();
115    Submenu::with_items(app, "Active profile", true, &item_refs)
116}
117
118pub fn setup(app: &AppHandle) -> tauri::Result<()> {
119    let prefs = MenuItem::with_id(app, "preferences", "Preferences…", true, None::<&str>)?;
120    let resume = MenuItem::with_id(app, "resume", "Resume", false, None::<&str>)?;
121    let resume_break = MenuItem::with_id(
122        app,
123        "resume_break",
124        resume_break_label(None),
125        false,
126        None::<&str>,
127    )?;
128
129    let pause_15m = MenuItem::with_id(app, "pause_15m", "15 minutes", true, None::<&str>)?;
130    let pause_30m = MenuItem::with_id(app, "pause_30m", "30 minutes", true, None::<&str>)?;
131    let pause_1h = MenuItem::with_id(app, "pause_1h", "1 hour", true, None::<&str>)?;
132    let pause_2h = MenuItem::with_id(app, "pause_2h", "2 hours", true, None::<&str>)?;
133    let pause_4h = MenuItem::with_id(app, "pause_4h", "4 hours", true, None::<&str>)?;
134    let pause_tomorrow = MenuItem::with_id(
135        app,
136        "pause_tomorrow",
137        "Until tomorrow 6 am",
138        true,
139        None::<&str>,
140    )?;
141    let pause_indef = MenuItem::with_id(app, "pause_indef", "Indefinitely", true, None::<&str>)?;
142
143    let pause_submenu = Submenu::with_items(
144        app,
145        "Pause for…",
146        true,
147        &[
148            &pause_15m,
149            &pause_30m,
150            &pause_1h,
151            &pause_2h,
152            &pause_4h,
153            &pause_tomorrow,
154            &pause_indef,
155        ],
156    )?;
157
158    let (initial_profiles, initial_active) = read_profiles_blocking(app);
159    let profile_submenu = build_profile_submenu(app, &initial_profiles, &initial_active)?;
160
161    let sep1 = PredefinedMenuItem::separator(app)?;
162    let sep2 = PredefinedMenuItem::separator(app)?;
163    let sep3 = PredefinedMenuItem::separator(app)?;
164    let sep4 = PredefinedMenuItem::separator(app)?;
165    let quit = MenuItem::with_id(app, "quit", "Quit Entracte", true, None::<&str>)?;
166
167    let menu = Menu::with_items(
168        app,
169        &[
170            &prefs,
171            &sep1,
172            &resume,
173            &pause_submenu,
174            &sep2,
175            &profile_submenu,
176            &sep3,
177            &resume_break,
178            &sep4,
179            &quit,
180        ],
181    )?;
182
183    let pause_submenu_for_event = pause_submenu.clone();
184    let resume_for_event = resume.clone();
185    let pause_submenu_for_click = pause_submenu.clone();
186    let resume_for_click = resume.clone();
187    let resume_break_for_event = resume_break.clone();
188
189    let tray_icon = Image::from_bytes(TRAY_ICON_BYTES)?;
190
191    let tray = TrayIconBuilder::with_id("main")
192        .icon(tray_icon)
193        .icon_as_template(true)
194        .menu(&menu)
195        .tooltip(tooltip_for(&initial_active))
196        .show_menu_on_left_click(true)
197        .on_menu_event(move |app, event| {
198            let id = event.id.as_ref();
199            if let Some(profile_name) = id.strip_prefix("profile:") {
200                let name = profile_name.to_string();
201                let app_handle = app.clone();
202                tauri::async_runtime::spawn(async move {
203                    let scheduler = app_handle.state::<Scheduler>().inner().clone();
204                    if let Err(e) =
205                        crate::scheduler::set_active_profile_impl(&app_handle, &scheduler, name)
206                            .await
207                    {
208                        eprintln!("set_active_profile failed: {e}");
209                    }
210                });
211                return;
212            }
213            match id {
214                "quit" => {
215                    app.exit(0);
216                    return;
217                }
218                "preferences" => {
219                    if let Some(w) = app.get_webview_window("main") {
220                        let _ = w.show();
221                        let _ = w.set_focus();
222                    }
223                    return;
224                }
225                "resume" => {
226                    let scheduler = app.state::<Scheduler>().inner().clone();
227                    let app_handle = app.clone();
228                    let pause_submenu = pause_submenu_for_click.clone();
229                    let resume = resume_for_click.clone();
230                    tauri::async_runtime::spawn(async move {
231                        *scheduler.pause_state.lock().await = PauseState::Running;
232                        let _ = pause_submenu.set_enabled(true);
233                        let _ = resume.set_enabled(false);
234                        let _ = app_handle.emit("pause:changed", false);
235                    });
236                    return;
237                }
238                "resume_break" => {
239                    let app_handle = app.clone();
240                    tauri::async_runtime::spawn(async move {
241                        let scheduler = app_handle.state::<Scheduler>().inner().clone();
242                        let _ =
243                            crate::scheduler::resume_last_break_impl(&app_handle, &scheduler).await;
244                    });
245                    return;
246                }
247                _ => {}
248            }
249
250            let duration: Option<Option<u64>> = match id {
251                "pause_15m" => Some(Some(15 * 60)),
252                "pause_30m" => Some(Some(30 * 60)),
253                "pause_1h" => Some(Some(60 * 60)),
254                "pause_2h" => Some(Some(2 * 60 * 60)),
255                "pause_4h" => Some(Some(4 * 60 * 60)),
256                "pause_tomorrow" => Some(Some(seconds_until_tomorrow_morning())),
257                "pause_indef" => Some(None),
258                _ => None,
259            };
260
261            if let Some(d) = duration {
262                let scheduler = app.state::<Scheduler>().inner().clone();
263                let app_handle = app.clone();
264                let pause_submenu = pause_submenu_for_click.clone();
265                let resume = resume_for_click.clone();
266                tauri::async_runtime::spawn(async move {
267                    let until = d.map(|s| Instant::now() + Duration::from_secs(s));
268                    *scheduler.pause_state.lock().await = PauseState::PausedUntil(until);
269                    let _ = pause_submenu.set_enabled(false);
270                    let _ = resume.set_enabled(true);
271                    let _ = app_handle.emit("pause:changed", true);
272                });
273            }
274        })
275        .build(app)?;
276
277    let menu_holder: Arc<Mutex<Menu<tauri::Wry>>> = Arc::new(Mutex::new(menu));
278    let profile_submenu_holder: Arc<Mutex<Submenu<tauri::Wry>>> =
279        Arc::new(Mutex::new(profile_submenu));
280    let tray_holder: Arc<TrayIcon<tauri::Wry>> = Arc::new(tray);
281
282    app.listen("pause:changed", move |event| {
283        let paused: bool = serde_json::from_str(event.payload()).unwrap_or(false);
284        let _ = pause_submenu_for_event.set_enabled(!paused);
285        let _ = resume_for_event.set_enabled(paused);
286    });
287
288    app.listen("last_break:changed", move |event| {
289        let info: LastBreakInfo =
290            serde_json::from_str(event.payload()).unwrap_or(LastBreakInfo { kind: None });
291        let _ = resume_break_for_event.set_text(resume_break_label(info.kind));
292        let _ = resume_break_for_event.set_enabled(info.kind.is_some());
293    });
294
295    let app_for_profile = app.clone();
296    let menu_for_profile = menu_holder.clone();
297    let profile_submenu_for_profile = profile_submenu_holder.clone();
298    let tray_for_profile = tray_holder.clone();
299    let prefs_for_rebuild = prefs.clone();
300    let resume_for_rebuild = resume.clone();
301    let pause_submenu_for_rebuild = pause_submenu.clone();
302    let resume_break_for_rebuild = resume_break.clone();
303    let sep1_for_rebuild = sep1.clone();
304    let sep2_for_rebuild = sep2.clone();
305    let sep3_for_rebuild = sep3.clone();
306    let sep4_for_rebuild = sep4.clone();
307    let quit_for_rebuild = quit.clone();
308    app.listen("profile:changed", move |_event| {
309        let app = app_for_profile.clone();
310        let menu_holder = menu_for_profile.clone();
311        let profile_submenu_holder = profile_submenu_for_profile.clone();
312        let tray = tray_for_profile.clone();
313        let prefs = prefs_for_rebuild.clone();
314        let resume = resume_for_rebuild.clone();
315        let pause_submenu = pause_submenu_for_rebuild.clone();
316        let resume_break = resume_break_for_rebuild.clone();
317        let sep1 = sep1_for_rebuild.clone();
318        let sep2 = sep2_for_rebuild.clone();
319        let sep3 = sep3_for_rebuild.clone();
320        let sep4 = sep4_for_rebuild.clone();
321        let quit = quit_for_rebuild.clone();
322        tauri::async_runtime::spawn(async move {
323            let scheduler = app.state::<Scheduler>().inner().clone();
324            let profiles: Vec<String> = scheduler
325                .profiles
326                .lock()
327                .await
328                .iter()
329                .map(|p| p.name.clone())
330                .collect();
331            let active = scheduler.active_profile_name.lock().await.clone();
332            let Ok(new_submenu) = build_profile_submenu(&app, &profiles, &active) else {
333                return;
334            };
335            let Ok(new_menu) = Menu::with_items(
336                &app,
337                &[
338                    &prefs,
339                    &sep1,
340                    &resume,
341                    &pause_submenu,
342                    &sep2,
343                    &new_submenu,
344                    &sep3,
345                    &resume_break,
346                    &sep4,
347                    &quit,
348                ],
349            ) else {
350                return;
351            };
352            let _ = tray.set_menu(Some(new_menu.clone()));
353            let _ = tray.set_tooltip(Some(tooltip_for(&active)));
354            if let Ok(mut slot) = menu_holder.lock() {
355                *slot = new_menu;
356            }
357            if let Ok(mut slot) = profile_submenu_holder.lock() {
358                *slot = new_submenu;
359            }
360        });
361    });
362
363    spawn_countdown_ticker(app.clone(), tray_holder.clone());
364
365    Ok(())
366}
367
368#[cfg_attr(target_os = "windows", allow(dead_code))]
369fn tray_title_for(snapshot: &TrayCountdownSnapshot, text_enabled: bool) -> Option<String> {
370    // Tray title is always-visible real estate. Users who turned off
371    // the countdown text don't want ANY text bleed (paused, reason,
372    // etc.) — the icon swap alone carries the signal. The tooltip
373    // (hover-only, opt-in) still shows the reason regardless.
374    if !text_enabled {
375        return Some(String::new());
376    }
377    let body = match snapshot {
378        TrayCountdownSnapshot::Disabled => return Some(String::new()),
379        TrayCountdownSnapshot::Paused => "paused".to_string(),
380        TrayCountdownSnapshot::Bedtime => return Some(String::new()),
381        TrayCountdownSnapshot::OnBreak => return Some(String::new()),
382        TrayCountdownSnapshot::Suppressed(r) => return Some(format!(" {}", r.short_label())),
383        TrayCountdownSnapshot::Idle => return Some(String::new()),
384        TrayCountdownSnapshot::Running(secs) => format_countdown(*secs),
385    };
386    Some(format!(" {body}"))
387}
388
389#[cfg_attr(target_os = "windows", allow(dead_code))]
390fn tray_icon_kind_for(snapshot: &TrayCountdownSnapshot) -> TrayIconKind {
391    match snapshot {
392        TrayCountdownSnapshot::Bedtime => TrayIconKind::Bedtime,
393        TrayCountdownSnapshot::Paused => TrayIconKind::Paused,
394        TrayCountdownSnapshot::Suppressed(_) => TrayIconKind::Inactive,
395        _ => TrayIconKind::Normal,
396    }
397}
398
399fn spawn_countdown_ticker(app: AppHandle, tray: Arc<TrayIcon<tauri::Wry>>) {
400    tauri::async_runtime::spawn(async move {
401        let mut last_icon: Option<TrayIconKind> = None;
402        let mut last_tooltip: Option<String> = None;
403        #[cfg(not(target_os = "windows"))]
404        let mut last_title: Option<String> = None;
405        loop {
406            tokio::time::sleep(Duration::from_secs(1)).await;
407            let scheduler = app.state::<Scheduler>().inner().clone();
408            let (snapshot, text_enabled) = scheduler.tray_countdown_snapshot().await;
409            let icon_kind = tray_icon_kind_for(&snapshot);
410            if Some(icon_kind) != last_icon {
411                if let Ok(icon) = Image::from_bytes(icon_kind.bytes()) {
412                    let _ = tray.set_icon(Some(icon));
413                    let _ = tray.set_icon_as_template(true);
414                }
415                last_icon = Some(icon_kind);
416            }
417            // Tooltip refresh also runs on Windows — that platform's
418            // tray doesn't render a title, but the tooltip is the only
419            // place a hover can say "Inactive: camera in use".
420            let profile = scheduler.active_profile_name.lock().await.clone();
421            let tooltip = tooltip_for_state(&profile, &snapshot);
422            if Some(&tooltip) != last_tooltip.as_ref() {
423                let _ = tray.set_tooltip(Some(tooltip.clone()));
424                last_tooltip = Some(tooltip);
425            }
426            #[cfg(not(target_os = "windows"))]
427            {
428                let title = tray_title_for(&snapshot, text_enabled);
429                if title != last_title {
430                    let _ = tray.set_title(title.clone());
431                    #[cfg(target_os = "macos")]
432                    {
433                        let _ = app.run_on_main_thread(apply_monospaced_status_titles);
434                    }
435                    last_title = title;
436                }
437            }
438            // `text_enabled` is consumed by the title-gating block above;
439            // Windows skips that block so we silence the unused warning.
440            #[cfg(target_os = "windows")]
441            let _ = text_enabled;
442        }
443    });
444}
445
446#[cfg(target_os = "macos")]
447fn apply_monospaced_status_titles() {
448    use objc2::msg_send;
449    use objc2::rc::Retained;
450    use objc2::runtime::AnyObject;
451    use objc2::AnyThread;
452    use objc2_app_kit::{
453        NSFont, NSFontAttributeName, NSFontFeatureSelectorIdentifierKey,
454        NSFontFeatureSettingsAttribute, NSFontFeatureTypeIdentifierKey, NSStatusBar,
455    };
456    use objc2_foundation::{
457        MainThreadMarker, NSArray, NSAttributedString, NSDictionary, NSNumber, NSString,
458    };
459
460    let Some(mtm) = MainThreadMarker::new() else {
461        return;
462    };
463
464    unsafe {
465        let bar = NSStatusBar::systemStatusBar();
466        let responds: bool = msg_send![&*bar, respondsToSelector: objc2::sel!(_statusItems)];
467        if !responds {
468            return;
469        }
470        let items: Retained<AnyObject> = msg_send![&*bar, _statusItems];
471        let n: usize = msg_send![&*items, count];
472        if n == 0 {
473            return;
474        }
475
476        let number_spacing_type = NSNumber::new_i32(6);
477        let monospaced_numbers_selector = NSNumber::new_i32(0);
478        let feature_dict = NSDictionary::from_slices::<NSString>(
479            &[
480                NSFontFeatureTypeIdentifierKey,
481                NSFontFeatureSelectorIdentifierKey,
482            ],
483            &[
484                &*number_spacing_type as &AnyObject,
485                &*monospaced_numbers_selector as &AnyObject,
486            ],
487        );
488        let features_array = NSArray::from_retained_slice(&[feature_dict]);
489        let desc_attrs = NSDictionary::from_slices::<NSString>(
490            &[NSFontFeatureSettingsAttribute],
491            &[&*features_array as &AnyObject],
492        );
493
494        let base_font = NSFont::menuBarFontOfSize(0.0);
495        let base_size = base_font.pointSize();
496        let base_desc = base_font.fontDescriptor();
497        let mono_desc = base_desc.fontDescriptorByAddingAttributes(&desc_attrs);
498        let Some(mono_font) = NSFont::fontWithDescriptor_size(&mono_desc, base_size) else {
499            return;
500        };
501
502        let attrs = NSDictionary::from_slices::<NSString>(
503            &[NSFontAttributeName],
504            &[&*mono_font as &AnyObject],
505        );
506
507        for i in 0..n {
508            let item: *mut objc2_app_kit::NSStatusItem = msg_send![&*items, pointerAtIndex: i];
509            if item.is_null() {
510                continue;
511            }
512            let item_ref: &objc2_app_kit::NSStatusItem = &*item;
513            let Some(button) = item_ref.button(mtm) else {
514                continue;
515            };
516            let title = button.title();
517            if title.length() == 0 {
518                continue;
519            }
520            let attr_str = NSAttributedString::initWithString_attributes(
521                NSAttributedString::alloc(),
522                &title,
523                Some(&attrs),
524            );
525            button.setAttributedTitle(&attr_str);
526        }
527    }
528}
529
530fn read_profiles_blocking(app: &AppHandle) -> (Vec<String>, String) {
531    let scheduler = app.state::<Scheduler>().inner().clone();
532    tauri::async_runtime::block_on(async move {
533        let profiles = scheduler
534            .profiles
535            .lock()
536            .await
537            .iter()
538            .map(|p| p.name.clone())
539            .collect();
540        let active = scheduler.active_profile_name.lock().await.clone();
541        (profiles, active)
542    })
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::scheduler::SuppressReason;
549
550    #[test]
551    fn tomorrow_morning_within_36_hours() {
552        let secs = seconds_until_tomorrow_morning();
553        assert!(secs >= 60);
554        assert!(secs <= 36 * 60 * 60);
555    }
556
557    #[test]
558    fn tooltip_format_includes_profile_name() {
559        assert_eq!(tooltip_for("Default"), "Entracte · Default");
560        assert_eq!(tooltip_for("Work"), "Entracte · Work");
561    }
562
563    #[test]
564    fn profile_menu_id_namespaces_name() {
565        assert_eq!(profile_menu_id("Default"), "profile:Default");
566        assert_eq!(profile_menu_id("Work mode"), "profile:Work mode");
567    }
568
569    fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
570        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
571        assert_eq!(&bytes[12..16], b"IHDR", "missing IHDR chunk");
572        let w = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
573        let h = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
574        (w, h)
575    }
576
577    #[test]
578    fn tray_icons_are_pngs_with_matching_dimensions() {
579        let running = png_dimensions(TRAY_ICON_BYTES);
580        let paused = png_dimensions(TRAY_ICON_PAUSED_BYTES);
581        let bedtime = png_dimensions(TRAY_ICON_BEDTIME_BYTES);
582        let inactive = png_dimensions(TRAY_ICON_INACTIVE_BYTES);
583        assert_eq!(
584            running, paused,
585            "running and paused tray icons must share dimensions so the swap is seamless"
586        );
587        assert_eq!(
588            running, bedtime,
589            "bedtime tray icon must share dimensions with the running icon"
590        );
591        assert_eq!(
592            running, inactive,
593            "inactive (auto-suppressed) tray icon must share dimensions with the running icon"
594        );
595    }
596
597    #[test]
598    fn tray_title_for_states_when_text_enabled() {
599        let on = true;
600        assert_eq!(
601            tray_title_for(&TrayCountdownSnapshot::Disabled, on),
602            Some(String::new())
603        );
604        assert_eq!(
605            tray_title_for(&TrayCountdownSnapshot::Paused, on),
606            Some(" paused".to_string())
607        );
608        assert_eq!(
609            tray_title_for(&TrayCountdownSnapshot::Bedtime, on),
610            Some(String::new())
611        );
612        assert_eq!(
613            tray_title_for(&TrayCountdownSnapshot::OnBreak, on),
614            Some(String::new())
615        );
616        assert_eq!(
617            tray_title_for(&TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd), on),
618            Some(" DND".to_string())
619        );
620        assert_eq!(
621            tray_title_for(
622                &TrayCountdownSnapshot::Suppressed(SuppressReason::Camera),
623                on
624            ),
625            Some(" camera".to_string())
626        );
627        assert_eq!(
628            tray_title_for(
629                &TrayCountdownSnapshot::Suppressed(SuppressReason::Video),
630                on
631            ),
632            Some(" video".to_string())
633        );
634        assert_eq!(
635            tray_title_for(&TrayCountdownSnapshot::Idle, on),
636            Some(String::new())
637        );
638        assert_eq!(
639            tray_title_for(&TrayCountdownSnapshot::Running(754), on),
640            Some(" 12:34".to_string())
641        );
642        assert_eq!(
643            tray_title_for(&TrayCountdownSnapshot::Running(65), on),
644            Some(" 1:05".to_string())
645        );
646    }
647
648    #[test]
649    fn tray_title_for_returns_empty_for_every_state_when_text_disabled() {
650        // `tray_countdown_enabled = false` means the user opted out of
651        // any always-visible text — paused / reason / countdown — and
652        // wants the icon swap to be the only signal. The tooltip
653        // (hover-only) still carries detail; see tooltip_for_state.
654        let off = false;
655        for snap in [
656            TrayCountdownSnapshot::Disabled,
657            TrayCountdownSnapshot::Paused,
658            TrayCountdownSnapshot::Bedtime,
659            TrayCountdownSnapshot::OnBreak,
660            TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd),
661            TrayCountdownSnapshot::Suppressed(SuppressReason::Camera),
662            TrayCountdownSnapshot::Suppressed(SuppressReason::WorkWindow),
663            TrayCountdownSnapshot::Idle,
664            TrayCountdownSnapshot::Running(60),
665            TrayCountdownSnapshot::Running(0),
666        ] {
667            assert_eq!(
668                tray_title_for(&snap, off),
669                Some(String::new()),
670                "{snap:?} must show no title when text is disabled",
671            );
672        }
673    }
674
675    #[test]
676    fn tooltip_for_state_appends_reason_only_when_inactive() {
677        // Sanity: the base profile tooltip is the prefix in every case;
678        // we only ever ADD a second line, never rewrite the first.
679        let base = tooltip_for("Default");
680        assert!(
681            tooltip_for_state("Default", &TrayCountdownSnapshot::Running(60)).starts_with(&base),
682            "tooltip should always lead with the profile line"
683        );
684        assert_eq!(
685            tooltip_for_state(
686                "Default",
687                &TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd)
688            ),
689            format!("{base}\nInactive: {}", SuppressReason::Dnd.human()),
690        );
691        assert_eq!(
692            tooltip_for_state("Default", &TrayCountdownSnapshot::Paused),
693            format!("{base}\nPaused"),
694        );
695        assert_eq!(
696            tooltip_for_state("Default", &TrayCountdownSnapshot::Bedtime),
697            format!("{base}\nBedtime"),
698        );
699        // No second line for transient/normal states.
700        assert_eq!(
701            tooltip_for_state("Default", &TrayCountdownSnapshot::Idle),
702            base
703        );
704        assert_eq!(
705            tooltip_for_state("Default", &TrayCountdownSnapshot::Running(60)),
706            base
707        );
708    }
709
710    #[test]
711    fn tray_icon_kind_routes_each_snapshot_to_the_right_asset() {
712        assert_eq!(
713            tray_icon_kind_for(&TrayCountdownSnapshot::Bedtime),
714            TrayIconKind::Bedtime
715        );
716        assert_eq!(
717            tray_icon_kind_for(&TrayCountdownSnapshot::Paused),
718            TrayIconKind::Paused
719        );
720        assert_eq!(
721            tray_icon_kind_for(&TrayCountdownSnapshot::Suppressed(SuppressReason::Camera)),
722            TrayIconKind::Inactive,
723            "auto-suppressed must use the distinct inactive icon, not the explicit-pause one"
724        );
725        for snap in [
726            TrayCountdownSnapshot::Disabled,
727            TrayCountdownSnapshot::OnBreak,
728            TrayCountdownSnapshot::Idle,
729            TrayCountdownSnapshot::Running(60),
730        ] {
731            assert_eq!(
732                tray_icon_kind_for(&snap),
733                TrayIconKind::Normal,
734                "{snap:?} should use the normal icon"
735            );
736        }
737    }
738
739    #[test]
740    fn tray_icon_kind_bytes_map_to_distinct_assets() {
741        assert_eq!(TrayIconKind::Normal.bytes(), TRAY_ICON_BYTES);
742        assert_eq!(TrayIconKind::Paused.bytes(), TRAY_ICON_PAUSED_BYTES);
743        assert_eq!(TrayIconKind::Bedtime.bytes(), TRAY_ICON_BEDTIME_BYTES);
744        assert_eq!(TrayIconKind::Inactive.bytes(), TRAY_ICON_INACTIVE_BYTES);
745        // Sanity-check the constants are not the same blob — if two of these
746        // ever drift to identical content the visual signal collapses.
747        assert_ne!(TRAY_ICON_BYTES, TRAY_ICON_BEDTIME_BYTES);
748        assert_ne!(TRAY_ICON_PAUSED_BYTES, TRAY_ICON_BEDTIME_BYTES);
749        assert_ne!(TRAY_ICON_PAUSED_BYTES, TRAY_ICON_INACTIVE_BYTES);
750        assert_ne!(TRAY_ICON_BYTES, TRAY_ICON_INACTIVE_BYTES);
751        assert_ne!(TRAY_ICON_INACTIVE_BYTES, TRAY_ICON_BEDTIME_BYTES);
752    }
753}