From 687ea314b6ffdb92d1e70724285a03f71f8c30fb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:16:28 +0000 Subject: [PATCH] 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); +}