diff --git a/crates/reco-gui/src/main.rs b/crates/reco-gui/src/main.rs index 2763a88b..0903e2d7 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 3eb6e210..416c5054 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;