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#[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 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 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 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
417fn icon_is_template(os: &str) -> bool {
425 os == "macos"
426}
427
428const TRAY_FILL_RGB: [u8; 3] = [0xF2, 0xF2, 0xF2];
436const TRAY_OUTLINE_RGB: [u8; 3] = [0x14, 0x14, 0x14];
437const TRAY_OUTLINE_RADIUS: i32 = 8;
440const TRAY_ALPHA_THRESHOLD: u8 = 16;
442
443fn 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
511fn 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 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 #[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 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 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 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 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 assert_eq!(&out[idx(2, 2)..idx(2, 2) + 4], &[200, 200, 200, 255]);
892 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 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 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}