//! System tray icon with context menu for the standalone app. use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; /// Actions the tray context menu can trigger. pub enum TrayAction { ShowWindow, TogglePlayback, Quit, } /// Owns the tray icon and tracks menu item IDs for event matching. pub struct AppTray { icon: TrayIcon, show_id: tray_icon::menu::MenuId, toggle_id: tray_icon::menu::MenuId, quit_id: tray_icon::menu::MenuId, } impl AppTray { /// Create the tray icon and context menu. Must be called on the main thread (macOS). pub fn new() -> Result { let show = MenuItem::new("Show Window", true, None); let toggle = MenuItem::new("Play / Pause", true, None); let quit = MenuItem::new("Quit", true, None); let show_id = show.id().clone(); let toggle_id = toggle.id().clone(); let quit_id = quit.id().clone(); let menu = Menu::new(); let _ = menu.append_items(&[ &show, &PredefinedMenuItem::separator(), &toggle, &PredefinedMenuItem::separator(), &quit, ]); let icon = TrayIconBuilder::new() .with_menu(Box::new(menu)) .with_tooltip("audiofiles") .with_icon(build_icon()) .build()?; Ok(Self { icon, show_id, toggle_id, quit_id, }) } /// Poll for menu events. Non-blocking, returns `None` if no event. pub fn poll(&self) -> Option { if let Ok(event) = MenuEvent::receiver().try_recv() { if event.id == self.show_id { Some(TrayAction::ShowWindow) } else if event.id == self.toggle_id { Some(TrayAction::TogglePlayback) } else if event.id == self.quit_id { Some(TrayAction::Quit) } else { None } } else { None } } /// Update the tooltip to reflect playback state. pub fn set_tooltip(&self, text: &str) { let _ = self.icon.set_tooltip(Some(text)); } } /// Generate an 18x18 RGBA waveform icon for the macOS menu bar. /// /// macOS menu bar working area is 22pt max; 18x18 matches system icon weight /// per Apple HIG. Five vertical bars suggest a waveform silhouette. pub(crate) fn build_icon() -> Icon { const SIZE: usize = 18; let mut rgba = vec![0u8; SIZE * SIZE * 4]; // Five vertical bars of varying heights to suggest a waveform. // Bar colour: dark grey (#3d3530) on transparent background. let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff]; let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)]; let bar_heights: [usize; 5] = [6, 12, 18, 10, 5]; for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() { let h = bar_heights[i]; let y_start = (SIZE - h) / 2; let y_end = y_start + h; for y in y_start..y_end { for x in x_start..x_end { let offset = (y * SIZE + x) * 4; rgba[offset..offset + 4].copy_from_slice(&bar_colour); } } } Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 18x18 RGBA icon") } #[cfg(test)] mod tests { use super::*; #[test] fn build_icon_produces_valid_18x18_rgba() { // build_icon should not panic and should produce an icon let _icon = build_icon(); } #[test] fn icon_rgba_buffer_has_correct_dimensions() { const SIZE: usize = 18; let mut rgba = vec![0u8; SIZE * SIZE * 4]; let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff]; let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)]; let bar_heights: [usize; 5] = [6, 12, 18, 10, 5]; for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() { let h = bar_heights[i]; let y_start = (SIZE - h) / 2; let y_end = y_start + h; for y in y_start..y_end { for x in x_start..x_end { let offset = (y * SIZE + x) * 4; rgba[offset..offset + 4].copy_from_slice(&bar_colour); } } } assert_eq!(rgba.len(), SIZE * SIZE * 4); // Middle bar (index 2) is full height — pixel at (9, 0) should be coloured // (row 0, col 9) -> offset (0 * SIZE + 9) * 4 let mid_offset = 9 * 4; assert_eq!(&rgba[mid_offset..mid_offset + 4], &bar_colour); // Corner (0,0) should be transparent assert_eq!(&rgba[0..4], &[0, 0, 0, 0]); } }