Skip to main content

studio_worker/ui/pages/
config.rs

1//! Config: the operator-editable subset of [`Config`] as widgets.
2//! The daemon owns the config: Save sends the edit to it
3//! (`PUT /daemon/config`), which validates, saves and applies it; the
4//! answer comes back through [`ConfigDraft::saved`] or
5//! [`ConfigDraft::save_failed`].  The window's own preferences (theme,
6//! reduce motion, notifications) sit on the same page but are applied and
7//! stored at once (see `ui::prefs`).
8//!
9//! Internal state (`worker_id`, `auth_token`, `install_id`,
10//! `registration_*`) is deliberately not surfaced here.  The
11//! auto-register flow owns it end-to-end.
12
13use std::path::{Path, PathBuf};
14
15use eframe::egui;
16
17use crate::config::{self, default_models_root, Config};
18
19use super::super::prefs::UiPrefs;
20use super::super::theme::{Palette, ThemeChoice, Tone};
21use super::super::widgets;
22
23/// Buffer the user is editing.  `dirty` is true when any field
24/// differs from `original`; Save / Reset clear it.
25#[derive(Debug, Clone)]
26pub struct ConfigDraft {
27    pub current: Config,
28    pub original: Config,
29    pub last_save_error: Option<String>,
30    /// A save is on its way to the daemon.
31    pub pending: bool,
32}
33
34impl ConfigDraft {
35    pub fn from(cfg: &Config) -> Self {
36        Self {
37            current: cfg.clone(),
38            original: cfg.clone(),
39            last_save_error: None,
40            pending: false,
41        }
42    }
43
44    pub fn dirty(&self) -> bool {
45        !configs_equal(&self.current, &self.original)
46    }
47
48    /// Follow the daemon's config while the operator is not editing, so
49    /// the tab never shows values the daemon no longer has.
50    pub fn follow(&mut self, live: &Config) {
51        if !self.dirty() && !self.pending && !configs_equal(&self.original, live) {
52            *self = Self::from(live);
53        }
54    }
55
56    /// The daemon saved `saved`: it is the new baseline.
57    pub fn saved(&mut self, saved: &Config) {
58        let changed = config::changed_fields(&self.original, saved).join(",");
59        tracing::info!(
60            target: "studio_worker::ui::config",
61            changed = ?changed,
62            "operator applied config changes via UI"
63        );
64        self.original = saved.clone();
65        self.current = saved.clone();
66        self.last_save_error = None;
67        self.pending = false;
68    }
69
70    /// The daemon refused the edit or could not be reached; keep it.
71    pub fn save_failed(&mut self, error: String) {
72        tracing::warn!(
73            target: "studio_worker::ui::config",
74            error = %error,
75            "config changes not applied"
76        );
77        self.last_save_error = Some(error);
78        self.pending = false;
79    }
80
81    pub fn reset(&mut self) {
82        self.current = self.original.clone();
83        self.last_save_error = None;
84    }
85}
86
87/// Equality over the operator-editable fields (see
88/// [`config::changed_fields`]).
89fn configs_equal(a: &Config, b: &Config) -> bool {
90    config::changed_fields(a, b).is_empty()
91}
92
93/// What the save footer says about the draft, and its tone.
94pub fn save_state(draft: &ConfigDraft) -> (String, Tone) {
95    if draft.pending {
96        ("Saving\u{2026}".into(), Tone::Busy)
97    } else if let Some(err) = &draft.last_save_error {
98        (format!("Not saved: {err}"), Tone::Bad)
99    } else if draft.dirty() {
100        ("Unsaved changes".into(), Tone::Busy)
101    } else {
102        ("Up to date".into(), Tone::Neutral)
103    }
104}
105
106/// What the operator did on the page.
107#[derive(Debug, Default)]
108pub struct ConfigOutcome {
109    /// The config to send to the daemon: Save was pressed.
110    pub save: Option<Config>,
111    /// A window preference changed: apply and store it.
112    pub prefs_changed: bool,
113}
114
115/// Draw the page.
116pub fn render(
117    ui: &mut egui::Ui,
118    draft: &mut ConfigDraft,
119    config_path: &Path,
120    prefs: &mut UiPrefs,
121) -> ConfigOutcome {
122    let mut outcome = ConfigOutcome::default();
123    egui::Panel::bottom("config-footer")
124        .frame(
125            egui::Frame::new()
126                .fill(Palette::of_ui(ui).page)
127                .inner_margin(egui::Margin::symmetric(0, 10)),
128        )
129        .exact_size(FOOTER_HEIGHT)
130        .show_inside(ui, |ui| {
131            outcome.save = footer(ui, draft);
132        });
133    egui::CentralPanel::default()
134        .frame(egui::Frame::NONE)
135        .show_inside(ui, |ui| {
136            egui::ScrollArea::vertical()
137                .id_salt("config")
138                .auto_shrink([false, false])
139                .show(ui, |ui| {
140                    widgets::page_title(
141                        ui,
142                        "Config",
143                        &format!(
144                            "The daemon keeps these in {}; Save sends them to it, and it \
145                             checks, saves and applies them.",
146                            config_path.display()
147                        ),
148                    );
149                    worker_sections(ui, draft);
150                    ui.add_space(12.0);
151                    outcome.prefs_changed = window_section(ui, prefs);
152                    ui.add_space(12.0);
153                });
154        });
155    outcome
156}
157
158/// Height of the save footer, in points: the same whatever it says.
159pub const FOOTER_HEIGHT: f32 = 52.0;
160
161fn footer(ui: &mut egui::Ui, draft: &mut ConfigDraft) -> Option<Config> {
162    let p = Palette::of_ui(ui);
163    let mut save = None;
164    ui.horizontal(|ui| {
165        let dirty = draft.dirty();
166        if widgets::primary_button(ui, "Save", dirty && !draft.pending, 96.0).clicked() {
167            draft.pending = true;
168            save = Some(draft.current.clone());
169        }
170        if widgets::button(ui, "Reset", dirty, 96.0)
171            .on_hover_text("go back to what the daemon has")
172            .clicked()
173        {
174            draft.reset();
175        }
176        ui.add_space(8.0);
177        let (text, tone) = save_state(draft);
178        if draft.pending {
179            ui.spinner();
180        }
181        ui.add(egui::Label::new(egui::RichText::new(text).color(p.tone(tone))).truncate());
182    });
183    save
184}
185
186fn worker_sections(ui: &mut egui::Ui, draft: &mut ConfigDraft) {
187    let c = &mut draft.current;
188    section(ui, "CONNECTION", "", |ui| {
189        labeled_text(ui, "Studio API base URL", &mut c.api_base_url);
190    });
191    section(ui, "WORKER", "", |ui| {
192        labeled_slider(
193            ui,
194            "VRAM threshold (GB)",
195            &mut c.vram_threshold_gb,
196            0.0,
197            96.0,
198        );
199        hint_row(ui, "The most device memory one studio job may ask for.");
200    });
201    section(ui, "AUTO-UPDATE", "", |ui| {
202        labeled_bool(ui, "Update automatically", &mut c.auto_update_enabled);
203        labeled_u64(
204            ui,
205            "Check every (seconds)",
206            &mut c.auto_update_interval_secs,
207        );
208        labeled_text(ui, "Release feed URL", &mut c.auto_update_feed);
209        labeled_bool(ui, "Track pre-releases", &mut c.auto_update_prerelease);
210    });
211    section(ui, "MODELS", "", |ui| {
212        labeled_folder(ui, "Models folder", &mut c.models_root);
213        hint_row(
214            ui,
215            "Where model weights are stored.  Serving many kinds of jobs takes a fair bit of \
216             disk space.  A new folder applies after a restart.",
217        );
218    });
219    section(ui, "START-UP", "", |ui| {
220        labeled_bool(ui, "Start the window minimised", &mut c.start_minimised);
221    });
222}
223
224/// The window's own preferences, applied and stored at once.
225fn window_section(ui: &mut egui::Ui, prefs: &mut UiPrefs) -> bool {
226    let before = *prefs;
227    section(
228        ui,
229        "THIS WINDOW",
230        "Applied and stored at once, for this window only.",
231        |ui| {
232            ui.label("Theme");
233            ui.horizontal(|ui| {
234                for choice in ThemeChoice::ALL {
235                    ui.selectable_value(&mut prefs.theme, choice, choice.label());
236                }
237            });
238            ui.end_row();
239            labeled_bool(ui, "Reduce motion", &mut prefs.reduce_motion);
240            hint_row(ui, "Hold the glow of running work steady.");
241            labeled_bool(
242                ui,
243                "Notify when a job completes",
244                &mut prefs.notify_on_completion,
245            );
246            labeled_bool(ui, "Notify when a job fails", &mut prefs.notify_on_failure);
247        },
248    );
249    *prefs != before
250}
251
252// ---------------------------------------------------------------------------
253// Widget helpers
254// ---------------------------------------------------------------------------
255
256fn section(ui: &mut egui::Ui, title: &str, note: &str, add: impl FnOnce(&mut egui::Ui)) {
257    widgets::card(ui, |ui| {
258        widgets::section_label(ui, title);
259        if !note.is_empty() {
260            ui.label(widgets::muted(ui, note));
261            ui.add_space(4.0);
262        }
263        egui::Grid::new(title)
264            .num_columns(2)
265            .spacing([20.0, 10.0])
266            .min_col_width(200.0)
267            .show(ui, |ui| {
268                add(ui);
269            });
270    });
271    ui.add_space(12.0);
272}
273
274fn hint_row(ui: &mut egui::Ui, text: &str) {
275    ui.label("");
276    let hint = widgets::muted(ui, text);
277    ui.add(egui::Label::new(hint).wrap());
278    ui.end_row();
279}
280
281fn labeled_text(ui: &mut egui::Ui, label: &str, value: &mut String) {
282    ui.label(label);
283    ui.add(egui::TextEdit::singleline(value).desired_width(380.0));
284    ui.end_row();
285}
286
287fn labeled_bool(ui: &mut egui::Ui, label: &str, value: &mut bool) {
288    ui.label(label);
289    ui.checkbox(value, "");
290    ui.end_row();
291}
292
293fn labeled_slider(ui: &mut egui::Ui, label: &str, value: &mut f32, min: f32, max: f32) {
294    ui.label(label);
295    ui.add(egui::Slider::new(value, min..=max).fixed_decimals(1));
296    ui.end_row();
297}
298
299fn labeled_u64(ui: &mut egui::Ui, label: &str, value: &mut u64) {
300    ui.label(label);
301    let mut buf = value.to_string();
302    if ui
303        .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0))
304        .changed()
305    {
306        if let Ok(n) = buf.parse::<u64>() {
307            *value = n;
308        }
309    }
310    ui.end_row();
311}
312
313/// A path with a folder picker: the text edit shows the value; Browse
314/// opens the native picker (rfd) and replaces it on confirm.
315// The picker is a native dialog: nothing to drive in a headless test.
316#[cfg_attr(coverage_nightly, coverage(off))]
317fn labeled_folder(ui: &mut egui::Ui, label: &str, value: &mut PathBuf) {
318    ui.label(label);
319    ui.horizontal(|ui| {
320        let mut buf = value.to_string_lossy().to_string();
321        let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(300.0));
322        if r.changed() {
323            *value = PathBuf::from(buf);
324        }
325        if ui.button("Browse\u{2026}").clicked() {
326            let starting = if value.is_absolute() {
327                value.clone()
328            } else {
329                default_models_root()
330            };
331            if let Some(picked) = rfd::FileDialog::new()
332                .set_directory(starting.parent().unwrap_or(&starting))
333                .pick_folder()
334            {
335                *value = picked;
336            }
337        }
338    });
339    ui.end_row();
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn draft_starts_clean() {
348        let cfg = Config::default();
349        let draft = ConfigDraft::from(&cfg);
350        assert!(!draft.dirty());
351    }
352
353    #[test]
354    fn draft_marks_dirty_after_edit() {
355        let cfg = Config::default();
356        let mut draft = ConfigDraft::from(&cfg);
357        draft.current.vram_threshold_gb = 24.0;
358        assert!(draft.dirty());
359    }
360
361    #[test]
362    fn draft_marks_dirty_when_models_root_changes() {
363        let cfg = Config::default();
364        let mut draft = ConfigDraft::from(&cfg);
365        draft.current.models_root = PathBuf::from("/tmp/other-models");
366        assert!(draft.dirty());
367    }
368
369    #[test]
370    fn saved_makes_the_answer_the_new_baseline() {
371        let mut draft = ConfigDraft::from(&Config::default());
372        draft.current.vram_threshold_gb = 24.0;
373        draft.pending = true;
374        let mut answer = draft.current.clone();
375        answer.vram_threshold_gb = 23.5;
376        draft.saved(&answer);
377        assert!(!draft.dirty() && !draft.pending);
378        assert_eq!(draft.current.vram_threshold_gb, 23.5);
379    }
380
381    #[test]
382    fn a_clean_draft_follows_the_daemon_but_an_edit_is_kept() {
383        let mut live = Config::default();
384        let mut draft = ConfigDraft::from(&live);
385        live.vram_threshold_gb = 5.0;
386        draft.follow(&live);
387        assert_eq!(draft.current.vram_threshold_gb, 5.0);
388
389        draft.current.vram_threshold_gb = 7.0;
390        live.vram_threshold_gb = 6.0;
391        draft.follow(&live);
392        assert_eq!(draft.current.vram_threshold_gb, 7.0, "edits survive a poll");
393    }
394
395    #[test]
396    fn reset_reverts_unsaved_edits() {
397        let cfg = Config::default();
398        let mut draft = ConfigDraft::from(&cfg);
399        draft.current.vram_threshold_gb = 33.0;
400        draft.reset();
401        assert!((draft.current.vram_threshold_gb - cfg.vram_threshold_gb).abs() < f32::EPSILON);
402        assert!(!draft.dirty());
403    }
404
405    #[test]
406    fn saved_emits_operator_apply_breadcrumb() {
407        use crate::test_support::capture;
408        let logs = capture(move || {
409            let mut draft = ConfigDraft::from(&Config::default());
410            draft.current.vram_threshold_gb = 24.0;
411            let answer = draft.current.clone();
412            draft.saved(&answer);
413        });
414        assert!(logs.contains("studio_worker::ui::config"), "{logs}");
415        assert!(logs.contains("changed=\"vram_threshold_gb\""), "{logs}");
416        assert!(
417            logs.contains("operator applied config changes via UI"),
418            "{logs}"
419        );
420    }
421
422    #[test]
423    fn save_failed_keeps_the_edit_and_the_error() {
424        let mut draft = ConfigDraft::from(&Config::default());
425        draft.current.vram_threshold_gb = 3.0;
426        draft.pending = true;
427        draft.save_failed("invalid config".into());
428        assert!(draft.dirty() && !draft.pending);
429        assert_eq!(draft.last_save_error.as_deref(), Some("invalid config"));
430    }
431
432    #[test]
433    fn the_footer_says_where_the_draft_stands() {
434        let mut draft = ConfigDraft::from(&Config::default());
435        assert_eq!(save_state(&draft), ("Up to date".into(), Tone::Neutral));
436        draft.current.vram_threshold_gb = 3.0;
437        assert_eq!(save_state(&draft), ("Unsaved changes".into(), Tone::Busy));
438        draft.pending = true;
439        assert_eq!(save_state(&draft).1, Tone::Busy);
440        draft.save_failed("vramThresholdGb: too big".into());
441        assert_eq!(
442            save_state(&draft),
443            ("Not saved: vramThresholdGb: too big".into(), Tone::Bad)
444        );
445    }
446
447    #[test]
448    fn the_page_draws_clean_dirty_and_pending() {
449        let mut draft = ConfigDraft::from(&Config::default());
450        let mut prefs = UiPrefs::default();
451        for step in 0..3 {
452            match step {
453                1 => draft.current.vram_threshold_gb = 3.0,
454                2 => draft.pending = true,
455                _ => {}
456            }
457            egui::__run_test_ui(|ui| {
458                let outcome = render(ui, &mut draft, Path::new("/tmp/c.toml"), &mut prefs);
459                assert!(outcome.save.is_none() && !outcome.prefs_changed);
460            });
461        }
462    }
463}