1use 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#[derive(Debug, Clone)]
26pub struct ConfigDraft {
27 pub current: Config,
28 pub original: Config,
29 pub last_save_error: Option<String>,
30 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 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 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 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
87fn configs_equal(a: &Config, b: &Config) -> bool {
90 config::changed_fields(a, b).is_empty()
91}
92
93pub 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#[derive(Debug, Default)]
108pub struct ConfigOutcome {
109 pub save: Option<Config>,
111 pub prefs_changed: bool,
113}
114
115pub 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
158pub 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
224fn 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
252fn 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#[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}