Skip to main content

max / audiofiles

4.7 KB · 147 lines History Blame Raw
1 //! System tray icon with context menu for the standalone app.
2
3 use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
4 use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
5
6 /// Actions the tray context menu can trigger.
7 pub enum TrayAction {
8 ShowWindow,
9 TogglePlayback,
10 Quit,
11 }
12
13 /// Owns the tray icon and tracks menu item IDs for event matching.
14 pub struct AppTray {
15 icon: TrayIcon,
16 show_id: tray_icon::menu::MenuId,
17 toggle_id: tray_icon::menu::MenuId,
18 quit_id: tray_icon::menu::MenuId,
19 }
20
21 impl AppTray {
22 /// Create the tray icon and context menu. Must be called on the main thread (macOS).
23 pub fn new() -> Result<Self, tray_icon::Error> {
24 let show = MenuItem::new("Show Window", true, None);
25 let toggle = MenuItem::new("Play / Pause", true, None);
26 let quit = MenuItem::new("Quit", true, None);
27
28 let show_id = show.id().clone();
29 let toggle_id = toggle.id().clone();
30 let quit_id = quit.id().clone();
31
32 let menu = Menu::new();
33 let _ = menu.append_items(&[
34 &show,
35 &PredefinedMenuItem::separator(),
36 &toggle,
37 &PredefinedMenuItem::separator(),
38 &quit,
39 ]);
40
41 let icon = TrayIconBuilder::new()
42 .with_menu(Box::new(menu))
43 .with_tooltip("audiofiles")
44 .with_icon(build_icon())
45 .build()?;
46
47 Ok(Self {
48 icon,
49 show_id,
50 toggle_id,
51 quit_id,
52 })
53 }
54
55 /// Poll for menu events. Non-blocking, returns `None` if no event.
56 pub fn poll(&self) -> Option<TrayAction> {
57 if let Ok(event) = MenuEvent::receiver().try_recv() {
58 if event.id == self.show_id {
59 Some(TrayAction::ShowWindow)
60 } else if event.id == self.toggle_id {
61 Some(TrayAction::TogglePlayback)
62 } else if event.id == self.quit_id {
63 Some(TrayAction::Quit)
64 } else {
65 None
66 }
67 } else {
68 None
69 }
70 }
71
72 /// Update the tooltip to reflect playback state.
73 pub fn set_tooltip(&self, text: &str) {
74 let _ = self.icon.set_tooltip(Some(text));
75 }
76 }
77
78 /// Generate an 18x18 RGBA waveform icon for the macOS menu bar.
79 ///
80 /// macOS menu bar working area is 22pt max; 18x18 matches system icon weight
81 /// per Apple HIG. Five vertical bars suggest a waveform silhouette.
82 pub(crate) fn build_icon() -> Icon {
83 const SIZE: usize = 18;
84 let mut rgba = vec![0u8; SIZE * SIZE * 4];
85
86 // Five vertical bars of varying heights to suggest a waveform.
87 // Bar colour: dark grey (#3d3530) on transparent background.
88 let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff];
89 let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)];
90 let bar_heights: [usize; 5] = [6, 12, 18, 10, 5];
91
92 for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() {
93 let h = bar_heights[i];
94 let y_start = (SIZE - h) / 2;
95 let y_end = y_start + h;
96 for y in y_start..y_end {
97 for x in x_start..x_end {
98 let offset = (y * SIZE + x) * 4;
99 rgba[offset..offset + 4].copy_from_slice(&bar_colour);
100 }
101 }
102 }
103
104 Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 18x18 RGBA icon")
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110
111 #[test]
112 fn build_icon_produces_valid_18x18_rgba() {
113 // build_icon should not panic and should produce an icon
114 let _icon = build_icon();
115 }
116
117 #[test]
118 fn icon_rgba_buffer_has_correct_dimensions() {
119 const SIZE: usize = 18;
120 let mut rgba = vec![0u8; SIZE * SIZE * 4];
121
122 let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff];
123 let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)];
124 let bar_heights: [usize; 5] = [6, 12, 18, 10, 5];
125
126 for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() {
127 let h = bar_heights[i];
128 let y_start = (SIZE - h) / 2;
129 let y_end = y_start + h;
130 for y in y_start..y_end {
131 for x in x_start..x_end {
132 let offset = (y * SIZE + x) * 4;
133 rgba[offset..offset + 4].copy_from_slice(&bar_colour);
134 }
135 }
136 }
137
138 assert_eq!(rgba.len(), SIZE * SIZE * 4);
139 // Middle bar (index 2) is full height — pixel at (9, 0) should be coloured
140 // (row 0, col 9) -> offset (0 * SIZE + 9) * 4
141 let mid_offset = 9 * 4;
142 assert_eq!(&rgba[mid_offset..mid_offset + 4], &bar_colour);
143 // Corner (0,0) should be transparent
144 assert_eq!(&rgba[0..4], &[0, 0, 0, 0]);
145 }
146 }
147