use std::{
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
time::Duration,
};
use eframe::egui;
use parking_lot::Mutex;
use tokio::runtime::Handle;
use crate::{
config::SharedConfig,
runtime::{WorkerObservers, HEARTBEAT_INTERVAL},
sys,
types::LogEntry,
};
use super::{
notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
tab::Tab,
tabs::{
about::{self as about_tab, AboutState},
config::{self as config_tab, ConfigDraft},
jobs as jobs_tab,
logs::{self as logs_tab, LogFilter},
status as status_tab,
},
tray::{self, TrayVariant},
};
pub struct AppDeps {
pub cfg: SharedConfig,
pub logs: Arc<Mutex<Vec<LogEntry>>>,
pub busy: Arc<AtomicBool>,
pub observers: WorkerObservers,
pub stop: Arc<AtomicBool>,
pub config_path: PathBuf,
pub tokio: Handle,
}
pub struct App {
deps: AppDeps,
tab: Tab,
registration: crate::auto_register::SharedRegistration,
config_draft: ConfigDraft,
log_filter: LogFilter,
about_state: AboutState,
vram_total_gb: f32,
last_seen_recent_count: usize,
notifier: Box<dyn Notifier + Send + Sync>,
notification_prefs: NotificationPrefs,
tray_variant: TrayVariant,
quit_requested: Arc<std::sync::atomic::AtomicBool>,
tray_state: Option<super::TrayState>,
}
impl App {
pub fn new(deps: AppDeps) -> Self {
Self::with_notifier(deps, Self::default_notifier())
}
pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
Self::with_notifier_and_registration(deps, notifier, crate::auto_register::shared_initial())
}
pub fn with_notifier_and_registration(
deps: AppDeps,
notifier: Box<dyn Notifier + Send + Sync>,
registration: crate::auto_register::SharedRegistration,
) -> Self {
let config_draft = {
let cfg = deps.cfg.lock();
ConfigDraft::from(&cfg)
};
let vram_total_gb = sys::detect_vram_gb().unwrap_or(0.0);
Self {
deps,
tab: Tab::initial(),
registration,
config_draft,
log_filter: LogFilter::default(),
about_state: AboutState::default(),
vram_total_gb,
last_seen_recent_count: 0,
notifier,
notification_prefs: NotificationPrefs::default(),
tray_variant: TrayVariant::Disconnected,
quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tray_state: None,
}
}
pub fn registration_handle(&self) -> crate::auto_register::SharedRegistration {
self.registration.clone()
}
pub fn attach_tray(&mut self, tray: super::TrayState) {
self.tray_state = Some(tray);
}
pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
self.quit_requested.clone()
}
pub fn notification_prefs(&self) -> NotificationPrefs {
self.notification_prefs
}
pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
self.notification_prefs = prefs;
}
pub fn tray_variant(&self) -> TrayVariant {
self.tray_variant
}
fn default_notifier() -> Box<dyn Notifier + Send + Sync> {
Self::default_notifier_box()
}
pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
Box::new(super::notifier::DesktopNotifier)
}
pub fn drain_notifications(&mut self) {
let new_entries: Vec<_> = {
let ring = self.deps.observers.recent_jobs.lock();
if ring.len() == self.last_seen_recent_count {
return;
}
let added = ring.len().saturating_sub(self.last_seen_recent_count);
self.last_seen_recent_count = ring.len();
ring.iter().take(added).rev().cloned().collect()
};
for entry in new_entries {
if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
self.notifier.show(&title, &body);
}
}
}
pub fn refresh_tray_variant(&mut self) -> TrayVariant {
let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
let hb = self.deps.observers.last_heartbeat.lock().clone();
let v = tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL);
if v != self.tray_variant {
if let Some(state) = self.tray_state.as_mut() {
if let Some(icon) = state.icon.as_ref() {
if let Ok(new_icon) = tray_icon::Icon::from_rgba(v.rgba_16(), 16, 16) {
let _ = icon.set_icon(Some(new_icon));
}
let _ = icon.set_tooltip(Some(v.tooltip()));
}
state.current_variant = v;
}
}
self.tray_variant = v;
v
}
pub fn render(&mut self, ui: &mut egui::Ui) {
egui::Panel::top("tab_bar").show_inside(ui, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
for tab in Tab::ALL {
let selected = self.tab == tab;
if ui.selectable_label(selected, tab.label()).clicked() {
self.tab = tab;
}
}
});
ui.add_space(4.0);
});
egui::CentralPanel::default().show_inside(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| match self.tab {
Tab::Status => self.render_status(ui),
Tab::Jobs => self.render_jobs(ui),
Tab::Config => self.render_config(ui),
Tab::Logs => self.render_logs(ui),
Tab::About => self.render_about(ui),
});
});
ui.ctx().request_repaint_after(Duration::from_millis(250));
}
fn pre_render(&mut self, ctx: &egui::Context) {
self.drain_notifications();
self.refresh_tray_variant();
if ctx.input(|i| i.viewport().close_requested())
&& !self
.quit_requested
.load(std::sync::atomic::Ordering::SeqCst)
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
if self
.quit_requested
.load(std::sync::atomic::Ordering::SeqCst)
{
self.deps
.stop
.store(true, std::sync::atomic::Ordering::SeqCst);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
pub fn current_tab(&self) -> Tab {
self.tab
}
pub fn set_tab(&mut self, tab: Tab) {
self.tab = tab;
}
pub fn deps(&self) -> &AppDeps {
&self.deps
}
fn render_jobs(&mut self, ui: &mut egui::Ui) {
let view = jobs_tab::JobsView::build(&self.deps.observers, chrono::Utc::now());
jobs_tab::render(ui, &view);
}
fn render_config(&mut self, ui: &mut egui::Ui) {
config_tab::render(
ui,
&mut self.config_draft,
&self.deps.config_path,
&mut self.notification_prefs,
);
{
let mut shared = self.deps.cfg.lock();
*shared = self.config_draft.current.clone();
}
}
fn render_logs(&mut self, ui: &mut egui::Ui) {
logs_tab::render(ui, &self.deps.logs, &mut self.log_filter);
}
fn render_about(&mut self, ui: &mut egui::Ui) {
let view = about_tab::AboutView::build(&self.about_state, &self.deps.config_path);
about_tab::render(
ui,
&view,
&self.about_state,
&self.deps.tokio,
&self.deps.config_path,
);
}
fn render_status(&mut self, ui: &mut egui::Ui) {
let registration_snapshot = self.registration.lock().clone();
let view = {
let cfg = self.deps.cfg.lock();
let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
let hb = self.deps.observers.last_heartbeat.lock().clone();
status_tab::StatusView::build(
&cfg,
®istration_snapshot,
busy,
hb.as_ref(),
self.vram_total_gb,
)
};
status_tab::render(ui, &view);
}
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.pre_render(&ctx);
self.render(ui);
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::*;
use crate::{config::Config, runtime::WorkerObservers};
fn mock_deps() -> AppDeps {
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
let handle = RT
.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(1)
.build()
.expect("tokio runtime")
})
.handle()
.clone();
let cfg = crate::config::shared(Config::default());
let logs = Arc::new(Mutex::new(Vec::new()));
let busy = Arc::new(AtomicBool::new(false));
let observers = WorkerObservers {
current_job: Arc::new(Mutex::new(None)),
recent_jobs: Arc::new(Mutex::new(VecDeque::new())),
last_heartbeat: Arc::new(Mutex::new(None)),
};
let stop = Arc::new(AtomicBool::new(false));
AppDeps {
cfg,
logs,
busy,
observers,
stop,
config_path: PathBuf::from("/tmp/studio-worker-test.toml"),
tokio: handle,
}
}
#[test]
fn new_defaults_to_status_tab() {
let app = App::new(mock_deps());
assert_eq!(app.current_tab(), Tab::Status);
}
#[test]
fn set_tab_switches() {
let mut app = App::new(mock_deps());
app.set_tab(Tab::Logs);
assert_eq!(app.current_tab(), Tab::Logs);
}
#[test]
fn render_does_not_panic_under_test_ui() {
let mut app = App::new(mock_deps());
egui::__run_test_ui(|ui| {
app.render(ui);
});
}
#[test]
fn render_each_tab_does_not_panic() {
for tab in Tab::ALL {
let mut app = App::new(mock_deps());
app.set_tab(tab);
egui::__run_test_ui(|ui| {
app.render(ui);
});
}
}
}