From 2bdb0ea01d86cacfa3875968a36c2d4357727305 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 18:52:20 -0600 Subject: [PATCH] UI.9: recover saved docking metadata and declare frontend MSRV --- Cargo.toml | 2 +- apps/designcraft-web/Cargo.toml | 1 + apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/main.rs | 2 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs.rs | 116 ++++++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/prefs.rs diff --git a/Cargo.toml b/Cargo.toml index 05d701e5..35a1ed71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtas version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index 75775c31..b1131c79 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -3,6 +3,7 @@ name = "designcraft-web" description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 416b0740..13254e11 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false default-run = "designcraft" diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index d0241fc5..35a5686f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -83,7 +83,7 @@ fn load_prefs(app: &mut DesignApp) { } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) + && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { app.ui = ui; } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 35a3ef0e..92bff320 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft-ui-egui" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5e1046d8..c003154a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod icons; pub mod menus; mod panel_docking; pub mod panels; +mod prefs; pub mod render_worker; mod rtl; pub mod story_editor; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs new file mode 100644 index 00000000..cb5a0b54 --- /dev/null +++ b/crates/ui-egui/src/prefs.rs @@ -0,0 +1,116 @@ +//! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. + +use craft_ui::docking::{Layout, Location}; +use serde_json::Value; + +use crate::UiState; + +impl UiState { + /// Read saved preferences, discarding only unreadable optional docking metadata. + /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. + pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result { + let mut saved: Value = serde_json::from_slice(bytes)?; + recover_docking(&mut saved); + if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { + for workspace in workspaces { + recover_docking(workspace); + } + } + serde_json::from_value(saved) + } +} + +fn recover_docking(saved: &mut Value) { + let Some(fields) = saved.as_object_mut() else { return }; + if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { + fields.remove("docking"); + } + if let Some(hidden) = fields.get_mut("dockingHidden") { + if let Some(entries) = hidden.as_object_mut() { + entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); + } else { + fields.remove("dockingHidden"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DesignApp, SavedWorkspace}; + use serde_json::json; + + fn populated() -> UiState { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + app.ui.ui_scale = 1.5; + app.ui.language = "ja".into(); + app.ui.custom_workspaces = vec![ + SavedWorkspace { + name: "Print".into(), + docking: app.ui.docking.clone(), + docking_hidden: app.ui.docking_hidden.clone(), + ..Default::default() + }, + SavedWorkspace { name: "Proof".into(), docking: app.ui.docking.clone(), ..Default::default() }, + ]; + app.ui + } + + #[test] + fn saved_preferences_preserve_valid_layouts_and_hidden_origins() { + let original = serde_json::to_value(populated()).unwrap(); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(loaded).unwrap(), original); + } + + #[test] + fn future_layouts_recover_independently_without_resetting_preferences_or_workspaces() { + let original = populated(); + for field in ["docking", "dockingHidden"] { + for invalid in [json!({"root":{"FutureLayout":{}}}), json!(false)] { + let mut saved = serde_json::to_value(&original).unwrap(); + saved[field] = invalid.clone(); + saved["customWorkspaces"][0][field] = invalid; + assert!(serde_json::from_value::(saved.clone()).is_err(), "command parsing remains strict"); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert_eq!(loaded.ui_scale, 1.5); + assert_eq!(loaded.language, "ja"); + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Print"); + assert_eq!(loaded.custom_workspaces[1].name, "Proof"); + assert_eq!(loaded.custom_workspaces[1].docking, original.custom_workspaces[1].docking); + if field == "docking" { + assert!(loaded.docking.is_none()); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } else { + assert!(loaded.docking_hidden.is_empty()); + assert!(loaded.custom_workspaces[0].docking_hidden.is_empty()); + assert_eq!(loaded.docking, original.docking); + assert_eq!(loaded.custom_workspaces[0].docking, original.custom_workspaces[0].docking); + } + } + } + } + + #[test] + fn unreadable_hidden_origin_does_not_discard_other_origins() { + let original = populated(); + let mut saved = serde_json::to_value(&original).unwrap(); + saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); + saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!original.docking_hidden.is_empty()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } + + #[test] + fn unrelated_malformed_preferences_are_still_rejected() { + assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); + assert!(UiState::from_preferences_json(br#"{"customWorkspaces":false}"#).is_err()); + } +}