diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 2847004b3..05c756a0c 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,6 +1,6 @@ #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] use super::gpu; -use super::prefs::save_prefs; +use super::prefs::{save_prefs, save_prefs_soon}; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; @@ -71,6 +71,9 @@ impl eframe::App for App { // (the system shutting down, the process ended or crashing) used to lose them (#1747). if self.app.take_prefs_changed() { file_access::unconfined(|| save_prefs(&self.app)); + } else { + // The workspace, panels and other UI settings change without the dialog (#4625). + file_access::unconfined(|| save_prefs_soon(&self.app)); } // Not while a file dialog's sheet has the keyboard: its name field's composition would go. #[cfg(target_os = "macos")] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index 56ea30f21..728fe9ba8 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -84,24 +84,200 @@ pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option(&b).ok()); + let merged = match (base.as_ref(), on_disk) { + (Some(base), Some(on_disk)) => merge_changes(base, &mine, on_disk), + _ => mine.clone(), + }; + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + let Ok(bytes) = serde_json::to_vec_pretty(&merged) else { return }; + // Preferences are best effort: a failed write keeps the previous file, and the changes are + // tried again on the next save. + if fileio::write_atomic(&p, &bytes).is_ok() { + set_saved_base(Some(mine)); } } +/// How often [`save_prefs_soon`] looks for changes to save. +const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(2); + +/// Save the preferences at most every [`SAVE_EVERY`] while the app runs, when something changed: +/// the workspace, panels, toolbars and the other UI settings kept only in the UI state were saved +/// only on quitting, so a session ended any other way (the window closed by the system, the +/// process ended or crashing) started the next one from the settings before it (#4625). +pub(crate) fn save_prefs_soon(app: &VectorcraftApp) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + let Ok(mut last) = LAST.lock() else { return }; + if last.is_some_and(|t| t.elapsed() < SAVE_EVERY) { + return; + } + *last = Some(std::time::Instant::now()); + drop(last); + save_prefs(app); +} + +/// The preferences as this window last read or saved them (`None`: not read yet). +static SAVED_BASE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn saved_base() -> Option { + SAVED_BASE.lock().ok().and_then(|b| b.clone()) +} + +fn set_saved_base(value: Option) { + if let Ok(mut b) = SAVED_BASE.lock() { + *b = value; + } +} + +/// Fields of the UI state that a start resets ([`vectorcraft_ui_egui::UiState::sanitized`]): a +/// change to them alone isn't saved, and they are never merged over another window's. +const PASSING: &[&str] = &["status", "dialog", "flyout", "palette_open", "palette_query", "about", "open_panel"]; + +/// Do `a` and `b` hold the same preferences, leaving out what a start resets ([`PASSING`])? +fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool { + match (a.as_object(), b.as_object()) { + (Some(a), Some(b)) => { + let keys: std::collections::BTreeSet<&String> = a.keys().chain(b.keys()).filter(|k| !PASSING.contains(&k.as_str())).collect(); + keys.into_iter().all(|k| a.get(k) == b.get(k)) + } + _ => a == b, + } +} + +/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI +/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change +/// different settings keep both. A field both changed takes this window's value (it saves last). +fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else { + return mine.clone(); + }; + let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect(); + for key in keys { + if PASSING.contains(&key.as_str()) { + if let Some(v) = mine.get(key) { + out.entry(key.clone()).or_insert_with(|| v.clone()); + } + continue; + } + let (was, now) = (base.get(key), mine.get(key)); + if was == now { + continue; + } + if key == "engine_prefs" + && let (Some(Value::Object(was)), Some(Value::Object(now)), Some(Value::Object(theirs))) = (was, now, out.get_mut(key)) + { + let names: std::collections::BTreeSet<&String> = was.keys().chain(now.keys()).collect(); + for name in names { + match (was.get(name), now.get(name)) { + (a, b) if a == b => {} + (_, Some(v)) => { + theirs.insert(name.clone(), v.clone()); + } + (_, None) => { + theirs.remove(name); + } + } + } + continue; + } + match now { + Some(v) => { + out.insert(key.clone(), v.clone()); + } + None => { + out.remove(key); + } + } + } + Value::Object(out) +} + /// The preferences file's contents: the UI state with the engine preferences. +#[cfg(test)] pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { + serde_json::to_vec_pretty(&prefs_value(app)?) +} + +/// [`prefs_bytes`] as JSON. +fn prefs_value(app: &VectorcraftApp) -> serde_json::Result { let mut ui = app.ui.clone(); ui.engine_prefs = app.session.prefs.to_json(); - serde_json::to_vec_pretty(&ui) + serde_json::to_value(&ui) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{merge_changes, same_lasting}; + + /// Two windows open at once (#4625): the one that saves last keeps the other's changes and + /// adds its own, instead of putting back the preferences it started from. + #[test] + fn a_window_saves_only_what_it_changed_over_another_windows_save() { + let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}}); + // The other window switched the workspace and the accent, and saved. + let on_disk = json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.0}}); + // This one changed only the UI scaling. + let mine = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.25}}); + let merged = merge_changes(&base, &mine, on_disk); + assert_eq!(merged, json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.25}})); + } + + #[test] + fn a_setting_both_windows_changed_takes_the_last_saved() { + let base = json!({"workspace": "Essentials", "engine_prefs": {"uiAccent": "blue"}}); + let on_disk = json!({"workspace": "Painting", "engine_prefs": {"uiAccent": "violet"}}); + let mine = json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}})); + } + + #[test] + fn fields_added_or_removed_by_this_window_are_saved() { + let base = json!({"docking": {"root": 1}, "engine_prefs": {"old": 1}}); + let on_disk = json!({"docking": {"root": 1}, "recent_files": ["a.svg"], "engine_prefs": {"old": 1}}); + let mine = json!({"dock_width": 300.0, "engine_prefs": {"new": 2}}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"dock_width": 300.0, "recent_files": ["a.svg"], "engine_prefs": {"new": 2}})); + } + + /// The status line and open dialogs reset at start: they don't make a save, and they don't + /// replace another window's. + #[test] + fn what_a_start_resets_is_left_out() { + let base = json!({"status": "", "dialog": null, "workspace": "Essentials"}); + let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials"}); + assert!(same_lasting(&base, &mine)); + let on_disk = json!({"status": "Opened", "dialog": null, "workspace": "Painting"}); + let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials", "toolbar": false}); + let merged = merge_changes(&base, &mine, on_disk); + assert_eq!(merged, json!({"status": "Opened", "dialog": null, "workspace": "Painting", "toolbar": false})); + assert!(!same_lasting(&base, &mine)); + } + + #[test] + fn a_file_that_is_not_an_object_is_replaced() { + let base = json!({"workspace": "Essentials"}); + let mine = json!({"workspace": "Painting"}); + assert_eq!(merge_changes(&base, &mine, json!([1, 2])), mine); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d525a05f4..2d8c7eaef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Preferences: the workspace, panels, toolbars and other UI settings are saved within 2 seconds of a change instead of only on quitting, and a window saves only what it changed, merged into the file as another VectorCraft window left it, so a second window closing last no longer puts back the settings it started with (#4625); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -361,6 +361,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Preferences: the workspace, panels, toolbars and other UI settings are saved within 2 seconds of a change instead of only on quitting, and a window saves only what it changed, merged into the file as another VectorCraft window left it, so a second window closing last no longer puts back the settings it started with (#4625). Tests `prefs::tests` in `apps/vectorcraft`; scores and estimates unchanged | | 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores and estimates unchanged | | 2026-10-30 | minor | Contextual Task Bar: with the Direct Selection tool, a path selected whole offers Corner, Smooth (every anchor), Connect (open paths) and Simplify instead of Edit Path and Offset Path, as the public overview lists for that tool (); a compound path under that tool no longer offers Edit Path (#4469). Tests in `crates/ui-egui/src/tests/taskbar.rs`; scores and estimates unchanged | | 2026-10-30 | minor | Align panel menu › Align to Glyph Bounds › Point Text and Area Text align and distribute type by the ink of its glyphs (#4231); the Character and Properties panels' controls for it are not done; scores and estimates unchanged |