From 1346580bdba6e23a4dcefaad8a615ed9509bbdfc Mon Sep 17 00:00:00 2001 From: Rufan Date: Sat, 18 Jul 2026 23:10:02 +0200 Subject: [PATCH] feat(reco-gui): default calibration preference + overwrite confirmation Adds a "Default calibration" path in Preferences that reco-gui falls back to whenever no calibration is otherwise loaded/picked for a session (wired into try_init_and_update, the single point every left/right/calibration pick path converges on before initialization). Saving over the file currently configured as the default now prompts for confirmation first ("Overwrite default calibration?"), since that file is meant to be a stable fallback for future sessions rather than scratch space for the current editing session. --- crates/reco-gui/src/main.rs | 80 +++++++++++++++++++++++++++++++-- crates/reco-gui/src/settings.rs | 47 +++++++++++++++++++ crates/reco-gui/ui/main.slint | 75 +++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/crates/reco-gui/src/main.rs b/crates/reco-gui/src/main.rs index 2763a88b8..0903e2d72 100644 --- a/crates/reco-gui/src/main.rs +++ b/crates/reco-gui/src/main.rs @@ -618,6 +618,21 @@ impl AppState { Ok(()) } + /// Whether the calibration currently loaded is the one configured in + /// preferences as the Default Calibration - i.e. saving now would + /// overwrite the fallback future sessions rely on, not just this + /// session's own file. `None == None` deliberately doesn't count (no + /// default configured means nothing to protect). + fn is_default_calibration(&self) -> bool { + match ( + &self.calibration_path, + &self.user_settings.default_calibration_path, + ) { + (Some(current), Some(default)) => current == default, + _ => false, + } + } + /// Restore Topology to the values loaded at init (or after auto-cal). fn reset_calibration(&mut self) { if let Some(base) = self.cal_baseline.clone() { @@ -2386,6 +2401,14 @@ fn main() -> anyhow::Result<()> { .unwrap_or_default() .into(), ); + app.set_prefs_default_calibration_path( + s.user_settings + .default_calibration_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + .into(), + ); app.set_recording_codec(s.user_settings.recording_codec.clone().into()); app.set_recording_quality(s.user_settings.recording_quality.clone().into()); app.set_recording_folder( @@ -2417,6 +2440,12 @@ fn main() -> anyhow::Result<()> { } else { Some(PathBuf::from(model_path)) }; + let default_cal_path = app.get_prefs_default_calibration_path().to_string(); + s.user_settings.default_calibration_path = if default_cal_path.is_empty() { + None + } else { + Some(PathBuf::from(default_cal_path)) + }; s.user_settings.recording_codec = app.get_recording_codec().to_string(); s.user_settings.recording_quality = app.get_recording_quality().to_string(); let rec_folder = app.get_recording_folder().to_string(); @@ -2470,6 +2499,18 @@ fn main() -> anyhow::Result<()> { } }); + let app_weak = app.as_weak(); + app.on_pick_prefs_default_calibration(move || { + let dialog = rfd::FileDialog::new() + .set_title("Select default calibration") + .add_filter("Calibration JSON", &["json"]); + if let Some(path) = dialog.pick_file() + && let Some(app) = app_weak.upgrade() + { + app.set_prefs_default_calibration_path(path.to_string_lossy().to_string().into()); + } + }); + let app_weak = app.as_weak(); app.on_pick_recording_folder(move || { let dialog = rfd::FileDialog::new().set_title("Select default recording folder"); @@ -2858,9 +2899,10 @@ fn main() -> anyhow::Result<()> { } }); - let app_weak = app.as_weak(); - let state_ref = Rc::clone(&state); - app.on_save_calibration(move || { + // Shared by both `save-calibration` (after the default-calibration + // check passes) and `confirm-save-calibration` (user already said + // "yes, overwrite the default" in the warning modal). + fn do_save_calibration(state_ref: &Rc>, app_weak: &slint::Weak) { let save_result = state_ref.borrow().save_calibration(); match save_result { Err(e) => { @@ -2886,6 +2928,24 @@ fn main() -> anyhow::Result<()> { } } } + } + + let app_weak = app.as_weak(); + let state_ref = Rc::clone(&state); + app.on_save_calibration(move || { + if state_ref.borrow().is_default_calibration() { + if let Some(app) = app_weak.upgrade() { + app.set_overwrite_default_cal_warning_open(true); + } + return; + } + do_save_calibration(&state_ref, &app_weak); + }); + + let app_weak = app.as_weak(); + let state_ref = Rc::clone(&state); + app.on_confirm_save_calibration(move || { + do_save_calibration(&state_ref, &app_weak); }); let app_weak = app.as_weak(); @@ -4225,6 +4285,20 @@ fn try_init_and_update(state: &Rc>, app_weak: &slint::Weak, + /// Calibration file to fall back to whenever no calibration is + /// otherwise loaded/picked for a session (see `try_init_and_update`'s + /// fallback). `None` means no default is configured - the app + /// behaves as it always has, requiring an explicit pick. + #[serde(default)] + pub default_calibration_path: Option, + /// Last window size, remembered across restarts. `None` means /// "use Slint's preferred-width / preferred-height defaults". #[serde(default)] @@ -116,6 +123,7 @@ impl Default for GuiSettings { default_quality: default_quality(), default_blend_width: default_blend_width(), ai_model_path: None, + default_calibration_path: None, window_size: None, window_maximized: false, recording_codec: default_codec(), @@ -168,6 +176,17 @@ impl GuiSettings { self.recent_calibration.push(path); self.save(); } + + /// The configured default-calibration fallback, if set and if it + /// still exists on disk - a deleted/moved default should silently + /// stop applying rather than pointing `calibration_path` at a dead + /// file (see `try_init_and_update`). + pub fn default_calibration(&self) -> Option { + self.default_calibration_path + .as_ref() + .filter(|p| p.exists()) + .cloned() + } } #[cfg(test)] @@ -193,4 +212,32 @@ mod tests { assert_eq!(s.default_quality, "balanced"); assert!(s.recent_left.is_empty()); } + + #[test] + fn default_calibration_none_when_unset() { + let s = GuiSettings::default(); + assert!(s.default_calibration().is_none()); + } + + #[test] + fn default_calibration_none_when_file_no_longer_exists() { + let mut s = GuiSettings::default(); + s.default_calibration_path = Some(PathBuf::from("does-not-exist.json")); + assert!(s.default_calibration().is_none()); + } + + #[test] + fn default_calibration_returns_path_when_it_exists() { + let dir = std::env::temp_dir().join(format!( + "reco-gui-settings-test-default-cal-{}", + std::process::id(), + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("default.json"); + std::fs::write(&path, b"{}").unwrap(); + + let mut s = GuiSettings::default(); + s.default_calibration_path = Some(path.clone()); + assert_eq!(s.default_calibration(), Some(path)); + } } diff --git a/crates/reco-gui/ui/main.slint b/crates/reco-gui/ui/main.slint index 3eb6e2101..416c5054d 100644 --- a/crates/reco-gui/ui/main.slint +++ b/crates/reco-gui/ui/main.slint @@ -623,6 +623,16 @@ export component RecoApp inherits Window { in-out property prefs-default-quality: "balanced"; in-out property prefs-default-blend-width: 0.05; in-out property prefs-ai-model-path: ""; + in-out property prefs-default-calibration-path: ""; + // Confirmation shown when Save Calibration would overwrite whichever + // file is configured above as the Default Calibration - that file is + // meant to be a stable fallback for future sessions, so overwriting + // it in-place needs an explicit "yes really" rather than silently + // changing what every session with no calibration falls back to. + // Rust decides whether to open this (it knows both paths); confirming + // calls `confirm-save-calibration` instead of `save-calibration` to + // skip the check on the second click. + in-out property overwrite-default-cal-warning-open: false; // Toast notification list (Tier 3a). in-out property <[Toast]> toasts: []; @@ -713,6 +723,10 @@ export component RecoApp inherits Window { callback changed-cal-camera-axis-offset(float); callback changed-cal-x-ty(float); callback save-calibration(); + // Confirmed overwrite of the Default Calibration (see + // `overwrite-default-cal-warning-open`) - performs the actual save + // that `save-calibration` held back pending confirmation. + callback confirm-save-calibration(); callback reset-calibration(); // Live lens parameter editing. Fires on any of the 8 sliders per // camera; Rust reads the property values for the currently-selected @@ -753,6 +767,7 @@ export component RecoApp inherits Window { callback open-prefs-dialog(); callback save-prefs(); callback pick-prefs-model(); + callback pick-prefs-default-calibration(); callback open-website(); callback open-forum(); in-out property prefs-telemetry-enabled: false; @@ -2099,6 +2114,20 @@ export component RecoApp inherits Window { } } + Text { text: "Default calibration"; color: #ccc; font-size: 12px; } + HorizontalLayout { + spacing: 6px; + LineEdit { + text <=> root.prefs-default-calibration-path; + placeholder-text: "Path to .json, or leave empty"; + horizontal-stretch: 1; + } + Button { + text: "Browse…"; + clicked => { root.pick-prefs-default-calibration(); } + } + } + Rectangle { height: 1px; background: root.border-subtle; } Text { text: "Recording defaults"; color: #ccc; font-size: 12px; font-weight: 600; } @@ -2188,6 +2217,52 @@ export component RecoApp inherits Window { } } + // ── Overwrite-default-calibration confirmation (modal) ── + if root.overwrite-default-cal-warning-open: Rectangle { + background: root.bg-scrim; + TouchArea { clicked => { root.overwrite-default-cal-warning-open = false; } } + + Rectangle { + width: 420px; + height: min(parent.height - 60px, 240px); + background: root.bg-surface; + border-radius: 12px; + border-width: 1px; + border-color: root.border-mid; + TouchArea {} + + VerticalLayout { + padding: 24px; + spacing: 14px; + + Text { text: "Overwrite default calibration?"; color: root.text-heading; font-size: 18px; font-weight: 600; } + + Text { + text: "This is your configured Default Calibration - the fallback used whenever no calibration is otherwise loaded. Saving now will overwrite it with the current session's changes."; + color: root.text-muted; + font-size: 12px; + wrap: word-wrap; + } + + Rectangle { vertical-stretch: 1; } + + HorizontalLayout { + spacing: 8px; + alignment: end; + Button { text: "Cancel"; clicked => { root.overwrite-default-cal-warning-open = false; } } + Button { + text: "Overwrite Default"; + primary: true; + clicked => { + root.overwrite-default-cal-warning-open = false; + root.confirm-save-calibration(); + } + } + } + } + } + } + // ── Bug report dialog ── if root.bug-dialog-open: Rectangle { background: root.bg-scrim;