Panels: use craft-ui's shared docking (#2093)

* Pin craft-libs at 420d21a

* Pin craft-libs at 16fb3d4

* Use craft-ui's shared docking workspace and ui.dock commands

Replace the local Workspace, its validation, apply, visibility and the
ui.dock parser with craft_ui::docking::Workspace and DockOperation.
CADCraft keeps its panels, the drawing view's rules, the default layout
and the workspace file. Add tests for loading a v1 workspace file and for
dragging a panel group by its bar.

* Pin craft-libs at 6e7f285

* Docking: reveal panels through craft-ui's set_visible, which reopens groups and places new panels itself

* Docking: palette switches regroup a panel whose recorded anchor is hidden instead of opening it alone
This commit is contained in:
aiongg
2026-10-24 20:23:39 -04:00
committed by GitHub
parent fcfdf33001
commit b901656669
9 changed files with 270 additions and 267 deletions
Generated
+10 -6
View File
@@ -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",
+6 -6
View File
@@ -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"
+44 -5
View File
@@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
}
fn snapshot(ui: &UiState) -> Result<Value, String> {
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<Option<UiState>, 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::<cadcraft_ui_egui::docking::Workspace>(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();
+1 -1
View File
@@ -452,7 +452,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
}
match serde_json::from_value::<crate::UiState>(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;
+125 -224
View File
@@ -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, String> {
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<Panel> {
@@ -69,230 +60,137 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels.
pub type Workspace = craft_ui::docking::Workspace<Panel>;
/// 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<Panel>| {
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<Panel>) -> 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<Panel> = 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<bool> =
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<Value, String> {
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<V
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get();
let mut workspace = app.ui.docking.clone();
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) {
app.set_status(format!("Panel layout could not be restored: {error}"));
workspace = Workspace::default();
workspace = default_workspace();
let _ = sync(&mut workspace, app);
}
// Bodies can dispatch commands (including the drawing's typed command line).
@@ -336,6 +234,9 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.font = crate::theme::body();
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
style.new_dock_zones = false;
style.title_bar_docks = false;
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
ui,
&workspace.layout,
@@ -370,7 +271,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let rendered = workspace;
let mut workspace = app.ui.docking.clone();
let _ = sync(&mut workspace, app);
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
let stale = workspace != rendered;
if stale {
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
}
+1 -1
View File
@@ -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,
+77 -18
View File
@@ -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<Panel> = 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::<Vec<_>>(), 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::<Panel>::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<Panel>> = 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);
}
+3 -3
View File
@@ -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");
}
+3 -3
View File
@@ -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