diff --git a/Cargo.lock b/Cargo.lock index 48e90a2b1..9dc213ab4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,12 +959,12 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-syntax", "krilla", @@ -974,9 +974,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] @@ -1835,7 +1839,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-ccitt", ] @@ -1849,7 +1853,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2195,7 +2199,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index d3ea9a09a..54b1bf711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } # foundation thiserror = "2" @@ -53,7 +53,7 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } egui = "0.36" egui_kittest = "0.36" eframe = { version = "0.36", default-features = false } @@ -62,9 +62,9 @@ rfd = "0.17" arboard = { version = "3.6", default-features = false } [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index e2a429d94..f4e34d4fb 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Op } fn snapshot(ui: &UiState) -> Result { - ui.docking.validate()?; + cadcraft_ui_egui::docking::validate(&ui.docking)?; if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { return Err("toolsetTab must be Drafting or Modeling".into()); } @@ -85,7 +85,7 @@ fn read(path: &Path) -> Result, String> { let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; if let Some(docking) = docking && let Ok(docking) = serde_json::from_value::(docking) - && docking.validate().is_ok() + && cadcraft_ui_egui::docking::validate(&docking).is_ok() { ui.docking = docking; } @@ -236,7 +236,7 @@ mod tests { #[test] fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { - use cadcraft_ui_egui::docking::Panel; + use cadcraft_ui_egui::docking::{Panel, permissions}; let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); @@ -244,19 +244,58 @@ mod tests { let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); ui.docking = app.ui.docking; - ui.docking.set_visible(Panel::ToolSets, false).unwrap(); + ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap(); ui.show_toolsets = false; store.save_if_changed(&ui).unwrap(); let (_, reloaded, error) = Workspace::at(Some(path)); assert!(error.is_none()); let mut reloaded = reloaded.unwrap(); assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); - reloaded.docking.set_visible(Panel::ToolSets, true).unwrap(); + reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap(); assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); fs::remove_dir_all(directory).unwrap(); } + #[test] + fn version_one_docking_file_still_loads() { + use cadcraft_ui_egui::docking::{Panel, reveal_panel}; + // Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a + // floating window as one `{panels, active, rect}` group, Tool Sets closed. + let file = r#"{"version":1,"workspace":{ + "docking":{ + "layout":{ + "root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0}, + "first":{"Tabs":{"panels":["canvas"],"active":0}}, + "second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}}, + "floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]}, + "hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"}, + "size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]}, + "showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#; + let directory = temp_dir(); + let path = directory.join("workspace.json"); + fs::write(&path, file).unwrap(); + let (_, restored, error) = Workspace::at(Some(path)); + assert!(error.is_none(), "{error:?}"); + let mut ui = restored.unwrap(); + assert_eq!(ui.toolset_tab, "Modeling"); + let docking = &ui.docking; + assert_eq!(docking.layout.floating.len(), 1); + let window = &docking.layout.floating[0]; + assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]); + assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}})); + assert_eq!( + serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"], + json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}) + ); + assert!(!docking.layout.contains(&Panel::ToolSets)); + reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap(); + let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(tools.anchor, Some(Panel::Canvas)); + assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}])); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn malformed_oversized_and_newer_state_are_reported_and_preserved() { let directory = temp_dir(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bc6d23ef5..b66c0919c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -452,7 +452,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } match serde_json::from_value::(cur) { Ok(u) => { - if let Err(error) = u.docking.validate() { + if let Err(error) = crate::docking::validate(&u.docking) { return err(error); } app.ui = u; diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index c44f8d1cf..6b9cedbe9 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock, + Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -40,25 +43,13 @@ impl Panel { Self::ExternalRefs => "External References", } } - fn default_side(self) -> Zone { - match self { - Self::Canvas => Zone::Right, - Self::ToolSets => Zone::Left, - Self::QuickCalc => Zone::Right, - Self::Layers => Zone::Right, - Self::Properties => Zone::Right, - Self::ExternalRefs => Zone::Right, - } - } /// Palettes outside the default arrangement, opened on request (QUICKCALC). pub const ON_DEMAND: &[Self] = &[Self::QuickCalc]; - fn parse(id: &str) -> Result { - Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } -fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +/// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it. +pub fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -69,230 +60,137 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels. +pub type Workspace = craft_ui::docking::Workspace; + +/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right. +pub fn default_workspace() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(220.0), + tabs(Panel::ToolSets), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Stack { + entries: vec![ + StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, + StackEntry { panel: Panel::Properties, open: true, height: None }, + ], + }, + ), + )), + floating: Vec::new(), + }) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(220.0), - tabs(Panel::ToolSets), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), - tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), +/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, +/// group or collapse it, or restore a palette over it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(|error| error.to_string())?; + if !workspace.layout.contains(&Panel::Canvas) { + return Err("The panel layout must contain its document view".into()); + } + if workspace.layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => { + return Err("The document view cannot be in a dock".into()); + } + _ => {} } } + let over_canvas = |location: &Location| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }; + if workspace.hidden.iter().any(|(_, location)| over_canvas(location)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } +/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place +/// for), keeping the drawing view's rules. +pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + let mut next = workspace.clone(); + next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?; + validate(&next)?; + *workspace = next; + Ok(()) +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) +/// Show or hide a panel for the Tool Sets and palette switches. Showing a panel whose recorded +/// anchor is shown restores it there without changing which tab is selected or which accordion +/// entries are open. Otherwise it goes through [`reveal_panel`], which regroups it with hidden +/// panels it was beside, and selects it. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); } - - /// Explicit opens select the requested tab and expand a collapsed stack entry. - pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> { - let mut next = self.clone(); - next.set_visible(panel, true)?; - next.apply(Action::Activate { panel })?; - *self = next; - Ok(()) + if !visible { + return workspace.set_visible(panel, false, None, permissions).map_err(|error| error.to_string()); } - - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - // Recreate the last surviving member first. Restoring a member also restores any - // hidden anchor it needs, so either close order retains the original tab order. - // Remove this member before recursion to bound malformed/cyclic saved anchors. + let mut next = workspace.clone(); + let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + // A direct restore against a hidden anchor would open a window of its own. + let direct = location.filter(|location| location.anchor.is_none_or(|anchor| next.layout.contains(&anchor))); + if direct.is_some_and(|location| next.layout.restore(panel, &location).is_ok()) { next.hidden.retain(|(id, _)| *id != panel); - if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor) - && !next.layout.contains(&anchor) - && next.hidden.iter().any(|(id, _)| *id == anchor) - { - next.set_visible(anchor, true)?; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) + validate(&next)?; + *workspace = next; + return Ok(()); } + reveal_panel(workspace, panel) +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); +/// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar. +fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let operation = if let Some(action) = params.get("action") { + UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? } + } else { + UiDock::Operation(serde_json::from_value(params.clone()).map_err(|error| error.to_string())?) + }; + let mut next = workspace.clone(); + match operation { + UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel }) if !next.layout.contains(&panel) => { + reveal_panel(&mut next, panel)?; } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); + operation => { + // Every ui.dock operation carries its own rect, so the float space is never consulted. + let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + DockCommand::UiDock(operation).apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?; } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal_panel(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) } + validate(&next)?; + *workspace = next; + Ok(()) } fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { - workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?; + set_visible(workspace, Panel::ToolSets, app.ui.show_toolsets)?; if !app.ui.show_palettes { - workspace.set_visible(Panel::Layers, false)?; - workspace.set_visible(Panel::Properties, false)?; + set_visible(workspace, Panel::Layers, false)?; + set_visible(workspace, Panel::Properties, false)?; } else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) { // Reopen the last surviving accordion entry before restoring its earlier sibling. - workspace.set_visible(Panel::Properties, true)?; - workspace.set_visible(Panel::Layers, true)?; + set_visible(workspace, Panel::Properties, true)?; + set_visible(workspace, Panel::Layers, true)?; } Ok(()) } @@ -305,10 +203,10 @@ fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) { pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { let mut workspace = app.ui.docking.clone(); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); } else { sync(&mut workspace, app)?; - workspace.command(params)?; + apply_command(&mut workspace, params)?; } publish_visibility(app, &workspace); app.ui.docking = workspace; @@ -318,9 +216,9 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result(ui.ctx(), egui::Id::new("cadcraft-docking")); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 087e5cbf8..a627baf08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,7 +156,7 @@ impl SaveFormat { impl Default for UiState { fn default() -> Self { UiState { - docking: docking::Workspace::default(), + docking: docking::default_workspace(), interface_language: i18n::Preference::default(), show_toolsets: true, diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 1e3ee118c..1cffab495 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -52,19 +52,19 @@ fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { assert_eq!(location.anchor, Some(Panel::ToolSets)); harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Float panel").click(); + harness.get_by_label("Float Panel").click(); harness.run_steps(5); - let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect; + let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect; harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Close panel").click(); + harness.get_by_label("Close Panel").click(); harness.run_steps(4); assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers)); harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); harness.run_steps(3); harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); harness.run_steps(4); - assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect); + assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect, rect); let saved = serde_json::to_string(&harness.state().ui).unwrap(); let expected = serde_json::to_value(&harness.state().ui.docking).unwrap(); let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0); @@ -88,6 +88,13 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}), json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}), json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}), + json!({"operation":"float", "panel":"layers", "rect":[1e39,0,300,200]}), + json!({"operation":"resizeStack", "panel":"layers", "height":1e39}), + json!({"operation":"resizeSplit", "path":[true,true,true,true,true,true,true,true], "size":{"Ratio":0.5}}), + json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}), + json!({"operation":"spin", "panel":"layers"}), + json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}), + json!(null), ] { assert!(harness.state_mut().run("ui.dock", params).is_err()); assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); @@ -171,28 +178,47 @@ fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() #[test] fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() { + use cadcraft_ui_egui::docking::{default_workspace, permissions, reveal_panel}; use craft_ui::docking::{Action, Placement}; for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] { - let mut workspace = cadcraft_ui_egui::docking::Workspace::default(); + let mut workspace = default_workspace(); let rect = [50.0, 60.0, 320.0, 400.0]; - workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap(); + workspace.apply(Action::Float { panel: Panel::Layers, rect }, permissions).unwrap(); workspace - .apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }) + .apply( + Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }, + permissions, + ) .unwrap(); - let expected = workspace.layout.floating[0].panels.clone(); + let expected: Vec = workspace.layout.floating[0].panels().copied().collect(); for panel in order { - workspace.set_visible(panel, false).unwrap(); + workspace.set_visible(panel, false, None, permissions).unwrap(); } let saved = serde_json::to_string(&workspace).unwrap(); workspace = serde_json::from_str(&saved).unwrap(); - workspace.reveal_panel(Panel::Properties).unwrap(); - workspace.reveal_panel(Panel::Layers).unwrap(); + reveal_panel(&mut workspace, Panel::Properties).unwrap(); + reveal_panel(&mut workspace, Panel::Layers).unwrap(); assert_eq!(workspace.layout.floating.len(), 1); - assert_eq!(workspace.layout.floating[0].panels, expected); + assert_eq!(workspace.layout.floating[0].panels().copied().collect::>(), expected); assert_eq!(workspace.layout.floating[0].rect, rect); } } +#[test] +fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let floating = &h.state().ui.docking.layout.floating; + assert_eq!(floating.len(), 1, "{floating:?}"); + assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}"); +} + #[test] fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { let mut harness = harness(None, 1000.0, 1.0); @@ -206,8 +232,7 @@ fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { harness.run_steps(3); let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap(); assert_eq!(location.anchor, Some(Panel::Layers)); - let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert_eq!(harness.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); } #[test] @@ -266,8 +291,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap(); h.run_steps(3); type_command(&mut h, "PROPERTIES"); - let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert!(h.state().ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Properties))); + assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); assert_eq!(h.state().session.doc().unwrap(), &drawing); } @@ -278,7 +303,7 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); type_command(&mut h, "ui.dock.reset"); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert_eq!(h.state().ui.docking.layout, expected.layout); assert_eq!(h.state().ui.docking.hidden, expected.hidden); assert_eq!(h.state().session.doc().unwrap(), &drawing); @@ -319,7 +344,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { h.event(Event::Text("ui.dock.reset".into())); h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(1); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command"); assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree"); assert_eq!(h.state().ui.docking.hidden, expected.hidden); @@ -359,3 +384,37 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { h.run_steps(4); assert!(h.state().ui.show_toolsets); } + +#[test] +fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { + use craft_ui::docking::{DockArea, Hit, Node}; + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); + h.run_steps(4); + let bar = |h: &Harness<'_, CadApp>| { + DockArea::::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking")) + .into_iter() + .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) + .map(|(_, rect)| rect) + }; + let from = bar(&h).expect("the Tool Sets group draws a drag bar").center(); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + let to = egui::pos2(drawing.right() - 10.0, drawing.center().y); + let document = h.state().session.doc().unwrap().clone(); + drag(&mut h, from, to); + let layout = &h.state().ui.docking.layout; + assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group"); + // The drawing's frame splits: the drawing on the left, the whole group on the right. + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + let mut beside = false; + while let Some(node) = nodes.pop() { + if let Node::Split { first, second, .. } = node { + beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. }) + if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]); + nodes.push(first); + nodes.push(second); + } + } + assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root); + assert_eq!(h.state().session.doc().unwrap(), &document); +} diff --git a/crates/ui-egui/tests/quickcalc_toggle.rs b/crates/ui-egui/tests/quickcalc_toggle.rs index 7f643c90d..9b6bec6d1 100644 --- a/crates/ui-egui/tests/quickcalc_toggle.rs +++ b/crates/ui-egui/tests/quickcalc_toggle.rs @@ -43,7 +43,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert!(h.state().ui.docking.layout.contains(&Panel::QuickCalc)); - let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect; + let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect; expression(&h).click(); h.run_steps(3); expression(&h).type_text("2+3"); @@ -55,7 +55,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert_eq!(h.get(by().role(Role::TextInput).value("2+3")).value().as_deref(), Some("2+3")); - assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect, floating); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect, floating); assert_eq!(h.state().session.running.as_ref().unwrap().id, "line"); assert_eq!(h.state().cmd.buffer, "12,34"); assert_eq!(h.state().session.doc().unwrap(), &drawing); @@ -131,7 +131,7 @@ fn explicit_opens_still_reveal_and_calculations_do_not_open_the_palette() { app.run("ui.dock", json!({"operation":"activate", "panel":"toolSets"})).unwrap(); app.run("ui.panel.quickcalc", Value::Null).unwrap(); let mut workspace = app.ui.docking.clone(); - workspace.reveal_panel(Panel::QuickCalc).unwrap(); + cadcraft_ui_egui::docking::reveal_panel(&mut workspace, Panel::QuickCalc).unwrap(); assert_eq!(app.ui.docking.layout, workspace.layout, "explicit open must activate an inactive tab"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 424d7cd60..63e565eb6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -134,7 +134,7 @@ Tool Sets, Layers, and Properties can be regrouped, split, floated within the ap Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`. -`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`, default `center`). Center and tab group with the target; side zones split beside it; above and below add a group in a floating window. `open` and `activate` show a hidden panel at its last place and select it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. The QuickCalc palette (`quickCalc`) is not part of the default arrangement. Typed `QUICKCALC`/`QC` and `ui.panel.quickcalc` explicitly open and activate it: it floats the first time and later @@ -150,9 +150,9 @@ Advanced `ui.dock` operations use the same validated transaction as pointer gest `moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and `size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), `setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. +A center or tab `move` can include `before` (panel ID or null to append) for tab order. The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel +this is also the form emitted by the renderer, and it takes every action of craft-ui's docking, group, column and window moves included. Protected-canvas and app-specific panel rules apply equally to both forms. ## Command-line replies