Skip to main content

entracte_lib/
tray.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
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, 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    let long_break_now = MenuItem::with_id(
129        app,
130        "long_break_now",
131        "Take a long break now",
132        true,
133        None::<&str>,
134    )?;
135
136    let pause_15m = MenuItem::with_id(app, "pause_15m", "15 minutes", true, None::<&str>)?;
137    let pause_30m = MenuItem::with_id(app, "pause_30m", "30 minutes", true, None::<&str>)?;
138    let pause_1h = MenuItem::with_id(app, "pause_1h", "1 hour", true, None::<&str>)?;
139    let pause_2h = MenuItem::with_id(app, "pause_2h", "2 hours", true, None::<&str>)?;
140    let pause_4h = MenuItem::with_id(app, "pause_4h", "4 hours", true, None::<&str>)?;
141    let pause_tomorrow = MenuItem::with_id(
142        app,
143        "pause_tomorrow",
144        "Until tomorrow 6 am",
145        true,
146        None::<&str>,
147    )?;
148    let pause_indef = MenuItem::with_id(app, "pause_indef", "Indefinitely", true, None::<&str>)?;
149    let pause_sep = PredefinedMenuItem::separator(app)?;
150    let pause_until = MenuItem::with_id(app, "pause_until", "Pause until…", true, None::<&str>)?;
151
152    let pause_submenu = Submenu::with_items(
153        app,
154        "Pause for…",
155        true,
156        &[
157            &pause_15m,
158            &pause_30m,
159            &pause_1h,
160            &pause_2h,
161            &pause_4h,
162            &pause_tomorrow,
163            &pause_indef,
164            &pause_sep,
165            &pause_until,
166        ],
167    )?;
168
169    let (initial_profiles, initial_active) = read_profiles_blocking(app);
170    let profile_submenu = build_profile_submenu(app, &initial_profiles, &initial_active)?;
171
172    let sep1 = PredefinedMenuItem::separator(app)?;
173    let sep2 = PredefinedMenuItem::separator(app)?;
174    let sep3 = PredefinedMenuItem::separator(app)?;
175    let sep4 = PredefinedMenuItem::separator(app)?;
176    let quit = MenuItem::with_id(app, "quit", "Quit Entracte", true, None::<&str>)?;
177
178    let menu = Menu::with_items(
179        app,
180        &[
181            &prefs,
182            &sep1,
183            &resume,
184            &pause_submenu,
185            &sep2,
186            &profile_submenu,
187            &sep3,
188            &resume_break,
189            &long_break_now,
190            &sep4,
191            &quit,
192        ],
193    )?;
194
195    let pause_submenu_for_event = pause_submenu.clone();
196    let resume_for_event = resume.clone();
197    let pause_submenu_for_click = pause_submenu.clone();
198    let resume_for_click = resume.clone();
199    let resume_break_for_event = resume_break.clone();
200
201    let tray_icon = tray_image(TrayIconKind::Normal, std::env::consts::OS)?;
202
203    let tray = TrayIconBuilder::with_id("main")
204        .icon(tray_icon)
205        .icon_as_template(icon_is_template(std::env::consts::OS))
206        .menu(&menu)
207        .tooltip(tooltip_for(&initial_active))
208        .show_menu_on_left_click(true)
209        .on_menu_event(move |app, event| {
210            let id = event.id.as_ref();
211            if let Some(profile_name) = id.strip_prefix("profile:") {
212                let name = profile_name.to_string();
213                let app_handle = app.clone();
214                tauri::async_runtime::spawn(async move {
215                    let scheduler = app_handle.state::<Scheduler>().inner().clone();
216                    if let Err(e) =
217                        crate::scheduler::set_active_profile_impl(&app_handle, &scheduler, name)
218                            .await
219                    {
220                        eprintln!("set_active_profile failed: {e}");
221                    }
222                });
223                return;
224            }
225            match id {
226                "quit" => {
227                    app.exit(0);
228                    return;
229                }
230                "preferences" => {
231                    crate::window::show_main_window(app);
232                    return;
233                }
234                "pause_until" => {
235                    crate::window::show_pause_window(app);
236                    return;
237                }
238                "resume" => {
239                    let scheduler = app.state::<Scheduler>().inner().clone();
240                    let app_handle = app.clone();
241                    let pause_submenu = pause_submenu_for_click.clone();
242                    let resume = resume_for_click.clone();
243                    tauri::async_runtime::spawn(async move {
244                        crate::scheduler::resume_impl(&scheduler).await;
245                        let _ = pause_submenu.set_enabled(true);
246                        let _ = resume.set_enabled(false);
247                        let _ = app_handle.emit("pause:changed", false);
248                    });
249                    return;
250                }
251                "resume_break" => {
252                    let app_handle = app.clone();
253                    tauri::async_runtime::spawn(async move {
254                        let scheduler = app_handle.state::<Scheduler>().inner().clone();
255                        let _ =
256                            crate::scheduler::resume_last_break_impl(&app_handle, &scheduler).await;
257                    });
258                    return;
259                }
260                "long_break_now" => {
261                    let app_handle = app.clone();
262                    tauri::async_runtime::spawn(async move {
263                        let scheduler = app_handle.state::<Scheduler>().inner().clone();
264                        crate::scheduler::start_long_break_now_impl(&app_handle, &scheduler).await;
265                    });
266                    return;
267                }
268                _ => {}
269            }
270
271            let duration: Option<Option<u64>> = match id {
272                "pause_15m" => Some(Some(15 * 60)),
273                "pause_30m" => Some(Some(30 * 60)),
274                "pause_1h" => Some(Some(60 * 60)),
275                "pause_2h" => Some(Some(2 * 60 * 60)),
276                "pause_4h" => Some(Some(4 * 60 * 60)),
277                "pause_tomorrow" => Some(Some(seconds_until_tomorrow_morning())),
278                "pause_indef" => Some(None),
279                _ => None,
280            };
281
282            if let Some(d) = duration {
283                let scheduler = app.state::<Scheduler>().inner().clone();
284                let app_handle = app.clone();
285                let pause_submenu = pause_submenu_for_click.clone();
286                let resume = resume_for_click.clone();
287                tauri::async_runtime::spawn(async move {
288                    // `d` is the inner `Option<u64>`: `None` = indefinite,
289                    // `Some(secs)` = timed. Route through the canonical
290                    // `pause_impl` so the pause persists, logs `PauseStart`,
291                    // and fires `pause_start` hooks like the Settings button
292                    // (#218).
293                    crate::scheduler::pause_impl(&scheduler, d).await;
294                    let _ = pause_submenu.set_enabled(false);
295                    let _ = resume.set_enabled(true);
296                    let _ = app_handle.emit("pause:changed", true);
297                });
298            }
299        })
300        .build(app)?;
301
302    let menu_holder: Arc<Mutex<Menu<tauri::Wry>>> = Arc::new(Mutex::new(menu));
303    let profile_submenu_holder: Arc<Mutex<Submenu<tauri::Wry>>> =
304        Arc::new(Mutex::new(profile_submenu));
305    let tray_holder: Arc<TrayIcon<tauri::Wry>> = Arc::new(tray);
306
307    app.listen("pause:changed", move |event| {
308        let paused: bool = serde_json::from_str(event.payload()).unwrap_or(false);
309        let _ = pause_submenu_for_event.set_enabled(!paused);
310        let _ = resume_for_event.set_enabled(paused);
311    });
312
313    app.listen("last_break:changed", move |event| {
314        let info: LastBreakInfo =
315            serde_json::from_str(event.payload()).unwrap_or(LastBreakInfo { kind: None });
316        let _ = resume_break_for_event.set_text(resume_break_label(info.kind));
317        let _ = resume_break_for_event.set_enabled(info.kind.is_some());
318    });
319
320    let app_for_profile = app.clone();
321    let menu_for_profile = menu_holder.clone();
322    let profile_submenu_for_profile = profile_submenu_holder.clone();
323    let tray_for_profile = tray_holder.clone();
324    let prefs_for_rebuild = prefs.clone();
325    let resume_for_rebuild = resume.clone();
326    let pause_submenu_for_rebuild = pause_submenu.clone();
327    let resume_break_for_rebuild = resume_break.clone();
328    let long_break_now_for_rebuild = long_break_now.clone();
329    let sep1_for_rebuild = sep1.clone();
330    let sep2_for_rebuild = sep2.clone();
331    let sep3_for_rebuild = sep3.clone();
332    let sep4_for_rebuild = sep4.clone();
333    let quit_for_rebuild = quit.clone();
334    app.listen("profile:changed", move |_event| {
335        let app = app_for_profile.clone();
336        let menu_holder = menu_for_profile.clone();
337        let profile_submenu_holder = profile_submenu_for_profile.clone();
338        let tray = tray_for_profile.clone();
339        let prefs = prefs_for_rebuild.clone();
340        let resume = resume_for_rebuild.clone();
341        let pause_submenu = pause_submenu_for_rebuild.clone();
342        let resume_break = resume_break_for_rebuild.clone();
343        let long_break_now = long_break_now_for_rebuild.clone();
344        let sep1 = sep1_for_rebuild.clone();
345        let sep2 = sep2_for_rebuild.clone();
346        let sep3 = sep3_for_rebuild.clone();
347        let sep4 = sep4_for_rebuild.clone();
348        let quit = quit_for_rebuild.clone();
349        tauri::async_runtime::spawn(async move {
350            let scheduler = app.state::<Scheduler>().inner().clone();
351            let profiles: Vec<String> = scheduler
352                .profiles
353                .lock()
354                .await
355                .iter()
356                .map(|p| p.name.clone())
357                .collect();
358            let active = scheduler.active_profile_name.lock().await.clone();
359            let Ok(new_submenu) = build_profile_submenu(&app, &profiles, &active) else {
360                return;
361            };
362            let Ok(new_menu) = Menu::with_items(
363                &app,
364                &[
365                    &prefs,
366                    &sep1,
367                    &resume,
368                    &pause_submenu,
369                    &sep2,
370                    &new_submenu,
371                    &sep3,
372                    &resume_break,
373                    &long_break_now,
374                    &sep4,
375                    &quit,
376                ],
377            ) else {
378                return;
379            };
380            let _ = tray.set_menu(Some(new_menu.clone()));
381            let _ = tray.set_tooltip(Some(tooltip_for(&active)));
382            if let Ok(mut slot) = menu_holder.lock() {
383                *slot = new_menu;
384            }
385            if let Ok(mut slot) = profile_submenu_holder.lock() {
386                *slot = new_submenu;
387            }
388        });
389    });
390
391    spawn_countdown_ticker(app.clone(), tray_holder.clone());
392
393    Ok(())
394}
395
396#[cfg_attr(target_os = "windows", allow(dead_code))]
397fn tray_title_for(snapshot: &TrayCountdownSnapshot, text_enabled: bool) -> Option<String> {
398    // Tray title is always-visible real estate. Users who turned off
399    // the countdown text don't want ANY text bleed (paused, reason,
400    // etc.) — the icon swap alone carries the signal. The tooltip
401    // (hover-only, opt-in) still shows the reason regardless.
402    if !text_enabled {
403        return Some(String::new());
404    }
405    let body = match snapshot {
406        TrayCountdownSnapshot::Disabled => return Some(String::new()),
407        TrayCountdownSnapshot::Paused => "paused".to_string(),
408        TrayCountdownSnapshot::Bedtime => return Some(String::new()),
409        TrayCountdownSnapshot::OnBreak => return Some(String::new()),
410        TrayCountdownSnapshot::Suppressed(r) => return Some(format!(" {}", r.short_label())),
411        TrayCountdownSnapshot::Idle => return Some(String::new()),
412        TrayCountdownSnapshot::Running(secs) => format_countdown(*secs),
413    };
414    Some(format!(" {body}"))
415}
416
417/// Whether the tray icon should be registered as a template image.
418///
419/// Template mode is a macOS-only concept: AppKit recolours a monochrome
420/// template glyph to suit the light/dark menu bar. On Linux
421/// (StatusNotifierItem / AppIndicator) and Windows there is no template
422/// recolouring, so a dark monochrome glyph stays dark and vanishes against
423/// a dark panel (#86). Only macOS gets template mode.
424fn icon_is_template(os: &str) -> bool {
425    os == "macos"
426}
427
428// Panel-agnostic recolouring of the monochrome template glyph for
429// platforms without template support (Linux/Windows). The glyph body is
430// painted near-white so it reads on the dark GNOME top bar — which is
431// black regardless of the GTK light/dark theme — and ringed with a
432// near-black outline so it still reads on light KDE/XFCE/Windows panels.
433// #86: turning template mode off alone left the glyph pure black, so it
434// stayed invisible on the dark panel that prompted the report.
435const TRAY_FILL_RGB: [u8; 3] = [0xF2, 0xF2, 0xF2];
436const TRAY_OUTLINE_RGB: [u8; 3] = [0x14, 0x14, 0x14];
437// Radius in source pixels. The PNGs are 200×200 and the panel renders
438// them ~22px tall, so this ~8px ring survives the downscale as a ~1px halo.
439const TRAY_OUTLINE_RADIUS: i32 = 8;
440// Pixels at/above this alpha count as glyph body; below is background.
441const TRAY_ALPHA_THRESHOLD: u8 = 16;
442
443/// Repaint a monochrome glyph's body to `fill` and ring it with `outline`,
444/// so it contrasts against both light and dark panels. Glyph-body alpha is
445/// preserved (anti-aliased edges stay smooth); the outline ring is fully
446/// opaque; everything else stays transparent.
447fn outline_glyph(
448    rgba: &[u8],
449    width: u32,
450    height: u32,
451    radius: i32,
452    fill: [u8; 3],
453    outline: [u8; 3],
454) -> Vec<u8> {
455    let w = width as i32;
456    let h = height as i32;
457    let is_body = |x: i32, y: i32| -> bool {
458        x >= 0
459            && y >= 0
460            && x < w
461            && y < h
462            && rgba[((y * w + x) as usize) * 4 + 3] >= TRAY_ALPHA_THRESHOLD
463    };
464    let r2 = radius * radius;
465    let mut out = vec![0u8; rgba.len()];
466    for y in 0..h {
467        for x in 0..w {
468            let i = ((y * w + x) as usize) * 4;
469            let a = rgba[i + 3];
470            if a >= TRAY_ALPHA_THRESHOLD {
471                out[i] = fill[0];
472                out[i + 1] = fill[1];
473                out[i + 2] = fill[2];
474                out[i + 3] = a;
475                continue;
476            }
477            let mut near = false;
478            'scan: for dy in -radius..=radius {
479                for dx in -radius..=radius {
480                    if dx * dx + dy * dy > r2 {
481                        continue;
482                    }
483                    if is_body(x + dx, y + dy) {
484                        near = true;
485                        break 'scan;
486                    }
487                }
488            }
489            if near {
490                out[i] = outline[0];
491                out[i + 1] = outline[1];
492                out[i + 2] = outline[2];
493                out[i + 3] = 255;
494            }
495        }
496    }
497    out
498}
499
500fn outline_glyph_for_panels(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
501    outline_glyph(
502        rgba,
503        width,
504        height,
505        TRAY_OUTLINE_RADIUS,
506        TRAY_FILL_RGB,
507        TRAY_OUTLINE_RGB,
508    )
509}
510
511/// Decode a tray-icon asset and adapt it to the platform: macOS keeps the
512/// raw black template (AppKit tints it), every other OS gets the
513/// light-fill/dark-outline recolour so the glyph survives a dark panel (#86).
514fn tray_image(kind: TrayIconKind, os: &str) -> tauri::Result<Image<'static>> {
515    let base = Image::from_bytes(kind.bytes())?;
516    if icon_is_template(os) {
517        return Ok(base);
518    }
519    let rgba = outline_glyph_for_panels(base.rgba(), base.width(), base.height());
520    Ok(Image::new_owned(rgba, base.width(), base.height()))
521}
522
523#[cfg_attr(target_os = "windows", allow(dead_code))]
524fn tray_icon_kind_for(snapshot: &TrayCountdownSnapshot) -> TrayIconKind {
525    match snapshot {
526        TrayCountdownSnapshot::Bedtime => TrayIconKind::Bedtime,
527        TrayCountdownSnapshot::Paused => TrayIconKind::Paused,
528        TrayCountdownSnapshot::Suppressed(_) => TrayIconKind::Inactive,
529        _ => TrayIconKind::Normal,
530    }
531}
532
533fn spawn_countdown_ticker(app: AppHandle, tray: Arc<TrayIcon<tauri::Wry>>) {
534    tauri::async_runtime::spawn(async move {
535        let mut last_icon: Option<TrayIconKind> = None;
536        let mut last_tooltip: Option<String> = None;
537        #[cfg(not(target_os = "windows"))]
538        let mut last_title: Option<String> = None;
539        loop {
540            tokio::time::sleep(Duration::from_secs(1)).await;
541            let scheduler = app.state::<Scheduler>().inner().clone();
542            let (snapshot, text_enabled) = scheduler.tray_countdown_snapshot().await;
543            let icon_kind = tray_icon_kind_for(&snapshot);
544            if Some(icon_kind) != last_icon {
545                if let Ok(icon) = tray_image(icon_kind, std::env::consts::OS) {
546                    let _ = tray.set_icon(Some(icon));
547                    let _ = tray.set_icon_as_template(icon_is_template(std::env::consts::OS));
548                }
549                last_icon = Some(icon_kind);
550            }
551            // Tooltip refresh also runs on Windows — that platform's
552            // tray doesn't render a title, but the tooltip is the only
553            // place a hover can say "Inactive: camera in use".
554            let profile = scheduler.active_profile_name.lock().await.clone();
555            let tooltip = tooltip_for_state(&profile, &snapshot);
556            if Some(&tooltip) != last_tooltip.as_ref() {
557                let _ = tray.set_tooltip(Some(tooltip.clone()));
558                last_tooltip = Some(tooltip);
559            }
560            #[cfg(not(target_os = "windows"))]
561            {
562                let title = tray_title_for(&snapshot, text_enabled);
563                if title != last_title {
564                    let _ = tray.set_title(title.clone());
565                    #[cfg(target_os = "macos")]
566                    {
567                        let _ = app.run_on_main_thread(apply_monospaced_status_titles);
568                    }
569                    last_title = title;
570                }
571            }
572            // `text_enabled` is consumed by the title-gating block above;
573            // Windows skips that block so we silence the unused warning.
574            #[cfg(target_os = "windows")]
575            let _ = text_enabled;
576        }
577    });
578}
579
580#[cfg(target_os = "macos")]
581fn apply_monospaced_status_titles() {
582    use objc2::msg_send;
583    use objc2::rc::Retained;
584    use objc2::runtime::AnyObject;
585    use objc2::AnyThread;
586    use objc2_app_kit::{
587        NSFont, NSFontAttributeName, NSFontFeatureSelectorIdentifierKey,
588        NSFontFeatureSettingsAttribute, NSFontFeatureTypeIdentifierKey, NSStatusBar,
589    };
590    use objc2_foundation::{
591        MainThreadMarker, NSArray, NSAttributedString, NSDictionary, NSNumber, NSString,
592    };
593
594    let Some(mtm) = MainThreadMarker::new() else {
595        return;
596    };
597
598    unsafe {
599        let bar = NSStatusBar::systemStatusBar();
600        let responds: bool = msg_send![&*bar, respondsToSelector: objc2::sel!(_statusItems)];
601        if !responds {
602            return;
603        }
604        let items: Retained<AnyObject> = msg_send![&*bar, _statusItems];
605        let n: usize = msg_send![&*items, count];
606        if n == 0 {
607            return;
608        }
609
610        let number_spacing_type = NSNumber::new_i32(6);
611        let monospaced_numbers_selector = NSNumber::new_i32(0);
612        let feature_dict = NSDictionary::from_slices::<NSString>(
613            &[
614                NSFontFeatureTypeIdentifierKey,
615                NSFontFeatureSelectorIdentifierKey,
616            ],
617            &[
618                &*number_spacing_type as &AnyObject,
619                &*monospaced_numbers_selector as &AnyObject,
620            ],
621        );
622        let features_array = NSArray::from_retained_slice(&[feature_dict]);
623        let desc_attrs = NSDictionary::from_slices::<NSString>(
624            &[NSFontFeatureSettingsAttribute],
625            &[&*features_array as &AnyObject],
626        );
627
628        let base_font = NSFont::menuBarFontOfSize(0.0);
629        let base_size = base_font.pointSize();
630        let base_desc = base_font.fontDescriptor();
631        let mono_desc = base_desc.fontDescriptorByAddingAttributes(&desc_attrs);
632        let Some(mono_font) = NSFont::fontWithDescriptor_size(&mono_desc, base_size) else {
633            return;
634        };
635
636        let attrs = NSDictionary::from_slices::<NSString>(
637            &[NSFontAttributeName],
638            &[&*mono_font as &AnyObject],
639        );
640
641        for i in 0..n {
642            let item: *mut objc2_app_kit::NSStatusItem = msg_send![&*items, pointerAtIndex: i];
643            if item.is_null() {
644                continue;
645            }
646            let item_ref: &objc2_app_kit::NSStatusItem = &*item;
647            let Some(button) = item_ref.button(mtm) else {
648                continue;
649            };
650            let title = button.title();
651            if title.length() == 0 {
652                continue;
653            }
654            let attr_str = NSAttributedString::initWithString_attributes(
655                NSAttributedString::alloc(),
656                &title,
657                Some(&attrs),
658            );
659            button.setAttributedTitle(&attr_str);
660        }
661    }
662}
663
664fn read_profiles_blocking(app: &AppHandle) -> (Vec<String>, String) {
665    let scheduler = app.state::<Scheduler>().inner().clone();
666    tauri::async_runtime::block_on(async move {
667        let profiles = scheduler
668            .profiles
669            .lock()
670            .await
671            .iter()
672            .map(|p| p.name.clone())
673            .collect();
674        let active = scheduler.active_profile_name.lock().await.clone();
675        (profiles, active)
676    })
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::scheduler::SuppressReason;
683
684    #[test]
685    fn tomorrow_morning_within_36_hours() {
686        let secs = seconds_until_tomorrow_morning();
687        assert!(secs >= 60);
688        assert!(secs <= 36 * 60 * 60);
689    }
690
691    #[test]
692    fn icon_is_template_only_on_macos() {
693        assert!(icon_is_template("macos"));
694        assert!(!icon_is_template("linux"));
695        assert!(!icon_is_template("windows"));
696    }
697
698    #[test]
699    fn tooltip_format_includes_profile_name() {
700        assert_eq!(tooltip_for("Default"), "Entracte · Default");
701        assert_eq!(tooltip_for("Work"), "Entracte · Work");
702    }
703
704    #[test]
705    fn profile_menu_id_namespaces_name() {
706        assert_eq!(profile_menu_id("Default"), "profile:Default");
707        assert_eq!(profile_menu_id("Work mode"), "profile:Work mode");
708    }
709
710    fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
711        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
712        assert_eq!(&bytes[12..16], b"IHDR", "missing IHDR chunk");
713        let w = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
714        let h = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
715        (w, h)
716    }
717
718    #[test]
719    fn tray_icons_are_pngs_with_matching_dimensions() {
720        let running = png_dimensions(TRAY_ICON_BYTES);
721        let paused = png_dimensions(TRAY_ICON_PAUSED_BYTES);
722        let bedtime = png_dimensions(TRAY_ICON_BEDTIME_BYTES);
723        let inactive = png_dimensions(TRAY_ICON_INACTIVE_BYTES);
724        assert_eq!(
725            running, paused,
726            "running and paused tray icons must share dimensions so the swap is seamless"
727        );
728        assert_eq!(
729            running, bedtime,
730            "bedtime tray icon must share dimensions with the running icon"
731        );
732        assert_eq!(
733            running, inactive,
734            "inactive (auto-suppressed) tray icon must share dimensions with the running icon"
735        );
736    }
737
738    #[test]
739    fn tray_title_for_states_when_text_enabled() {
740        let on = true;
741        assert_eq!(
742            tray_title_for(&TrayCountdownSnapshot::Disabled, on),
743            Some(String::new())
744        );
745        assert_eq!(
746            tray_title_for(&TrayCountdownSnapshot::Paused, on),
747            Some(" paused".to_string())
748        );
749        assert_eq!(
750            tray_title_for(&TrayCountdownSnapshot::Bedtime, on),
751            Some(String::new())
752        );
753        assert_eq!(
754            tray_title_for(&TrayCountdownSnapshot::OnBreak, on),
755            Some(String::new())
756        );
757        assert_eq!(
758            tray_title_for(&TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd), on),
759            Some(" DND".to_string())
760        );
761        assert_eq!(
762            tray_title_for(
763                &TrayCountdownSnapshot::Suppressed(SuppressReason::Camera),
764                on
765            ),
766            Some(" camera".to_string())
767        );
768        assert_eq!(
769            tray_title_for(
770                &TrayCountdownSnapshot::Suppressed(SuppressReason::Video),
771                on
772            ),
773            Some(" video".to_string())
774        );
775        assert_eq!(
776            tray_title_for(&TrayCountdownSnapshot::Idle, on),
777            Some(String::new())
778        );
779        assert_eq!(
780            tray_title_for(&TrayCountdownSnapshot::Running(754), on),
781            Some(" 12:34".to_string())
782        );
783        assert_eq!(
784            tray_title_for(&TrayCountdownSnapshot::Running(65), on),
785            Some(" 1:05".to_string())
786        );
787    }
788
789    #[test]
790    fn tray_title_for_returns_empty_for_every_state_when_text_disabled() {
791        // `tray_countdown_enabled = false` means the user opted out of
792        // any always-visible text — paused / reason / countdown — and
793        // wants the icon swap to be the only signal. The tooltip
794        // (hover-only) still carries detail; see tooltip_for_state.
795        let off = false;
796        for snap in [
797            TrayCountdownSnapshot::Disabled,
798            TrayCountdownSnapshot::Paused,
799            TrayCountdownSnapshot::Bedtime,
800            TrayCountdownSnapshot::OnBreak,
801            TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd),
802            TrayCountdownSnapshot::Suppressed(SuppressReason::Camera),
803            TrayCountdownSnapshot::Suppressed(SuppressReason::WorkWindow),
804            TrayCountdownSnapshot::Idle,
805            TrayCountdownSnapshot::Running(60),
806            TrayCountdownSnapshot::Running(0),
807        ] {
808            assert_eq!(
809                tray_title_for(&snap, off),
810                Some(String::new()),
811                "{snap:?} must show no title when text is disabled",
812            );
813        }
814    }
815
816    #[test]
817    fn tooltip_for_state_appends_reason_only_when_inactive() {
818        // Sanity: the base profile tooltip is the prefix in every case;
819        // we only ever ADD a second line, never rewrite the first.
820        let base = tooltip_for("Default");
821        assert!(
822            tooltip_for_state("Default", &TrayCountdownSnapshot::Running(60)).starts_with(&base),
823            "tooltip should always lead with the profile line"
824        );
825        assert_eq!(
826            tooltip_for_state(
827                "Default",
828                &TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd)
829            ),
830            format!("{base}\nInactive: {}", SuppressReason::Dnd.human()),
831        );
832        assert_eq!(
833            tooltip_for_state("Default", &TrayCountdownSnapshot::Paused),
834            format!("{base}\nPaused"),
835        );
836        assert_eq!(
837            tooltip_for_state("Default", &TrayCountdownSnapshot::Bedtime),
838            format!("{base}\nBedtime"),
839        );
840        // No second line for transient/normal states.
841        assert_eq!(
842            tooltip_for_state("Default", &TrayCountdownSnapshot::Idle),
843            base
844        );
845        assert_eq!(
846            tooltip_for_state("Default", &TrayCountdownSnapshot::Running(60)),
847            base
848        );
849    }
850
851    #[test]
852    fn tray_icon_kind_routes_each_snapshot_to_the_right_asset() {
853        assert_eq!(
854            tray_icon_kind_for(&TrayCountdownSnapshot::Bedtime),
855            TrayIconKind::Bedtime
856        );
857        assert_eq!(
858            tray_icon_kind_for(&TrayCountdownSnapshot::Paused),
859            TrayIconKind::Paused
860        );
861        assert_eq!(
862            tray_icon_kind_for(&TrayCountdownSnapshot::Suppressed(SuppressReason::Camera)),
863            TrayIconKind::Inactive,
864            "auto-suppressed must use the distinct inactive icon, not the explicit-pause one"
865        );
866        for snap in [
867            TrayCountdownSnapshot::Disabled,
868            TrayCountdownSnapshot::OnBreak,
869            TrayCountdownSnapshot::Idle,
870            TrayCountdownSnapshot::Running(60),
871        ] {
872            assert_eq!(
873                tray_icon_kind_for(&snap),
874                TrayIconKind::Normal,
875                "{snap:?} should use the normal icon"
876            );
877        }
878    }
879
880    #[test]
881    fn outline_glyph_recolours_body_and_rings_it() {
882        // 5×5 with a single opaque body pixel at the centre, radius 1.
883        let (w, h) = (5u32, 5u32);
884        let mut rgba = vec![0u8; (w * h * 4) as usize];
885        let idx = |x: u32, y: u32| ((y * w + x) * 4) as usize;
886        rgba[idx(2, 2) + 3] = 255;
887
888        let out = outline_glyph(&rgba, w, h, 1, [200, 200, 200], [10, 10, 10]);
889
890        // Body becomes fill, alpha preserved.
891        assert_eq!(&out[idx(2, 2)..idx(2, 2) + 4], &[200, 200, 200, 255]);
892        // Orthogonal neighbours (dx²+dy² ≤ 1) become opaque outline.
893        for (x, y) in [(1, 2), (3, 2), (2, 1), (2, 3)] {
894            assert_eq!(
895                &out[idx(x, y)..idx(x, y) + 4],
896                &[10, 10, 10, 255],
897                "({x},{y}) should be outline"
898            );
899        }
900        // Diagonals (dx²+dy² = 2 > 1) and far corners stay transparent.
901        for (x, y) in [(1, 1), (3, 3), (0, 0), (4, 4)] {
902            assert_eq!(out[idx(x, y) + 3], 0, "({x},{y}) should stay transparent");
903        }
904    }
905
906    #[test]
907    fn outline_glyph_preserves_anti_aliased_body_alpha() {
908        let (w, h) = (3u32, 3u32);
909        let mut rgba = vec![0u8; (w * h * 4) as usize];
910        let centre = ((w + 1) * 4) as usize;
911        rgba[centre + 3] = 128;
912        let out = outline_glyph(&rgba, w, h, 1, [242, 242, 242], [20, 20, 20]);
913        assert_eq!(&out[centre..centre + 4], &[242, 242, 242, 128]);
914    }
915
916    #[test]
917    fn outline_for_panels_gives_real_glyph_both_fill_and_outline() {
918        let img = Image::from_bytes(TRAY_ICON_BYTES).unwrap();
919        let out = outline_glyph_for_panels(img.rgba(), img.width(), img.height());
920        assert_eq!(out.len(), img.rgba().len(), "dimensions must be preserved");
921        let pixels = out.as_chunks::<4>().0;
922        let has_fill = pixels
923            .iter()
924            .any(|p| p[3] > 0 && p[0] > 0xE0 && p[1] > 0xE0 && p[2] > 0xE0);
925        let has_outline = pixels
926            .iter()
927            .any(|p| p[3] == 255 && p[0] < 0x30 && p[1] < 0x30 && p[2] < 0x30);
928        assert!(
929            has_fill,
930            "recoloured glyph must contain near-white body pixels"
931        );
932        assert!(
933            has_outline,
934            "recoloured glyph must contain a near-black outline ring"
935        );
936    }
937
938    #[test]
939    fn tray_image_recolours_off_macos_but_not_on_macos() {
940        let raw = Image::from_bytes(TRAY_ICON_BYTES).unwrap();
941        let mac = tray_image(TrayIconKind::Normal, "macos").unwrap();
942        assert_eq!(
943            mac.rgba(),
944            raw.rgba(),
945            "macOS keeps the raw template for AppKit to tint"
946        );
947        let linux = tray_image(TrayIconKind::Normal, "linux").unwrap();
948        assert_ne!(
949            linux.rgba(),
950            raw.rgba(),
951            "Linux must recolour so the glyph survives a dark panel"
952        );
953        assert_eq!(linux.width(), raw.width());
954        assert_eq!(linux.height(), raw.height());
955    }
956
957    #[test]
958    fn tray_icon_kind_bytes_map_to_distinct_assets() {
959        assert_eq!(TrayIconKind::Normal.bytes(), TRAY_ICON_BYTES);
960        assert_eq!(TrayIconKind::Paused.bytes(), TRAY_ICON_PAUSED_BYTES);
961        assert_eq!(TrayIconKind::Bedtime.bytes(), TRAY_ICON_BEDTIME_BYTES);
962        assert_eq!(TrayIconKind::Inactive.bytes(), TRAY_ICON_INACTIVE_BYTES);
963        // Sanity-check the constants are not the same blob — if two of these
964        // ever drift to identical content the visual signal collapses.
965        assert_ne!(TRAY_ICON_BYTES, TRAY_ICON_BEDTIME_BYTES);
966        assert_ne!(TRAY_ICON_PAUSED_BYTES, TRAY_ICON_BEDTIME_BYTES);
967        assert_ne!(TRAY_ICON_PAUSED_BYTES, TRAY_ICON_INACTIVE_BYTES);
968        assert_ne!(TRAY_ICON_BYTES, TRAY_ICON_INACTIVE_BYTES);
969        assert_ne!(TRAY_ICON_INACTIVE_BYTES, TRAY_ICON_BEDTIME_BYTES);
970    }
971}