Skip to main content

studio_worker/ui/
app.rs

1//! The eframe `App` impl.  Holds shared state (the same `Arc<Mutex<…>>`
2//! handles the runtime loops use) and dispatches to per-tab renderers.
3
4use std::{
5    path::PathBuf,
6    sync::{atomic::AtomicBool, Arc},
7    time::Duration,
8};
9
10use eframe::egui;
11use parking_lot::Mutex;
12use tokio::runtime::Handle;
13
14use crate::{
15    config::SharedConfig,
16    runtime::{WorkerObservers, HEARTBEAT_INTERVAL},
17    sys,
18    types::LogEntry,
19};
20
21use super::{
22    notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
23    tab::Tab,
24    tabs::{
25        about::{self as about_tab, AboutState},
26        config::{self as config_tab, ConfigDraft},
27        jobs as jobs_tab,
28        logs::{self as logs_tab, LogFilter},
29        status as status_tab,
30    },
31    tray::{self, TrayVariant},
32};
33
34/// Tracing target for App-level lifecycle + tray events.  Stable so
35/// operators can filter with `RUST_LOG=studio_worker::ui::app=info`.
36const TRACE_TARGET: &str = "studio_worker::ui::app";
37
38/// Emit a structured breadcrumb when the tray health indicator flips
39/// between idle / busy / disconnected.  Pulled out of
40/// [`App::refresh_tray_variant`] so it is unit-testable without
41/// constructing a (non-`Send`) `App` + a real OS tray.
42fn log_tray_variant_change(from: TrayVariant, to: TrayVariant) {
43    tracing::info!(
44        target: TRACE_TARGET,
45        op = "tray_variant",
46        from = ?from,
47        to = ?to,
48        "tray status indicator changed"
49    );
50}
51
52/// Everything `App` needs to render and act on the world.
53pub struct AppDeps {
54    pub cfg: SharedConfig,
55    pub logs: Arc<Mutex<Vec<LogEntry>>>,
56    pub busy: Arc<AtomicBool>,
57    /// Runtime-only Pause / Resume flag, shared with the WS session
58    /// (heartbeat advertises `auto_enabled = !paused`, new offers
59    /// are rejected while set).  Not persisted to `Config`.
60    pub paused: Arc<AtomicBool>,
61    pub observers: WorkerObservers,
62    pub stop: Arc<AtomicBool>,
63    pub config_path: PathBuf,
64    pub tokio: Handle,
65}
66
67pub struct App {
68    deps: AppDeps,
69    tab: Tab,
70    registration: crate::auto_register::SharedRegistration,
71    config_draft: ConfigDraft,
72    log_filter: LogFilter,
73    about_state: AboutState,
74    vram_total_gb: f32,
75    /// Identity (`job_id` + `finished_at`) of the newest recent-job we
76    /// have already raised a notification for.  Tracking identity
77    /// rather than ring length means a saturated, capped
78    /// `recent_jobs` ring (whose length pins at `RECENT_JOBS_CAP`)
79    /// can't make new arrivals invisible.
80    last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
81    notifier: Box<dyn Notifier + Send + Sync>,
82    notification_prefs: NotificationPrefs,
83    tray_variant: TrayVariant,
84    quit_requested: Arc<std::sync::atomic::AtomicBool>,
85    tray: Option<super::tray_host::TrayHandle>,
86    /// One-shot request to minimise the window on the first frame
87    /// (config `start_minimised`, default true).  Minimised to the
88    /// taskbar — not hidden — so the window stays reachable even when
89    /// no tray host is available.
90    start_minimised_pending: bool,
91}
92
93impl App {
94    pub fn new(deps: AppDeps) -> Self {
95        Self::with_notifier(deps, Self::default_notifier())
96    }
97
98    /// Used by tests to inject a `CapturingNotifier`.
99    pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
100        Self::with_notifier_and_registration(deps, notifier, crate::auto_register::shared_initial())
101    }
102
103    /// Used by `ui::run` to share the orchestration loop's
104    /// `SharedRegistration` slot with the UI.
105    pub fn with_notifier_and_registration(
106        deps: AppDeps,
107        notifier: Box<dyn Notifier + Send + Sync>,
108        registration: crate::auto_register::SharedRegistration,
109    ) -> Self {
110        let (config_draft, start_minimised_pending) = {
111            let cfg = deps.cfg.lock();
112            (ConfigDraft::from(&cfg), cfg.start_minimised)
113        };
114        let vram_total_gb = sys::detect_vram_gb().unwrap_or(0.0);
115        Self {
116            deps,
117            tab: Tab::initial(),
118            registration,
119            config_draft,
120            log_filter: LogFilter::default(),
121            about_state: AboutState::default(),
122            vram_total_gb,
123            last_notified: None,
124            notifier,
125            notification_prefs: NotificationPrefs::default(),
126            tray_variant: TrayVariant::Disconnected,
127            quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
128            tray: None,
129            start_minimised_pending,
130        }
131    }
132
133    /// Whether the first frame will request a minimised window.
134    /// Public for the start-minimised regression tests.
135    pub fn start_minimised_pending(&self) -> bool {
136        self.start_minimised_pending
137    }
138
139    pub fn registration_handle(&self) -> crate::auto_register::SharedRegistration {
140        self.registration.clone()
141    }
142
143    pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
144        self.tray = Some(tray);
145    }
146
147    pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
148        self.quit_requested.clone()
149    }
150
151    pub fn notification_prefs(&self) -> NotificationPrefs {
152        self.notification_prefs
153    }
154
155    pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
156        self.notification_prefs = prefs;
157    }
158
159    pub fn tray_variant(&self) -> TrayVariant {
160        self.tray_variant
161    }
162
163    fn default_notifier() -> Box<dyn Notifier + Send + Sync> {
164        Self::default_notifier_box()
165    }
166
167    /// Exposed for `ui::run` which builds a notifier before App::new.
168    pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
169        Box::new(super::notifier::DesktopNotifier)
170    }
171
172    /// Process any new entries in the recent-jobs ring and emit
173    /// notifications according to current prefs.  Idempotent.
174    pub fn drain_notifications(&mut self) {
175        // `record_recent_job` pushes newest-first, so walk from the
176        // front collecting every entry newer than the last one we
177        // notified on (identified by `job_id` + `finished_at`).  This
178        // stays correct even once the capped ring saturates and its
179        // length stops changing.
180        let new_entries: Vec<_> = {
181            let ring = self.deps.observers.recent_jobs.lock();
182            let mut collected = Vec::new();
183            for entry in ring.iter() {
184                if self
185                    .last_notified
186                    .as_ref()
187                    .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
188                {
189                    break;
190                }
191                collected.push(entry.clone());
192            }
193            collected
194        };
195        if let Some(newest) = new_entries.first() {
196            self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
197        }
198        // `collected` is newest-first; notify oldest-first so the OS
199        // notification order matches completion order.
200        for entry in new_entries.into_iter().rev() {
201            if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
202                self.notifier.show(&title, &body);
203            }
204        }
205    }
206
207    /// Recompute the tray variant from live state.  Pushes the new
208    /// icon + tooltip to the OS tray when the variant changes.
209    pub fn refresh_tray_variant(&mut self) -> TrayVariant {
210        let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
211        let hb = self.deps.observers.last_heartbeat.lock().clone();
212        let v = tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL);
213        if v != self.tray_variant {
214            log_tray_variant_change(self.tray_variant, v);
215            // Push the new health colour + tooltip to the OS tray.  The
216            // per-platform backend (ksni on Linux, tray-icon on
217            // mac/win) surfaces its own failures; here we just forward.
218            if let Some(tray) = self.tray.as_mut() {
219                tray.set_variant(v);
220            }
221        }
222        self.tray_variant = v;
223        v
224    }
225
226    /// Shared by both the real `ui` entry point and the headless test
227    /// harness so the layout is exercised in tests too.  Takes a
228    /// `&mut Ui` rather than a `&Context` to match the new eframe
229    /// 0.34 API (`Panel::show_inside`).
230    pub fn render(&mut self, ui: &mut egui::Ui) {
231        egui::Panel::top("tab_bar").show_inside(ui, |ui| {
232            ui.add_space(4.0);
233            ui.horizontal(|ui| {
234                for tab in Tab::ALL {
235                    let selected = self.tab == tab;
236                    if ui.selectable_label(selected, tab.label()).clicked() {
237                        self.tab = tab;
238                    }
239                }
240            });
241            ui.add_space(4.0);
242        });
243
244        egui::CentralPanel::default().show_inside(ui, |ui| {
245            egui::ScrollArea::vertical().show(ui, |ui| match self.tab {
246                Tab::Status => self.render_status(ui),
247                Tab::Jobs => self.render_jobs(ui),
248                Tab::Config => self.render_config(ui),
249                Tab::Logs => self.render_logs(ui),
250                Tab::About => self.render_about(ui),
251            });
252        });
253
254        // The background loops mutate shared state asynchronously; ask
255        // egui to repaint so updates surface without a user event.
256        ui.ctx().request_repaint_after(Duration::from_millis(250));
257    }
258
259    /// Shared housekeeping invoked before every frame's render — keeps
260    /// the `ui()` entry point thin.
261    fn pre_render(&mut self, ctx: &egui::Context) {
262        // Honour `start_minimised` on the first frame.
263        if self.start_minimised_pending {
264            self.start_minimised_pending = false;
265            tracing::info!(
266                target: TRACE_TARGET,
267                op = "start_minimised",
268                "minimising window on startup (config start_minimised)"
269            );
270            ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
271        }
272
273        self.drain_notifications();
274        self.refresh_tray_variant();
275
276        // Hide-to-tray: intercept the OS close request, keep loops
277        // alive, hide the window.  Quit comes from the tray menu.
278        if ctx.input(|i| i.viewport().close_requested())
279            && !self
280                .quit_requested
281                .load(std::sync::atomic::Ordering::SeqCst)
282        {
283            tracing::info!(
284                target: TRACE_TARGET,
285                op = "hide_to_tray",
286                "window close intercepted; hiding to tray (worker keeps running)"
287            );
288            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
289            ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
290        }
291
292        // Tray Quit was clicked — stop the loops, then close.
293        if self
294            .quit_requested
295            .load(std::sync::atomic::Ordering::SeqCst)
296        {
297            self.deps
298                .stop
299                .store(true, std::sync::atomic::Ordering::SeqCst);
300            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
301        }
302    }
303
304    /// Expose the current tab for tests + future tray-state derivation.
305    pub fn current_tab(&self) -> Tab {
306        self.tab
307    }
308
309    /// Switch tab (used by tests + tray menu in Phase 10).
310    pub fn set_tab(&mut self, tab: Tab) {
311        self.tab = tab;
312    }
313
314    pub fn deps(&self) -> &AppDeps {
315        &self.deps
316    }
317
318    fn render_jobs(&mut self, ui: &mut egui::Ui) {
319        let view = jobs_tab::JobsView::build(&self.deps.observers, chrono::Utc::now());
320        jobs_tab::render(ui, &view);
321    }
322
323    fn render_config(&mut self, ui: &mut egui::Ui) {
324        let saved = config_tab::render(
325            ui,
326            &mut self.config_draft,
327            &self.deps.config_path,
328            &mut self.notification_prefs,
329        );
330        // After Save the on-disk file is the new truth; mirror back to
331        // the shared `Arc<Mutex<Config>>` so loops see new values on
332        // the next tick.
333        if saved {
334            let mut shared = self.deps.cfg.lock();
335            *shared = self.config_draft.current.clone();
336        }
337    }
338
339    fn render_logs(&mut self, ui: &mut egui::Ui) {
340        logs_tab::render(ui, &self.deps.observers.recent_logs, &mut self.log_filter);
341    }
342
343    fn render_about(&mut self, ui: &mut egui::Ui) {
344        let view = about_tab::AboutView::build(&self.about_state, &self.deps.config_path);
345        about_tab::render(
346            ui,
347            &view,
348            &self.about_state,
349            &self.deps.tokio,
350            &self.deps.config_path,
351        );
352    }
353
354    fn render_status(&mut self, ui: &mut egui::Ui) {
355        let registration_snapshot = self.registration.lock().clone();
356        let paused_flag = self.deps.paused.clone();
357        let view = {
358            let cfg = self.deps.cfg.lock();
359            let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
360            let paused = paused_flag.load(std::sync::atomic::Ordering::SeqCst);
361            let hb = self.deps.observers.last_heartbeat.lock().clone();
362            status_tab::StatusView::build(
363                &cfg,
364                &registration_snapshot,
365                busy,
366                paused,
367                hb.as_ref(),
368                self.vram_total_gb,
369            )
370        };
371        status_tab::render(ui, &view, &paused_flag);
372    }
373}
374
375impl eframe::App for App {
376    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
377        let ctx = ui.ctx().clone();
378        self.pre_render(&ctx);
379        self.render(ui);
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use std::collections::VecDeque;
386
387    use super::*;
388    use crate::{config::Config, runtime::WorkerObservers};
389
390    fn mock_deps() -> AppDeps {
391        // Build a single-thread tokio runtime so `Handle::current()`
392        // resolves inside the test process without main.rs being in
393        // play.  The runtime is leaked intentionally — the handle
394        // stays alive for the duration of the test.
395        static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
396        let handle = RT
397            .get_or_init(|| {
398                tokio::runtime::Builder::new_multi_thread()
399                    .enable_all()
400                    .worker_threads(1)
401                    .build()
402                    .expect("tokio runtime")
403            })
404            .handle()
405            .clone();
406        let cfg = crate::config::shared(Config::default());
407        let logs = Arc::new(Mutex::new(Vec::new()));
408        let busy = Arc::new(AtomicBool::new(false));
409        let paused = Arc::new(AtomicBool::new(false));
410        let observers = WorkerObservers {
411            current_job: Arc::new(Mutex::new(None)),
412            recent_jobs: Arc::new(Mutex::new(VecDeque::new())),
413            last_heartbeat: Arc::new(Mutex::new(None)),
414            recent_logs: Arc::new(Mutex::new(VecDeque::new())),
415        };
416        let stop = Arc::new(AtomicBool::new(false));
417        AppDeps {
418            cfg,
419            logs,
420            busy,
421            paused,
422            observers,
423            stop,
424            config_path: PathBuf::from("/tmp/studio-worker-test.toml"),
425            tokio: handle,
426        }
427    }
428
429    #[test]
430    fn start_minimised_pending_follows_the_config() {
431        // Default config: minimised on the first frame.
432        let app = App::new(mock_deps());
433        assert!(
434            app.start_minimised_pending(),
435            "default config must request a minimised start"
436        );
437
438        // Operator opted out: no minimise request.
439        let deps = mock_deps();
440        deps.cfg.lock().start_minimised = false;
441        let app = App::new(deps);
442        assert!(!app.start_minimised_pending());
443    }
444
445    #[test]
446    fn log_tray_variant_change_emits_structured_transition() {
447        use crate::test_support::capture;
448        let logs = capture(|| {
449            super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
450        });
451        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
452        assert!(
453            logs.contains("studio_worker::ui::app"),
454            "expected app target, got: {logs}"
455        );
456        assert!(
457            logs.contains("op=\"tray_variant\""),
458            "expected op field: {logs}"
459        );
460        assert!(
461            logs.contains("from=Disconnected"),
462            "expected from field: {logs}"
463        );
464        assert!(logs.contains("to=Busy"), "expected to field: {logs}");
465    }
466
467    #[test]
468    fn new_defaults_to_status_tab() {
469        let app = App::new(mock_deps());
470        assert_eq!(app.current_tab(), Tab::Status);
471    }
472
473    #[test]
474    fn set_tab_switches() {
475        let mut app = App::new(mock_deps());
476        app.set_tab(Tab::Logs);
477        assert_eq!(app.current_tab(), Tab::Logs);
478    }
479
480    /// Headless smoke test: drive one full frame through `render` and
481    /// assert it doesn't panic.  Uses `egui::__run_test_ctx` so no
482    /// display server is required — runs fine on CI.
483    #[test]
484    fn render_does_not_panic_under_test_ui() {
485        let mut app = App::new(mock_deps());
486        egui::__run_test_ui(|ui| {
487            app.render(ui);
488        });
489    }
490
491    #[test]
492    fn render_each_tab_does_not_panic() {
493        for tab in Tab::ALL {
494            let mut app = App::new(mock_deps());
495            app.set_tab(tab);
496            egui::__run_test_ui(|ui| {
497                app.render(ui);
498            });
499        }
500    }
501
502    #[test]
503    fn config_tab_does_not_publish_unsaved_edits_to_shared_config() {
504        let mut app = App::new(mock_deps());
505        app.config_draft.current.api_base_url = "https://unsaved.example".into();
506
507        egui::__run_test_ui(|ui| {
508            app.render_config(ui);
509        });
510
511        assert_eq!(
512            app.deps.cfg.lock().api_base_url,
513            Config::default().api_base_url,
514            "editing the draft must not affect the live runtime config until Save succeeds"
515        );
516    }
517
518    fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
519        let now = chrono::Utc::now();
520        crate::runtime::RecentJob {
521            job_id: id.into(),
522            kind: crate::types::TaskKind::Image,
523            model: "synthetic".into(),
524            prompt: "p".into(),
525            outcome: crate::runtime::JobOutcome::Completed,
526            started_at: now,
527            finished_at: now,
528        }
529    }
530
531    /// Shared handle into a `CapturingNotifier`'s recorded
532    /// (title, body) pairs.
533    type Captured = Arc<Mutex<Vec<(String, String)>>>;
534
535    fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
536        let captured: Captured = Arc::new(Mutex::new(Vec::new()));
537        let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
538            captured: captured.clone(),
539        });
540        let mut app = App::with_notifier(deps, notifier);
541        app.set_notification_prefs(NotificationPrefs {
542            on_completion: true,
543            on_failure: true,
544        });
545        (app, captured)
546    }
547
548    #[test]
549    fn drain_notifications_fires_for_each_new_completed_job() {
550        let deps = mock_deps();
551        let observers = deps.observers.clone();
552        let (mut app, captured) = app_with_capturing_notifier(deps);
553
554        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
555        crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
556        app.drain_notifications();
557
558        assert_eq!(captured.lock().len(), 2);
559    }
560
561    #[test]
562    fn drain_notifications_is_idempotent_without_new_jobs() {
563        let deps = mock_deps();
564        let observers = deps.observers.clone();
565        let (mut app, captured) = app_with_capturing_notifier(deps);
566
567        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
568        app.drain_notifications();
569        app.drain_notifications();
570        app.drain_notifications();
571
572        assert_eq!(
573            captured.lock().len(),
574            1,
575            "re-draining with no new jobs must not re-notify"
576        );
577    }
578
579    #[test]
580    fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
581        let deps = mock_deps();
582        let observers = deps.observers.clone();
583        let (mut app, captured) = app_with_capturing_notifier(deps);
584
585        // Fill the capped ring past `RECENT_JOBS_CAP` so its length
586        // pins at the cap and a length-based "added" comparison can no
587        // longer detect new arrivals.
588        for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
589            crate::runtime::record_recent_job(
590                &observers,
591                completed_recent_job(&format!("warm-{i}")),
592            );
593        }
594        app.drain_notifications();
595        captured.lock().clear();
596
597        // A brand-new job after saturation must still raise a
598        // notification — the regression this test guards.
599        crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
600        app.drain_notifications();
601
602        let shown = captured.lock();
603        assert_eq!(
604            shown.len(),
605            1,
606            "a job completing after the ring saturates must still notify"
607        );
608        assert!(
609            shown[0].1.contains("image"),
610            "notification body should describe the new job, got: {:?}",
611            shown[0]
612        );
613    }
614}