Skip to main content

studio_worker/ui/tabs/
config.rs

1//! Config tab — user-editable subset of [`Config`] reachable as
2//! widgets.  Save writes through `crate::config::save`; the runtime
3//! loops pick up the new values on their next tick because every
4//! tick snapshots `Arc<Mutex<Config>>`.
5//!
6//! Internal state (`worker_id`, `auth_token`, `install_id`,
7//! `registration_*`) is deliberately not surfaced here.  The
8//! auto-register flow owns it end-to-end.
9
10use std::path::{Path, PathBuf};
11
12use eframe::egui;
13
14use crate::config::{self, default_models_root, Config};
15
16use super::super::notifier::NotificationPrefs;
17use crate::autostart;
18
19/// Buffer the user is editing.  `dirty` is true when any field
20/// differs from `original`; Save / Reset clear it.
21#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23    pub current: Config,
24    pub original: Config,
25    pub last_save_error: Option<String>,
26    /// Last autostart-toggle failure, surfaced next to the toggle so
27    /// the operator sees why it did not stick (the checkbox otherwise
28    /// silently reverts on the next frame because `is_enabled()`
29    /// re-reads disk).
30    pub autostart_error: Option<String>,
31}
32
33impl ConfigDraft {
34    pub fn from(cfg: &Config) -> Self {
35        Self {
36            current: cfg.clone(),
37            original: cfg.clone(),
38            last_save_error: None,
39            autostart_error: None,
40        }
41    }
42
43    pub fn dirty(&self) -> bool {
44        !configs_equal(&self.current, &self.original)
45    }
46
47    pub fn save(&mut self, path: &Path) -> Result<(), String> {
48        match config::save(&self.current, path) {
49            Ok(()) => {
50                self.original = self.current.clone();
51                self.last_save_error = None;
52                Ok(())
53            }
54            Err(e) => {
55                let msg = format!("{e}");
56                self.last_save_error = Some(msg.clone());
57                Err(msg)
58            }
59        }
60    }
61
62    pub fn reset(&mut self) {
63        self.current = self.original.clone();
64        self.last_save_error = None;
65        self.autostart_error = None;
66    }
67}
68
69/// Equality over the persisted, user-editable fields.  Internal state
70/// (registration ids, auth token, worker id, install id) is excluded
71/// because the UI never mutates it; the auto-register flow owns it.
72fn configs_equal(a: &Config, b: &Config) -> bool {
73    a.api_base_url == b.api_base_url
74        && (a.vram_threshold_gb - b.vram_threshold_gb).abs() < f32::EPSILON
75        && a.auto_start == b.auto_start
76        && a.auto_update_enabled == b.auto_update_enabled
77        && a.auto_update_interval_secs == b.auto_update_interval_secs
78        && a.auto_update_feed == b.auto_update_feed
79        && a.auto_update_prerelease == b.auto_update_prerelease
80        && a.models_root == b.models_root
81}
82
83pub fn render(
84    ui: &mut egui::Ui,
85    draft: &mut ConfigDraft,
86    config_path: &Path,
87    notification_prefs: &mut NotificationPrefs,
88) -> bool {
89    let mut saved = false;
90    ui.heading("Configuration");
91    ui.label(
92        egui::RichText::new(format!("{}", config_path.display()))
93            .color(egui::Color32::from_gray(150))
94            .small(),
95    );
96    ui.add_space(8.0);
97
98    section(ui, "Connection", |ui| {
99        labeled_text(ui, "API base URL", &mut draft.current.api_base_url);
100    });
101
102    section(ui, "Worker", |ui| {
103        labeled_slider(
104            ui,
105            "VRAM threshold (GB)",
106            &mut draft.current.vram_threshold_gb,
107            0.0,
108            96.0,
109        );
110        labeled_bool(ui, "Auto-start on boot", &mut draft.current.auto_start);
111    });
112
113    section(ui, "Auto-update", |ui| {
114        labeled_bool(
115            ui,
116            "Auto-update enabled",
117            &mut draft.current.auto_update_enabled,
118        );
119        labeled_u64(
120            ui,
121            "Interval (seconds)",
122            &mut draft.current.auto_update_interval_secs,
123        );
124        labeled_text(ui, "Release feed URL", &mut draft.current.auto_update_feed);
125        labeled_bool(
126            ui,
127            "Track pre-releases",
128            &mut draft.current.auto_update_prerelease,
129        );
130    });
131
132    section(ui, "Models", |ui| {
133        labeled_folder(ui, "Models root", &mut draft.current.models_root);
134        ui.label("");
135        ui.label(
136            egui::RichText::new(
137                "This is where the models will be stored.  You might need a fair bit \
138                 of disk space to be able to satisfy different types of jobs.",
139            )
140            .italics()
141            .color(egui::Color32::from_gray(160)),
142        );
143        ui.end_row();
144    });
145
146    section(ui, "Notifications", |ui| {
147        ui.label("On job completion");
148        ui.checkbox(&mut notification_prefs.on_completion, "");
149        ui.end_row();
150        ui.label("On job failure");
151        ui.checkbox(&mut notification_prefs.on_failure, "");
152        ui.end_row();
153    });
154
155    let mut autostart_enabled = autostart::is_enabled();
156    let prev_autostart = autostart_enabled;
157    section(ui, "Background mode", |ui| {
158        ui.label("Run in tray on login");
159        ui.checkbox(&mut autostart_enabled, "");
160        ui.end_row();
161    });
162    if autostart_enabled != prev_autostart {
163        let outcome = match std::env::current_exe() {
164            Ok(exe) if autostart_enabled => autostart::enable(&exe),
165            Ok(_) => autostart::disable(),
166            Err(e) => Err(anyhow::anyhow!("cannot resolve current executable: {e}")),
167        };
168        // `autostart::enable`/`disable` already emit a structured
169        // tracing event; surface any failure in the UI too so the
170        // operator sees why the toggle did not stick instead of it
171        // silently reverting on the next frame.
172        draft.autostart_error = outcome.err().map(|e| format!("{e}"));
173    }
174    if let Some(err) = &draft.autostart_error {
175        ui.colored_label(
176            egui::Color32::LIGHT_RED,
177            format!("could not change autostart: {err}"),
178        );
179    }
180
181    ui.add_space(12.0);
182    ui.horizontal(|ui| {
183        let dirty = draft.dirty();
184        let save = ui.add_enabled(dirty, egui::Button::new("Save"));
185        if save.clicked() {
186            saved = draft.save(config_path).is_ok();
187        }
188        if ui.add_enabled(dirty, egui::Button::new("Reset")).clicked() {
189            draft.reset();
190        }
191        if let Some(err) = &draft.last_save_error {
192            ui.colored_label(egui::Color32::LIGHT_RED, format!("save failed: {err}"));
193        } else if !dirty && draft.last_save_error.is_none() {
194            ui.label(
195                egui::RichText::new("up to date")
196                    .italics()
197                    .color(egui::Color32::from_gray(150)),
198            );
199        }
200    });
201    saved
202}
203
204// ---------------------------------------------------------------------------
205// Widget helpers
206// ---------------------------------------------------------------------------
207
208fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
209    egui::CollapsingHeader::new(title)
210        .default_open(true)
211        .show(ui, |ui| {
212            egui::Grid::new(title)
213                .num_columns(2)
214                .spacing([12.0, 6.0])
215                .show(ui, |ui| {
216                    add(ui);
217                });
218        });
219    ui.add_space(4.0);
220}
221
222fn labeled_text(ui: &mut egui::Ui, label: &str, value: &mut String) {
223    ui.label(label);
224    ui.add(egui::TextEdit::singleline(value).desired_width(360.0));
225    ui.end_row();
226}
227
228fn labeled_bool(ui: &mut egui::Ui, label: &str, value: &mut bool) {
229    ui.label(label);
230    ui.checkbox(value, "");
231    ui.end_row();
232}
233
234fn labeled_slider(ui: &mut egui::Ui, label: &str, value: &mut f32, min: f32, max: f32) {
235    ui.label(label);
236    ui.add(egui::Slider::new(value, min..=max).fixed_decimals(1));
237    ui.end_row();
238}
239
240fn labeled_u64(ui: &mut egui::Ui, label: &str, value: &mut u64) {
241    ui.label(label);
242    let mut buf = value.to_string();
243    if ui
244        .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0))
245        .changed()
246    {
247        if let Ok(n) = buf.parse::<u64>() {
248            *value = n;
249        }
250    }
251    ui.end_row();
252}
253
254/// Path-with-folder-picker widget.  The text edit reflects the
255/// current value at all times; the "Browse…" button opens the
256/// native picker (rfd) and overwrites it on confirm.
257fn labeled_folder(ui: &mut egui::Ui, label: &str, value: &mut PathBuf) {
258    ui.label(label);
259    ui.horizontal(|ui| {
260        let mut buf = value.to_string_lossy().to_string();
261        let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(280.0));
262        if r.changed() {
263            *value = PathBuf::from(buf);
264        }
265        if ui.button("Browse…").clicked() {
266            let starting = if value.is_absolute() {
267                value.clone()
268            } else {
269                default_models_root()
270            };
271            if let Some(picked) = rfd::FileDialog::new()
272                .set_directory(starting.parent().unwrap_or(&starting))
273                .pick_folder()
274            {
275                *value = picked;
276            }
277        }
278    });
279    ui.end_row();
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use tempfile::tempdir;
286
287    #[test]
288    fn draft_starts_clean() {
289        let cfg = Config::default();
290        let draft = ConfigDraft::from(&cfg);
291        assert!(!draft.dirty());
292    }
293
294    #[test]
295    fn draft_marks_dirty_after_edit() {
296        let cfg = Config::default();
297        let mut draft = ConfigDraft::from(&cfg);
298        draft.current.vram_threshold_gb = 24.0;
299        assert!(draft.dirty());
300    }
301
302    #[test]
303    fn draft_marks_dirty_when_models_root_changes() {
304        let cfg = Config::default();
305        let mut draft = ConfigDraft::from(&cfg);
306        draft.current.models_root = PathBuf::from("/tmp/other-models");
307        assert!(draft.dirty());
308    }
309
310    #[test]
311    fn save_writes_through_and_clears_dirty() {
312        let dir = tempdir().unwrap();
313        let path = dir.path().join("config.toml");
314        let cfg = Config::default();
315        let mut draft = ConfigDraft::from(&cfg);
316        draft.current.vram_threshold_gb = 24.0;
317        draft.save(&path).unwrap();
318        assert!(!draft.dirty());
319        // Reload from disk and confirm the value persisted.
320        let (loaded, _) = config::load(Some(&path.to_string_lossy())).unwrap();
321        assert!((loaded.vram_threshold_gb - 24.0).abs() < f32::EPSILON);
322    }
323
324    #[test]
325    fn reset_reverts_unsaved_edits() {
326        let cfg = Config::default();
327        let mut draft = ConfigDraft::from(&cfg);
328        draft.current.vram_threshold_gb = 33.0;
329        draft.reset();
330        assert!((draft.current.vram_threshold_gb - cfg.vram_threshold_gb).abs() < f32::EPSILON);
331        assert!(!draft.dirty());
332    }
333
334    #[test]
335    fn reset_clears_autostart_error() {
336        let cfg = Config::default();
337        let mut draft = ConfigDraft::from(&cfg);
338        draft.autostart_error = Some("boom".into());
339        draft.reset();
340        assert!(draft.autostart_error.is_none());
341    }
342
343    #[test]
344    fn save_failure_records_last_save_error() {
345        let cfg = Config::default();
346        let mut draft = ConfigDraft::from(&cfg);
347        // /proc is read-only on Linux — a write attempt fails.
348        let bad = Path::new("/proc/this-should-fail/config.toml");
349        let res = draft.save(bad);
350        assert!(res.is_err());
351        assert!(draft.last_save_error.is_some());
352    }
353}