1use 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#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23 pub current: Config,
24 pub original: Config,
25 pub last_save_error: Option<String>,
26 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
69fn 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 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
204fn 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
254fn 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 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 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}