1use std::sync::{
17 atomic::{AtomicBool, Ordering},
18 Arc,
19};
20
21use eframe::egui;
22
23use super::tray::{self, TrayVariant};
24
25const TRACE_TARGET: &str = "studio_worker::ui::tray";
28
29pub struct TrayHandle {
33 inner: Inner,
34}
35
36impl TrayHandle {
37 pub fn set_variant(&mut self, variant: TrayVariant) {
40 self.inner.set_variant(variant);
41 }
42}
43
44#[cfg(target_os = "linux")]
49struct Inner {
50 tx: tokio::sync::mpsc::UnboundedSender<TrayVariant>,
51 warned: bool,
52}
53
54#[cfg(target_os = "linux")]
55impl Inner {
56 fn set_variant(&mut self, variant: TrayVariant) {
57 if self.tx.send(variant).is_err() && !self.warned {
62 self.warned = true;
63 tracing::warn!(
64 target: TRACE_TARGET,
65 op = "set_variant",
66 "linux tray service is not running; status icon will not update"
67 );
68 }
69 }
70}
71
72#[cfg(target_os = "linux")]
76struct KsniTray {
77 variant: TrayVariant,
78 paused: Arc<AtomicBool>,
79 quit: Arc<AtomicBool>,
80 ctx: egui::Context,
81}
82
83#[cfg(target_os = "linux")]
84impl KsniTray {
85 fn show_window(&self) {
86 tracing::info!(target: TRACE_TARGET, "open window requested from tray");
87 self.ctx
88 .send_viewport_cmd(egui::ViewportCommand::Visible(true));
89 self.ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
90 self.ctx.request_repaint();
91 }
92
93 fn toggle_pause(&self) {
94 let was_paused = self.paused.fetch_xor(true, Ordering::SeqCst);
95 tracing::info!(
96 target: TRACE_TARGET,
97 paused = !was_paused,
98 "pause toggled from tray menu"
99 );
100 self.ctx.request_repaint();
101 }
102
103 fn request_quit(&self) {
104 tracing::info!(
105 target: TRACE_TARGET,
106 "quit requested from tray menu; stopping worker"
107 );
108 self.quit.store(true, Ordering::SeqCst);
109 self.ctx.request_repaint();
110 }
111}
112
113#[cfg(target_os = "linux")]
114impl ksni::Tray for KsniTray {
115 fn id(&self) -> String {
116 "studio-worker".into()
117 }
118
119 fn title(&self) -> String {
120 "studio-worker".into()
121 }
122
123 fn icon_pixmap(&self) -> Vec<ksni::Icon> {
124 vec![ksni::Icon {
125 width: 16,
126 height: 16,
127 data: tray::rgba_to_argb32(&self.variant.rgba_16()),
128 }]
129 }
130
131 fn tool_tip(&self) -> ksni::ToolTip {
132 ksni::ToolTip {
133 title: self.variant.tooltip().to_string(),
134 ..Default::default()
135 }
136 }
137
138 fn activate(&mut self, _x: i32, _y: i32) {
139 self.show_window();
140 }
141
142 fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
143 use ksni::menu::StandardItem;
144 let labels = tray::menu_labels(!self.paused.load(Ordering::SeqCst));
146 vec![
147 StandardItem {
148 label: labels.open_window.to_string(),
149 activate: Box::new(|t: &mut Self| t.show_window()),
150 ..Default::default()
151 }
152 .into(),
153 StandardItem {
154 label: labels.toggle_auto.clone(),
155 activate: Box::new(|t: &mut Self| t.toggle_pause()),
156 ..Default::default()
157 }
158 .into(),
159 ksni::MenuItem::Separator,
160 StandardItem {
161 label: labels.quit.to_string(),
162 activate: Box::new(|t: &mut Self| t.request_quit()),
163 ..Default::default()
164 }
165 .into(),
166 ]
167 }
168}
169
170#[cfg(target_os = "linux")]
177pub fn install(
178 ctx: egui::Context,
179 paused: Arc<AtomicBool>,
180 quit: Arc<AtomicBool>,
181 tokio: tokio::runtime::Handle,
182 _initial_paused: bool,
183) -> Option<TrayHandle> {
184 use ksni::TrayMethods;
185 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TrayVariant>();
186 tokio.spawn(async move {
187 let tray = KsniTray {
188 variant: TrayVariant::Disconnected,
189 paused,
190 quit,
191 ctx,
192 };
193 let handle = match tray.spawn().await {
194 Ok(h) => {
195 tracing::info!(target: TRACE_TARGET, "linux tray (ksni) started");
196 h
197 }
198 Err(e) => {
199 tracing::warn!(
200 target: TRACE_TARGET,
201 error = %e,
202 "linux tray (ksni) failed to start; running without a tray"
203 );
204 return;
205 }
206 };
207 while let Some(variant) = rx.recv().await {
208 handle
209 .update(move |t: &mut KsniTray| t.variant = variant)
210 .await;
211 }
212 handle.shutdown().await;
214 });
215 Some(TrayHandle {
216 inner: Inner { tx, warned: false },
217 })
218}
219
220#[cfg(not(target_os = "linux"))]
225struct Inner {
226 icon: Option<tray_icon::TrayIcon>,
227}
228
229#[cfg(not(target_os = "linux"))]
230impl Inner {
231 fn set_variant(&mut self, variant: TrayVariant) {
232 let Some(icon) = self.icon.as_ref() else {
233 return;
234 };
235 match tray_icon::Icon::from_rgba(variant.rgba_16(), 16, 16) {
236 Ok(new_icon) => {
237 if let Err(e) = icon.set_icon(Some(new_icon)) {
238 tracing::warn!(
239 target: TRACE_TARGET,
240 op = "set_variant",
241 error = %e,
242 "failed to update tray icon"
243 );
244 }
245 }
246 Err(e) => tracing::warn!(
247 target: TRACE_TARGET,
248 op = "set_variant",
249 error = %e,
250 "failed to build tray icon image"
251 ),
252 }
253 if let Err(e) = icon.set_tooltip(Some(variant.tooltip())) {
254 tracing::warn!(
255 target: TRACE_TARGET,
256 op = "set_variant",
257 error = %e,
258 "failed to update tray tooltip"
259 );
260 }
261 }
262}
263
264#[cfg(not(target_os = "linux"))]
269pub fn install(
270 ctx: egui::Context,
271 paused: Arc<AtomicBool>,
272 quit: Arc<AtomicBool>,
273 _tokio: tokio::runtime::Handle,
274 initial_paused: bool,
275) -> Option<TrayHandle> {
276 use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem};
277 use tray_icon::{Icon, TrayIconBuilder};
278
279 let open_id = MenuId::new(tray::menu_ids::OPEN_WINDOW);
280 let toggle_id = MenuId::new(tray::menu_ids::TOGGLE_AUTO);
281 let quit_id = MenuId::new(tray::menu_ids::QUIT);
282
283 let labels = tray::menu_labels(!initial_paused);
285 let menu = Menu::new();
286 let _ = menu.append(&MenuItem::with_id(
287 open_id.clone(),
288 labels.open_window,
289 true,
290 None,
291 ));
292 let _ = menu.append(&MenuItem::with_id(
293 toggle_id.clone(),
294 &labels.toggle_auto,
295 true,
296 None,
297 ));
298 let _ = menu.append(&MenuItem::with_id(quit_id.clone(), labels.quit, true, None));
299
300 let variant = TrayVariant::Disconnected;
301 let icon = Icon::from_rgba(variant.rgba_16(), 16, 16).ok();
302 let mut builder = TrayIconBuilder::new()
303 .with_tooltip(variant.tooltip())
304 .with_menu(Box::new(menu));
305 if let Some(i) = icon {
306 builder = builder.with_icon(i);
307 }
308 let tray_icon = match builder.build() {
309 Ok(t) => Some(t),
310 Err(e) => {
311 tracing::warn!(
312 target: TRACE_TARGET,
313 error = %e,
314 "tray build failed; running without a tray"
315 );
316 None
317 }
318 };
319
320 std::thread::spawn(move || {
322 let rx = MenuEvent::receiver();
323 while let Ok(event) = rx.recv() {
324 if event.id == open_id {
325 tracing::info!(target: TRACE_TARGET, "open window requested from tray menu");
326 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
327 ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
328 } else if event.id == toggle_id {
329 let was_paused = paused.fetch_xor(true, Ordering::SeqCst);
330 tracing::info!(
331 target: TRACE_TARGET,
332 paused = !was_paused,
333 "pause toggled from tray menu"
334 );
335 } else if event.id == quit_id {
336 tracing::info!(
337 target: TRACE_TARGET,
338 "quit requested from tray menu; stopping worker"
339 );
340 quit.store(true, Ordering::SeqCst);
341 }
342 ctx.request_repaint();
343 }
344 });
345
346 Some(TrayHandle {
347 inner: Inner { icon: tray_icon },
348 })
349}