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.
This commit is contained in:
aiongg
2026-10-19 05:16:28 +00:00
parent ecd7e5d063
commit 687ea314b6
5 changed files with 134 additions and 24 deletions
Generated
+1 -1
View File
@@ -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",
+4
View File
@@ -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" }
+1 -1
View File
@@ -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);
+70 -14
View File
@@ -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<String> {
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<String> {
/// 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<String>, panel: &str) -> Vec<String> {
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<String>> = layout.root.iter().collect();
@@ -88,6 +88,11 @@ fn tab_group(layout: &Layout<String>, panel: &str) -> Vec<String> {
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<String>, panel: &str) -> bool {
return e.open;
}
}
Node::Tabs { .. } => {}
_ => {}
}
}
false
@@ -216,6 +221,29 @@ fn apply_action(app: &mut DesignApp, action: Action<String>) -> Result<Value, St
| Action::SetStackOpen { panel, .. }
| Action::ResizeStack { panel, .. } => 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<String>) -> Result<Value, St
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let returning = match &action {
Action::Close { panel } => 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<String>) -> Vec<String> {
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<String>, 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::<LegacyDrag>(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<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
@@ -674,6 +727,9 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
ui.separator();
}
}
fn floating_size(&self, id: &String) -> Option<egui::Vec2> {
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();
}
+58 -8
View File
@@ -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<String>, 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<String>> = 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);
}