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#[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
73fn 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 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 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 #[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 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 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 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 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}