1use std::path::{Path, PathBuf};
12
13use eframe::egui;
14
15use crate::config::{self, default_models_root, Config};
16
17use super::super::notifier::NotificationPrefs;
18
19#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23 pub current: Config,
24 pub original: Config,
25 pub last_save_error: Option<String>,
26 pub pending: bool,
28}
29
30impl ConfigDraft {
31 pub fn from(cfg: &Config) -> Self {
32 Self {
33 current: cfg.clone(),
34 original: cfg.clone(),
35 last_save_error: None,
36 pending: false,
37 }
38 }
39
40 pub fn dirty(&self) -> bool {
41 !configs_equal(&self.current, &self.original)
42 }
43
44 pub fn follow(&mut self, live: &Config) {
47 if !self.dirty() && !self.pending && !configs_equal(&self.original, live) {
48 *self = Self::from(live);
49 }
50 }
51
52 pub fn saved(&mut self, saved: &Config) {
54 let changed = config::changed_fields(&self.original, saved).join(",");
55 tracing::info!(
56 target: "studio_worker::ui::config",
57 changed = ?changed,
58 "operator applied config changes via UI"
59 );
60 self.original = saved.clone();
61 self.current = saved.clone();
62 self.last_save_error = None;
63 self.pending = false;
64 }
65
66 pub fn save_failed(&mut self, error: String) {
68 tracing::warn!(
69 target: "studio_worker::ui::config",
70 error = %error,
71 "config changes not applied"
72 );
73 self.last_save_error = Some(error);
74 self.pending = false;
75 }
76
77 pub fn reset(&mut self) {
78 self.current = self.original.clone();
79 self.last_save_error = None;
80 }
81}
82
83fn configs_equal(a: &Config, b: &Config) -> bool {
86 config::changed_fields(a, b).is_empty()
87}
88
89pub fn render(
92 ui: &mut egui::Ui,
93 draft: &mut ConfigDraft,
94 config_path: &Path,
95 notification_prefs: &mut NotificationPrefs,
96) -> Option<Config> {
97 let mut save_requested = None;
98 ui.heading("Configuration");
99 ui.label(
100 egui::RichText::new(format!("{}", config_path.display()))
101 .color(egui::Color32::from_gray(150))
102 .small(),
103 );
104 ui.add_space(8.0);
105
106 section(ui, "Connection", |ui| {
107 labeled_text(ui, "API base URL", &mut draft.current.api_base_url);
108 });
109
110 section(ui, "Worker", |ui| {
111 labeled_slider(
112 ui,
113 "VRAM threshold (GB)",
114 &mut draft.current.vram_threshold_gb,
115 0.0,
116 96.0,
117 );
118 });
119
120 section(ui, "Auto-update", |ui| {
121 labeled_bool(
122 ui,
123 "Auto-update enabled",
124 &mut draft.current.auto_update_enabled,
125 );
126 labeled_u64(
127 ui,
128 "Interval (seconds)",
129 &mut draft.current.auto_update_interval_secs,
130 );
131 labeled_text(ui, "Release feed URL", &mut draft.current.auto_update_feed);
132 labeled_bool(
133 ui,
134 "Track pre-releases",
135 &mut draft.current.auto_update_prerelease,
136 );
137 });
138
139 section(ui, "Models", |ui| {
140 labeled_folder(ui, "Models root", &mut draft.current.models_root);
141 ui.label("");
142 ui.label(
143 egui::RichText::new(
144 "This is where the models will be stored. You might need a fair bit \
145 of disk space to be able to satisfy different types of jobs.",
146 )
147 .italics()
148 .color(egui::Color32::from_gray(160)),
149 );
150 ui.end_row();
151 });
152
153 section(ui, "Notifications", |ui| {
154 ui.label("On job completion");
155 ui.checkbox(&mut notification_prefs.on_completion, "");
156 ui.end_row();
157 ui.label("On job failure");
158 ui.checkbox(&mut notification_prefs.on_failure, "");
159 ui.end_row();
160 });
161
162 section(ui, "Window", |ui| {
163 ui.label("Start minimised");
164 ui.checkbox(&mut draft.current.start_minimised, "");
165 ui.end_row();
166 });
167
168 ui.add_space(12.0);
169 ui.horizontal(|ui| {
170 let dirty = draft.dirty();
171 let save = ui.add_enabled(dirty && !draft.pending, egui::Button::new("Save"));
172 if save.clicked() {
173 draft.pending = true;
174 save_requested = Some(draft.current.clone());
175 }
176 if ui.add_enabled(dirty, egui::Button::new("Reset")).clicked() {
177 draft.reset();
178 }
179 if draft.pending {
180 ui.spinner();
181 ui.label("saving\u{2026}");
182 } else if let Some(err) = &draft.last_save_error {
183 ui.colored_label(egui::Color32::LIGHT_RED, format!("save failed: {err}"));
184 } else if !dirty && draft.last_save_error.is_none() {
185 ui.label(
186 egui::RichText::new("up to date")
187 .italics()
188 .color(egui::Color32::from_gray(150)),
189 );
190 }
191 });
192 save_requested
193}
194
195fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
200 egui::CollapsingHeader::new(title)
201 .default_open(true)
202 .show(ui, |ui| {
203 egui::Grid::new(title)
204 .num_columns(2)
205 .spacing([12.0, 6.0])
206 .show(ui, |ui| {
207 add(ui);
208 });
209 });
210 ui.add_space(4.0);
211}
212
213fn labeled_text(ui: &mut egui::Ui, label: &str, value: &mut String) {
214 ui.label(label);
215 ui.add(egui::TextEdit::singleline(value).desired_width(360.0));
216 ui.end_row();
217}
218
219fn labeled_bool(ui: &mut egui::Ui, label: &str, value: &mut bool) {
220 ui.label(label);
221 ui.checkbox(value, "");
222 ui.end_row();
223}
224
225fn labeled_slider(ui: &mut egui::Ui, label: &str, value: &mut f32, min: f32, max: f32) {
226 ui.label(label);
227 ui.add(egui::Slider::new(value, min..=max).fixed_decimals(1));
228 ui.end_row();
229}
230
231fn labeled_u64(ui: &mut egui::Ui, label: &str, value: &mut u64) {
232 ui.label(label);
233 let mut buf = value.to_string();
234 if ui
235 .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0))
236 .changed()
237 {
238 if let Ok(n) = buf.parse::<u64>() {
239 *value = n;
240 }
241 }
242 ui.end_row();
243}
244
245fn labeled_folder(ui: &mut egui::Ui, label: &str, value: &mut PathBuf) {
249 ui.label(label);
250 ui.horizontal(|ui| {
251 let mut buf = value.to_string_lossy().to_string();
252 let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(280.0));
253 if r.changed() {
254 *value = PathBuf::from(buf);
255 }
256 if ui.button("Browse…").clicked() {
257 let starting = if value.is_absolute() {
258 value.clone()
259 } else {
260 default_models_root()
261 };
262 if let Some(picked) = rfd::FileDialog::new()
263 .set_directory(starting.parent().unwrap_or(&starting))
264 .pick_folder()
265 {
266 *value = picked;
267 }
268 }
269 });
270 ui.end_row();
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn draft_starts_clean() {
279 let cfg = Config::default();
280 let draft = ConfigDraft::from(&cfg);
281 assert!(!draft.dirty());
282 }
283
284 #[test]
285 fn draft_marks_dirty_after_edit() {
286 let cfg = Config::default();
287 let mut draft = ConfigDraft::from(&cfg);
288 draft.current.vram_threshold_gb = 24.0;
289 assert!(draft.dirty());
290 }
291
292 #[test]
293 fn draft_marks_dirty_when_models_root_changes() {
294 let cfg = Config::default();
295 let mut draft = ConfigDraft::from(&cfg);
296 draft.current.models_root = PathBuf::from("/tmp/other-models");
297 assert!(draft.dirty());
298 }
299
300 #[test]
301 fn saved_makes_the_answer_the_new_baseline() {
302 let mut draft = ConfigDraft::from(&Config::default());
303 draft.current.vram_threshold_gb = 24.0;
304 draft.pending = true;
305 let mut answer = draft.current.clone();
306 answer.vram_threshold_gb = 23.5;
307 draft.saved(&answer);
308 assert!(!draft.dirty() && !draft.pending);
309 assert_eq!(draft.current.vram_threshold_gb, 23.5);
310 }
311
312 #[test]
313 fn a_clean_draft_follows_the_daemon_but_an_edit_is_kept() {
314 let mut live = Config::default();
315 let mut draft = ConfigDraft::from(&live);
316 live.vram_threshold_gb = 5.0;
317 draft.follow(&live);
318 assert_eq!(draft.current.vram_threshold_gb, 5.0);
319
320 draft.current.vram_threshold_gb = 7.0;
321 live.vram_threshold_gb = 6.0;
322 draft.follow(&live);
323 assert_eq!(draft.current.vram_threshold_gb, 7.0, "edits survive a poll");
324 }
325
326 #[test]
327 fn reset_reverts_unsaved_edits() {
328 let cfg = Config::default();
329 let mut draft = ConfigDraft::from(&cfg);
330 draft.current.vram_threshold_gb = 33.0;
331 draft.reset();
332 assert!((draft.current.vram_threshold_gb - cfg.vram_threshold_gb).abs() < f32::EPSILON);
333 assert!(!draft.dirty());
334 }
335
336 #[test]
337 fn saved_emits_operator_apply_breadcrumb() {
338 use crate::test_support::capture;
339 let logs = capture(move || {
340 let mut draft = ConfigDraft::from(&Config::default());
341 draft.current.vram_threshold_gb = 24.0;
342 let answer = draft.current.clone();
343 draft.saved(&answer);
344 });
345 assert!(logs.contains("studio_worker::ui::config"), "{logs}");
346 assert!(logs.contains("changed=\"vram_threshold_gb\""), "{logs}");
347 assert!(
348 logs.contains("operator applied config changes via UI"),
349 "{logs}"
350 );
351 }
352
353 #[test]
354 fn save_failed_keeps_the_edit_and_the_error() {
355 let mut draft = ConfigDraft::from(&Config::default());
356 draft.current.vram_threshold_gb = 3.0;
357 draft.pending = true;
358 draft.save_failed("invalid config".into());
359 assert!(draft.dirty() && !draft.pending);
360 assert_eq!(draft.last_save_error.as_deref(), Some("invalid config"));
361 }
362}