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