From 0666e8e978a5653cdaca8267b2c2584396e3933a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 08:19:56 +0200 Subject: [PATCH] Panels: a double-click on the tab strip right of the tabs collapses the group (fixes #3620) --- crates/ui-egui/src/dock.rs | 15 ++++--- crates/ui-egui/src/floating.rs | 46 ++++++++++++++++++--- crates/ui-egui/src/panel_docking.rs | 37 ++++++++++++++++- crates/ui-egui/src/panel_docking_tests.rs | 49 +++++++++++++++++++++++ 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0b845cfe4..f2f7035a0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -234,9 +234,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click -/// collapses the group to its tabs or expands it, a tab dragged out of the strip floats on its own, -/// and the strip right of the tabs floats the whole group. True when the shown panel floats now. +/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click on a +/// tab or on the strip right of the tabs collapses the group to its tabs or expands it, a tab +/// dragged out of the strip floats on its own, and the strip right of the tabs floats the whole +/// group. True when the shown panel floats now. fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let t = Tokens::get(ui.ctx()); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); @@ -276,8 +277,12 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { } let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag()); - if resp.dragged() && outside { + let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::click_and_drag()); + if resp.double_clicked() { + if let Err(error) = app.run("window.panel.collapse", json!({ "panel": active.info().0 })) { + app.ui.status = error; + } + } else if resp.dragged() && outside { tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left())); } resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 9efe0789d..5701d5890 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -678,7 +678,8 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { enum Action { /// Show the group's `k`th tab. Activate(usize, usize), - /// A double-click on a tab collapses the group to its tabs, or expands it. + /// A double-click on a tab or the strip right of the tabs collapses the group to its tabs, or + /// expands it. Collapse(usize), /// Its × puts the panels of the set's groups back in the dock. Close(Vec), @@ -1015,9 +1016,9 @@ fn draw_groups( ui.painter().rect_filled(gap, 0.0, t.tab_strip); gap.top() }; - // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the - // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group - // (out of its set). + // Tab strip: a click shows a tab, a double-click on a tab or on the strip right of the tabs + // collapses the group, a drag out of the strip floats a tab on its own; a lone tab or the + // strip right of the tabs moves the group (out of its set). let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); let lone = g.ids.len() == 1; @@ -1049,8 +1050,12 @@ fn draw_groups( } let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); - if resp.drag_started() { + // Click as well as drag, so a press there no longer starts the move at once and a + // double-click can collapse the group. + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::click_and_drag()); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.drag_started() { *action = Some(match alone { Some(pos) => Action::Move(g.active, pos), None => Action::Detach(g.gi, strip.left()), @@ -1777,6 +1782,35 @@ mod tests { assert!(!h.app.ui.floating_panels[0].collapsed); } + /// Illustrator also collapses the group from the blank part of the strip, see + /// . + #[test] + fn a_double_click_on_the_strip_right_of_the_tabs_collapses_a_floating_group_in_place() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap(); + h.settle(); + let owned = |h: &Harness| format!("{:?}", sets(&h.app.ui.floating_panels)); + let before = owned(&h); + let open = h.temp_rect(group_rect_id("layers")); + // Left of the panel menu, well right of the lone Layers tab. + let blank = pos2(open.right() - 40.0, open.top() + 1.0 + TITLE + STRIP / 2.0); + let double = |h: &mut Harness| { + h.time += 1.0; + h.frame(vec![Event::PointerMoved(blank), button(blank, true)]); + h.frame(vec![button(blank, false)]); + h.frame(vec![button(blank, true)]); + h.frame(vec![button(blank, false)]); + h.settle(); + }; + double(&mut h); + assert!(h.app.ui.floating_panels[0].collapsed, "collapsed"); + assert_eq!(owned(&h), before, "still in its set"); + assert_eq!(h.temp_rect(group_rect_id("layers")).min, open.min, "not moved"); + double(&mut h); + assert!(!h.app.ui.floating_panels[0].collapsed, "expanded"); + } + #[test] fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 2789c4648..73452c485 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -798,7 +798,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { self.commands.push(("window.panel.dock", json!({"panel": active, "group": true}))); } } else { - let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag()); + // Click as well as drag, so a double-click here collapses the group + // (`tab_area_double_clicked`) rather than floating it on the first press. + let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::click_and_drag()); if response.drag_started() && let Some(pos) = response.interact_pointer_pos() { @@ -890,6 +892,36 @@ fn root_groups(ctx: &egui::Context, layout: &Layout, content: egui::Rect out } +/// The panel shown by the dock group whose strip was double-clicked this frame right of its tabs +/// and left of its panel menu, from which Illustrator also collapses the group +/// (). Most of it has no +/// widget of its own, so the press is read from the input; `layer` is the dock's, so a press on a +/// floating group or a menu over the strip doesn't count. Accordions are left out. +fn tab_area_double_clicked( + ctx: &egui::Context, + layer: egui::LayerId, + layout: &Layout, + groups: &[crate::floating::RootGroup], +) -> Option<&'static str> { + let at = ctx.input(|i| if i.pointer.button_double_clicked(egui::PointerButton::Primary) { i.pointer.interact_pos() } else { None })?; + // No layer: the background, where the dock panel is. + if ctx.layer_id_at(at).is_some_and(|l| l != layer) { + return None; + } + let group = groups.iter().find(|g| g.strip.contains(at))?; + tab_group_shortens(layout, group.shown)?; + let area = egui::Id::new("vectorcraft-panel-docking"); + let right = |id: egui::Id| ctx.read_response(id).map(|r| r.rect.right()); + let tabs = group + .panels + .iter() + .filter_map(|p| right(area.with(("tab", &p.to_string())))) + .chain(right(area.with(("overflow", &group.shown.to_string())))) + .fold(group.strip.left(), f32::max); + // The panel menu is 16 wide, 12 in from the right, with 4 to spare (`group_header`). + (at.x >= tabs && at.x < group.strip.right() - 24.0).then_some(group.shown) +} + pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let root_key = egui::Id::new("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { @@ -1078,6 +1110,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } } let groups = if collapsed { Vec::new() } else { root_groups(ui.ctx(), &display, tree, style.tab_height) }; + if let Some(panel) = tab_area_double_clicked(ui.ctx(), ui.layer_id(), &display, &groups) { + content.commands.push(("window.panel.collapse", json!({"panel": panel}))); + } ui.ctx().data_mut(|data| data.insert_temp(crate::floating::root_groups_id(), groups)); for (id, params) in content.commands { if let Err(error) = app.run(id, params) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 3f4a9e5a2..1579f4683 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -1052,6 +1052,55 @@ fn a_double_click_on_a_legacy_dock_tab_collapses_the_tabbed_group() { assert!(layers_drawn(&h), "Layers shows again"); } +/// Illustrator also collapses the group from the blank part of the strip, see +/// . The legacy dock's strip right of its tabs collapses the tabbed group, and floats nothing. +#[test] +fn a_double_click_on_the_legacy_dock_strip_right_of_the_tabs_collapses_the_tabbed_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let libraries = h.query_all_by_label("Libraries").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = h.ctx.data(|d| d.get_temp::(crate::floating::dock_rect_id())).unwrap(); + let blank = pos2(libraries.right() + 12.0, libraries.center().y); + assert!(blank.x < dock.right() - 30.0, "left of the panel menu: {libraries:?} {dock:?}"); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + click(&mut h, tab); + h.run_steps(45); + assert!(layers_drawn(&h), "Layers shows"); + double_click(&mut h, blank); + assert!(h.state().ui.docking.is_none() && h.state().ui.floating_panels.is_empty(), "nothing floats"); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + double_click(&mut h, blank); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert!(layers_drawn(&h), "Layers shows again"); +} + +/// The shared dock's strip right of a group's tabs, and the grip beside its panel menu, collapse +/// the group to its tabs and expand it, without floating it. +#[test] +fn a_double_click_on_the_shared_dock_strip_right_of_the_tabs_collapses_its_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let strip = root_groups(&h)[0].strip; + let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; + // Between the tab and the group's header (80 wide), and on the header's grip. + let gap = pos2(tab.right() + 10.0, strip.center().y); + assert!(gap.x < strip.right() - 80.0, "{tab:?} {strip:?}"); + let grip = pos2(strip.right() - 50.0, strip.center().y); + for at in [gap, grip] { + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("swatches"), "{at:?}"); + assert!(root_groups(&h)[0].rect.height() < 40.0, "{at:?}"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty(), "{at:?}"); + assert_eq!(h.state().ui.docking, saved, "nothing floats: {at:?}"); + } +} + /// `window.panel.collapse` checks its parameters, and a collapsed group survives a reload. #[test] fn the_collapse_command_validates_and_survives_a_reload() {