egui037: retain CADCraft shared docking on current main

This commit is contained in:
skylartaylor
2026-10-25 03:24:02 +01:00
10 changed files with 264 additions and 257 deletions
Generated
+5 -1
View File
@@ -971,9 +971,13 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b"
dependencies = [
"egui",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
]
+3 -1
View File
@@ -53,7 +53,9 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
# 650b1c0 is the reviewed craft-libs integration: 25da's egui-0.37 port plus 6e7f's
# shared docking `Workspace`/`DockCommand`. Raw 6e7f is egui 0.36 and is not consumable here.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
+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::unique("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::unique("cadcraft-docking"));
}
+1 -1
View File
@@ -158,7 +158,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::unique("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
+2
View File
@@ -1,5 +1,7 @@
# CADCraft — egui 0.37 migration plan
**Status (2026-10-25):** reconciled main `b90165666917ec3ac6c308c2ea1c55bb6ea8317d` and passed all 7 mandatory CI stages. Managed job `egui037-cadcraft-main-refresh-ci-20261025-0615` recorded 2,451 passing test executions, zero failures and 1 ignored. The shared-docking adoption uses the reviewed craft-ui revision `650b1c0b058017c4eb3af06d5b39586d6732e07b`; the lock changed only that package and its dependency list. This revision must be published before a public consumer build. Native/platform GUI and IME workflows, final packages and paired app performance remain open. Earlier source identities and counts below are historical.
Current landing status, 2026-10-25: the implementation integrates main `fcfdf33001cf2462bdafb6e1237becd8ef0ea1cb` and selects egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, shared UI `25da1314d443171b4adeaad2cd9454b273f35c6f` and independent PDF/document-path revisions in the lockfile. Full managed CI0300 passed all seven stages, with 2,448 passing tests, no failures and one ignored across 103 summaries. Fresh review0320 verified the tested source, all seven latest-main paths and all 13 new source files.
The review also identified inherited upstream issues in MEASUREINIT reset/web startup and PDF font hardening; these are carried explicitly in the review packet. The egui licence-source URL has been updated to the selected revision; the font bytes are unchanged. Final notice validation is pending. Current-source Windows/macOS/FreeBSD, visible GUI, native IME and performance acceptance are not established by the Linux CI result. Human review and human merge are required.