From 687ea314b6ffdb92d1e70724285a03f71f8c30fb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:16:28 +0000 Subject: [PATCH 01/44] Docking trial: craft-ui docking branch, group drag by the drag bar Patch craft-ui to aiongg/craft-libs 4bf1164 (docking branch). Floating windows hold groups (Floating::single, panels()); matches on Node and Action get wildcard arms; apply_action validates MoveGroup, MoveColumn, MoveWindow, the group close actions, minimize and resize, and records return locations for every panel a close action removes. PanelContent gives preferred floating sizes. A pointer test drags the docked Character Styles | Paragraph Styles group by its drag bar to the canvas and onto another strip: order and the front tab are kept. --- Cargo.lock | 2 +- Cargo.toml | 4 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panel_docking.rs | 84 +++++++++++++++++++---- crates/ui-egui/src/panel_docking_tests.rs | 66 +++++++++++++++--- 5 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3db056251..7e9ae36d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ 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/aiongg/craft-libs?rev=4bf11647adb05e1b6f5a49c1ae5f7105bf746c71#4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index ca5d0baaf..8f169d311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,3 +123,7 @@ hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } + +# Docking trial: craft-ui from the craft-libs `docking` branch. +[patch."https://github.com/storytold/craft-libs"] +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e42..382e259af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5059,7 +5059,7 @@ mod tests { // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); let layout = app.ui.docking.as_ref().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index bec168ea1..4be709b70 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1,6 +1,6 @@ //! Application-owned panel IDs and commands around the shared docking layout. -use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Target, Zone}; use serde_json::{Value, json}; use crate::DesignApp; @@ -53,7 +53,7 @@ fn legacy(app: &DesignApp) -> Layout { let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; for (id, at) in &app.ui.floating { if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { - layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); + layout.floating.push(Floating::single(vec![id.clone()], 0, [at[0], at[1], 280.0, 360.0])); } } if let Some(id) = &app.ui.open_panel @@ -72,7 +72,7 @@ fn legacy(app: &DesignApp) -> Layout { /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. fn tab_group(layout: &Layout, panel: &str) -> Vec { - if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == panel)) { + if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); } let mut visit: Vec<&Node> = layout.root.iter().collect(); @@ -88,6 +88,11 @@ fn tab_group(layout: &Layout, panel: &str) -> Vec { visit.push(second); } Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + Node::Dock { columns, .. } => { + if let Some(group) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return group.panels.clone(); + } + } _ => {} } } @@ -113,7 +118,7 @@ fn open_in_stack(layout: &Layout, panel: &str) -> bool { return e.open; } } - Node::Tabs { .. } => {} + _ => {} } } false @@ -216,6 +221,29 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result registered(panel), Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + registered(panel) + && match to { + Target::At { anchor, placement } => { + registered(anchor) + && match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + } + } + _ => true, + } + } + Action::CloseGroup { panel } + | Action::CloseWindow { panel } + | Action::CloseOthers { panel } + | Action::SetMinimized { panel, .. } + | Action::SetIconic { panel, .. } + | Action::ResizeColumn { panel, .. } + | Action::ResizeGroup { panel, .. } + | Action::ResizeIconic { panel, .. } => registered(panel), + // Native OS windows are not enabled in DesignCraft; newer actions are rejected. + _ => false, }; if !known { return Err("docking action contains an unknown panel or an excessive split path".into()); @@ -224,19 +252,24 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result layout.location(panel).ok().map(|location| (panel.clone(), location)), + // Window › a closed panel, and Dock Panel after a float, bring it back where it was. + let returning: Vec<_> = match &action { Action::Float { panel, .. } => { - layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() } - _ => None, + _ => layout + .closed_by(&action) + .unwrap_or_default() + .into_iter() + .filter_map(|panel| layout.location(&panel).ok().map(|location| (panel, location))) + .collect(), }; let reveal = match &action { Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), _ => None, }; layout.apply(action).map_err(|error| error.to_string())?; - if let Some((panel, location)) = returning { + for (panel, location) in returning { app.ui.docking_hidden.insert(panel, location); } app.ui.docking = Some(layout); @@ -266,7 +299,8 @@ fn front_tabs(layout: &Layout) -> Vec { visit.push(second); } Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), - Node::Stack { .. } => {} + Node::Dock { columns, .. } => out.extend(columns.iter().flat_map(|c| &c.groups).filter_map(|g| g.active_panel().cloned())), + _ => {} } } out @@ -291,7 +325,14 @@ fn restore_front_tabs(layout: &mut Layout, front: &[String]) { *active = i; } } - Node::Stack { .. } => {} + Node::Dock { columns, .. } => { + for group in columns.iter_mut().flat_map(|c| &mut c.groups) { + if let Some(i) = group.panels.iter().position(|p| front.contains(p)) { + group.active = i; + } + } + } + _ => {} } } } @@ -523,7 +564,9 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { app.ui.status = error.to_string(); } if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. } | Action::MoveGroup { .. })) + && cancel_legacy_drag(app, ui.ctx()) + { return app.ui.docking.is_some(); } ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); @@ -548,7 +591,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 2.0; for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { if app.ui.docking.as_ref().is_some_and(|layout| { - layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) + layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels().any(|p| p == id))) }) { continue; } @@ -652,6 +695,16 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { }); } +/// The size a panel floats at (tab strip included), whatever the size of the dock it left. +fn floating_size(panel: &str) -> egui::Vec2 { + match panel { + "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), + "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), + "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), + _ => egui::vec2(280.0, 360.0), + } +} + struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { @@ -674,6 +727,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { ui.separator(); } } + fn floating_size(&self, id: &String) -> Option { + Some(floating_size(id)) + } fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 { 24.0 } @@ -750,7 +806,7 @@ fn sync_legacy(app: &mut DesignApp) { app.ui.floating = layout .floating .iter() - .flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))) + .flat_map(|window| window.panels().map(|id| (id.clone(), [window.rect[0], window.rect[1]]))) .chain(float_only) .collect(); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f436cfb5e..8d8e34fdd 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -13,7 +13,7 @@ fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_owners app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); let before_float = app.ui.docking.clone().unwrap(); app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); - assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"])); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["properties"])); app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); @@ -87,12 +87,12 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); drag(&mut h, source, egui::pos2(160.0, 140.0)); let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); - assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); - assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); + assert!(!layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels.contains(&"layers".into()))); assert!(valid(layout)); } @@ -217,7 +217,7 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); assert_eq!( - app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect, + app.ui.docking.as_ref().unwrap().floating.iter().find(|window| window.panels().any(|p| p == "properties")).unwrap().rect, [44.0, 66.0, 360.0, 450.0] ); app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) @@ -712,7 +712,7 @@ fn paragraph_styles_open_floating_with_character_styles() { let mut app = app(); app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); + let group = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. @@ -721,11 +721,11 @@ fn paragraph_styles_open_floating_with_character_styles() { app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"])); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"])); // Once showing, Window › Character Styles brings that tab forward. app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); + let group = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); assert_eq!(group.panels[group.active], "characterStyles"); assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); } @@ -744,7 +744,7 @@ fn paragraph_styles_closed_from_the_dock_reopen_there() { app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); assert!(layout.contains(&"paragraphStyles".to_string())); - assert!(layout.floating.iter().all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); + assert!(layout.floating.iter().flat_map(|w| &w.groups).all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); } fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { @@ -787,3 +787,53 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(h.query_by_label("Close Panel").is_none()); assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu"); } + +fn group_with<'a>(layout: &'a Layout, panel: &str) -> (bool, &'a [String], usize) { + if let Some(g) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (true, &g.panels, g.active); + } + let mut visit: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + match node { + Node::Split { first, second, .. } => visit.extend([&**first, &**second]), + Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels, *active), + _ => {} + } + } + panic!("{panel} is not in {layout:?}"); +} + +fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { + h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("drag-bar", Some(&first.to_string())))).expect("the group's drag bar").rect +} + +/// The docked Character Styles | Paragraph Styles group moves as one by its drag bar: dropped on +/// the canvas it floats, dropped on another strip it joins as tabs. Order and the tab in front +/// are kept. +#[test] +fn dragging_a_docked_group_by_its_bar_moves_every_tab() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Split": "Bottom"}}}}}); + app.run("window.panel.layout", json!({"action": below})).unwrap(); + let styles = ["characterStyles", "paragraphStyles"]; + assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, &styles.map(String::from)[..], 1)); + + let mut h = harness(app, vec2(1280.0, 800.0)); + let bar = drag_bar(&h, "characterStyles"); + drag(&mut h, bar.center(), egui::pos2(400.0, 400.0)); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(group_with(layout, "paragraphStyles"), (true, &styles.map(String::from)[..], 1), "{layout:?}"); + assert!(valid(layout)); + + let bar = drag_bar(&h, "characterStyles"); + let properties = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, bar.center(), properties); + let layout = h.state().ui.docking.as_ref().unwrap(); + let (floating, panels, active) = group_with(layout, "characterStyles"); + assert!(!floating, "{layout:?}"); + let at = panels.iter().position(|p| p == "characterStyles").unwrap(); + assert_eq!(panels.get(at + 1).map(String::as_str), Some("paragraphStyles"), "{panels:?}"); + assert_eq!(panels[active], "paragraphStyles"); + assert!(layout.floating.is_empty(), "{:?}", layout.floating); +} From 5f5b6164c2644cef045db16c2269c1468d93bd7f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:09:25 +0000 Subject: [PATCH 02/44] Docking trial: one dock path, ui.docking, localized dock labels --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/chrome.rs | 10 +- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/dock.rs | 11 +- crates/ui-egui/src/lib.rs | 122 ++--- crates/ui-egui/src/menus.rs | 27 +- crates/ui-egui/src/panel_docking.rs | 535 ++++++++++++++++------ crates/ui-egui/src/panel_docking_tests.rs | 131 +++++- crates/ui-egui/src/panels/mod.rs | 9 + crates/ui-egui/src/panels/properties.rs | 4 +- docs/control-protocol.md | 55 ++- 12 files changed, 644 insertions(+), 269 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e9ae36d8..1e77d5344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=4bf11647adb05e1b6f5a49c1ae5f7105bf746c71#4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" +source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8f169d311..9ecd08b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..1f865a3dc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1250,15 +1250,15 @@ mod tests { h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - // Preflight is not a dock tab: `window.panel` shows it as its own panel. - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + // Preflight is in the iconic column: `window.panel` shows it. + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); h.get_by_label("Preflight"); // With the panel already showing, the entry keeps it open. h.get_by_label("No errors ▾").click(); h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. @@ -1272,7 +1272,7 @@ mod tests { count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); h.get_by_label("Preflight"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); // A double-click with the panel already showing keeps it open. @@ -1280,7 +1280,7 @@ mod tests { count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// A very long document name made its tab grow past the window, hiding the close button diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..ea81c278e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -4,6 +4,7 @@ //! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command //! - `engine.commands`: engine + UI commands with enablement //! - `document.inspect`: document summary; `ui.inspect`: UI state +//! - `ui.docking`: the panel layout in use and the focused panel //! - `ui.menu.list`: flattened menu tree //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: @@ -77,6 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "docking": crate::panel_docking::inspect(app), "window": [r.width(), r.height()], "documents": app .session @@ -169,6 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", json!({}))), "ui.inspect" => ok(inspect(app, ctx)), + "ui.docking" => ok(crate::panel_docking::inspect(app)), "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()), @@ -361,7 +364,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { - app.ui.dock_expanded = v; + r = crate::panel_docking::set_iconic(app, !v); } wrap(r) } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fb9a7cf17..a825e3502 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -58,10 +58,8 @@ pub fn panel_label(id: &str) -> &'static str { DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1) } +/// The classic dock, replaced by the docking layout (`panel_docking::show`) and no longer drawn. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - if crate::panel_docking::show(app, ui) { - return; - } let t = Tokens::get(ui.ctx()); // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. if app.ui.dock_expanded { @@ -331,7 +329,7 @@ pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { /// Put a floating panel back in the dock's icon column. pub fn dock_panel(app: &mut DesignApp, id: &str) { - if app.ui.docking.is_some() && !float_only(id) { + if !float_only(id) { if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { app.status(error); } @@ -342,11 +340,10 @@ pub fn dock_panel(app: &mut DesignApp, id: &str) { /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { - // A customized layout draws its own floating groups; float-only panels are drawn here. - let customized = app.ui.docking.is_some(); + // The docking layout draws its own floating groups; float-only panels are drawn here. let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { - if customized && !float_only(&id) { + if !float_only(&id) { continue; } let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcb764c5a..0a76e123c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -259,11 +259,15 @@ pub struct UiState { pub open_panel: Option, /// Panels torn off the dock into their own floating windows: (panel id, top-left position). pub floating: Vec<(String, [f32; 2])>, - /// Shared panel layout after the user customizes the legacy dock. + /// The panel layout once the user customizes it; `None` is the workspace's default layout + /// (`panel_docking::default_layout`). #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// The panel (or the canvas) last clicked or selected in the docking layout. + #[serde(skip)] + pub docking_focused: Option, /// Invalidates in-flight gestures when a workspace is replaced. #[serde(skip)] pub docking_generation: u64, @@ -404,6 +408,7 @@ impl Default for UiState { docking_generation: 0, docking_raise: None, docking_opened: None, + docking_focused: None, dock_expanded: true, icon_groups: None, units: Unit::Picas, @@ -1488,6 +1493,63 @@ impl DesignApp { raw.events.extend(events.drain(..n)); } + /// The document area: the start screen, or the document tabs over one or two canvas panes. + pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) { + let t = theme::Tokens::get(ui.ctx()); + if self.session.active().is_none() { + chrome::start_screen(self, ui); + return; + } + if !presenting { + chrome::doc_tabs(self, ui); + } + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { + // Two panes with a divider; each has its own view (zoom, scroll) and render cache. + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { + self.switch_pane(k as u8); + // A new pane starts at the other pane's view. + if k == 1 && self.view().is_none() { + self.switch_pane(0); + let v = self.view().copied(); + self.switch_pane(1); + if let (Some(mut v), Some(m)) = (v, self.view_mut()) { + v.fitted = false; + *m = v; + } + } + ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); + } + self.switch_pane(self.focus_pane); + } else { + self.switch_pane(0); + // With a second window, the main one takes the focus back when clicked. + if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { + self.focus_pane = 0; + } + canvas::show(self, ui); + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1523,62 +1585,13 @@ impl DesignApp { if self.ui.hidden_panels != 1 { toolbar::show(self, ui); } - if self.ui.hidden_panels == 0 { - dock::show(self, ui); - } } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.session.active().is_none() { - chrome::start_screen(self, ui); - return; - } - if !presenting { - chrome::doc_tabs(self, ui); - } - let split_layout = (self.split && !presenting) - .then(|| { - craft_ui::layout::split_rect( - ui.available_rect_before_wrap(), - craft_ui::layout::SplitAxis::Horizontal, - craft_ui::layout::SplitSize::Ratio(0.5), - 2.0, - 0.0, - ) - }) - .flatten(); - if let Some(layout) = split_layout { - // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - ui.painter().rect_filled(layout.divider, 0.0, t.border); - // Resolve the clicked pane before either canvas handles this frame's keys. - if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { - if layout.first.contains(p) { - self.focus_pane = 0; - } else if layout.second.contains(p) { - self.focus_pane = 1; - } - } - for (k, half) in [layout.first, layout.second].into_iter().enumerate() { - self.switch_pane(k as u8); - // A new pane starts at the other pane's view. - if k == 1 && self.view().is_none() { - self.switch_pane(0); - let v = self.view().copied(); - self.switch_pane(1); - if let (Some(mut v), Some(m)) = (v, self.view_mut()) { - v.fitted = false; - *m = v; - } - } - ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - } - self.switch_pane(self.focus_pane); + // The panel layout holds the canvas as its protected `canvas` panel. + if presenting || self.ui.hidden_panels != 0 { + self.canvas_area(ui, presenting); } else { - self.switch_pane(0); - // With a second window, the main one takes the focus back when clicked. - if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { - self.focus_pane = 0; - } - canvas::show(self, ui); + panel_docking::show(self, ui); } }); // New Window: pane 1 in a window of its own. @@ -1618,7 +1631,6 @@ impl DesignApp { } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { - dock::flyout(self, &ctx); dock::floating(self, &ctx); } story_editor::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 382e259af..3558be93a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1826,11 +1826,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), - "window.collapseToIcons" => { - app.ui.dock_expanded = false; - app.ui.open_panel = None; - Ok(Value::Null) - } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = @@ -4003,8 +3998,9 @@ mod tests { out.textures_delta.clear(); } }; - // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). - let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("panel_flyout")); + // The docked panel's tab and a floating panel group. + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"preflight".to_string())); + let docked = || ctx.read_response(tab).is_some(); let group = egui::LayerId::new(egui::Order::Middle, egui::Id::new("designcraft-panel-docking").with(("floating", "preflight"))); let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); let toggles = [ @@ -4014,14 +4010,14 @@ mod tests { ]; app.run("window.panel", json!({"panel": "preflight"})).unwrap(); frame(&mut app); - assert!(app.ui.docking.is_none() && shown(flyout)); + assert!(docked()); for (id, hide, show) in &toggles { app.run(id, hide.clone()).unwrap(); frame(&mut app); - assert!(!shown(flyout), "{id} hides the flyout"); + assert!(!docked(), "{id} hides the dock"); app.run(id, show.clone()).unwrap(); frame(&mut app); - assert!(shown(flyout), "{id} again brings it back"); + assert!(docked(), "{id} again brings it back"); } app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap(); frame(&mut app); @@ -5055,7 +5051,7 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); activate(&mut app, "app.characterPanel", &Value::Null); - assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + assert!(crate::panel_docking::showing(&app, "character")); // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); let layout = app.ui.docking.as_ref().unwrap(); @@ -5132,7 +5128,10 @@ mod tests { assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false)); // Window › Pages opens Pages from its icon group, not the Properties stack. run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap(); - assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false)); + assert!(crate::panel_docking::showing(&app, "pages")); + let layout = app.ui.docking.clone().unwrap(); + assert!(!layout.contains(&"properties".to_string())); + assert_eq!(crate::panel_docking::tab_group(&layout, "pages"), ["pages", "layers", "links"]); // A workspace saved from it keeps the groups. run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); @@ -5154,8 +5153,8 @@ mod tests { assert_eq!(groups[5], ["character", "characterStyles"]); for panel in ["pages", "paragraphStyles", "characterStyles"] { run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap(); - assert_eq!(app.ui.open_panel.as_deref(), Some(panel)); - assert!(!app.ui.dock_expanded); + assert!(crate::panel_docking::showing(&app, panel), "{panel}"); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty(), "{panel} opens from its icon group"); } run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.icon_groups, Some(groups)); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 4be709b70..393c977e5 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1,6 +1,10 @@ //! Application-owned panel IDs and commands around the shared docking layout. +//! +//! The window's panels and the canvas are one craft-ui layout: a horizontal split of the +//! protected `canvas` panel and a dock at the logical end edge (the right in left-to-right +//! languages, the left in right-to-left ones). -use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Target, Zone}; +use craft_ui::docking::{Action, Column, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, Zone}; use serde_json::{Value, json}; use crate::DesignApp; @@ -43,35 +47,144 @@ fn normalize(raw: &str) -> Option<&'static str> { .map(|(id, _, _)| *id) } -pub(crate) fn valid(layout: &Layout) -> bool { - layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +/// The panel that holds the document area. It never moves, floats, closes or takes tabs. +pub(crate) const CANVAS: &str = "canvas"; +/// Width of an expanded dock column. +const COLUMN_WIDTH: f32 = 260.0; +/// Width of an iconic column of bare icons, and of one whose groups show their names. +const ICON_WIDTH: f32 = 37.0; +const NAMED_ICON_WIDTH: f32 = 150.0; +/// Height of the group a workspace opens below Properties · Pages · Layers. +const OPEN_PANEL_HEIGHT: f32 = 260.0; + +fn registered(id: &str) -> bool { + id == CANVAS || normalize(id) == Some(id) } -fn legacy(app: &DesignApp) -> Layout { - let panels: Vec = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); - let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); - let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; - for (id, at) in &app.ui.floating { - if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { - layout.floating.push(Floating::single(vec![id.clone()], 0, [at[0], at[1], 280.0, 360.0])); +/// The policy commands and the renderer apply: the canvas is protected. +fn model_permissions(id: &String) -> Permissions { + if id == CANVAS { Permissions::PROTECTED } else { Permissions::default() } +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() + && layout.panels().iter().all(|id| registered(id)) + && Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&CANVAS.to_string()) +} + +/// The layout of a workspace that has not been customized, from its panel settings: the +/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an +/// open flyout panel stacked below it) and whose inner column is iconic. Essentials Classic +/// and Typography (`icon_groups`) have the iconic column only. +pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { + let mut placed: Vec = Vec::new(); + let take = |id: &str, placed: &mut Vec| -> Option { + let id = normalize(id)?; + (!placed.iter().any(|p| p == id)).then(|| { + placed.push(id.to_string()); + id.to_string() + }) + }; + let mut floating = Vec::new(); + for (id, at) in &ui.floating { + if at.iter().all(|v| v.is_finite()) + && let Some(id) = take(id, &mut placed) + { + floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); } } - if let Some(id) = &app.ui.open_panel - && normalize(id).is_some() - && !layout.contains(id) - { - // Preserve the visible flyout when the first unrelated docking gesture succeeds. - if let Some(Node::Tabs { panels, active }) = &mut layout.root { - panels.push(id.clone()); - *active = panels.len().saturating_sub(1); + let mut columns = Vec::new(); + match &ui.icon_groups { + Some(groups) => { + let groups: Vec> = groups + .iter() + .map(|g| g.iter().filter_map(|id| take(id, &mut placed)).collect::>()) + .filter(|panels| !panels.is_empty()) + .map(|panels| Group::new(panels, 0)) + .collect(); + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(NAMED_ICON_WIDTH) }); + } + } + None => { + let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| take(p.0, &mut placed)).collect(); + let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); + let mut groups = Vec::new(); + if !panels.is_empty() { + groups.push(Group::new(panels, active)); + } + // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed + // height, so Properties keeps most of the column. + if let Some(id) = ui.open_panel.as_deref().and_then(|id| take(id, &mut placed)) { + groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); + } + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); + } + // Window › Styles opens the style panels as a floating group, so they start outside. + let icons: Vec> = crate::dock::ICON_PANELS + .iter() + .filter(|p| !FLOATING_GROUPS.iter().any(|g| g.contains(&p.0))) + .filter_map(|p| take(p.0, &mut placed)) + .map(|id| Group::new(vec![id], 0)) + .collect(); + if !icons.is_empty() { + columns.push(Column { groups: icons, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(ICON_WIDTH) }); + } } } - layout + let width: f32 = columns.iter().map(|c| if c.iconic { c.iconic_width.unwrap_or(ICON_WIDTH) } else { c.width }).sum(); + Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedSecond(width), + first: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + second: Box::new(Node::Dock { side: Side::End, columns }), + }), + floating, + } +} + +/// The layout in use: the customized one, or the workspace's default. +fn layout_of(app: &DesignApp) -> Layout { + app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) +} + +/// Is `panel` showing: in front of its group, and not in a column collapsed to icons? +pub(crate) fn showing(app: &DesignApp, panel: &str) -> bool { + layout_of(app).panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front +} + +/// Is `panel` in a dock column collapsed to icons? +fn in_iconic_column(layout: &Layout, panel: &str) -> bool { + dock_columns(layout).iter().any(|c| c.iconic && c.groups.iter().any(|g| g.panels.iter().any(|p| p == panel))) +} + +/// Every dock column, from each dock's window edge inwards. +fn dock_columns(layout: &Layout) -> Vec<&Column> { + let mut out = Vec::new(); + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(second); + visit.push(first); + } + Node::Dock { columns, .. } => out.extend(columns), + _ => {} + } + } + out } /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. -fn tab_group(layout: &Layout, panel: &str) -> Vec { +pub(crate) fn tab_group(layout: &Layout, panel: &str) -> Vec { if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); } @@ -124,14 +237,26 @@ fn open_in_stack(layout: &Layout, panel: &str) -> bool { false } -fn first_root(layout: &Layout, except: &str) -> Option { - let root = Layout { root: layout.root.clone(), floating: Vec::new() }; - root.panels().into_iter().find(|p| p.as_str() != except).cloned() +/// Where a panel with no other destination goes: a panel of the first expanded dock column, +/// else of any column, else of the root. +fn first_dock_panel(layout: &Layout, except: &str) -> Option { + let usable = |p: &&String| p.as_str() != except && p.as_str() != CANVAS; + let columns = dock_columns(layout); + let in_columns = + |iconic: bool| columns.iter().filter(|c| c.iconic == iconic).flat_map(|c| &c.groups).flat_map(|g| &g.panels).find(usable).cloned(); + in_columns(false).or_else(|| in_columns(true)).or_else(|| { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(usable).cloned() + }) } fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { - let anchor = first_root(layout, panel); - layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) + let anchor = first_dock_panel(layout, panel); + apply(layout, Action::Open { panel: panel.into(), anchor }) +} + +fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + layout.apply_with_permissions(action, model_permissions).map_err(|e| e.to_string()) } fn number(params: &Value, key: &str, default: f32) -> Result { @@ -145,7 +270,33 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } -fn placement(params: &Value) -> Result, String> { +/// The side of the dock holding `panel`, if a dock column holds it. +fn dock_side(layout: &Layout, panel: &str) -> Option { + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Dock { side, columns } if columns.iter().flat_map(|c| &c.groups).any(|g| g.panels.iter().any(|p| p == panel)) => { + return Some(*side); + } + _ => {} + } + } + None +} + +/// The placement a `zone` names beside `anchor`. In a dock column or a floating window, top and +/// bottom add a group above or below the anchor's group; in a dock column, left and right add a +/// column on that side of the anchor's. Elsewhere the sides split the anchor's group. +fn placement(params: &Value, layout: &Layout, anchor: &str, rtl: bool) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; return Ok(Placement::Tab { before: Some(id.into()) }); @@ -154,9 +305,18 @@ fn placement(params: &Value) -> Result, String> { None => "tab", Some(value) => value.as_str().ok_or("zone must be a string")?, }; + let side = dock_side(layout, anchor); + let floating = layout.floating.iter().any(|w| w.contains(anchor)); Ok(match zone { "tab" => Placement::Tab { before: None }, "center" => Placement::Split(Zone::Center), + "top" | "bottom" if side.is_some() || floating => Placement::Group { below: zone == "bottom" }, + "left" | "right" if floating => return Err("a floating window has no columns; use top, bottom or tab".into()), + "left" | "right" if let Some(side) = side => { + // Columns count from the window edge inwards; `after` is away from the edge. + let edge_is_right = (side == Side::End) != rtl; + Placement::Column { after: (zone == "right") != edge_is_right } + } "left" => Placement::Split(Zone::Left), "right" => Placement::Split(Zone::Right), "top" => Placement::Split(Zone::Top), @@ -166,10 +326,11 @@ fn placement(params: &Value) -> Result, String> { } pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option> { - if id == "app.tablePanel" && app.ui.docking.is_some() { - let panel = "table".to_string(); - let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); - return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + if id == "app.tablePanel" { + return Some(change(app, if showing(app, "table") { "close" } else { "activate" }, &json!({"panel": "table"}))); + } + if id == "window.collapseToIcons" { + return Some(set_iconic(app, true)); } if id == "window.panel.closeGroup" { return Some(close_group(app, params)); @@ -194,7 +355,8 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option "dock", "window.panel.activate" => "activate", "window.panel.close" => "close", - "window.panel" if app.ui.docking.is_some() => "activate", + // Float-only panels (Type › Tabs) are not in the layout. + "window.panel" if params.get("panel").and_then(Value::as_str).and_then(normalize).is_some() => "activate", _ => return None, }; Some(change(app, operation, params)) @@ -203,7 +365,7 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result { - let registered = |id: &String| normalize(id) == Some(id.as_str()); + let registered = |id: &String| self::registered(id); let known = match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { registered(panel) @@ -251,28 +413,25 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result = match &action { Action::Float { panel, .. } => { layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() } - _ => layout - .closed_by(&action) - .unwrap_or_default() - .into_iter() - .filter_map(|panel| layout.location(&panel).ok().map(|location| (panel, location))) - .collect(), + _ => closed.iter().filter_map(|panel| layout.location(panel).ok().map(|location| (panel.clone(), location))).collect(), }; let reveal = match &action { Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), _ => None, }; - layout.apply(action).map_err(|error| error.to_string())?; + apply(&mut layout, action)?; for (panel, location) in returning { app.ui.docking_hidden.insert(panel, location); } app.ui.docking = Some(layout); + release_closed(app, &closed); sync_legacy(app); if let Some(panel) = reveal { app.ui.docking_opened = Some(panel.clone()); @@ -355,7 +514,7 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Result = app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); let mut restored = false; @@ -403,7 +562,7 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Result { if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { @@ -429,24 +588,24 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), - None => first_root(&layout, panel), + None => first_dock_panel(&layout, panel), }; - let placement = placement(params)?; if let Some(anchor) = anchor { + let placement = placement(params, &layout, &anchor, crate::i18n::is_rtl(&app.ui.language))?; ensure(&mut layout, panel)?; - layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Move { panel: panel.into(), anchor, placement })?; } else if operation == "dock" { if layout.contains(&panel.to_string()) { - layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Close { panel: panel.into() })?; } - layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Open { panel: panel.into(), anchor: None })?; } else { return Err("move requires an existing destination panel".into()); } } "close" => { hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); - layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Close { panel: panel.into() })?; } _ => { if !layout.contains(&panel.to_string()) @@ -457,10 +616,17 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Result>(), "layout": app.ui.docking})) } -pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { +/// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last +/// opened (for `ui.set closePanel`) and a pending raise. +fn release_closed(app: &mut DesignApp, closed: &[String]) { + for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise] { + if slot.as_ref().is_some_and(|panel| closed.contains(panel)) { + *slot = None; + } + } +} + +/// Window › Collapse to Icons collapses every dock column; `iconic: false` (the control +/// channel's `dockExpanded`) expands the outer column. +pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = layout_of(app); + let columns = dock_columns(&layout); + let firsts = |c: &&Column| c.groups.first().and_then(|g| g.panels.first()).cloned(); + let targets: Vec = if iconic { + columns.iter().filter(|c| !c.iconic).filter_map(firsts).collect() + } else { + columns.first().filter(|c| c.iconic).and_then(firsts).into_iter().collect() + }; + for panel in targets { + apply(&mut layout, Action::SetIconic { panel, iconic })?; + } + app.ui.docking = Some(layout); + app.ui.dock_expanded = !iconic; + app.ui.open_panel = None; + Ok(Value::Null) +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let area = egui::Id::new("designcraft-panel-docking"); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); @@ -482,22 +681,11 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { craft_ui::docking::cancel_drag::(ui.ctx(), area); ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } - - let cancelled = - ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); - if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { - return false; - } - let Some(layout) = app.ui.docking.as_ref() else { return false }; - if !valid(layout) { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { app.ui.docking = None; app.status(crate::i18n::tr(&app.ui.language, "Invalid panel layout; restored the default dock")); - return false; - } - let mut layout = layout.clone(); - if !app.ui.dock_expanded { - layout.root = None; } + let layout = layout_of(app); let raise = app.ui.docking_raise.take(); let mut blocked = Vec::new(); if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { @@ -507,79 +695,87 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); blocked.push(rect); } - let t = crate::theme::Tokens::get(ui.ctx()); - let language = app.ui.language.clone(); - let mut draw = |ui: &mut egui::Ui| { - let mut style = craft_ui::docking::DockStyle::from_ui(ui); - style.tab_height = 27.0; - style.background = t.panel; - style.tab_background = t.tab_strip; - style.active_background = t.panel; - style.text = t.text_strong; - style.inactive_text = t.text_dim; - style.border = egui::Stroke::new(1.0, t.border); - style.accent = t.accent; - style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned(); - style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); - style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); - style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); - style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); - style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); - let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); - if let Some(panel) = &raise { - area = area.raise_panel(panel.clone()); - } - area.show_customized( - ui, - &layout, - &style, - |id| { - crate::i18n::tr( - &language, - crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), - ) - .to_owned() - }, - // Close comes from the panel's own tab commands, so the surface doesn't add another. - |_| craft_ui::docking::Permissions { closable: false, ..Default::default() }, - |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, - &mut PanelContent(app), - ) - }; - let output = if layout.root.is_some() { - egui::Panel::right("shared-panel-dock") - .default_size(310.0) - .size_range(220.0..=720.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel)) - .show(ui, &mut draw) - .inner - } else { - draw(ui) - }; + let style = dock_style(ui, &app.ui.language); + let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked); + if let Some(panel) = raise { + dock = dock.raise_panel(panel); + } + let mut content = PanelContent(app, Vec::new()); + let output = dock.show_content(ui, &layout, &style, &mut content); + let floats = content.1; if let Some(panel) = output.focused { - app.ui.docking_opened = Some(panel); + if panel != CANVAS { + app.ui.docking_opened = Some(panel.clone()); + } + app.ui.docking_focused = Some(panel); } if let Some(error) = output.error { app.ui.status = error.to_string(); } - if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. } | Action::MoveGroup { .. })) - && cancel_legacy_drag(app, ui.ctx()) - { - return app.ui.docking.is_some(); - } - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); - } - for action in output.actions { + for action in output.actions.into_iter().chain(floats) { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } } - icon_rail(app, ui); - true } +/// What the control channel reports: the layout in use (craft-ui's serialized `Layout`), +/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, and +/// the panels closed with a remembered place. +pub(crate) fn inspect(app: &DesignApp) -> Value { + json!({ + "layout": layout_of(app), + "customized": app.ui.docking.is_some(), + "focused": app.ui.docking_focused, + "rightToLeft": crate::i18n::is_rtl(&app.ui.language), + "hidden": app.ui.docking_hidden.keys().collect::>(), + }) +} + +fn dock_style(ui: &egui::Ui, language: &str) -> craft_ui::docking::DockStyle { + let t = crate::theme::Tokens::get(ui.ctx()); + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.right_to_left = crate::i18n::is_rtl(language); + style.localized(|key| DOCK_LABELS.iter().find(|(k, _)| *k == key).map(|(_, english)| crate::i18n::tr(language, english).to_owned())) +} + +/// DesignCraft's wording of craft-ui's docking labels, translated through `i18n::tr`. +const DOCK_LABELS: &[(&str, &str)] = &[ + ("dock.float", "Float Panel"), + ("dock.close", "Close"), + ("dock.move", "Group with"), + ("dock.panels", "Panels"), + ("dock.resize", "Resize panels"), + ("dock.resizeWindow", "Resize panel window"), + ("dock.moveGroup", "Move Tab Group"), + ("dock.closeGroup", "Close Tab Group"), + ("dock.minimize", "Minimize"), + ("dock.expand", "Expand"), + ("dock.floatGroup", "Float Tab Group"), + ("dock.groupWith", "Group Tab Group with"), + ("dock.moveColumn", "Move Panel Column"), + ("dock.collapseToIcons", "Collapse to Icons"), + ("dock.expandPanels", "Expand Panels"), + ("dock.moveWindow", "Move Panel Window"), + ("dock.closeWindow", "Close Panel Window"), + ("dock.undock", "Undock Panel"), + ("dock.undockGroup", "Undock Panel Group"), + ("dock.closePanelGroup", "Close Panel Group"), + ("dock.closeOthers", "Close Other Panels in Group"), + ("dock.maximize", "Maximize Frame"), + ("dock.restoreFrame", "Restore Frame Size"), + ("dock.newWindow", "Move Panel to New Window"), +]; + +#[allow(dead_code)] // The classic icon rail; removed with the classic dock. fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons") @@ -616,7 +812,7 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re if response.drag_started() && let Some(id) = normalize(panel) { - let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut layout = layout_of(app); let bounds = ui.max_rect(); let source = egui::Rect::from_min_size( egui::pos2(bounds.left(), response.rect.top()), @@ -705,9 +901,51 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } -struct PanelContent<'a>(&'a mut DesignApp); +/// The panels' content, and the floats released over the canvas this frame. +struct PanelContent<'a>(&'a mut DesignApp, Vec>); impl craft_ui::docking::DockContent for PanelContent<'_> { + /// A panel or group released over the document area floats there, as in InDesign, instead of + /// splitting the canvas. The window's edge bands still make new docks and root splits. + fn resolve_drag( + &mut self, + _: &egui::Context, + style: &craft_ui::docking::DockStyle, + request: &craft_ui::docking::DropRequest<'_, String>, + ) -> Option { + let pointer = request.pointer; + let over_canvas = request.targets.iter().rev().find(|t| t.rect.contains(pointer)).is_some_and(|t| t.anchor == CANVAS); + let band = style.edge_band.max(style.new_dock_band); + if !over_canvas || !request.workspace.shrink(band).contains(pointer) { + return None; + } + // A floating window moving live has no float rect; it moves as before. + if request.whole_group && request.floating_rect.is_none() { + return None; + } + if request.released + && let Some(r) = request.floating_rect + { + let rect = [r.left(), r.top(), r.width(), r.height()]; + let panel = request.panel.clone(); + self.1.push(if request.whole_group { + Action::MoveGroup { panel, to: Target::Float { rect: Some(rect) } } + } else { + Action::Float { panel, rect } + }); + } + Some(craft_ui::docking::DropFeedback { preview: request.floating_rect, insertion: None }) + } fn body(&mut self, ui: &mut egui::Ui, id: &String) { + if id == CANVAS { + let fill = crate::theme::Tokens::get(ui.ctx()).pasteboard; + ui.painter().rect_filled(ui.max_rect(), 0.0, fill); + // A bare panel has no tab to report focus through, so the canvas reports its own. + if ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer() { + self.0.ui.docking_focused = Some(CANVAS.to_string()); + } + self.0.canvas_area(ui, false); + return; + } egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { if crate::dock::scrolls_itself(id) { crate::dock::panel_body(self.0, ui, id); @@ -719,17 +957,23 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); }); } - fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { - // The docking surface adds Float Panel and Group with after these, so a tab's menu lists - // what the classic dock's does. - if let Some(panel) = normalize(id) { - tab_commands(self.0, ui, panel); - ui.separator(); - } + fn logical_label(&self, id: &String) -> String { + let english = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map_or(id.as_str(), |p| p.1); + crate::i18n::tr(&self.0.ui.language, english).to_owned() + } + fn permissions(&self, id: &String) -> Permissions { + model_permissions(id) + } + fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { + let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(220.0, 80.0) }; + craft_ui::docking::PanelLimits { min, ..Default::default() } } fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } + fn bare(&self, id: &String) -> bool { + id == CANVAS + } fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 { 24.0 } @@ -750,7 +994,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { /// closed with Close. fn close_group(app: &mut DesignApp, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let layout = layout_of(app); let group = tab_group(&layout, panel); for p in &group { change(app, "close", &json!({"panel": p}))?; @@ -758,8 +1002,9 @@ fn close_group(app: &mut DesignApp, params: &Value) -> Result { Ok(json!({"closed": group})) } -/// InDesign's commands on a panel tab (and at the end of its panel menu): Close, Close Tab Group, -/// Minimize (an open accordion entry) and Collapse to Icons. +/// InDesign's panel tab commands at the end of a panel's three-line menu: Close, Close Tab Group, +/// Minimize (an open accordion entry) and Collapse to Icons. A tab's own context menu is +/// craft-ui's, which lists the same commands. fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { let language = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&language, s).to_owned(); @@ -775,26 +1020,24 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { if ui.button(crate::rtl::widget(ui, tr("Close Tab Group"))).clicked() { run(app, ui, "window.panel.closeGroup", json!({"panel": panel})); } - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let layout = layout_of(app); let minimizable = open_in_stack(&layout, panel); if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { run(app, ui, "window.panel.layout", json!({"action": {"SetStackOpen": {"panel": panel, "open": false}}})); } ui.separator(); - if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { + let expanded = dock_columns(&layout).iter().any(|c| !c.iconic); + if ui.add_enabled(expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { run(app, ui, "window.collapseToIcons", json!({})); } } /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { - if app.ui.docking.is_some() { - if let Some(panel) = app.ui.docking_opened.clone() { - change(app, "close", &json!({"panel": panel}))?; - } - } else { - app.ui.open_panel = None; + if let Some(panel) = app.ui.docking_opened.clone() { + change(app, "close", &json!({"panel": panel}))?; } + app.ui.open_panel = None; Ok(()) } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 8d8e34fdd..abbcf1391 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -59,8 +59,7 @@ fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |_| {}); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -188,7 +187,7 @@ fn capture_custom_panel_docking_visual_fixtures() { } #[test] -fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { +fn escape_cancels_a_tab_drag_without_customizing_the_workspace() { let mut h = harness(app(), vec2(1280.0, 800.0)); let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); h.event(Event::PointerMoved(source)); @@ -197,7 +196,6 @@ fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { h.run_steps(1); h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); h.run_steps(2); - assert!(h.state().ui.docking.is_some()); h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(1); assert!(h.state().ui.docking.is_none()); @@ -235,27 +233,26 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti assert_eq!(app.ui.docking, before); assert_eq!(app.ui.docking_hidden, hidden); } + let stack = Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }; app.ui.docking = Some(Layout { - root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::Ratio(0.7), + first: Box::new(Node::Tabs { panels: vec![CANVAS.into()], active: 0 }), + second: Box::new(stack), + }), floating: Vec::new(), }); app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); - let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") }; + let Some(Node::Split { second, .. }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected split") }; + let Node::Stack { entries } = &**second else { panic!("expected stack") }; assert!(!entries[0].open); assert_eq!(entries[0].height, Some(150.0)); } fn group_members_for_test(layout: &Layout, panel: &str) -> Vec { - let mut pending: Vec<_> = layout.root.iter().collect(); - while let Some(node) = pending.pop() { - match node { - Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), - Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]), - _ => {} - } - } - Vec::new() + tab_group(layout, panel) } #[test] @@ -309,7 +306,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); // Floating Layers leaves Pages the active tab beside Properties. app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "stroke")); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { app.logic(ui.ctx()); @@ -326,7 +323,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { h.run_steps(1); } h.run_steps(2); - assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert!(!in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); } @@ -628,7 +625,7 @@ fn capture_legacy_panel_menu_visual_fixture() { } #[test] -fn legacy_dock_menu_button_opens_the_active_panel_menu() { +fn dock_menu_button_opens_the_active_panel_menu() { let mut app = app(); app.run("file.new", json!({})).unwrap(); app.ui.dock_tab = "pages".into(); @@ -636,8 +633,8 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { let mut h = harness(app, vec2(1280.0, 800.0)); assert!(h.state().ui.docking.is_none()); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; - let at = egui::pos2(dock.right() - 15.0, tab.center().y); + // The outer dock column is at the window's right edge; the ≡ ends its tab strip. + let at = egui::pos2(1280.0 - 12.0, tab.center().y); h.event(Event::PointerMoved(at)); h.run_steps(1); for pressed in [true, false] { @@ -697,9 +694,11 @@ fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { h.run_steps(1); h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); + // The column bar's » button is also labelled Collapse to Icons. for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { - assert!(h.query_by_label(item).is_some(), "{item}"); + assert!(h.query_all_by_label(item).next().is_some(), "{item}"); } + assert_eq!(h.query_all_by_label("Minimize").count(), 1, "the tab menu lists Minimize once"); h.get_by_label("Close").click(); h.run_steps(2); assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); @@ -716,7 +715,7 @@ fn paragraph_styles_open_floating_with_character_styles() { assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. - assert!(matches!(&layout.root, Some(craft_ui::docking::Node::Tabs { panels, active }) if panels[*active] == "properties"), "{:?}", layout.root); + assert!(front_tabs(&layout).iter().any(|p| p == "properties"), "{:?}", layout.root); // Other panels from Window leave it open. app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); @@ -768,8 +767,7 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(app.ui.docking.is_some()); let mut h = harness(app, vec2(1280.0, 800.0)); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap().outer_rect; - click(&mut h, egui::pos2(dock.right() - 12.0, tab.center().y), PointerButton::Primary); + click(&mut h, egui::pos2(1280.0 - 12.0, tab.center().y), PointerButton::Primary); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); for item in ["Numbering & Section Options…", "Close Tab Group", "Float Panel"] { assert!(h.query_by_label(item).is_some(), "{item}"); @@ -797,6 +795,11 @@ fn group_with<'a>(layout: &'a Layout, panel: &str) -> (bool, &'a [String match node { Node::Split { first, second, .. } => visit.extend([&**first, &**second]), Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels, *active), + Node::Dock { columns, .. } => { + if let Some(g) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (false, &g.panels, g.active); + } + } _ => {} } } @@ -814,7 +817,7 @@ fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { fn dragging_a_docked_group_by_its_bar_moves_every_tab() { let mut app = app(); app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Split": "Bottom"}}}}}); + let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Group": {"below": true}}}}}}); app.run("window.panel.layout", json!({"action": below})).unwrap(); let styles = ["characterStyles", "paragraphStyles"]; assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, &styles.map(String::from)[..], 1)); @@ -837,3 +840,81 @@ fn dragging_a_docked_group_by_its_bar_moves_every_tab() { assert_eq!(panels[active], "paragraphStyles"); assert!(layout.floating.is_empty(), "{:?}", layout.floating); } + +fn dock_of(layout: &Layout) -> (Side, &[Column]) { + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("root is not a split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &[CANVAS.to_string()]), "{layout:?}"); + let Node::Dock { side, columns } = &**second else { panic!("second is not a dock: {layout:?}") }; + (*side, columns) +} + +#[test] +fn default_workspaces_are_the_canvas_beside_an_end_dock() { + let mut app = app(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (side, columns) = dock_of(&layout); + assert_eq!(side, Side::End); + let [outer, icons] = columns else { panic!("{columns:?}") }; + assert!(!outer.iconic); + assert_eq!(outer.groups.len(), 1); + assert_eq!(outer.groups[0].panels, ["properties", "pages", "layers"]); + assert!(icons.iconic); + assert!(icons.groups.iter().all(|g| g.panels.len() == 1)); + assert!(!layout.contains(&"paragraphStyles".to_string()), "Window › Styles opens the styles floating"); + // Essentials Classic: only its named icon groups, in one iconic column. + app.run("window.workspace", json!({"name": "Essentials Classic"})).unwrap(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (_, columns) = dock_of(&layout); + let [column] = columns else { panic!("{columns:?}") }; + assert!(column.iconic); + let expected: Vec> = app.ui.icon_groups.clone().unwrap(); + let groups: Vec> = column.groups.iter().map(|g| g.panels.clone()).collect(); + assert_eq!(groups.len(), expected.len()); + assert!(groups.iter().flatten().all(|p| expected.iter().flatten().any(|e| e == p))); +} + +#[test] +fn the_dock_is_at_the_right_in_english_and_the_left_in_arabic() { + let tab = |h: &Harness<'static, DesignApp>| { + h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).map(|r| r.rect).unwrap() + }; + let h = harness(app(), vec2(1280.0, 800.0)); + assert!(tab(&h).left() > 640.0, "{:?}", tab(&h)); + let mut arabic = app(); + arabic.ui.language = "ar".into(); + let h = harness(arabic, vec2(1280.0, 800.0)); + assert!(tab(&h).right() < 640.0, "{:?}", tab(&h)); +} + +#[test] +fn ui_docking_reports_the_layout_and_the_focused_panel() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let ctx = h.ctx.clone(); + let docking = |app: &mut DesignApp| { + let (req, _rx) = crate::control::ControlRequest::new("ui.docking", json!({})); + match crate::control::handle(app, &ctx, &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => panic!("ui.docking took a screenshot"), + } + }; + let v = docking(h.state_mut()); + assert_eq!(v["ok"], true, "{v}"); + let result = &v["result"]; + assert_eq!(result["customized"], false); + assert_eq!(result["rightToLeft"], false); + assert!(result["focused"].is_null()); + let layout: Layout = serde_json::from_value(result["layout"].clone()).unwrap(); + assert_eq!(layout, default_layout(&h.state().ui)); + let at = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + click(&mut h, at, PointerButton::Primary); + let v = docking(h.state_mut()); + assert_eq!(v["result"]["focused"], "layers", "{v}"); + // Closing the focused panel releases the focus. + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + let v = docking(h.state_mut()); + assert!(v["result"]["focused"].is_null(), "{v}"); + assert_eq!(v["result"]["customized"], true); + assert_eq!(v["result"]["hidden"], json!(["layers"])); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..42755d75d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -741,6 +741,15 @@ mod font_menu_ui_tests { for k in 0..count { h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); h.run_steps(4); + // A short Properties group (below it, the workspace's open panel) scrolls to the field. + let mut nodes: Vec<_> = h + .query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(designcraft_fonts::DEFAULT_FAMILY)) + .collect(); + nodes.sort_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y).then(a.rect().min.x.total_cmp(&b.rect().min.x))); + if let Some(node) = nodes.get(k) { + node.scroll_to_me(); + } + h.run_steps(4); let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; search_in_menu(&mut h, at, &format!("{name}, field {k}")); assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fded030b9..79cf2f0e7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1173,7 +1173,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { ) .clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 12.0; @@ -1191,7 +1191,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("style.paragraph.apply", json!({"name": cur, "clearOverrides": true})); } if icon_toggle(ui, "style-load", false, crate::i18n::tr(&app.ui.language, "Styles panel")).clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } }); }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1dc0eed49..78ef44fa3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | -| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | +| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf | +| `ui.docking` | — | The panel layout in use and the focused panel (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | @@ -77,18 +78,30 @@ Panel arrangements support tab grouping and reordering, horizontal and vertical and movable, resizable floating groups inside the application window. Drag a panel tab, or use its context menu. Floating groups remain within the application viewport. -The shared layout begins with the existing workspace on the first docking operation. -Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the -positions of hidden panels. Resetting a built-in workspace restores its default dock. +The window is one panel layout: the protected `canvas` panel (the document area) beside a +dock at the logical end edge, the right in left-to-right languages and the left in +right-to-left ones. Each built-in workspace has a default layout. Essentials and most others +have an outer column with the Properties · Pages · Layers group and an iconic column with the +other panels; Essentials Classic and Typography have their named iconic groups only. A panel a +workspace opens (Printing and Proofing: Swatches) is a group below Properties · Pages · Layers. +The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped +over it floats there. Workspace saves retain its groups, active tabs, split sizes, floating +geometry, and the positions of hidden panels. Resetting a built-in workspace restores its +default dock. - `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders - tabs by panel ID. The destination must already be visible. + tabs by panel ID. The destination must already be visible. Beside a panel in a dock column, + `top` and `bottom` add a group above or below its group and `left` and `right` add a column + on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` + are errors. - `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. - `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous dock placement when possible, or to the supplied destination. -- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved - position when possible. `window.panel.close {panel}` hides it. +- `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, + restoring its saved position when possible; a panel in an iconic column expands that + column. `window.panel.close {panel}` hides it. +- `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and invalid destinations return an error without changing the arrangement. @@ -102,8 +115,26 @@ resizes the root divider (`false`/`true` path entries select first/second childr The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale split paths return an error without changing the workspace. -After panel customization, `app.tablePanel` (including Shift+F9) still toggles the -Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; -`dockExpanded:false` hides the root dock while floating groups remain visible. -The icon rail can reopen a collapsed dock through any root panel. Activating a -floating panel also raises its group. These operations change only UI layout. +`app.tablePanel` (including Shift+F9) toggles the Table panel. `ui.set` with `closePanel` +closes the last explicitly opened panel; `dockExpanded:false` collapses every dock column +to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also +raises its group. These operations change only UI layout. + +`ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as +`docking`: + +```json +{"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 297.0}, + "first": {"Tabs": {"panels": ["canvas"], "active": 0}}, + "second": {"Dock": {"side": "End", "columns": [ + {"groups": [{"panels": ["properties", "pages", "layers"], "active": 0}], "width": 260.0, + "iconic": false, "iconic_width": 37.0}, + {"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true, + "iconic_width": 37.0}]}}}}, + "floating": []}, + "customized": false, "focused": "canvas", "rightToLeft": false, "hidden": []} +``` + +`layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's +default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or +null; `hidden` lists closed panels that remember their place. From 84e4032c114d1547d0944de3d5046d475788e1e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:24:39 +0000 Subject: [PATCH 03/44] Docking trial: migrate ui.json, delete the classic dock --- ROADMAP.md | 2 +- apps/designcraft/src/main.rs | 56 +++- crates/ui-egui/src/chrome.rs | 7 +- crates/ui-egui/src/dock.rs | 288 ++-------------- crates/ui-egui/src/lib.rs | 23 +- crates/ui-egui/src/menus.rs | 64 ++-- crates/ui-egui/src/panel_docking.rs | 387 ++++++++++++---------- crates/ui-egui/src/panel_docking_tests.rs | 100 +----- crates/ui-egui/src/panels/properties.rs | 13 +- crates/ui-egui/src/prefs.rs | 201 ++++++++++- docs/control-protocol.md | 18 +- docs/target-app-parity.md | 2 +- 12 files changed, 539 insertions(+), 622 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..815ac6cc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable, resizable floating window; Dock Panel puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b56878176..3c5a79f20 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -143,15 +143,47 @@ fn prefs_path() -> Option { profile::config_dir().map(|b| b.join("ui.json")) } +/// Set when `ui.json` could not be read in full and no copy of it could be kept: the file is +/// then left as it is rather than overwritten. +static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy an unreadable (or partly unreadable) `ui.json` beside it, as `ui.unreadable.json` or the +/// first free `ui.unreadable-N.json`, before the next save replaces it. A newer DesignCraft may +/// have written it. +fn keep_unreadable_prefs(path: &std::path::Path, bytes: &[u8]) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { "ui.unreadable.json".to_string() } else { format!("ui.unreadable-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + fn load_prefs(app: &mut DesignApp) { if no_prefs() { return; } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { - app.ui = ui; + match designcraft_ui_egui::UiState::read_preferences(&bytes) { + Ok(read) => { + app.ui = read.ui; + if !read.intact { + keep_unreadable_prefs(&p, &bytes); + } + } + Err(_) => keep_unreadable_prefs(&p, &bytes), + } } // Engine preferences (Preferences dialog, favourites…) live beside the UI state. if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) @@ -168,7 +200,9 @@ fn save_prefs(app: &DesignApp) { } if let Some(p) = prefs_path() { let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) + && let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) + { let _ = std::fs::write(&p, bytes); } if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { @@ -556,6 +590,22 @@ fn main() -> std::process::ExitCode { #[cfg(test)] mod tests { + #[test] + fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_unreadable_prefs(&path, b"{\"docking\": {\"root\": {\"FutureLayout\": {}}}}"); + super::keep_unreadable_prefs(&path, b"not json"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); + } + #[test] fn relink_open_dialog_lists_graphics() { let filters = super::open_filters("relink"); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1f865a3dc..eea906ee6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -735,12 +735,9 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs -/// only when Preflight isn't showing. +/// Show the Preflight panel. fn show_preflight(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() != Some("preflight") - && let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) - { + if let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) { app.status(e); } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a825e3502..0528cfdd6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,16 +1,16 @@ -//! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon -//! column whose panels open as flyouts beside the dock. +//! The panels: their ids, names, icons and contents, and the windows of the float-only panels. +//! `panel_docking` places the others in the docking layout. -use egui::{Color32, Sense, Stroke, vec2}; +use egui::{Sense, vec2}; use crate::theme::{Tokens, semibold}; -use crate::{DesignApp, icons, panels}; +use crate::{DesignApp, panels}; -/// (id, label, icon) of the tabs in the expanded group. +/// (id, label, icon) of the Properties · Pages · Layers group of the default dock. pub const DOCK_TABS: &[(&str, &str, &str)] = &[("properties", "Properties", "panel-properties"), ("pages", "Pages", "panel-pages"), ("layers", "Layers", "panel-layers")]; -/// Panels in the collapsed icon column. +/// (id, label, icon) of the other dock panels, which start in the iconic column. pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("stroke", "Stroke", "panel-stroke"), ("swatches", "Swatches", "panel-swatches"), @@ -50,7 +50,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("dataMerge", "Data Merge", "panel-datamerge"), ]; -/// Panels that only float (they open from a menu, not from the icon column). +/// Panels that only float, outside the docking layout (they open from a menu). pub const FLOATING_PANELS: &[(&str, &str)] = &[("tabs", "Tabs")]; /// The English name of panel `id`. @@ -58,197 +58,6 @@ pub fn panel_label(id: &str) -> &'static str { DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1) } -/// The classic dock, replaced by the docking layout (`panel_docking::show`) and no longer drawn. -pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. - if app.ui.dock_expanded { - egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show( - ui, - |ui| { - dock_header(ui, &t, "»"); - // Tab strip (27 pt). - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.tab_strip); - let mut x = strip.min.x; - for (id, label, _) in DOCK_TABS { - let active = app.ui.dock_tab == *id; - let g = crate::rtl::plain( - ui.ctx(), - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - if active { t.text_strong } else { t.text_dim }, - ); - let w = g.size().x + 26.0; - let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); - if active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( - ui, - r, - |_, _| {}, - ); - if response.clicked() { - app.ui.dock_tab = id.to_string(); - } - crate::panel_docking::legacy_tab(app, ui, id, &response); - x += w; - } - let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); - if menu.hovered() { - ui.painter().rect_filled(menu_r, 3.0, t.hover); - } - icons::paint(ui.painter(), menu_r, "menu", t.icon); - let tab = app.ui.dock_tab.clone(); - egui::Popup::menu(&menu) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| crate::panel_docking::three_line_menu(app, ui, &tab)); - egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { - // The Pages panel scrolls its parts itself and keeps its footer at the bottom. - if app.ui.dock_tab == "pages" { - panels::pages::show(app, ui); - return; - } - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { - "layers" => panels::layers::show(app, ui), - _ => panels::properties::show(app, ui), - }); - }); - }, - ); - } - // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups - // (Essentials Classic) show their names. - let groups = app.ui.icon_groups.clone(); - egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { - dock_header(ui, &t, "«"); - ui.add_space(4.0); - match &groups { - Some(groups) => { - for (i, group) in groups.iter().enumerate() { - if i > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); - } - for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) { - let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id); - let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); - if on { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active); - } else if response.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover); - } - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon); - crate::rtl::paint( - ui.painter(), - r.left_center() + vec2(32.0, 0.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - t.text, - ); - icon_clicked(app, ui, id, &response); - } - } - } - None => { - ui.vertical_centered(|ui| { - for (id, label, icon) in ICON_PANELS { - let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id); - let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); - icon_clicked(app, ui, id, &response); - ui.add_space(1.0); - } - }); - } - } - }); -} - -/// Width of the icon column: icons only, or icons with names for a workspace's groups. -fn icon_column_width(app: &DesignApp) -> f32 { - if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 } -} - -/// (id, label, icon) of any dock panel. -fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { - DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() -} - -/// A click on a panel's icon: raise it when it floats, else open or close its flyout. -fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { - if response.clicked() { - if app.ui.floating.iter().any(|(p, _)| p == id) { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id)))); - } else { - let open = app.ui.open_panel.as_deref() == Some(id); - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; - } - } - crate::panel_docking::legacy_tab(app, ui, id, response); -} - -/// 11 pt dock header strip with a collapse chevron. -fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover()); - ui.painter().rect_filled(r, 0.0, t.tab_strip); - ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim); - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); -} - -/// A panel opened from the icon column, shown as a floating flyout next to the dock. -pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { - if app.ui.docking.is_some() { - return; - } - let Some(id) = app.ui.open_panel.clone() else { return }; - let t = Tokens::get(ctx); - let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let screen = ctx.content_rect(); - let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; - let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); - let mut open = true; - egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { - ui.set_width(256.0); - // A previous shorter flyout must not constrain the next panel's scroll viewport. - ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0)); - let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); - let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() { - open = false; - } - ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - // Float: the button beside the close box, or drag the header away from the dock. - let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel")); - }); - float_glyph(ui.painter(), float_r, t.text_dim); - let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y)); - let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag()); - let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0); - if fr.clicked() || torn { - let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) }; - float_panel(app, &id, at); - return; - } - egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - scrolled_body(app, ui, &id, screen.height() - 220.0); - }); - }); - }); - if !open { - app.ui.open_panel = None; - } -} - /// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its /// parts itself and keeps its footer at the bottom, so it gets the height instead. fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { @@ -315,41 +124,16 @@ pub(crate) fn float_only(id: &str) -> bool { FLOATING_PANELS.iter().any(|p| p.0 == id) } -/// Tear panel `id` off the dock into a floating window at `at`. -pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if float_only(id) { - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); - return; - } - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { - app.status(error); - } -} - -/// Put a floating panel back in the dock's icon column. -pub fn dock_panel(app: &mut DesignApp, id: &str) { - if !float_only(id) { - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { - app.status(error); - } - return; - } - app.ui.floating.retain(|(p, _)| p != id); -} - -/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. -pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { - // The docking layout draws its own floating groups; float-only panels are drawn here. +/// The float-only panels' windows (the Tabs panel). The docking layout draws the other +/// floating panels. +pub fn float_only_windows(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { if !float_only(&id) { continue; } let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let docks = !float_only(&id); let mut open = true; - let mut dock = false; let mut w = egui::Window::new(label.clone()) .id(egui::Id::new(("floating_panel", &id))) .title_bar(false) @@ -364,7 +148,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { w = w.current_pos(pos).min_width(width).max_width(width); } let r = w.show(ctx, |ui| { - // Header strip (drag it to move the panel): name, Dock, close. + // Header strip (drag it to move the panel): name and close. let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width().max(200.0), 24.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); @@ -379,20 +163,6 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { open = false; } ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0)); - if docks - && ui - .interact(dock_r, ui.id().with("fdock"), Sense::click()) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel")); - }) - .clicked() - { - dock = true; - } - if docks { - dock_glyph(ui.painter(), dock_r, t.text_dim); - } egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { scrolled_body(app, ui, &id, ctx.content_rect().height() - 160.0); }); @@ -403,29 +173,16 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { { f.1 = [r.response.rect.min.x, r.response.rect.min.y]; } - if !open || dock { - dock_panel(app, &id); + if !open { + app.ui.floating.retain(|(p, _)| *p != id); } } } -/// Two overlapping boxes: "float this panel". -fn float_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let s = Stroke::new(1.0, c); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(3.5, 6.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(6.5, 3.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); -} - -/// A box with a bar on its right: "back into the dock". -fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let b = egui::Rect::from_min_size(r.min + vec2(3.5, 4.5), vec2(11.0, 9.0)); - p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); - p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); -} - #[cfg(test)] mod visual_regressions { use super::*; + use egui::Color32; fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; @@ -441,9 +198,7 @@ mod visual_regressions { for brightness in crate::theme::Brightness::ALL { for width in [800.0, 1280.0] { for scale in [1.0, 1.5, 2.0] { - let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); + let app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); let mut ready = false; let mut harness = egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( @@ -454,7 +209,7 @@ mod visual_regressions { ready = true; return; } - show(app, ui); + crate::panel_docking::show(app, ui); }, app, ); @@ -462,10 +217,11 @@ mod visual_regressions { harness.run_steps(4); let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); - let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let tab = harness.ctx.read_response(tab).unwrap().rect; let region = serde_json::json!({ "screen": [width, 640.0], "scale": scale, - "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + "regions": {"dock-tabs": [tab.left(), tab.top(), (width - tab.left()), tab.height()]} }); std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); } @@ -491,9 +247,9 @@ mod visual_regressions { let tokens = Tokens::for_brightness(brightness); crate::theme::apply(&ctx, &tokens); let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); - let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); output.textures_delta.clear(); assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); assert_eq!(ctx.pixels_per_point(), scale); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0a76e123c..370388317 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub use control::{ControlRequest, ControlResponse}; +pub use prefs::ReadPreferences; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; @@ -116,7 +117,7 @@ pub struct SavedWorkspace { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, - /// The workspace's own icon column (see [`UiState::icon_groups`]). + /// The workspace's own iconic column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, /// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the @@ -253,11 +254,14 @@ pub struct UiState { pub shortcuts: std::collections::BTreeMap, pub control_bar: bool, pub tools_double_column: bool, - /// Expanded right-dock panel group tab. + /// The front tab of the default layout's Properties · Pages · Layers group. `dock_tab`, + /// `open_panel`, `floating`, `dock_expanded` and `icon_groups` describe a workspace's + /// default layout (`panel_docking::default_layout`). pub dock_tab: String, - /// Panel opened from the collapsed icon column (flyout). + /// The panel it opens below that group (Printing and Proofing: Swatches). pub open_panel: Option, - /// Panels torn off the dock into their own floating windows: (panel id, top-left position). + /// Its floating panels, and the float-only panels' windows (Type › Tabs): (panel id, + /// top-left position). A customized layout holds its own floating panels. pub floating: Vec<(String, [f32; 2])>, /// The panel layout once the user customizes it; `None` is the workspace's default layout /// (`panel_docking::default_layout`). @@ -274,12 +278,13 @@ pub struct UiState { /// One-frame reveal request; z-order is never persisted. #[serde(skip)] pub docking_raise: Option, - /// Last explicitly revealed customized panel, for the legacy closePanel control. + /// The panel last revealed explicitly, which `ui.set closePanel` closes. #[serde(skip)] pub docking_opened: Option, + /// Whether the default layout's outer column is expanded (false: collapsed to icons). pub dock_expanded: bool, - /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon - /// column instead of every panel; `None` is the full icon column. + /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the + /// iconic column instead of every panel; `None` is one icon per panel. #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, pub units: Unit, @@ -1563,7 +1568,7 @@ impl DesignApp { let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; if presenting || self.ui.hidden_panels != 0 { - panel_docking::cancel_gesture(self, &ctx); + panel_docking::cancel_gesture(&ctx); } canvas::begin_frame(&ctx); let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); @@ -1631,7 +1636,7 @@ impl DesignApp { } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { - dock::floating(self, &ctx); + dock::float_only_windows(self, &ctx); } story_editor::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3558be93a..9e4ceda19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,7 +236,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), - ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own floating window (window.panel.float)"), ( "window.panel.layout", "Change Panel Layout", @@ -255,7 +255,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), - ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), + ("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), @@ -925,7 +925,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ pub const WORKSPACES: &[&str] = &["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"]; -/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom. +/// Essentials Classic's iconic column: InDesign's iconic panel groups, top to bottom. pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["pages", "layers", "links"], &["stroke", "color"], @@ -955,10 +955,10 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it. fn toggle_tabs_panel(app: &mut DesignApp) { if app.ui.floating.iter().any(|(p, _)| p == "tabs") { - crate::dock::dock_panel(app, "tabs"); + app.ui.floating.retain(|(p, _)| p != "tabs"); } else { let at = app.canvas_rect.map_or(egui::pos2(300.0, 140.0), |r| r.min + egui::vec2(80.0, 60.0)); - crate::dock::float_panel(app, "tabs", at); + app.ui.floating.push(("tabs".into(), [at.x, at.y])); app.ui.tabs_panel.snap_request = 2; } } @@ -1342,10 +1342,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) }; - Ok(Value::Null) - } "app.tabsPanel" => { toggle_tabs_panel(app); Ok(Value::Null) @@ -1647,19 +1643,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), + // `panel_docking::command` takes the dock panels (and the Window › Styles group) first. "window.panel" => { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); - // A panel in the workspace's icon groups opens from there (Essentials Classic's Pages). - let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel)); - if panel == "tabs" { - toggle_tabs_panel(app); - } else if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { - app.ui.dock_tab = panel; - app.ui.dock_expanded = true; - app.ui.open_panel = None; - } else if !app.ui.floating.iter().any(|(p, _)| *p == panel) { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) }; + let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties"); + if panel != "tabs" { + return crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": panel})); } + toggle_tabs_panel(app); Ok(Value::Null) } "fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => { @@ -1737,22 +1727,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - if !crate::dock::ICON_PANELS.iter().any(|(id, _, _)| *id == panel) && !crate::dock::FLOATING_PANELS.iter().any(|(id, _)| *id == panel) { - return Some(Err(format!("{id}: unknown panel `{panel}`"))); - } - if id == "window.floatPanel" { - let at = egui::pos2( - p.get("x").and_then(Value::as_f64).unwrap_or(400.0) as f32, - p.get("y").and_then(Value::as_f64).unwrap_or(160.0) as f32, - ); - crate::dock::float_panel(app, panel, at); - } else { - crate::dock::dock_panel(app, panel); - } - Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::>()})) - } "window.controlBar" => flag(&mut app.ui.control_bar), "view.flattenerPreview" => flag(&mut app.ui.flattener_preview), "view.tagMarkers" => { @@ -1904,7 +1878,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(CLASSIC_GROUPS), @@ -4730,13 +4704,21 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "swatches", "x": 50, "y": 60})).unwrap().unwrap(); assert_eq!(r["floating"], json!(["swatches"])); - assert_eq!(app.ui.floating[0].1, [50.0, 60.0]); - // Showing a floating panel doesn't also open its flyout. + let window = &app.ui.docking.as_ref().unwrap().floating[0]; + assert_eq!([window.rect[0], window.rect[1]], [50.0, 60.0]); + // Showing a floating panel leaves it where it is. run_ui(&mut app, "window.panel", &json!({"panel": "swatches"})).unwrap().unwrap(); assert_eq!(app.ui.open_panel, None); - run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); - assert!(app.ui.floating.is_empty()); + let r = run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); + assert_eq!(r["floating"], json!([])); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty()); assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err()); + // Float-only panels (Type › Tabs) have their own windows outside the layout. + let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "tabs", "x": 10, "y": 20})).unwrap().unwrap(); + assert_eq!(r["floating"], json!(["tabs"])); + assert_eq!(app.ui.floating, [("tabs".to_string(), [10.0, 20.0])]); + run_ui(&mut app, "window.dockPanel", &json!({"panel": "tabs"})).unwrap().unwrap(); + assert!(app.ui.floating.is_empty()); } /// One frame as the desktop app runs it: queued synthetic input, then the native menu item diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 393c977e5..c0e0cfd5f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -9,36 +9,10 @@ use serde_json::{Value, json}; use crate::DesignApp; -#[derive(Clone, Default)] -struct LegacyDrag { - layout: Option>, - open_panel: Option, - floating: Vec<(String, [f32; 2])>, - generation: u64, -} - -pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { - cancel_legacy_drag(app, ctx); +pub(crate) fn cancel_gesture(ctx: &egui::Context) { craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); } -fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { - let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); - let previous = ctx.data_mut(|data| data.remove_temp::(key)); - if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); - if previous.generation != app.ui.docking_generation { - return false; - } - app.ui.open_panel = previous.open_panel; - app.ui.floating = previous.floating; - app.ui.docking = previous.layout; - true - } else { - false - } -} - fn normalize(raw: &str) -> Option<&'static str> { crate::dock::DOCK_TABS .iter() @@ -72,67 +46,53 @@ pub(crate) fn valid(layout: &Layout) -> bool { && Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&CANVAS.to_string()) } -/// The layout of a workspace that has not been customized, from its panel settings: the -/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an -/// open flyout panel stacked below it) and whose inner column is iconic. Essentials Classic -/// and Typography (`icon_groups`) have the iconic column only. -pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { - let mut placed: Vec = Vec::new(); - let take = |id: &str, placed: &mut Vec| -> Option { - let id = normalize(id)?; - (!placed.iter().any(|p| p == id)).then(|| { - placed.push(id.to_string()); - id.to_string() - }) - }; - let mut floating = Vec::new(); - for (id, at) in &ui.floating { - if at.iter().all(|v| v.is_finite()) - && let Some(id) = take(id, &mut placed) - { - floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); +/// Panels already given a place, so each appears once. +#[derive(Default)] +struct Placed(Vec); + +impl Placed { + /// The registered id of `raw` if no other place holds it yet. + fn take(&mut self, raw: &str) -> Option { + let id = normalize(raw)?; + if self.0.iter().any(|p| p == id) { + return None; } + self.0.push(id.to_string()); + Some(id.to_string()) } - let mut columns = Vec::new(); - match &ui.icon_groups { - Some(groups) => { - let groups: Vec> = groups - .iter() - .map(|g| g.iter().filter_map(|id| take(id, &mut placed)).collect::>()) - .filter(|panels| !panels.is_empty()) - .map(|panels| Group::new(panels, 0)) - .collect(); - if !groups.is_empty() { - columns.push(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(NAMED_ICON_WIDTH) }); - } - } - None => { - let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| take(p.0, &mut placed)).collect(); - let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); - let mut groups = Vec::new(); - if !panels.is_empty() { - groups.push(Group::new(panels, active)); - } - // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed - // height, so Properties keeps most of the column. - if let Some(id) = ui.open_panel.as_deref().and_then(|id| take(id, &mut placed)) { - groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); - } - if !groups.is_empty() { - columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); - } - // Window › Styles opens the style panels as a floating group, so they start outside. - let icons: Vec> = crate::dock::ICON_PANELS + + /// A group of the unplaced registered panels of `panels`, with the one at `active` in front + /// when it is among them. + fn group(&mut self, panels: &[String], active: usize) -> Option> { + let front = panels.get(active).and_then(|p| normalize(p)); + let kept: Vec = panels.iter().filter_map(|p| self.take(p)).collect(); + let active = front.and_then(|f| kept.iter().position(|p| p == f)).unwrap_or(0); + (!kept.is_empty()).then(|| Group::new(kept, active)) + } +} + +/// A workspace's iconic column: its named `icon_groups` (Essentials Classic, Typography), or +/// one group per panel not placed yet. Window › Styles opens the style panels as a floating +/// group, so they start outside it. +fn icon_column(icon_groups: Option<&[Vec]>, placed: &mut Placed) -> Option> { + let (groups, width): (Vec>, f32) = match icon_groups { + Some(groups) => (groups.iter().filter_map(|g| placed.group(g, 0)).collect(), NAMED_ICON_WIDTH), + None => ( + crate::dock::DOCK_TABS .iter() + .chain(crate::dock::ICON_PANELS) .filter(|p| !FLOATING_GROUPS.iter().any(|g| g.contains(&p.0))) - .filter_map(|p| take(p.0, &mut placed)) + .filter_map(|p| placed.take(p.0)) .map(|id| Group::new(vec![id], 0)) - .collect(); - if !icons.is_empty() { - columns.push(Column { groups: icons, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(ICON_WIDTH) }); - } - } - } + .collect(), + ICON_WIDTH, + ), + }; + (!groups.is_empty()).then_some(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(width) }) +} + +/// The canvas beside a dock of `columns` (from the window edge inwards) at the logical end edge. +fn with_canvas(columns: Vec>, floating: Vec>) -> Layout { let width: f32 = columns.iter().map(|c| if c.iconic { c.iconic_width.unwrap_or(ICON_WIDTH) } else { c.width }).sum(); Layout { root: Some(Node::Split { @@ -145,6 +105,118 @@ pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { } } +/// The layout of a workspace that has not been customized, from its panel settings: the +/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an +/// the panel the workspace opens stacked below it) and whose inner column is iconic. Essentials Classic +/// and Typography (`icon_groups`) have the iconic column only. +pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { + let mut placed = Placed::default(); + let mut floating = Vec::new(); + for (id, at) in &ui.floating { + if at.iter().all(|v| v.is_finite()) + && let Some(id) = placed.take(id) + { + floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); + } + } + let mut columns = Vec::new(); + if ui.icon_groups.is_none() { + let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| placed.take(p.0)).collect(); + let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); + let mut groups = Vec::new(); + if !panels.is_empty() { + groups.push(Group::new(panels, active)); + } + // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed + // height, so Properties keeps most of the column. + if let Some(id) = ui.open_panel.as_deref().and_then(|id| placed.take(id)) { + groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); + } + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); + } + } + columns.extend(icon_column(ui.icon_groups.as_deref(), &mut placed)); + with_canvas(columns, floating) +} + +/// Is `layout` from before the canvas joined the panel layout? Those layouts held only the +/// right dock's panels and floating windows; every later one holds the canvas. +pub(crate) fn dock_only(layout: &Layout) -> bool { + !layout.panels().iter().any(|p| p.as_str() == CANVAS) +} + +/// A [`dock_only`] layout in the current shape: its tab groups, accordions and splits become +/// the dock's expanded columns (collapsed to icons when the dock was), the panels it does not +/// hold form the iconic column, and its floating windows stay. Panels closed with a remembered +/// place (`hidden`) stay closed. None when the layout is invalid. +pub(crate) fn from_dock_only<'a>( + old: &Layout, + hidden: impl IntoIterator, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) -> Option> { + old.validate().ok()?; + let mut placed = Placed::default(); + // Reading order (start to end); a dock lists its columns from the window edge inwards. + let mut reading = old.root.as_ref().map(|root| dock_only_columns(root, 0, &mut placed)).unwrap_or_default(); + reading.reverse(); + let mut columns: Vec> = + reading.into_iter().map(|groups| Column { groups, width: COLUMN_WIDTH, iconic: !dock_expanded, iconic_width: Some(ICON_WIDTH) }).collect(); + let mut floating = Vec::new(); + for window in &old.floating { + let groups: Vec> = window + .groups + .iter() + .filter_map(|g| placed.group(&g.panels, g.active).map(|kept| Group { minimized: g.minimized, height: g.height, ..kept })) + .collect(); + if !groups.is_empty() { + floating.push(Floating { groups, rect: window.rect, native: None }); + } + } + for panel in hidden { + let _ = placed.take(panel); + } + columns.extend(icon_column(icon_groups, &mut placed)); + let layout = with_canvas(columns, floating); + valid(&layout).then_some(layout) +} + +/// The groups of a dock-only layout's node, column by column in reading order. A vertical split +/// stacks its parts in one column; a horizontal split puts them side by side. +fn dock_only_columns(node: &Node, depth: usize, placed: &mut Placed) -> Vec>> { + if depth > craft_ui::docking::MAX_DEPTH { + return Vec::new(); + } + let column = |groups: Vec>| if groups.is_empty() { Vec::new() } else { vec![groups] }; + match node { + Node::Tabs { panels, active } => column(placed.group(panels, *active).into_iter().collect()), + Node::Stack { entries } => column( + entries + .iter() + .filter_map(|e| placed.take(&e.panel).map(|id| Group { minimized: !e.open, height: e.height, ..Group::new(vec![id], 0) })) + .collect(), + ), + Node::Split { axis, first, second, .. } => { + let mut parts = dock_only_columns(first, depth.saturating_add(1), placed); + parts.extend(dock_only_columns(second, depth.saturating_add(1), placed)); + match axis { + craft_ui::layout::SplitAxis::Horizontal => parts, + _ => column(parts.into_iter().flatten().collect()), + } + } + Node::Dock { columns, .. } => columns + .iter() + .rev() + .filter_map(|c| { + let groups: Vec> = c.groups.iter().filter_map(|g| placed.group(&g.panels, g.active)).collect(); + (!groups.is_empty()).then_some(groups) + }) + .collect(), + _ => Vec::new(), + } +} + /// The layout in use: the customized one, or the workspace's default. fn layout_of(app: &DesignApp) -> Layout { app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) @@ -184,6 +256,7 @@ fn dock_columns(layout: &Layout) -> Vec<&Column> { /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. +#[cfg(test)] pub(crate) fn tab_group(layout: &Layout, panel: &str) -> Vec { if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); @@ -333,7 +406,19 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result) -> Result Result { + let at = if matches!(id, "window.floatPanel" | "window.panel.float") { + Some([number(params, "x", 400.0)?, number(params, "y", 160.0)?]) + } else { + None + }; + app.ui.floating.retain(|(p, _)| p != panel); + if let Some(at) = at { + app.ui.floating.push((panel.to_string(), at)); + } + Ok(json!({"panel": panel, "floating": floating_panels(app)})) +} + +/// Once the layout is customized it holds every floating dock panel, so `UiState::floating` +/// keeps only the float-only panels. +fn keep_float_only(app: &mut DesignApp) { + if app.ui.docking.is_some() { + app.ui.floating.retain(|(id, _)| crate::dock::float_only(id)); + } +} + +/// Every floating panel: the layout's floating windows, then the float-only panels. +pub(crate) fn floating_panels(app: &DesignApp) -> Vec { + let layout = layout_of(app); + let docked = layout.floating.iter().flat_map(|w| w.panels().cloned()); + docked.chain(app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).map(|(id, _)| id.clone())).collect() } /// The panel in front of each tab group in the dock (not the floating groups). @@ -627,14 +742,14 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result>(), "layout": app.ui.docking})) + Ok(json!({"panel": panel, "floating": floating_panels(app), "layout": app.ui.docking})) } /// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last @@ -679,7 +794,6 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); if changed_workspace { craft_ui::docking::cancel_drag::(ui.ctx(), area); - ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { app.ui.docking = None; @@ -775,72 +889,9 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.newWindow", "Move Panel to New Window"), ]; -#[allow(dead_code)] // The classic icon rail; removed with the classic dock. -fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = crate::theme::Tokens::get(ui.ctx()); - let column = egui::Panel::right("shared-panel-icons") - .exact_size(38.0) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.panel)) - .show(ui, |ui| { - egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if app.ui.docking.as_ref().is_some_and(|layout| { - layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels().any(|p| p == id))) - }) { - continue; - } - - let response = - crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); - if response.clicked() { - let _ = app.run("window.panel.activate", json!({"panel":id})); - } - legacy_tab(app, ui, id, &response); - } - }); - }) - .response - .rect; - let _ = column; -} - -pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - let Some(panel) = normalize(panel) else { return }; - response.context_menu(|ui| panel_menu(app, ui, panel)); - if response.drag_started() - && let Some(id) = normalize(panel) - { - let mut layout = layout_of(app); - let bounds = ui.max_rect(); - let source = egui::Rect::from_min_size( - egui::pos2(bounds.left(), response.rect.top()), - egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), - ); - if ensure(&mut layout, id).is_ok() - && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source) - { - ui.ctx().data_mut(|data| { - data.insert_temp( - egui::Id::new("designcraft-panel-docking").with("legacy-origin"), - LegacyDrag { - layout: app.ui.docking.clone(), - open_panel: app.ui.open_panel.clone(), - floating: app.ui.floating.clone(), - generation: app.ui.docking_generation, - }, - ) - }); - app.ui.docking = Some(layout); - app.ui.open_panel = None; - } - } -} - -/// The menu behind a panel's three-line button, in either dock: the panel's own commands, then -/// its docking commands. -pub(crate) fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { +/// The menu behind a panel's three-line button: the panel's own commands, then its docking +/// commands. +fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { if panel == "pages" { crate::panels::pages::panel_menu(app, ui); ui.separator(); @@ -990,18 +1041,6 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } -/// Close Tab Group: `panel` and the panels tabbed with it, each restorable from Window as one -/// closed with Close. -fn close_group(app: &mut DesignApp, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - let layout = layout_of(app); - let group = tab_group(&layout, panel); - for p in &group { - change(app, "close", &json!({"panel": p}))?; - } - Ok(json!({"closed": group})) -} - /// InDesign's panel tab commands at the end of a panel's three-line menu: Close, Close Tab Group, /// Minimize (an open accordion entry) and Collapse to Icons. A tab's own context menu is /// craft-ui's, which lists the same commands. @@ -1041,20 +1080,6 @@ pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { Ok(()) } -// Legacy inspection fields are derived from the shared layout after customization. -fn sync_legacy(app: &mut DesignApp) { - if let Some(layout) = &app.ui.docking { - // Float-only panels aren't in the layout; they keep their place in the list. - let float_only: Vec<(String, [f32; 2])> = app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).cloned().collect(); - app.ui.floating = layout - .floating - .iter() - .flat_map(|window| window.panels().map(|id| (id.clone(), [window.rect[0], window.rect[1]]))) - .chain(float_only) - .collect(); - } -} - #[cfg(test)] #[path = "panel_docking_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index abbcf1391..b44265e7d 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -136,16 +136,7 @@ fn capture_custom_panel_docking_visual_fixtures() { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Custom panel workspace"); - if app.ui.language == "ar" { - ui.label("Shaped action references:"); - for action in ["Float Panel", "Group with", "Close Panel"] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); - } - } - }); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -174,10 +165,7 @@ fn capture_custom_panel_docking_visual_fixtures() { harness.run_steps(2); assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); for action in ["Float Panel", "Group with", "Close Panel"] { - assert!( - harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, - "both logical reference and actual menu label must exist" - ); + assert!(harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 1, "the menu lists {action}"); } harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); } @@ -352,7 +340,7 @@ fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { } #[test] -fn first_customization_preserves_the_visible_legacy_flyout() { +fn first_customization_keeps_the_panel_the_workspace_opened() { let mut app = app(); app.ui.open_panel = Some("paragraphStyles".into()); app.ui.dock_expanded = false; @@ -383,7 +371,7 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() let floating = h.state().ui.floating.clone(); // The app calls this before dock rendering when panels are hidden or Presentation starts. let ctx = h.ctx.clone(); - cancel_gesture(h.state_mut(), &ctx); + cancel_gesture(&ctx); assert_eq!(h.state().ui.docking, destination); assert_eq!(h.state().ui.open_panel, open_panel); assert_eq!(h.state().ui.floating, floating); @@ -393,7 +381,7 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() } #[test] -fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() { +fn table_and_control_visibility_work_after_customization_without_document_edits() { let mut app = app(); app.run("file.new", json!({})).unwrap(); let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); @@ -548,82 +536,6 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); } -#[test] -fn capture_legacy_panel_menu_visual_fixture() { - let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; - std::fs::create_dir_all(&directory).unwrap(); - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); - app.run("app.language", json!({"lang":"ar"})).unwrap(); - let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let mut ready = false; - let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), &app.ui.language); - crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); - ready = true; - return; - } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Shaped legacy menu references:"); - for label in ["Float Panel", "Layers"] { - crate::rtl::label(ui, crate::i18n::tr("ar", label)); - } - }); - }, - app, - ); - h.input_mut().max_texture_side = Some(8192); - h.run_steps(5); - assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); - let at = h.get_by_label("Properties").rect().center(); - h.input_mut().events.extend([ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, - ]); - h.run_steps(1); - h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(egui::Popup::is_any_open(&h.ctx)); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); - let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); - h.hover_at(group); - h.run_steps(4); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); - assert!(h.state().ui.docking.is_none()); - let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); - let logical = crate::i18n::tr("ar", last_label); - let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); - let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); - let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); - let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); - assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); - h.input_mut().events.extend([ - Event::PointerMoved(scroll_at), - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, - ]); - h.run_steps(12); - let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); - assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); - assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); - h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); - h.run_steps(4); - let layout = h.state().ui.docking.as_ref().unwrap(); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); - let group = group_members_for_test(layout, last); - assert!(group.iter().any(|panel| panel == "properties")); - assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); - assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); - h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); -} - #[test] fn dock_menu_button_opens_the_active_panel_menu() { let mut app = app(); @@ -757,7 +669,7 @@ fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { } /// Window › Paragraph Styles switches to the docking layout. After Window › Pages the Pages panel -/// keeps its three-line panel menu, and its tab menu lists what the classic dock's does. +/// keeps its three-line panel menu, and its tab menu lists InDesign's tab commands. #[test] fn pages_keeps_its_panel_menu_in_the_docking_layout() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 79cf2f0e7..e216df3b3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,12 +16,9 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; -/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it -/// toggles the flyout, so a flyout that is already open is left as it is. +/// A link to another panel: `window.panel` shows it in the docking layout. fn show_panel(app: &mut DesignApp, panel: &str) { - if app.ui.open_panel.as_deref() != Some(panel) { - let _ = app.run("window.panel", json!({"panel": panel})); - } + let _ = app.run("window.panel", json!({"panel": panel})); } fn units(app: &DesignApp) -> Unit { @@ -1692,12 +1689,8 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a -/// flyout, so it is skipped while the Typography flyout is open. +/// Opens the Typography panel, or brings it to the front when it is already open. pub fn show_typography_panel(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() == Some("typography") { - return; - } let _ = app.run("window.panel", json!({"panel": "typography"})); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index cb5a0b54c..37a70c3d6 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,43 +1,92 @@ //! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. +use std::collections::BTreeMap; + use craft_ui::docking::{Layout, Location}; use serde_json::Value; -use crate::UiState; +use crate::{SavedWorkspace, UiState, panel_docking}; + +/// Saved preferences, and whether reading them discarded anything. +pub struct ReadPreferences { + pub ui: UiState, + /// False when unreadable docking data was dropped. The caller keeps the file it read + /// instead of overwriting it. + pub intact: bool, +} impl UiState { /// Read saved preferences, discarding only unreadable optional docking metadata. /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result { + Self::read_preferences(bytes).map(|read| read.ui) + } + + /// [`Self::from_preferences_json`], also reporting whether anything was discarded. Layouts + /// saved before the canvas joined the panel layout are converted (see + /// [`panel_docking::from_dock_only`]); that is not a loss. + pub fn read_preferences(bytes: &[u8]) -> serde_json::Result { let mut saved: Value = serde_json::from_slice(bytes)?; - recover_docking(&mut saved); + let mut intact = recover_docking(&mut saved); if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { for workspace in workspaces { - recover_docking(workspace); + intact &= recover_docking(workspace); } } - serde_json::from_value(saved) + let mut ui: UiState = serde_json::from_value(saved)?; + migrate(&mut ui.docking, &mut ui.floating, &ui.docking_hidden, ui.dock_expanded, ui.icon_groups.as_deref()); + for workspace in &mut ui.custom_workspaces { + let SavedWorkspace { docking, floating, docking_hidden, dock_expanded, icon_groups, .. } = workspace; + migrate(docking, floating, docking_hidden, *dock_expanded, icon_groups.as_deref()); + } + Ok(ReadPreferences { ui, intact }) } } -fn recover_docking(saved: &mut Value) { - let Some(fields) = saved.as_object_mut() else { return }; +/// Drop docking data this version cannot read. True when nothing was dropped. +fn recover_docking(saved: &mut Value) -> bool { + let Some(fields) = saved.as_object_mut() else { return true }; + let mut intact = true; if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { fields.remove("docking"); + intact = false; } if let Some(hidden) = fields.get_mut("dockingHidden") { if let Some(entries) = hidden.as_object_mut() { + let before = entries.len(); entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); + intact &= entries.len() == before; } else { fields.remove("dockingHidden"); + intact = false; } } + intact +} + +/// Convert a layout saved before the canvas joined the panel layout. One that cannot be +/// converted gives way to the workspace's default layout, which the workspace fields +/// (`dockTab`, `openPanel`, `floating`, `dockExpanded`, `iconGroups`) still describe. +fn migrate( + docking: &mut Option>, + floating: &mut Vec<(String, [f32; 2])>, + hidden: &BTreeMap>, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) { + let Some(old) = docking.as_ref().filter(|layout| panel_docking::dock_only(layout)) else { return }; + *docking = panel_docking::from_dock_only(old, hidden.keys(), dock_expanded, icon_groups); + if docking.is_some() { + // The converted layout holds the floating dock panels. + floating.retain(|(id, _)| crate::dock::float_only(id)); + } } #[cfg(test)] mod tests { use super::*; - use crate::{DesignApp, SavedWorkspace}; + use crate::DesignApp; + use craft_ui::docking::{Column, Node, Side}; use serde_json::json; fn populated() -> UiState { @@ -108,6 +157,144 @@ mod tests { assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); } + #[test] + fn unreadable_docking_is_reported_so_the_file_is_kept() { + let mut saved = serde_json::to_value(populated()).unwrap(); + assert!(UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + saved["customWorkspaces"][1]["docking"] = json!({"root":{"FutureLayout":{}}}); + assert!(!UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + } + + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. + fn dock(layout: &Layout) -> &[Column] { + assert!(crate::panel_docking::valid(layout), "{layout:?}"); + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("expected the canvas split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &["canvas"])); + let Node::Dock { side: Side::End, columns } = &**second else { panic!("expected an end dock: {second:?}") }; + columns + } + + fn panels(column: &Column) -> Vec> { + column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect() + } + + /// Hand-written pre-trial `ui.json` files: each loads through the app's path with nothing lost. + fn load(saved: serde_json::Value) -> UiState { + UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).map(|read| read.ui).unwrap() + } + + #[test] + fn classic_dock_file_loads_as_its_workspace_default_layout() { + let ui = load(json!({ + "workspace": "Printing and Proofing", "dockTab": "layers", "dockExpanded": true, "openPanel": "swatches", + "floating": [["glyphs", [100.0, 120.0]]], + })); + assert!(ui.docking.is_none()); + let layout = crate::panel_docking::default_layout(&ui); + let columns = dock(&layout); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 2, "Layers stays in front"); + assert!(!columns[0].iconic && columns[1].iconic); + assert!(columns[1].groups.iter().all(|g| !g.panels.iter().any(|p| p == "glyphs" || p == "swatches"))); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["glyphs"]); + assert_eq!(&layout.floating[0].rect[..2], &[100.0, 120.0]); + } + + #[test] + fn dock_only_layout_becomes_the_main_column() { + let ui = load(json!({ + "dockExpanded": true, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers", "swatches"], "active": 3}}, "floating": []}, + "customWorkspaces": [{ + "name": "Split", "dockExpanded": true, + "docking": {"root": {"Split": {"axis": "Horizontal", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["links"], "active": 0}}, + "second": {"Split": {"axis": "Vertical", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["properties", "pages"], "active": 1}}, + "second": {"Stack": {"entries": [ + {"panel": "layers", "open": true, "height": null}, + {"panel": "swatches", "open": false, "height": 120.0}]}}}}}}, + "floating": []}, + }], + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers", "swatches"]]); + assert_eq!(columns[0].groups[0].active, 3); + assert!(!columns[0].iconic); + let icons = &columns[1]; + assert!(icons.iconic && icons.groups.iter().any(|g| g.panels == ["stroke"])); + assert!(icons.groups.iter().all(|g| g.panels != ["swatches"]), "each panel appears once"); + + // Splits: a vertical split stacks groups in one column, a horizontal one adds a column + // (the dock lists columns from the window edge inwards). + let columns = dock(ui.custom_workspaces[0].docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages"], vec!["layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 1); + assert!(columns[0].groups[2].minimized && columns[0].groups[2].height == Some(120.0), "a closed accordion entry stays minimized"); + assert_eq!(panels(&columns[1]), [vec!["links"]]); + assert!(columns[2].iconic); + } + + #[test] + fn icon_groups_become_the_iconic_column_in_order() { + let ui = load(json!({ + "dockExpanded": true, + "iconGroups": [["pages", "layers"], ["properties", "swatches", "color"]], + "docking": {"root": {"Tabs": {"panels": ["properties"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(columns.len(), 2); + assert_eq!(panels(&columns[0]), [vec!["properties"]]); + assert!(columns[1].iconic); + assert_eq!(panels(&columns[1]), [vec!["pages", "layers"], vec!["swatches", "color"]]); + } + + #[test] + fn collapsed_dock_makes_the_main_column_iconic() { + let ui = load(json!({ + "dockExpanded": false, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"]]); + assert!(columns.iter().all(|c| c.iconic)); + } + + #[test] + fn floating_windows_and_hidden_places_survive_the_migration() { + let saved = json!({ + "dockExpanded": true, + "floating": [["glyphs", [40.0, 60.0]], ["character", [40.0, 60.0]], ["tabs", [10.0, 20.0]]], + "docking": { + "root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, + "floating": [{"panels": ["glyphs", "character"], "active": 1, "rect": [40.0, 60.0, 300.0, 400.0]}], + }, + "dockingHidden": {"color": {"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, + "size": {"Ratio": 0.5}, "stack": null}}, + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(read.intact, "converting is not a loss"); + let ui = read.ui; + let layout = ui.docking.clone().unwrap(); + let columns = dock(&layout); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["glyphs", "character"]); + assert_eq!(layout.floating[0].groups[0].active, 1); + assert_eq!(layout.floating[0].rect, [40.0, 60.0, 300.0, 400.0]); + assert_eq!(ui.floating, [("tabs".to_string(), [10.0, 20.0])], "the layout holds the floating dock panels"); + assert!(ui.docking_hidden.contains_key("color") && !layout.contains(&"color".to_string()), "a closed panel stays closed"); + assert!(columns[1].groups.iter().all(|g| g.panels != ["glyphs"])); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + // Saved again, the file is in the current shape and loads unchanged. + let again = UiState::from_preferences_json(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(again.docking, app.ui.docking); + } + #[test] fn unrelated_malformed_preferences_are_still_rejected() { assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 78ef44fa3..446460981 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -87,7 +87,12 @@ workspace opens (Printing and Proofing: Swatches) is a group below Properties · The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped over it floats there. Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the positions of hidden panels. Resetting a built-in workspace restores its -default dock. +default dock. Preferences saved before the canvas joined the layout load with nothing lost: +the classic dock becomes the workspace's default layout, and a customized dock-only layout +becomes the dock's expanded columns (iconic if the dock was collapsed), with the remaining +panels (or the workspace's named groups) in the iconic column and its floating windows and +hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to +`ui.unreadable.json` before it is saved again. - `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders @@ -95,12 +100,17 @@ default dock. `top` and `bottom` add a group above or below its group and `left` and `right` add a column on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` are errors. -- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. -- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous - dock placement when possible, or to the supplied destination. +- `window.panel.float {panel, x?, y?, width?, height?}` (and `window.floatPanel {panel, x?, y?}`) + detaches a panel. +- `window.panel.dock {panel, anchor?, zone?, before?}` (and `window.dockPanel {panel}`) + returns a panel to its previous dock placement when possible, or to the supplied + destination. For the float-only Tabs panel (`tabs`), which has its own window outside the + layout, float opens that window and dock closes it. - `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, restoring its saved position when possible; a panel in an iconic column expands that column. `window.panel.close {panel}` hides it. +- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it (craft-ui's + `CloseGroup`); the result lists them in `closed`. - `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4a7e9b99f..8231f8c17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable, resizable floating window; Dock Panel puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. From b66c3223f1fc0555c09894c3b21f0f885cb57e44 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:57:34 +0000 Subject: [PATCH 04/44] Docking trial: craft-ui f4f9e5b, DockCommand, hit rects, iconic column --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 70 ++- crates/ui-egui/src/menus.rs | 38 +- crates/ui-egui/src/panel_docking.rs | 660 ++++++++++------------ crates/ui-egui/src/panel_docking_tests.rs | 152 ++++- crates/ui-egui/src/panel_icons.rs | 168 ++++++ crates/ui-egui/src/prefs.rs | 43 +- docs/control-protocol.md | 77 ++- 11 files changed, 809 insertions(+), 416 deletions(-) create mode 100644 crates/ui-egui/src/panel_icons.rs diff --git a/Cargo.lock b/Cargo.lock index 1e77d5344..5db9c5acd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" +source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9ecd08b25..63ea8c527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index eea906ee6..6041f8790 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1249,7 +1249,7 @@ mod tests { h.run_steps(3); // Preflight is in the iconic column: `window.panel` shows it. assert!(crate::panel_docking::showing(&h.state().app, "preflight")); - h.get_by_label("Preflight"); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); // With the panel already showing, the entry keeps it open. h.get_by_label("No errors ▾").click(); h.run_steps(3); @@ -1270,9 +1270,12 @@ mod tests { count.click(); h.run_steps(3); assert!(crate::panel_docking::showing(&h.state().app, "preflight")); - h.get_by_label("Preflight"); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); - // A double-click with the panel already showing keeps it open. + // A double-click with the panel already showing keeps it open (the first click closes + // its flyout, the double-click opens it again). Past the double-click time, so the + // clicks are not counted with the ones before. + h.run_steps(30); let count = h.get_by_label("No errors ▾"); count.click(); count.click(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ea81c278e..1ff3218de 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -78,7 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), - "docking": crate::panel_docking::inspect(app), + "docking": crate::panel_docking::inspect(app, ctx), "window": [r.width(), r.height()], "documents": app .session @@ -171,7 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", json!({}))), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app)), + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 370388317..de859f4ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ mod modal_drag; mod new_document; mod object_control; mod panel_docking; +mod panel_icons; pub mod panels; mod prefs; pub mod render_worker; @@ -88,6 +89,59 @@ pub struct Services { pub clipboard_text: Option, } +/// Closed panels, each with where it goes back, in craft-ui's `Workspace::hidden` shape +/// (`[[panel, location], …]`). A floating panel's entry is where Dock Panel returns it. +/// Files from before this shape hold an object keyed by panel; it still loads. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(transparent)] +pub struct HiddenPanels(pub Vec<(String, craft_ui::docking::Location)>); + +impl<'de> Deserialize<'de> for HiddenPanels { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Shape { + List(Vec<(String, craft_ui::docking::Location)>), + Keyed(std::collections::BTreeMap>), + } + let entries = match Shape::deserialize(deserializer)? { + Shape::List(entries) => entries, + Shape::Keyed(entries) => entries.into_iter().collect(), + }; + let mut hidden = Self::default(); + for (panel, location) in entries { + hidden.insert(panel, location); + } + Ok(hidden) + } +} + +impl HiddenPanels { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn contains_key(&self, panel: &str) -> bool { + self.0.iter().any(|(p, _)| p == panel) + } + pub fn get(&self, panel: &str) -> Option<&craft_ui::docking::Location> { + self.0.iter().find(|(p, _)| p == panel).map(|(_, location)| location) + } + /// Record where `panel` goes back, replacing an earlier entry. + pub fn insert(&mut self, panel: String, location: craft_ui::docking::Location) { + self.remove(&panel); + self.0.push((panel, location)); + } + pub fn remove(&mut self, panel: &str) { + self.0.retain(|(p, _)| p != panel); + } + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(p, _)| p) + } + pub fn clear(&mut self) { + self.0.clear(); + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ScreenMode { @@ -115,8 +169,8 @@ pub struct SavedWorkspace { /// Custom panel membership and placement; absent in older workspace files. #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, /// The workspace's own iconic column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, @@ -267,8 +321,8 @@ pub struct UiState { /// (`panel_docking::default_layout`). #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, /// The panel (or the canvas) last clicked or selected in the docking layout. #[serde(skip)] pub docking_focused: Option, @@ -281,6 +335,12 @@ pub struct UiState { /// The panel last revealed explicitly, which `ui.set closePanel` closes. #[serde(skip)] pub docking_opened: Option, + /// The panel whose iconic-column flyout is open, as of the last frame or a command that + /// opened it. + #[serde(skip)] + pub docking_flyout: Option, + /// Auto-Collapse Iconic Panels: a flyout closes on Escape or a click outside it. + pub dock_auto_collapse: bool, /// Whether the default layout's outer column is expanded (false: collapsed to icons). pub dock_expanded: bool, /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the @@ -413,6 +473,8 @@ impl Default for UiState { docking_generation: 0, docking_raise: None, docking_opened: None, + docking_flyout: None, + dock_auto_collapse: true, docking_focused: None, dock_expanded: true, icon_groups: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e4ceda19..abed8cea7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,7 +236,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), - ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own floating window (window.panel.float)"), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?, width?, height?} — the panel in its own floating window (window.panel.float)"), ( "window.panel.layout", "Change Panel Layout", @@ -247,13 +247,38 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.panel.move", "Move Panel", None, - "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel|null, group?, placement?} — group, split or reorder a panel (group: its whole tab group)", + ), + ( + "window.panel.float", + "Float Panel", + None, + "{panel, x?, y?, width?, height?, group?, column?} — detach a panel (or its group, or its dock column) inside the application window", + ), + ( + "window.panel.dock", + "Dock Panel", + None, + "{panel, anchor?, zone?, before?, group?, column?, side?: Start|End} — dock a floating panel: beside anchor, in a new column at side, or back where it floated from", ), - ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), - ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), + ("window.panel.closeOthers", "Close Other Panels in Group", None, "{panel} — hide the panels tabbed with it"), + ("window.panel.minimize", "Minimize", None, "{panel, minimized?} — minimize the panel's group to its tab strip (toggles without minimized)"), + ("window.panel.iconic", "Collapse to Icons", None, "{panel, iconic?} — collapse the panel's dock column to icons (toggles without iconic)"), + ("window.panel.window", "Move Panel to New Window", None, "{panel, native?} — not available: DesignCraft keeps panels in its window"), + ("window.workspace.new", "New Workspace", None, "{name} — save the current workspace (window.newWorkspace)"), + ("window.workspace.delete", "Delete Workspace", None, "{name} — delete a saved workspace (window.deleteWorkspace)"), + ("window.workspace.reset", "Reset Workspace", None, "{} — back to the current workspace as saved (window.resetWorkspace)"), + ("window.workspace.lock", "Lock Workspace", None, "{locked?} — not available in DesignCraft"), + ( + "ui.dock", + "Change Panel Layout", + None, + "{action} | {operation: open|close|activate|float|moveFloating|move|setStackOpen|resizeStack|resizeSplit, …} — craft-ui's shared ui.dock shape", + ), + ("ui.dock.reset", "Reset Workspace", None, "{} — window.workspace.reset"), ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), ("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), @@ -1848,6 +1873,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option bool { if is_window_control(id) { return window_control_available(app, id); } + // Shared docking commands DesignCraft does not provide (native panel windows, Lock Workspace). + if matches!(id, "window.panel.window" | "window.workspace.lock") { + return false; + } match designcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), None => true, diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index c0e0cfd5f..44b327196 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -4,7 +4,9 @@ //! protected `canvas` panel and a dock at the logical end edge (the right in left-to-right //! languages, the left in right-to-left ones). -use craft_ui::docking::{Action, Column, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, Zone}; +use craft_ui::docking::{ + Action, Column, DockCommand, DockOperation, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, +}; use serde_json::{Value, json}; use crate::DesignApp; @@ -222,9 +224,12 @@ fn layout_of(app: &DesignApp) -> Layout { app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) } -/// Is `panel` showing: in front of its group, and not in a column collapsed to icons? +/// Is `panel` showing: in front of its group and not in a column collapsed to icons, or in +/// the open flyout of an iconic column? pub(crate) fn showing(app: &DesignApp, panel: &str) -> bool { - layout_of(app).panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front + let layout = layout_of(app); + layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front + || (app.ui.docking_flyout.as_deref() == Some(panel) && in_iconic_column(&layout, panel)) } /// Is `panel` in a dock column collapsed to icons? @@ -323,12 +328,7 @@ fn first_dock_panel(layout: &Layout, except: &str) -> Option { }) } -fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { - let anchor = first_dock_panel(layout, panel); - apply(layout, Action::Open { panel: panel.into(), anchor }) -} - -fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { +fn apply(layout: &mut Layout, action: Action) -> Result, String> { layout.apply_with_permissions(action, model_permissions).map_err(|e| e.to_string()) } @@ -343,122 +343,253 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } -/// The side of the dock holding `panel`, if a dock column holds it. -fn dock_side(layout: &Layout, panel: &str) -> Option { - let mut visit: Vec<&Node> = layout.root.iter().collect(); - let mut seen = 0usize; - while let Some(node) = visit.pop() { - seen = seen.saturating_add(1); - if seen > 2 * craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Dock { side, columns } if columns.iter().flat_map(|c| &c.groups).any(|g| g.panels.iter().any(|p| p == panel)) => { - return Some(*side); - } - _ => {} - } +/// The shared docking command a DesignCraft command id names. `window.floatPanel` and +/// `window.dockPanel` are DesignCraft's names for Float Panel and Dock Panel. `window.workspace` +/// stays DesignCraft's own command (same id and params), with the other workspace commands. +fn shared_id(id: &str) -> Option<&'static str> { + match id { + "window.floatPanel" => Some("window.panel.float"), + "window.dockPanel" => Some("window.panel.dock"), + "window.workspace" => None, + _ => DockCommand::::IDS.iter().copied().find(|known| *known == id), } - None -} - -/// The placement a `zone` names beside `anchor`. In a dock column or a floating window, top and -/// bottom add a group above or below the anchor's group; in a dock column, left and right add a -/// column on that side of the anchor's. Elsewhere the sides split the anchor's group. -fn placement(params: &Value, layout: &Layout, anchor: &str, rtl: bool) -> Result, String> { - if let Some(before) = params.get("before") { - let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; - return Ok(Placement::Tab { before: Some(id.into()) }); - } - let zone = match params.get("zone") { - None => "tab", - Some(value) => value.as_str().ok_or("zone must be a string")?, - }; - let side = dock_side(layout, anchor); - let floating = layout.floating.iter().any(|w| w.contains(anchor)); - Ok(match zone { - "tab" => Placement::Tab { before: None }, - "center" => Placement::Split(Zone::Center), - "top" | "bottom" if side.is_some() || floating => Placement::Group { below: zone == "bottom" }, - "left" | "right" if floating => return Err("a floating window has no columns; use top, bottom or tab".into()), - "left" | "right" if let Some(side) = side => { - // Columns count from the window edge inwards; `after` is away from the edge. - let edge_is_right = (side == Side::End) != rtl; - Placement::Column { after: (zone == "right") != edge_is_right } - } - "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 tab, center, left, right, top or bottom".into()), - }) } pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option> { if id == "app.tablePanel" { - return Some(change(app, if showing(app, "table") { "close" } else { "activate" }, &json!({"panel": "table"}))); + let id = if showing(app, "table") { "window.panel.close" } else { "window.panel.activate" }; + return Some(shared_command(app, id, &json!({"panel": "table"}))); } if id == "window.collapseToIcons" { return Some(set_iconic(app, true)); } - if id == "window.panel.closeGroup" { - return Some( - params - .get("panel") - .and_then(Value::as_str) - .and_then(normalize) - .ok_or_else(|| "panel must name a registered panel".to_string()) - .and_then(|panel| apply_action(app, Action::CloseGroup { panel: panel.into() })), - ); - } if matches!(id, "window.floatPanel" | "window.dockPanel" | "window.panel.float" | "window.panel.dock") && let Some(panel) = params.get("panel").and_then(Value::as_str).filter(|p| crate::dock::float_only(p)) { return Some(float_only_panel(app, id, panel, params)); } - if id == "window.panel" - && let Some(result) = open_floating_group(app, params) - { - return Some(result); - } - if id == "window.panel.layout" { - return Some( - params - .get("action") - .ok_or_else(|| "action is required".to_string()) - .and_then(|value| serde_json::from_value::>(value.clone()).map_err(|error| error.to_string())) - .and_then(|action| apply_action(app, action)), - ); - } - let operation = match id { - "window.panel.move" => "move", - "window.panel.float" | "window.floatPanel" => "float", - "window.panel.dock" | "window.dockPanel" => "dock", - "window.panel.activate" => "activate", - "window.panel.close" => "close", + if id == "window.panel" { + if let Some(result) = open_floating_group(app, params) { + return Some(result); + } // Float-only panels (Type › Tabs) are not in the layout. - "window.panel" if params.get("panel").and_then(Value::as_str).and_then(normalize).is_some() => "activate", - _ => return None, - }; - Some(change(app, operation, params)) + params.get("panel").and_then(Value::as_str).and_then(normalize)?; + return Some(shared_command(app, "window.panel.activate", params)); + } + let id = shared_id(id)?; + Some(shared_command(app, id, params)) } -// UI output and automation share this atomic dispatcher. The application owns the registry -// and saved return locations; craft-ui owns tree validation and structural changes. -fn apply_action(app: &mut DesignApp, action: Action) -> Result { +/// Command params with DesignCraft's panel names resolved (a panel id or English name, in any +/// case) and `onto` read as `anchor`. +fn resolve_panels(params: &Value) -> Result { + let mut params = if params.is_null() { json!({}) } else { params.clone() }; + let fields = params.as_object_mut().ok_or("params must be an object")?; + if let Some(onto) = fields.remove("onto") { + fields.entry("anchor").or_insert(onto); + } + for key in ["panel", "anchor", "target", "before"] { + let Some(value) = fields.get_mut(key) else { continue }; + if key == "before" && value.is_null() { + continue; + } + let id = value.as_str().and_then(normalize).ok_or_else(|| format!("{key} must name a registered panel"))?; + *value = Value::String(id.to_owned()); + } + Ok(params) +} + +/// The panel `params` names, resolved, if it names one. +fn named_panel(params: &Value) -> Option { + params.get("panel").and_then(Value::as_str).map(str::to_owned) +} + +/// Is `panel` alone in a floating window? +fn floats_alone(layout: &Layout, panel: &str) -> bool { + layout.floating.iter().any(|w| w.contains(&panel.to_string()) && w.panels().count() == 1) +} + +/// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in +/// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; +/// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking +/// it, floats a panel at its own size, and returns a floated panel to its dock place on Dock +/// Panel. +fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { + let mut params = resolve_panels(params)?; + if id == "window.panel.float" + && let Some(panel) = named_panel(¶ms) + && !floats_alone(&layout_of(app), &panel) + && let Some(fields) = params.as_object_mut() + { + let size = floating_size(&panel); + for (key, default) in [("x", 400.0), ("y", 140.0), ("width", size.x), ("height", size.y)] { + fields.entry(key).or_insert(json!(default)); + } + } + if let Some(fields) = params.as_object_mut() { + fields.insert("command".into(), Value::String(id.to_owned())); + } + let command: DockCommand = serde_json::from_value(params).map_err(|e| e.to_string())?; + match &command { + DockCommand::Workspace { name } => return app.run("window.workspace", json!({"name": name})), + DockCommand::NewWorkspace { name } => return app.run("window.newWorkspace", json!({"name": name})), + DockCommand::DeleteWorkspace { name } => return app.run("window.deleteWorkspace", json!({"name": name})), + DockCommand::ResetWorkspace {} => return app.run("window.resetWorkspace", json!({})), + DockCommand::Lock { .. } => return Err("Lock Workspace is not available in DesignCraft".into()), + DockCommand::Window { .. } => return Err("DesignCraft does not move panels to their own windows".into()), + _ => {} + } + let rtl = crate::i18n::is_rtl(&app.ui.language); + let mut txn = Transaction::begin(app)?; + let shows = match &command { + DockCommand::Activate { panel } + | DockCommand::Move { panel, .. } + | DockCommand::Float { panel, .. } + | DockCommand::Dock { panel, .. } + | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel.clone()), + _ => None, + }; + let mut shown = false; + if let Some(panel) = shows.as_ref().filter(|p| !txn.workspace.layout.contains(p)) { + let fallback = first_dock_panel(&txn.workspace.layout, panel); + txn.workspace.set_visible(panel.clone(), true, fallback).map_err(|e| e.to_string())?; + shown = true; + } + let returned = match &command { + DockCommand::Dock { panel, anchor: None, side: None, group: false, column: false, .. } if !shown => txn.dock_back(panel)?, + _ => false, + }; + let actions = match &command { + // Showing it was the whole command (craft-ui's `DockCommand::apply` does the same). + DockCommand::Dock { anchor: None, side: None, .. } if shown || returned => Vec::new(), + _ => command.to_actions(&txn.workspace, rtl).map_err(|e| e.to_string())?, + }; + let reveal = match &command { + DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }, + DockCommand::UiDock(UiDock::Operation( + DockOperation::Float { panel, .. } | DockOperation::MoveFloating { panel, .. } | DockOperation::Move { panel, .. }, + )) => Some(panel.clone()), + _ => shows, + }; + let mut closed = Vec::new(); + for action in actions { + closed.extend(txn.apply(action)?); + } + Ok(txn.commit(app, closed, reveal)) +} + +/// A change to the layout in use, committed only when every step succeeded. +struct Transaction { + /// The layout, and the closed panels with where each one goes back. + workspace: Workspace, + /// Where each floating panel was docked before it floated, for Dock Panel. + returns: crate::HiddenPanels, +} + +impl Transaction { + fn begin(app: &DesignApp) -> Result { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let layout = layout_of(app); + let mut workspace = Workspace::new(layout); + let mut returns = crate::HiddenPanels::default(); + for (panel, location) in + app.ui.docking_hidden.0.iter().filter(|(id, _)| normalize(id) == Some(id.as_str())).take(craft_ui::docking::MAX_PANELS) + { + if !workspace.layout.contains(panel) { + workspace.hidden.push((panel.clone(), location.clone())); + } else if workspace.layout.floating.iter().any(|w| w.contains(panel)) { + returns.insert(panel.clone(), location.clone()); + } + } + Ok(Self { workspace, returns }) + } + + /// Dock Panel for a floating panel that floated from the dock: back to that place, while + /// the panel it was beside is still docked. True when it went back. + fn dock_back(&mut self, panel: &String) -> Result { + let layout = &self.workspace.layout; + if !layout.floating.iter().any(|w| w.contains(panel)) { + return Ok(false); + } + let docked = Layout { root: layout.root.clone(), floating: Vec::new() }; + let Some(location) = self + .returns + .get(panel) + .filter(|location| location.floating.is_none() && location.anchor.as_ref().is_none_or(|anchor| docked.contains(anchor))) + else { + return Ok(false); + }; + let mut candidate = layout.clone(); + apply(&mut candidate, Action::Close { panel: panel.clone() })?; + if candidate.restore(panel.clone(), location).is_err() { + return Ok(false); + } + self.workspace.layout = candidate; + self.returns.remove(panel); + Ok(true) + } + + /// One action from a command or the renderer, for registered panels only. Floating a + /// docked panel remembers its place for Dock Panel. + fn apply(&mut self, action: Action) -> Result, String> { + if !known(&action) { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if let Action::Float { panel, .. } = &action + && let Ok(location) = self.workspace.layout.location(panel) + && location.floating.is_none() + { + self.returns.insert(panel.clone(), location); + } + self.workspace.apply(action, model_permissions).map_err(|e| e.to_string()) + } + + /// Store the result. `reveal` is the panel the command showed, raised (or its flyout opened) + /// on the next frame. + fn commit(self, app: &mut DesignApp, closed: Vec, reveal: Option) -> Value { + let Self { workspace, returns } = self; + let layout = workspace.layout; + let floating = |p: &String| layout.floating.iter().any(|w| w.contains(p)); + let mut hidden = crate::HiddenPanels(workspace.hidden); + for (panel, location) in returns.0 { + if floating(&panel) && !hidden.contains_key(&panel) { + hidden.0.push((panel, location)); + } + } + let reveal = reveal.filter(|panel| layout.contains(panel)); + let flyout = reveal.as_ref().filter(|panel| in_iconic_column(&layout, panel)).cloned(); + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + release_closed(app, &closed); + keep_float_only(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.docking_flyout = flyout; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + app.ui.open_panel = None; + json!({"layout": app.ui.docking, "closed": closed, "floating": floating_panels(app)}) + } +} + +/// Whether every panel an action names is registered (and a split path is not excessive). +/// The application owns the registry; craft-ui owns tree validation and structural changes. +fn known(action: &Action) -> bool { let registered = |id: &String| self::registered(id); - let known = match &action { + let tab = |placement: &Placement| match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + }; + match action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { - registered(panel) - && registered(anchor) - && match placement { - Placement::Tab { before: Some(id) } => registered(id), - _ => true, - } + registered(panel) && registered(anchor) && tab(placement) } Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), Action::Float { panel, .. } @@ -471,13 +602,7 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result { registered(panel) && match to { - Target::At { anchor, placement } => { - registered(anchor) - && match placement { - Placement::Tab { before: Some(id) } => registered(id), - _ => true, - } - } + Target::At { anchor, placement } => registered(anchor) && tab(placement), _ => true, } } @@ -491,42 +616,7 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result registered(panel), // Native OS windows are not enabled in DesignCraft; newer actions are rejected. _ => false, - }; - if !known { - return Err("docking action contains an unknown panel or an excessive split path".into()); } - if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { - return Err("saved panel layout is invalid; reset the workspace first".into()); - } - let mut layout = layout_of(app); - let closed = layout.closed_by(&action).map_err(|e| e.to_string())?; - // Window › a closed panel, and Dock Panel after a float, bring it back where it was. - let returning: Vec<_> = match &action { - Action::Float { panel, .. } => { - layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() - } - _ => closed.iter().filter_map(|panel| layout.location(panel).ok().map(|location| (panel.clone(), location))).collect(), - }; - let reveal = match &action { - Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), - _ => None, - }; - apply(&mut layout, action)?; - for (panel, location) in returning { - app.ui.docking_hidden.insert(panel, location); - } - app.ui.docking = Some(layout); - release_closed(app, &closed); - keep_float_only(app); - if let Some(panel) = reveal { - app.ui.docking_opened = Some(panel.clone()); - app.ui.docking_raise = Some(panel); - app.ui.dock_expanded = true; - } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { - app.ui.docking_opened = None; - } - app.ui.open_panel = None; - Ok(json!({"layout": app.ui.docking, "closed": closed})) } /// Type › Tabs and the other float-only panels: their own windows outside the docking layout, @@ -559,65 +649,13 @@ pub(crate) fn floating_panels(app: &DesignApp) -> Vec { docked.chain(app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).map(|(id, _)| id.clone())).collect() } -/// The panel in front of each tab group in the dock (not the floating groups). -fn front_tabs(layout: &Layout) -> Vec { - let mut out = Vec::new(); - let mut visit: Vec<&Node> = layout.root.iter().collect(); - while let Some(node) = visit.pop() { - if out.len() > craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), - Node::Dock { columns, .. } => out.extend(columns.iter().flat_map(|c| &c.groups).filter_map(|g| g.active_panel().cloned())), - _ => {} - } - } - out -} - -/// Bring each of `front` back in front of the dock tab group that holds it. -fn restore_front_tabs(layout: &mut Layout, front: &[String]) { - let mut visit: Vec<&mut Node> = layout.root.iter_mut().collect(); - let mut seen = 0usize; - while let Some(node) = visit.pop() { - seen = seen.saturating_add(1); - if seen > 2 * craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|p| front.contains(p)) { - *active = i; - } - } - Node::Dock { columns, .. } => { - for group in columns.iter_mut().flat_map(|c| &mut c.groups) { - if let Some(i) = group.panels.iter().position(|p| front.contains(p)) { - group.active = i; - } - } - } - _ => {} - } - } -} - /// Panels InDesign opens together as one floating group from the Window menu, in tab order: /// Window › Styles › Paragraph Styles (or Character Styles) brings both. const FLOATING_GROUPS: &[&[&str]] = &[&["characterStyles", "paragraphStyles"]]; /// Window › a panel of a [`FLOATING_GROUPS`] group none of whose panels is showing: the group -/// opens floating over the right of the canvas, with that panel in front. None otherwise (the -/// panel opens as before). +/// opens as one floating window over the right of the canvas, with that panel in front. None +/// otherwise (the panel opens as before). fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option> { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; let group = FLOATING_GROUPS.iter().find(|g| g.contains(&panel))?; @@ -626,7 +664,7 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Option Result { - let first = group.first().ok_or("empty panel group")?; - change(app, "float", &json!({"panel": first, "x": x, "y": y, "width": w, "height": h}))?; - for p in group.get(1..).unwrap_or_default() { - change(app, "move", &json!({"panel": p, "anchor": first}))?; - } - change(app, "activate", &json!({"panel": panel})) + let mut txn = Transaction::begin(app)?; + let panels: Vec = group.iter().map(|p| (*p).to_string()).collect(); + let active = group.iter().position(|p| *p == panel).unwrap_or(0); + let mut layout = txn.workspace.layout.clone(); + layout.floating.push(Floating::single(panels, active, [x, y, w, h])); + layout.validate().map_err(|e| e.to_string())?; + txn.workspace.layout = layout; + txn.workspace.hidden.retain(|(p, _)| !group.contains(&p.as_str())); + Ok(txn.commit(app, Vec::new(), Some(panel.to_string()))) }; - let result = open(app); - if result.is_ok() - && let Some(layout) = app.ui.docking.as_mut() - { - restore_front_tabs(layout, &front); - } - if result.is_err() { - // All or nothing. - (app.ui.docking, app.ui.docking_hidden, app.ui.floating, app.ui.open_panel) = before; - } - Some(result) -} - -fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { - return Err("saved panel layout is invalid; reset the workspace first".into()); - } - let mut layout = layout_of(app); - let mut hidden: std::collections::BTreeMap<_, _> = - app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); - let mut restored = false; - if operation == "dock" - && params.get("anchor").or_else(|| params.get("onto")).is_none() - && let Some(location) = hidden.get(panel).filter(|location| { - location.floating.is_none() - && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) - }) - { - let mut candidate = layout.clone(); - if candidate.contains(&panel.to_string()) { - apply(&mut candidate, Action::Close { panel: panel.into() })?; - } - if candidate.restore(panel.into(), location).is_ok() { - layout = candidate; - restored = true; - hidden.remove(panel); - } - } - match operation { - "dock" if restored => {} - "float" => { - let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; - ensure(&mut layout, panel)?; - if let Ok(location) = layout.location(&panel.to_string()) - && location.floating.is_none() - { - hidden.insert(panel.into(), location); - } - apply(&mut layout, Action::Float { panel: panel.into(), rect })?; - } - "move" | "dock" => { - if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { - return Err("move requires a destination panel".into()); - } - let anchor = match params.get("anchor").or_else(|| params.get("onto")) { - Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), - None => first_dock_panel(&layout, panel), - }; - if let Some(anchor) = anchor { - let placement = placement(params, &layout, &anchor, crate::i18n::is_rtl(&app.ui.language))?; - ensure(&mut layout, panel)?; - apply(&mut layout, Action::Move { panel: panel.into(), anchor, placement })?; - } else if operation == "dock" { - if layout.contains(&panel.to_string()) { - apply(&mut layout, Action::Close { panel: panel.into() })?; - } - apply(&mut layout, Action::Open { panel: panel.into(), anchor: None })?; - } else { - return Err("move requires an existing destination panel".into()); - } - } - "close" => { - hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); - apply(&mut layout, Action::Close { panel: panel.into() })?; - } - _ => { - if !layout.contains(&panel.to_string()) - && let Some(location) = hidden.get(panel) - { - // A missing neighbor is allowed: the normal visible dock becomes the fallback. - let _ = layout.restore(panel.into(), location); - } - ensure(&mut layout, panel)?; - hidden.remove(panel); - // Iconic columns open no flyouts yet, so showing a panel there expands its column. - if in_iconic_column(&layout, panel) { - apply(&mut layout, Action::SetIconic { panel: panel.into(), iconic: false })?; - } - } - } - app.ui.docking = Some(layout); - app.ui.docking_hidden = hidden; - if operation == "close" { - release_closed(app, &[panel.to_owned()]); - } - keep_float_only(app); - app.ui.open_panel = None; - app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; - app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; - if operation != "close" { - app.ui.dock_expanded = true; - } - Ok(json!({"panel": panel, "floating": floating_panels(app), "layout": app.ui.docking})) + Some(open(app)) } /// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last /// opened (for `ui.set closePanel`) and a pending raise. fn release_closed(app: &mut DesignApp, closed: &[String]) { - for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise] { + for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise, &mut app.ui.docking_flyout] { if slot.as_ref().is_some_and(|panel| closed.contains(panel)) { *slot = None; } @@ -810,13 +745,18 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { blocked.push(rect); } let style = dock_style(ui, &app.ui.language); - let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked); + let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked).auto_collapse_iconic(app.ui.dock_auto_collapse); if let Some(panel) = raise { dock = dock.raise_panel(panel); } - let mut content = PanelContent(app, Vec::new()); - let output = dock.show_content(ui, &layout, &style, &mut content); - let floats = content.1; + let icons = crate::panel_icons::panel_icons(ui.ctx(), style.icon_size); + let output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, icons }); + app.ui.docking_flyout = craft_ui::docking::flyout::(ui.ctx(), area); + for event in &output.events { + if *event == craft_ui::docking::DockEvent::ToggleAutoCollapse { + app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse; + } + } if let Some(panel) = output.focused { if panel != CANVAS { app.ui.docking_opened = Some(panel.clone()); @@ -826,7 +766,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(error) = output.error { app.ui.status = error.to_string(); } - for action in output.actions.into_iter().chain(floats) { + for action in output.actions { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } @@ -834,15 +774,21 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } /// What the control channel reports: the layout in use (craft-ui's serialized `Layout`), -/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, and -/// the panels closed with a remembered place. -pub(crate) fn inspect(app: &DesignApp) -> Value { +/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, the +/// panels closed with a remembered place, the panel whose flyout is open, and where each +/// docking gesture starts in the last frame (`hitRects`). +pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, egui::Id::new("designcraft-panel-docking")); + let hit_rects: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); json!({ "layout": layout_of(app), "customized": app.ui.docking.is_some(), "focused": app.ui.docking_focused, + "flyout": app.ui.docking_flyout, + "autoCollapse": app.ui.dock_auto_collapse, "rightToLeft": crate::i18n::is_rtl(&app.ui.language), "hidden": app.ui.docking_hidden.keys().collect::>(), + "hitRects": hit_rects, }) } @@ -865,7 +811,7 @@ fn dock_style(ui: &egui::Ui, language: &str) -> craft_ui::docking::DockStyle { const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.float", "Float Panel"), ("dock.close", "Close"), - ("dock.move", "Group with"), + ("dock.movePanelTo", "Group with"), ("dock.panels", "Panels"), ("dock.resize", "Resize panels"), ("dock.resizeWindow", "Resize panel window"), @@ -874,10 +820,12 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.minimize", "Minimize"), ("dock.expand", "Expand"), ("dock.floatGroup", "Float Tab Group"), - ("dock.groupWith", "Group Tab Group with"), + ("dock.moveGroupTo", "Group Tab Group with"), ("dock.moveColumn", "Move Panel Column"), ("dock.collapseToIcons", "Collapse to Icons"), ("dock.expandPanels", "Expand Panels"), + ("dock.collapseColumn", "Collapse to Icons"), + ("dock.expandColumn", "Expand Panels"), ("dock.moveWindow", "Move Panel Window"), ("dock.closeWindow", "Close Panel Window"), ("dock.undock", "Undock Panel"), @@ -887,6 +835,7 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.maximize", "Maximize Frame"), ("dock.restoreFrame", "Restore Frame Size"), ("dock.newWindow", "Move Panel to New Window"), + ("dock.autoCollapse", "Auto-Collapse Iconic Panels"), ]; /// The menu behind a panel's three-line button: the panel's own commands, then its docking @@ -952,65 +901,33 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } -/// The panels' content, and the floats released over the canvas this frame. -struct PanelContent<'a>(&'a mut DesignApp, Vec>); +/// The panels' content and icons. +struct PanelContent<'a> { + app: &'a mut DesignApp, + icons: std::collections::HashMap>, +} impl craft_ui::docking::DockContent for PanelContent<'_> { - /// A panel or group released over the document area floats there, as in InDesign, instead of - /// splitting the canvas. The window's edge bands still make new docks and root splits. - fn resolve_drag( - &mut self, - _: &egui::Context, - style: &craft_ui::docking::DockStyle, - request: &craft_ui::docking::DropRequest<'_, String>, - ) -> Option { - let pointer = request.pointer; - let over_canvas = request.targets.iter().rev().find(|t| t.rect.contains(pointer)).is_some_and(|t| t.anchor == CANVAS); - let band = style.edge_band.max(style.new_dock_band); - if !over_canvas || !request.workspace.shrink(band).contains(pointer) { - return None; - } - // A floating window moving live has no float rect; it moves as before. - if request.whole_group && request.floating_rect.is_none() { - return None; - } - if request.released - && let Some(r) = request.floating_rect - { - let rect = [r.left(), r.top(), r.width(), r.height()]; - let panel = request.panel.clone(); - self.1.push(if request.whole_group { - Action::MoveGroup { panel, to: Target::Float { rect: Some(rect) } } - } else { - Action::Float { panel, rect } - }); - } - Some(craft_ui::docking::DropFeedback { preview: request.floating_rect, insertion: None }) - } fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { let fill = crate::theme::Tokens::get(ui.ctx()).pasteboard; ui.painter().rect_filled(ui.max_rect(), 0.0, fill); - // A bare panel has no tab to report focus through, so the canvas reports its own. - if ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer() { - self.0.ui.docking_focused = Some(CANVAS.to_string()); - } - self.0.canvas_area(ui, false); + self.app.canvas_area(ui, false); return; } egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { if crate::dock::scrolls_itself(id) { - crate::dock::panel_body(self.0, ui, id); + crate::dock::panel_body(self.app, ui, id); return; } egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) - .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + .show(ui, |ui| crate::dock::panel_body(self.app, ui, id)); }); } fn logical_label(&self, id: &String) -> String { let english = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map_or(id.as_str(), |p| p.1); - crate::i18n::tr(&self.0.ui.language, english).to_owned() + crate::i18n::tr(&self.app.ui.language, english).to_owned() } fn permissions(&self, id: &String) -> Permissions { model_permissions(id) @@ -1022,6 +939,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } + fn icon(&self, id: &String) -> Option> { + self.icons.get(id).cloned() + } fn bare(&self, id: &String) -> bool { id == CANVAS } @@ -1030,7 +950,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn group_header(&mut self, ui: &mut egui::Ui, _: &[String], active: &String, _: bool) { if let Some(panel) = normalize(active) { - three_line_button(self.0, ui, panel); + three_line_button(self.app, ui, panel); } } fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { @@ -1074,7 +994,7 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { if let Some(panel) = app.ui.docking_opened.clone() { - change(app, "close", &json!({"panel": panel}))?; + shared_command(app, "window.panel.close", &json!({"panel": panel}))?; } app.ui.open_panel = None; Ok(()) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index b44265e7d..f182bf644 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -311,7 +311,10 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { h.run_steps(1); } h.run_steps(2); - assert!(!in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); + // Stroke stays in the iconic column and opens as its flyout. + assert!(in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("stroke")); + assert!(showing(h.state(), "stroke")); assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); } @@ -399,7 +402,7 @@ fn table_and_control_visibility_work_after_customization_without_document_edits( assert!(!app.ui.dock_expanded); assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("properties"), "it opens as a flyout from the collapsed dock"); assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); assert_eq!(app.session.active().unwrap().history.undo.len(), undo); @@ -591,9 +594,12 @@ fn collapse_to_icons_shrinks_the_dock_to_its_icons() { assert!(app.ui.dock_expanded); app.run("window.collapseToIcons", json!({})).unwrap(); assert!(!app.ui.dock_expanded); - // A panel chosen from Window opens the dock again. + // A panel chosen from Window opens as a flyout beside its icon; the dock stays collapsed. app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); + assert_eq!(app.ui.docking_raise.as_deref(), Some("pages")); + assert!(showing(&app, "pages")); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "pages")); } #[test] @@ -627,7 +633,8 @@ fn paragraph_styles_open_floating_with_character_styles() { assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. - assert!(front_tabs(&layout).iter().any(|p| p == "properties"), "{:?}", layout.root); + assert_eq!(layout.panel_state(&"properties".to_string()), craft_ui::docking::PanelState::Front, "{:?}", layout.root); + assert_eq!(layout.floating.len(), 1, "one floating window holds both tabs"); // Other panels from Window leave it open. app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); @@ -830,3 +837,138 @@ fn ui_docking_reports_the_layout_and_the_focused_panel() { assert_eq!(v["result"]["customized"], true); assert_eq!(v["result"]["hidden"], json!(["layers"])); } + +fn hits(h: &Harness<'static, DesignApp>) -> Vec<(craft_ui::docking::Hit, egui::Rect)> { + craft_ui::docking::DockArea::::hit_rects(&h.ctx, egui::Id::new("designcraft-panel-docking")) +} + +/// `window.panel.*` ids and DesignCraft's aliases go through craft-ui's `DockCommand`. +#[test] +fn shared_panel_commands_float_dock_and_reject_atomically() { + let mut app = app(); + let docked = default_layout(&app.ui); + app.run("window.floatPanel", json!({"panel": "Layers", "x": 60, "y": 90})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter().find(|w| w.contains(&"layers".to_string())).expect("Layers floats"); + assert_eq!(window.rect, [60.0, 90.0, 300.0, 420.0], "at the given place, at its own size"); + app.run("window.dockPanel", json!({"panel": "layers"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!( + layout.root, + docked.root.map(|mut root| { + // Back in its place, in front of its group. + if let Node::Split { second, .. } = &mut root + && let Node::Dock { columns, .. } = &mut **second + && let Some(group) = columns.first_mut().and_then(|c| c.groups.first_mut()) + { + group.active = 2; + } + root + }), + "Dock Panel returns it where it floated from" + ); + // A whole group, by any of its panels, then into a new column of the end dock. + app.run("window.panel.float", json!({"panel": "pages", "group": true, "x": 80, "y": 80, "width": 300, "height": 400})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["properties", "pages", "layers"]); + app.run("window.panel.dock", json!({"panel": "pages", "group": true, "side": "End"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(tab_group(&layout, "pages"), ["properties", "pages", "layers"]); + // A lone panel to a new dock at the start edge. + app.run("window.panel.float", json!({"panel": "stroke"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); + assert!( + matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. })) + ); + // Close Others and Minimize are shared commands too. + app.run("window.panel.closeOthers", json!({"panel": "pages"})).unwrap(); + assert_eq!(tab_group(app.ui.docking.as_ref().unwrap(), "pages"), ["pages"]); + assert!(app.ui.docking_hidden.contains_key("properties") && app.ui.docking_hidden.contains_key("layers")); + app.run("window.panel.minimize", json!({"panel": "pages"})).unwrap(); + let location = app.ui.docking.as_ref().unwrap().location(&"pages".to_string()).unwrap(); + assert!(location.group.is_some_and(|g| g.minimized)); + // Rejected commands change nothing. + let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone()); + for (id, params) in [ + ("window.workspace.lock", json!({})), + ("window.panel.window", json!({"panel": "pages"})), + ("window.panel.move", json!({"panel": "stroke", "anchor": "pages", "zone": "left", "before": "pages"})), + ("window.panel.dock", json!({"panel": "stroke", "side": "Middle"})), + ] { + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!((app.ui.docking.clone(), app.ui.docking_hidden.clone()), before, "{id}"); + } + // Every shared id is registered for the command list and MCP. + for id in craft_ui::docking::DockCommand::::IDS { + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == *id), "{id} is not registered"); + } +} + +/// A tab released over the canvas floats there (craft-ui: the protected canvas takes no tabs or +/// splits); a lone panel released in the window's left edge band makes a new dock there. +#[test] +fn tabs_dropped_on_the_canvas_float_and_at_the_far_edge_make_a_dock() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let tab = |h: &Harness<'static, DesignApp>, panel: &str| { + hits(h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab(panel.into())).map(|(_, r)| r) + }; + let layers = tab(&h, "layers").expect("the Layers tab"); + drag(&mut h, layers.center(), egui::pos2(500.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(layout.floating.iter().any(|w| w.contains(&"layers".to_string())), "{layout:?}"); + // A new dock takes a panel that is alone in its group: the floating Layers, by its title bar. + // The harness's area starts 8 pt in from the window edge. + let title = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Title("layers".into())).expect("the title bar").1; + drag(&mut h, title.center(), egui::pos2(12.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(valid(&layout)); + let mut docks = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side, columns } => { + docks.push((*side, columns.iter().flat_map(|c| &c.groups).flat_map(|g| g.panels.clone()).collect::>())) + } + _ => {} + } + } + assert!(docks.iter().any(|(side, panels)| *side == Side::Start && panels == &["layers"]), "{docks:?} {:?}", layout.floating); +} + +/// `ui.docking` lists where each gesture starts: tabs, drag bars and the iconic column's icons. +#[test] +fn ui_docking_reports_hit_rects() { + let h = harness(app(), vec2(1280.0, 800.0)); + let v = crate::panel_docking::inspect(h.state(), &h.ctx); + let rects = v["hitRects"].as_array().expect("hitRects"); + let rect_of = |hit: Value| { + let entry = rects.iter().find(|r| r["hit"] == hit).unwrap_or_else(|| panic!("no {hit} in {rects:?}")); + let r: Vec = serde_json::from_value(entry["rect"].clone()).unwrap(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), vec2(r[2], r[3])) + }; + let tab = rect_of(json!({"Tab": "pages"})); + let bar = rect_of(json!({"DragBar": "properties"})); + let icon = rect_of(json!({"Icon": "stroke"})); + assert!(tab.width() > 20.0 && bar.width() >= 24.0 && icon.width() > 20.0, "{tab:?} {bar:?} {icon:?}"); + assert!((tab.center().y - bar.center().y).abs() < 1.0, "the bar is in the tab strip"); + assert!(icon.left() > 640.0, "the iconic column is at the end edge"); +} + +/// Clicking an icon in the iconic column opens its panel as a flyout on the canvas side; a +/// second click closes it. +#[test] +fn clicking_an_iconic_column_icon_opens_its_flyout() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let icon = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Icon("swatches".into())).expect("the Swatches icon").1; + click(&mut h, icon.center(), PointerButton::Primary); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("swatches")); + let tab = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab("swatches".into())).expect("the flyout's tab").1; + assert!(tab.right() <= icon.left() + 1.0, "the flyout is beside the column, on the canvas side: {tab:?} {icon:?}"); + assert!(showing(h.state(), "swatches")); + click(&mut h, icon.center(), PointerButton::Primary); + assert!(h.state().ui.docking_flyout.is_none()); +} diff --git a/crates/ui-egui/src/panel_icons.rs b/crates/ui-egui/src/panel_icons.rs new file mode 100644 index 000000000..5093d5ae1 --- /dev/null +++ b/crates/ui-egui/src/panel_icons.rs @@ -0,0 +1,168 @@ +//! Panel icons as textures, for craft-ui's iconic columns, which draw an `ImageSource`. +//! +//! DesignCraft draws its icons in code ([`crate::icons::paint`]). Each panel's icon is painted +//! once, white, into a scratch egui context; the shapes are tessellated there and the triangles +//! filled on the CPU. The column tints the texture with its text colour. + +use std::collections::HashMap; + +use egui::{ + Color32, ColorImage, Context, Id, ImageSource, LayerId, Pos2, RawInput, Rect, TextureHandle, TextureId, TextureOptions, + epaint::{ImageData, Mesh, Primitive}, + load::SizedTexture, + pos2, vec2, +}; + +/// Icons per row of the scratch canvas. +const COLUMNS: usize = 8; + +#[derive(Clone, Default)] +struct Cache { + /// Side of each texture in pixels. + px: usize, + textures: Vec<(String, TextureHandle)>, +} + +fn cache_id() -> Id { + Id::new("designcraft-panel-icon-textures") +} + +/// Every dock panel's icon at `size` points, keyed by panel id. Textures are made once per +/// pixel size and kept in the context. +pub(crate) fn panel_icons(ctx: &Context, size: f32) -> HashMap> { + let scale = size * ctx.pixels_per_point(); + let px = if scale.is_finite() { scale.round().clamp(8.0, 128.0) as usize } else { 20 }; + let cached = ctx.data(|d| d.get_temp::(cache_id())).filter(|cache| cache.px == px); + let cache = cached.unwrap_or_else(|| { + let panels: Vec<(&str, &str)> = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).map(|(id, _, icon)| (*id, *icon)).collect(); + let images = render(px, &panels); + let textures = panels + .iter() + .zip(images) + .map(|((id, _), image)| ((*id).to_string(), ctx.load_texture(format!("designcraft-panel-icon-{id}"), image, TextureOptions::LINEAR))) + .collect(); + let cache = Cache { px, textures }; + ctx.data_mut(|d| d.insert_temp(cache_id(), cache.clone())); + cache + }); + cache + .textures + .iter() + .map(|(id, texture)| (id.clone(), ImageSource::Texture(SizedTexture::new(texture.id(), vec2(px as f32, px as f32))))) + .collect() +} + +/// The icons of `panels` (id, icon name), white on transparent, `px` pixels square. +fn render(px: usize, panels: &[(&str, &str)]) -> Vec { + let cell = px as f32; + let rows = panels.len().div_ceil(COLUMNS).max(1); + let (width, height) = (COLUMNS * px, rows * px); + let cell_rect = |i: usize| Rect::from_min_size(pos2((i % COLUMNS) as f32 * cell, (i / COLUMNS) as f32 * cell), vec2(cell, cell)); + let scratch = Context::default(); + scratch.set_fonts(crate::theme::font_definitions(&[], "en")); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))), ..Default::default() }; + let mut output = scratch.run_ui(input, |ui| { + let painter = ui.ctx().layer_painter(LayerId::background()); + for (i, (_, icon)) in panels.iter().enumerate() { + let rect = cell_rect(i); + crate::icons::paint(&painter.with_clip_rect(rect), rect, icon, Color32::WHITE); + } + }); + let atlas = + output.textures_delta.set.get(&TextureId::default()).and_then(|deltas| deltas.iter().find(|delta| delta.pos.is_none())).map(|delta| { + match &delta.image { + ImageData::Color(image) => image.clone(), + } + }); + let shapes = std::mem::take(&mut output.shapes); + let primitives = scratch.tessellate(shapes, output.pixels_per_point); + output.textures_delta.clear(); + let mut canvas = vec![[0.0f32; 4]; width * height]; + for primitive in &primitives { + if let Primitive::Mesh(mesh) = &primitive.primitive { + fill(&mut canvas, width, height, mesh, primitive.clip_rect, atlas.as_deref()); + } + } + (0..panels.len()) + .map(|i| { + let origin = cell_rect(i).min; + let (x0, y0) = (origin.x as usize, origin.y as usize); + let mut pixels = Vec::with_capacity(px * px * 4); + for y in y0..y0 + px { + for x in x0..x0 + px { + let [r, g, b, a] = canvas.get(y * width + x).copied().unwrap_or_default(); + pixels.extend([r, g, b, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + ColorImage::from_rgba_premultiplied([px, px], &pixels) + }) + .collect() +} + +/// Fill `mesh`'s triangles into a premultiplied RGBA canvas, sampling the font atlas for text. +/// Pixel centres inside a triangle take its interpolated colour; egui's feathered edges give +/// the anti-aliasing. +fn fill(canvas: &mut [[f32; 4]], width: usize, height: usize, mesh: &Mesh, clip: Rect, atlas: Option<&ColorImage>) { + let bounds = clip.intersect(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))); + if !bounds.is_positive() { + return; + } + let texel = |u: f32, v: f32| -> [f32; 4] { + if mesh.texture_id != TextureId::default() { + return [1.0; 4]; + } + let Some(atlas) = atlas else { return [1.0; 4] }; + let [w, h] = atlas.size; + let x = ((u * w as f32) as usize).min(w.saturating_sub(1)); + let y = ((v * h as f32) as usize).min(h.saturating_sub(1)); + atlas.pixels.get(y * w + x).map_or([1.0; 4], |c| c.to_array().map(|v| f32::from(v) / 255.0)) + }; + let edge = |a: Pos2, b: Pos2, p: Pos2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + for triangle in mesh.indices.as_chunks::<3>().0 { + let vertex = |i: usize| triangle.get(i).and_then(|&index| mesh.vertices.get(index as usize)); + let (Some(a), Some(b), Some(c)) = (vertex(0), vertex(1), vertex(2)) else { continue }; + let area = edge(a.pos, b.pos, c.pos); + if !area.is_finite() || area.abs() < 1e-6 { + continue; + } + let min = a.pos.min(b.pos).min(c.pos).max(bounds.min); + let max = a.pos.max(b.pos).max(c.pos).min(bounds.max); + if !(min.x < max.x && min.y < max.y) { + continue; + } + let colors = [a.color, b.color, c.color].map(|c| c.to_array().map(|v| f32::from(v) / 255.0)); + for y in min.y.floor() as usize..(max.y.ceil() as usize).min(height) { + for x in min.x.floor() as usize..(max.x.ceil() as usize).min(width) { + let p = pos2(x as f32 + 0.5, y as f32 + 0.5); + let wa = edge(b.pos, c.pos, p) / area; + let wb = edge(c.pos, a.pos, p) / area; + let wc = 1.0 - wa - wb; + if wa < 0.0 || wb < 0.0 || wc < 0.0 { + continue; + } + let u = wa * a.uv.x + wb * b.uv.x + wc * c.uv.x; + let v = wa * a.uv.y + wb * b.uv.y + wc * c.uv.y; + let t = texel(u, v); + let src: [f32; 4] = std::array::from_fn(|k| (wa * colors[0][k] + wb * colors[1][k] + wc * colors[2][k]) * t[k]); + if let Some(dst) = canvas.get_mut(y * width + x) { + let keep = 1.0 - src[3]; + *dst = std::array::from_fn(|k| src[k] + dst[k] * keep); + } + } + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn panel_icons_are_painted_into_their_textures() { + let images = super::render(20, &[("pages", "panel-pages"), ("character", "panel-character")]); + assert_eq!(images.len(), 2); + for image in &images { + assert_eq!(image.size, [20, 20]); + let inked = image.pixels.iter().filter(|p| p.a() > 128).count(); + assert!(inked > 10 && inked < 300, "{inked} inked pixels"); + } + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 37a70c3d6..6e5f599b0 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,11 +1,9 @@ //! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. -use std::collections::BTreeMap; - use craft_ui::docking::{Layout, Location}; use serde_json::Value; -use crate::{SavedWorkspace, UiState, panel_docking}; +use crate::{HiddenPanels, SavedWorkspace, UiState, panel_docking}; /// Saved preferences, and whether reading them discarded anything. pub struct ReadPreferences { @@ -52,7 +50,12 @@ fn recover_docking(saved: &mut Value) -> bool { intact = false; } if let Some(hidden) = fields.get_mut("dockingHidden") { - if let Some(entries) = hidden.as_object_mut() { + // `[[panel, location], …]`, or an object keyed by panel from older files. + if let Some(entries) = hidden.as_array_mut() { + let before = entries.len(); + entries.retain(|entry| serde_json::from_value::<(String, Location)>(entry.clone()).is_ok()); + intact &= entries.len() == before; + } else if let Some(entries) = hidden.as_object_mut() { let before = entries.len(); entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); intact &= entries.len() == before; @@ -70,7 +73,7 @@ fn recover_docking(saved: &mut Value) -> bool { fn migrate( docking: &mut Option>, floating: &mut Vec<(String, [f32; 2])>, - hidden: &BTreeMap>, + hidden: &HiddenPanels, dock_expanded: bool, icon_groups: Option<&[Vec]>, ) { @@ -149,8 +152,8 @@ mod tests { fn unreadable_hidden_origin_does_not_discard_other_origins() { let original = populated(); let mut saved = serde_json::to_value(&original).unwrap(); - saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); - saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + saved["dockingHidden"].as_array_mut().unwrap().push(json!(["futurePanel", {"FutureLocation":{}}])); + saved["customWorkspaces"][0]["dockingHidden"].as_array_mut().unwrap().push(json!(false)); let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); assert!(!original.docking_hidden.is_empty()); assert_eq!(loaded.docking_hidden, original.docking_hidden); @@ -295,6 +298,32 @@ mod tests { assert_eq!(again.docking, app.ui.docking); } + /// `dockingHidden` as an object keyed by panel (files before craft-ui's `Workspace::hidden` + /// list) loads as the list, drops only its unreadable entries and saves as the list. + #[test] + fn hidden_panels_keyed_by_panel_load_as_the_workspace_list() { + let place = json!({"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, "size": {"Ratio": 0.5}, "stack": null}); + let saved = json!({ + "dockingHidden": {"color": place, "swatches": place, "futurePanel": {"FutureLocation": {}}}, + "customWorkspaces": [{"name": "Old", "dockingHidden": {"stroke": place}}], + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!read.intact, "the unreadable entry is reported so the file is kept"); + let ui = read.ui; + assert_eq!(ui.docking_hidden.keys().collect::>(), ["color", "swatches"]); + assert_eq!(ui.custom_workspaces[0].docking_hidden.keys().collect::>(), ["stroke"]); + let written = serde_json::to_value(&ui).unwrap(); + assert_eq!(written["dockingHidden"][0][0], "color"); + let workspace: craft_ui::docking::Workspace = + serde_json::from_value(json!({"layout": {"root": null, "floating": []}, "hidden": written["dockingHidden"]})).unwrap(); + assert_eq!(workspace.hidden, ui.docking_hidden.0, "the same shape as craft-ui's Workspace::hidden"); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + } + #[test] fn unrelated_malformed_preferences_are_still_rejected() { assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 446460981..41dc02a01 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,7 +36,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | | `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf | -| `ui.docking` | — | The panel layout in use and the focused panel (see Shared panel docking) | +| `ui.docking` | — | The panel layout in use, the focused panel and the docking hit rects (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | @@ -94,23 +94,47 @@ panels (or the workspace's named groups) in the iconic column and its floating w hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to `ui.unreadable.json` before it is saved again. -- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. - `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders - tabs by panel ID. The destination must already be visible. Beside a panel in a dock column, - `top` and `bottom` add a group above or below its group and `left` and `right` add a column - on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` - are errors. -- `window.panel.float {panel, x?, y?, width?, height?}` (and `window.floatPanel {panel, x?, y?}`) - detaches a panel. -- `window.panel.dock {panel, anchor?, zone?, before?}` (and `window.dockPanel {panel}`) - returns a panel to its previous dock placement when possible, or to the supplied - destination. For the float-only Tabs panel (`tabs`), which has its own window outside the - layout, float opens that window and dock closes it. +The `window.panel.*`, `window.workspace.*` and `ui.dock` commands are craft-ui's shared +docking commands (`DockCommand`), with the same ids and params in every craft app. `panel`, +`anchor`, `target` and `before` take a panel ID or its English name in any case; `onto` is +read as `anchor`. + +- `window.panel.move {panel, anchor, zone?, before?, group?, placement?}` moves a panel into + the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, `bottom`, + `above` or `below`; `before` reorders tabs by panel ID (`null` appends). Beside a panel in a + dock column, `top` and `bottom` add a group above or below its group and `left` and `right` + add a column on that side; in a floating window, `top` and `bottom` add a group and `left` + and `right` are errors (craft-ui's `Placement::for_zone`). `group: true` moves the panel's + whole tab group; `placement` is a serialized craft-ui `Placement` and takes precedence over + `zone` and `before`. +- `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and + `window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column + (`column`). A panel floats at 400, 140 at its own size unless the params say otherwise; a + panel already alone in a floating window moves or resizes. +- `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and + `window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side` + (`Start` or `End`; a new dock when there is none). Without either, a panel that floated from + the dock goes back to that place, and another floating panel joins the first dock group. For + the float-only Tabs panel (`tabs`), which has its own window outside the layout, float opens + that window and dock closes it. - `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, - restoring its saved position when possible; a panel in an iconic column expands that - column. `window.panel.close {panel}` hides it. -- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it (craft-ui's - `CloseGroup`); the result lists them in `closed`. + restoring its saved position when possible; a panel in an iconic column opens as a flyout + beside its icon. `window.panel.close {panel}` hides it. +- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it, and + `window.panel.closeOthers {panel}` hides the panels tabbed with it; the result lists them in + `closed`. +- `window.panel.minimize {panel, minimized?}` minimizes the panel's group to its tab strip and + `window.panel.iconic {panel, iconic?}` collapses its dock column to icons; without the flag + they toggle. +- `window.workspace.new {name}`, `window.workspace.delete {name}` and `window.workspace.reset` + (also `ui.dock.reset`) are `window.newWorkspace`, `window.deleteWorkspace` and + `window.resetWorkspace`. `window.workspace {name}` switches workspaces as before. +- `ui.dock {action}` or `ui.dock {operation, …}` is the shape LightCraft, PDFCraft, CADCraft + and SoundCraft use: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`, + `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}` and + `resizeSplit {path, size}`, with `rect` as `[x, y, width, height]`. +- `window.panel.window` (native panel windows) and `window.workspace.lock` are not available + in DesignCraft and return an error. - `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and @@ -130,6 +154,10 @@ closes the last explicitly opened panel; `dockExpanded:false` collapses every do to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also raises its group. These operations change only UI layout. +Clicking an icon in an iconic column opens its group as a flyout beside the column, on the +canvas side; clicking the icon again closes it. With Auto-Collapse Iconic Panels on (the +default; a column bar's menu toggles it), Escape or a click outside the flyout closes it too. + `ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as `docking`: @@ -142,9 +170,20 @@ raises its group. These operations change only UI layout. {"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true, "iconic_width": 37.0}]}}}}, "floating": []}, - "customized": false, "focused": "canvas", "rightToLeft": false, "hidden": []} + "customized": false, "focused": "canvas", "flyout": null, "autoCollapse": true, + "rightToLeft": false, "hidden": [], + "hitRects": [{"hit": {"Tab": "properties"}, "rect": [983.0, 0.0, 74.0, 27.0]}, + {"hit": {"DragBar": "properties"}, "rect": [1190.0, 0.0, 56.0, 27.0]}, + {"hit": {"Icon": "stroke"}, "rect": [1243.0, 14.0, 37.0, 30.0]}, …]} ``` `layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or -null; `hidden` lists closed panels that remember their place. +null; `flyout` is the panel whose iconic-column flyout is open; `autoCollapse` is the +Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place. +`hitRects` lists where each docking gesture starts in the last frame, from craft-ui's +`DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`, +`Overflow`, `ColumnBar`, `Collapse`, `StackToggle`, `Title`, `Close`, `Icon`, `IconGrip`, +`Resize` with its edge, or `Divider`), naming groups, columns and windows by their first panel; +`rect` is `[x, y, width, height]` in points. Drag from a rect's centre with `ui.drag`, or click +it with `ui.click`, to script a gesture. From e6f125437ac821723e25033e19b263863190874c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:18:20 +0000 Subject: [PATCH 05/44] Docking trial: craft-ui 6261d9d, paint_icon, float space, flyout event --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/panel_docking.rs | 60 +++++--- crates/ui-egui/src/panel_docking_tests.rs | 22 +++ crates/ui-egui/src/panel_icons.rs | 168 ---------------------- docs/control-protocol.md | 10 +- 7 files changed, 74 insertions(+), 191 deletions(-) delete mode 100644 crates/ui-egui/src/panel_icons.rs diff --git a/Cargo.lock b/Cargo.lock index 5db9c5acd..32b91c425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" +source = "git+https://github.com/aiongg/craft-libs?rev=6261d9d39a0a109a832685f37af6ce75321caea1#6261d9d39a0a109a832685f37af6ce75321caea1" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 63ea8c527..f5c0281d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "6261d9d39a0a109a832685f37af6ce75321caea1" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de859f4ff..5fd50284e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,7 +22,6 @@ mod modal_drag; mod new_document; mod object_control; mod panel_docking; -mod panel_icons; pub mod panels; mod prefs; pub mod render_worker; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 44b327196..a55f059d7 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -5,14 +5,22 @@ //! languages, the left in right-to-left ones). use craft_ui::docking::{ - Action, Column, DockCommand, DockOperation, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, + Action, Column, DockCommand, DockOperation, FloatSpace, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, }; use serde_json::{Value, json}; use crate::DesignApp; +/// The id of the dock area that draws the panels. +fn area_id() -> egui::Id { + egui::Id::new("designcraft-panel-docking") +} + +/// The dock area a headless app floats into, before it ever drew a frame. +const HEADLESS_AREA: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 900.0)); + pub(crate) fn cancel_gesture(ctx: &egui::Context) { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, area_id()); } fn normalize(raw: &str) -> Option<&'static str> { @@ -412,17 +420,20 @@ fn floats_alone(layout: &Layout, panel: &str) -> bool { /// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in /// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; /// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking -/// it, floats a panel at its own size, and returns a floated panel to its dock place on Dock -/// Panel. +/// it, floats a panel at its own size (also when it was hidden, which craft-ui never drew), +/// and returns a floated panel to its dock place on Dock Panel. fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { let mut params = resolve_panels(params)?; + let whole = |key: &str| params.get(key).and_then(Value::as_bool).unwrap_or(false); if id == "window.panel.float" + && !whole("group") + && !whole("column") && let Some(panel) = named_panel(¶ms) && !floats_alone(&layout_of(app), &panel) && let Some(fields) = params.as_object_mut() { let size = floating_size(&panel); - for (key, default) in [("x", 400.0), ("y", 140.0), ("width", size.x), ("height", size.y)] { + for (key, default) in [("width", size.x), ("height", size.y)] { fields.entry(key).or_insert(json!(default)); } } @@ -440,6 +451,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result {} } let rtl = crate::i18n::is_rtl(&app.ui.language); + let space = float_space(app); let mut txn = Transaction::begin(app)?; let shows = match &command { DockCommand::Activate { panel } @@ -462,7 +474,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result Vec::new(), - _ => command.to_actions(&txn.workspace, rtl).map_err(|e| e.to_string())?, + _ => command.to_actions(&txn.workspace, rtl, &space).map_err(|e| e.to_string())?, }; let reveal = match &command { DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action { @@ -481,6 +493,14 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result FloatSpace { + let Some(ctx) = app.egui_ctx.as_ref() else { return FloatSpace::new(HEADLESS_AREA) }; + craft_ui::docking::float_space::(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())) +} + /// A change to the layout in use, committed only when every step succeeded. struct Transaction { /// The layout, and the closed panels with where each one goes back. @@ -721,7 +741,7 @@ pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); @@ -749,12 +769,12 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(panel) = raise { dock = dock.raise_panel(panel); } - let icons = crate::panel_icons::panel_icons(ui.ctx(), style.icon_size); - let output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, icons }); - app.ui.docking_flyout = craft_ui::docking::flyout::(ui.ctx(), area); - for event in &output.events { - if *event == craft_ui::docking::DockEvent::ToggleAutoCollapse { - app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse; + let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app }); + for event in std::mem::take(&mut output.events) { + match event { + craft_ui::docking::DockEvent::ToggleAutoCollapse => app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse, + craft_ui::docking::DockEvent::FlyoutChanged { panel } => app.ui.docking_flyout = panel, + _ => {} } } if let Some(panel) = output.focused { @@ -778,7 +798,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// panels closed with a remembered place, the panel whose flyout is open, and where each /// docking gesture starts in the last frame (`hitRects`). pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { - let hits = craft_ui::docking::DockArea::::hit_rects(ctx, egui::Id::new("designcraft-panel-docking")); + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, area_id()); let hit_rects: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); json!({ "layout": layout_of(app), @@ -901,10 +921,14 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } +/// The name of a dock panel's icon in [`crate::icons`]. +fn panel_icon(panel: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|(id, _, _)| *id == panel).map(|(_, _, icon)| *icon) +} + /// The panels' content and icons. struct PanelContent<'a> { app: &'a mut DesignApp, - icons: std::collections::HashMap>, } impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { @@ -939,8 +963,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } - fn icon(&self, id: &String) -> Option> { - self.icons.get(id).cloned() + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, id: &String, color: egui::Color32) -> bool { + let Some(icon) = panel_icon(id) else { return false }; + crate::icons::paint(painter, rect, icon, color); + true } fn bare(&self, id: &String) -> bool { id == CANVAS diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f182bf644..b410f76ac 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -972,3 +972,25 @@ fn clicking_an_iconic_column_icon_opens_its_flyout() { click(&mut h, icon.center(), PointerButton::Primary); assert!(h.state().ui.docking_flyout.is_none()); } + +/// Iconic columns paint DesignCraft's own panel icons, in the colour craft-ui passes; a panel +/// without an icon paints nothing. +#[test] +fn panel_icons_are_painted_in_code() { + let mut app = app(); + let mut content = PanelContent { app: &mut app }; + let ctx = egui::Context::default(); + let mut paint = |panel: &str| { + let mut painted = false; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel.to_string(), egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + let (painted, shapes) = paint("pages"); + assert!(painted && shapes > 0, "{painted} {shapes}"); + assert_eq!(paint(CANVAS), (false, 0)); +} diff --git a/crates/ui-egui/src/panel_icons.rs b/crates/ui-egui/src/panel_icons.rs deleted file mode 100644 index 5093d5ae1..000000000 --- a/crates/ui-egui/src/panel_icons.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Panel icons as textures, for craft-ui's iconic columns, which draw an `ImageSource`. -//! -//! DesignCraft draws its icons in code ([`crate::icons::paint`]). Each panel's icon is painted -//! once, white, into a scratch egui context; the shapes are tessellated there and the triangles -//! filled on the CPU. The column tints the texture with its text colour. - -use std::collections::HashMap; - -use egui::{ - Color32, ColorImage, Context, Id, ImageSource, LayerId, Pos2, RawInput, Rect, TextureHandle, TextureId, TextureOptions, - epaint::{ImageData, Mesh, Primitive}, - load::SizedTexture, - pos2, vec2, -}; - -/// Icons per row of the scratch canvas. -const COLUMNS: usize = 8; - -#[derive(Clone, Default)] -struct Cache { - /// Side of each texture in pixels. - px: usize, - textures: Vec<(String, TextureHandle)>, -} - -fn cache_id() -> Id { - Id::new("designcraft-panel-icon-textures") -} - -/// Every dock panel's icon at `size` points, keyed by panel id. Textures are made once per -/// pixel size and kept in the context. -pub(crate) fn panel_icons(ctx: &Context, size: f32) -> HashMap> { - let scale = size * ctx.pixels_per_point(); - let px = if scale.is_finite() { scale.round().clamp(8.0, 128.0) as usize } else { 20 }; - let cached = ctx.data(|d| d.get_temp::(cache_id())).filter(|cache| cache.px == px); - let cache = cached.unwrap_or_else(|| { - let panels: Vec<(&str, &str)> = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).map(|(id, _, icon)| (*id, *icon)).collect(); - let images = render(px, &panels); - let textures = panels - .iter() - .zip(images) - .map(|((id, _), image)| ((*id).to_string(), ctx.load_texture(format!("designcraft-panel-icon-{id}"), image, TextureOptions::LINEAR))) - .collect(); - let cache = Cache { px, textures }; - ctx.data_mut(|d| d.insert_temp(cache_id(), cache.clone())); - cache - }); - cache - .textures - .iter() - .map(|(id, texture)| (id.clone(), ImageSource::Texture(SizedTexture::new(texture.id(), vec2(px as f32, px as f32))))) - .collect() -} - -/// The icons of `panels` (id, icon name), white on transparent, `px` pixels square. -fn render(px: usize, panels: &[(&str, &str)]) -> Vec { - let cell = px as f32; - let rows = panels.len().div_ceil(COLUMNS).max(1); - let (width, height) = (COLUMNS * px, rows * px); - let cell_rect = |i: usize| Rect::from_min_size(pos2((i % COLUMNS) as f32 * cell, (i / COLUMNS) as f32 * cell), vec2(cell, cell)); - let scratch = Context::default(); - scratch.set_fonts(crate::theme::font_definitions(&[], "en")); - let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))), ..Default::default() }; - let mut output = scratch.run_ui(input, |ui| { - let painter = ui.ctx().layer_painter(LayerId::background()); - for (i, (_, icon)) in panels.iter().enumerate() { - let rect = cell_rect(i); - crate::icons::paint(&painter.with_clip_rect(rect), rect, icon, Color32::WHITE); - } - }); - let atlas = - output.textures_delta.set.get(&TextureId::default()).and_then(|deltas| deltas.iter().find(|delta| delta.pos.is_none())).map(|delta| { - match &delta.image { - ImageData::Color(image) => image.clone(), - } - }); - let shapes = std::mem::take(&mut output.shapes); - let primitives = scratch.tessellate(shapes, output.pixels_per_point); - output.textures_delta.clear(); - let mut canvas = vec![[0.0f32; 4]; width * height]; - for primitive in &primitives { - if let Primitive::Mesh(mesh) = &primitive.primitive { - fill(&mut canvas, width, height, mesh, primitive.clip_rect, atlas.as_deref()); - } - } - (0..panels.len()) - .map(|i| { - let origin = cell_rect(i).min; - let (x0, y0) = (origin.x as usize, origin.y as usize); - let mut pixels = Vec::with_capacity(px * px * 4); - for y in y0..y0 + px { - for x in x0..x0 + px { - let [r, g, b, a] = canvas.get(y * width + x).copied().unwrap_or_default(); - pixels.extend([r, g, b, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - } - ColorImage::from_rgba_premultiplied([px, px], &pixels) - }) - .collect() -} - -/// Fill `mesh`'s triangles into a premultiplied RGBA canvas, sampling the font atlas for text. -/// Pixel centres inside a triangle take its interpolated colour; egui's feathered edges give -/// the anti-aliasing. -fn fill(canvas: &mut [[f32; 4]], width: usize, height: usize, mesh: &Mesh, clip: Rect, atlas: Option<&ColorImage>) { - let bounds = clip.intersect(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))); - if !bounds.is_positive() { - return; - } - let texel = |u: f32, v: f32| -> [f32; 4] { - if mesh.texture_id != TextureId::default() { - return [1.0; 4]; - } - let Some(atlas) = atlas else { return [1.0; 4] }; - let [w, h] = atlas.size; - let x = ((u * w as f32) as usize).min(w.saturating_sub(1)); - let y = ((v * h as f32) as usize).min(h.saturating_sub(1)); - atlas.pixels.get(y * w + x).map_or([1.0; 4], |c| c.to_array().map(|v| f32::from(v) / 255.0)) - }; - let edge = |a: Pos2, b: Pos2, p: Pos2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); - for triangle in mesh.indices.as_chunks::<3>().0 { - let vertex = |i: usize| triangle.get(i).and_then(|&index| mesh.vertices.get(index as usize)); - let (Some(a), Some(b), Some(c)) = (vertex(0), vertex(1), vertex(2)) else { continue }; - let area = edge(a.pos, b.pos, c.pos); - if !area.is_finite() || area.abs() < 1e-6 { - continue; - } - let min = a.pos.min(b.pos).min(c.pos).max(bounds.min); - let max = a.pos.max(b.pos).max(c.pos).min(bounds.max); - if !(min.x < max.x && min.y < max.y) { - continue; - } - let colors = [a.color, b.color, c.color].map(|c| c.to_array().map(|v| f32::from(v) / 255.0)); - for y in min.y.floor() as usize..(max.y.ceil() as usize).min(height) { - for x in min.x.floor() as usize..(max.x.ceil() as usize).min(width) { - let p = pos2(x as f32 + 0.5, y as f32 + 0.5); - let wa = edge(b.pos, c.pos, p) / area; - let wb = edge(c.pos, a.pos, p) / area; - let wc = 1.0 - wa - wb; - if wa < 0.0 || wb < 0.0 || wc < 0.0 { - continue; - } - let u = wa * a.uv.x + wb * b.uv.x + wc * c.uv.x; - let v = wa * a.uv.y + wb * b.uv.y + wc * c.uv.y; - let t = texel(u, v); - let src: [f32; 4] = std::array::from_fn(|k| (wa * colors[0][k] + wb * colors[1][k] + wc * colors[2][k]) * t[k]); - if let Some(dst) = canvas.get_mut(y * width + x) { - let keep = 1.0 - src[3]; - *dst = std::array::from_fn(|k| src[k] + dst[k] * keep); - } - } - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn panel_icons_are_painted_into_their_textures() { - let images = super::render(20, &[("pages", "panel-pages"), ("character", "panel-character")]); - assert_eq!(images.len(), 2); - for image in &images { - assert_eq!(image.size, [20, 20]); - let inked = image.pixels.iter().filter(|p| p.a() > 128).count(); - assert!(inked > 10 && inked < 300, "{inked} inked pixels"); - } - } -} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 41dc02a01..7c1f946cd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -109,8 +109,11 @@ read as `anchor`. `zone` and `before`. - `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and `window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column - (`column`). A panel floats at 400, 140 at its own size unless the params say otherwise; a - panel already alone in a floating window moves or resizes. + (`column`). Without a position it floats 24 points inside the dock area's top-left corner, + clear of the menu bar; a panel floats at its own size and a tab group or column at the size + a drag would float it at. Given values replace these. A panel already alone in a floating + window moves or resizes, and a tab group that is already a whole floating window keeps its + rect unless the params change it. - `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and `window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side` (`Start` or `End`; a new dock when there is none). Without either, a panel that floated from @@ -179,7 +182,8 @@ default; a column bar's menu toggles it), Escape or a click outside the flyout c `layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or -null; `flyout` is the panel whose iconic-column flyout is open; `autoCollapse` is the +null; `flyout` is the panel whose iconic-column flyout is open (as drawn in the last frame, +or as a command that opens one sets it); `autoCollapse` is the Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place. `hitRects` lists where each docking gesture starts in the last frame, from craft-ui's `DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`, From 8be489eaecdd9458730d0be376d43e42d823fa54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:25:02 +0000 Subject: [PATCH 06/44] Docking trial: craft-ui c715d3f, seeded float space --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/panel_docking.rs | 38 +++++++++-------------------- 3 files changed, 13 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32b91c425..85374c054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=6261d9d39a0a109a832685f37af6ce75321caea1#6261d9d39a0a109a832685f37af6ce75321caea1" +source = "git+https://github.com/aiongg/craft-libs?rev=c715d3f25fdfd615ae1685daa328776e9d7ad9a8#c715d3f25fdfd615ae1685daa328776e9d7ad9a8" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index f5c0281d2..52f732973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "6261d9d39a0a109a832685f37af6ce75321caea1" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c715d3f25fdfd615ae1685daa328776e9d7ad9a8" } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a55f059d7..7edc2705d 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -407,36 +407,12 @@ fn resolve_panels(params: &Value) -> Result { Ok(params) } -/// The panel `params` names, resolved, if it names one. -fn named_panel(params: &Value) -> Option { - params.get("panel").and_then(Value::as_str).map(str::to_owned) -} - -/// Is `panel` alone in a floating window? -fn floats_alone(layout: &Layout, panel: &str) -> bool { - layout.floating.iter().any(|w| w.contains(&panel.to_string()) && w.panels().count() == 1) -} - /// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in /// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; /// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking -/// it, floats a panel at its own size (also when it was hidden, which craft-ui never drew), -/// and returns a floated panel to its dock place on Dock Panel. +/// it, and returns a floated panel to its dock place on Dock Panel. fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { let mut params = resolve_panels(params)?; - let whole = |key: &str| params.get(key).and_then(Value::as_bool).unwrap_or(false); - if id == "window.panel.float" - && !whole("group") - && !whole("column") - && let Some(panel) = named_panel(¶ms) - && !floats_alone(&layout_of(app), &panel) - && let Some(fields) = params.as_object_mut() - { - let size = floating_size(&panel); - for (key, default) in [("width", size.x), ("height", size.y)] { - fields.entry(key).or_insert(json!(default)); - } - } if let Some(fields) = params.as_object_mut() { fields.insert("command".into(), Value::String(id.to_owned())); } @@ -496,9 +472,17 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result