diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index f69e02d8a..a25782d6e 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -14,6 +14,9 @@ pub mod download; #[cfg(target_arch = "wasm32")] mod host; +#[cfg(any(target_arch = "wasm32", test))] +mod panel_layout; + #[cfg(target_arch = "wasm32")] mod web { use cadcraft_engine::Session; @@ -59,10 +62,14 @@ mod web { self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } - /// Preferences (interface theme and language) survive reloads; egui state is not kept. + /// Preferences and validated panel placement survive reloads; egui memory is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + match crate::panel_layout::save(&self.0.ui) { + Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout), + Err(error) => log::warn!("panel layout was not saved: {error}"), + } } fn persist_egui_memory(&self) -> bool { false @@ -160,6 +167,11 @@ mod web { if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } + if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY)) + && let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout) + { + log::warn!("panel layout was not restored: {error}"); + } // `?cpu`: no render state for the app, so the canvas draws with egui shapes. if !crate::wants_cpu(&query()) && let Some(rs) = &cc.wgpu_render_state diff --git a/apps/cadcraft-web/src/panel_layout.rs b/apps/cadcraft-web/src/panel_layout.rs new file mode 100644 index 000000000..766879c83 --- /dev/null +++ b/apps/cadcraft-web/src/panel_layout.rs @@ -0,0 +1,104 @@ +//! Browser panel layout storage, independent of drawings and native workspace files. + +use cadcraft_ui_egui::{UiState, docking}; +use serde_json::{Value, json}; + +#[cfg(target_arch = "wasm32")] +pub const KEY: &str = "cadcraft.panel-layout"; +const MAX_BYTES: usize = 64 * 1024; + +/// Serialize only validated panel placement and visibility, with a bounded record size. +pub fn save(ui: &UiState) -> Result { + docking::validate(&ui.docking)?; + let saved = json!({ + "version": docking::LAYOUT_VERSION, + "docking": ui.docking, + "showToolsets": ui.show_toolsets, + "showPalettes": ui.show_palettes, + }) + .to_string(); + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + Ok(saved) +} + +/// Validate the entire optional record before changing any UI field. +pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> { + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?; + if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) { + return Err("unsupported panel layout version".into()); + } + let workspace: docking::Workspace = + serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?; + docking::validate(&workspace)?; + let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?; + let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?; + ui.docking = workspace; + ui.show_toolsets = tools; + ui.show_palettes = palettes; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + use cadcraft_ui_egui::{CadApp, Services}; + + #[test] + fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() { + let mut source = CadApp::new(Session::default(), Services::default()); + source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap(); + source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + let saved = save(&source.ui).unwrap(); + let mut destination = CadApp::new(Session::default(), Services::default()); + destination.session.start("line").unwrap(); + let before = destination.session.doc().unwrap().clone(); + restore(&mut destination.ui, &saved).unwrap(); + assert_eq!(destination.ui.docking, source.ui.docking); + assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets); + assert_eq!(destination.ui.show_palettes, source.ui.show_palettes); + assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line")); + assert_eq!(destination.session.doc().unwrap(), &before); + } + + #[test] + fn malformed_or_unsupported_storage_is_rejected_atomically() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + for (field, bad) in [ + ("version", json!(0)), + ("version", json!(docking::LAYOUT_VERSION + 1)), + ("docking", json!({})), + ("showToolsets", json!("false")), + ("showPalettes", Value::Null), + ] { + let mut invalid = valid.clone(); + invalid[field] = bad; + assert!(restore(&mut ui, &invalid.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] { + assert!(restore(&mut ui, &invalid).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + } + + #[test] + fn storage_cannot_remove_the_document_view() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let mut invalid = ui.clone(); + invalid.docking.layout.root = None; + assert!(save(&invalid).is_err()); + let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + record["docking"] = serde_json::to_value(&invalid.docking).unwrap(); + assert!(restore(&mut ui, &record.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c06e8f242..6bf0c20f8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (browser panel layout storage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD @@ -166,6 +166,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | ViewCube | interactive 3D | clickable for 2D (Top/Home → extents); dragging does nothing (#7) | | Navigation bar, SteeringWheels | yes | no | | Workspaces | Drafting & Annotation, 3D Modeling | no | +| Browser panel layout storage | not applicable | Versioned, validated panel placement and visibility saved through the browser host; floating palettes remain embedded. Requires available browser storage and a completed host save; named workspaces and drawing persistence are separate. | | Options / Preferences dialog | many tabs | **no** (issue #23) | | Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); no CUI or tool palettes | | Themes | Dark, Light | Dark, Light, System (#179) | @@ -203,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) | diff --git a/packaging/web/README.md b/packaging/web/README.md index 024e00c01..73a74f493 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -50,6 +50,18 @@ location /cadcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Panel layout storage + +The browser host saves docked, floating and hidden palette arrangements alongside interface +preferences in its local storage. Floating palettes remain embedded in the browser canvas; +they do not become native operating-system windows. Window > Reset Palettes restores the +default arrangement. This record contains panel placement and visibility, not drawing data. + +The optional `cadcraft.panel-layout` record uses the shared docking layout version and is +limited to 64 KiB. Malformed, incompatible or invalid arrangements are ignored atomically, +leaving the startup defaults usable. Storage availability and autosave follow the browser +host's existing eframe persistence; clearing site data removes the saved layout. + ## Embedding in a page (iframe) ```html