From 2c715c9fe375d253c23c19694818e7617d0b5d26 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 02:32:31 +0300 Subject: [PATCH] Fix #2995: reorder grouped panel tabs by dragging within the tab strip Horizontal drags on dock and native floating tab strips now reorder tabs instead of only tearing off; storytold/vectorcraft#2995. Co-authored-by: Cursor --- crates/ui-egui/src/dock.rs | 89 +++++++++++++++++++++++++++-- crates/ui-egui/src/floating.rs | 40 +++++++++++-- crates/ui-egui/src/panel_docking.rs | 22 ++++++- crates/ui-egui/src/state.rs | 4 ++ 4 files changed, 144 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8e0c63dcf..8fa6d1a7c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -4,7 +4,7 @@ //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. -use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2}; +use egui::{CornerRadius, Pos2, Rect, Sense, Stroke, Ui, vec2}; use serde_json::json; use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS}; @@ -242,13 +242,16 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0)); let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true); - for (i, resp) in out.responses { - let Some(&tab) = tabs.get(i) else { continue }; + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (i, resp) in &out.responses { + let Some(&tab) = tabs.get(*i) else { continue }; let id = tab.info().0; - if resp.clicked() { + if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) { + apply_dock_tab_reorder(app, &tabs, from, to); + } else if resp.clicked() { app.ui.dock_tab = tab; } - crate::panel_docking::legacy_tab(app, ui, id, &resp); + crate::panel_docking::legacy_tab_in_strip(app, ui, id, &resp, Some(strip)); } if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() { app.ui.dock_tab = tab; @@ -271,6 +274,49 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { /// App styling and overflow menus around the shared tab fitting/interaction components. Docking /// and floating groups consume the responses themselves, preserving their different drag rules. +/// Drag a tab onto another tab in the same strip to reorder (pointer still on the strip at release). +pub(crate) fn tab_reorder_on_release( + strip: Rect, + pointer: Option, + source: &egui::Response, + tabs: &[(usize, egui::Response)], +) -> Option<(usize, usize)> { + if !source.drag_stopped() { + return None; + } + let Some(pointer) = pointer.filter(|p| p.is_finite()) else { return None }; + if !strip.expand(4.0).contains(pointer) { + return None; + } + if source.drag_delta().length() < 2.0 { + return None; + } + let from = tabs.iter().find(|(_, r)| r.id == source.id).map(|(i, _)| *i)?; + let to = tabs.iter().find(|(_, r)| r.rect.contains(pointer)).map(|(i, _)| *i)?; + (from != to).then_some((from, to)) +} + +fn apply_dock_tab_reorder(app: &mut VectorcraftApp, visible: &[DockTab], from: usize, to: usize) { + let Some(from_tab) = visible.get(from) else { return }; + let Some(to_tab) = visible.get(to) else { return }; + if app.ui.docking.is_some() { + if let Err(error) = app.run( + "window.panel.move", + json!({ "panel": from_tab.info().0, "anchor": to_tab.info().0, "before": to_tab.info().0 }), + ) { + app.ui.status = error; + } + return; + } + let mut order: Vec = floating::docked_tabs(&app.ui).collect(); + if from >= order.len() || to >= order.len() { + return; + } + let item = order.remove(from); + order.insert(to, item); + app.ui.dock_tab_order = order; +} + pub(crate) struct PanelTabs { pub responses: Vec<(usize, egui::Response)>, pub picked: Option, @@ -690,6 +736,27 @@ mod tests { fn arrow(&self) -> egui::Pos2 { egui::pos2(SCREEN.x - 10.0, HEADER / 2.0) } + + fn dock_tabs(&self) -> Vec { + let dock = self.ctx.data(|d| d.get_temp::(floating::dock_rect_id())).unwrap(); + let mut tabs: Vec = + widget_rects(&self.ctx, vec2(40.0, 32.0)).filter(|r| (31.0..=34.0).contains(&r.height()) && dock.contains_rect(*r)).collect(); + tabs.sort_by(|a, b| a.left().total_cmp(&b.left())); + tabs + } + + fn drag(&mut self, from: Pos2, to: Pos2) { + let button = |pos, pressed| { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE } + }; + self.frame(vec![egui::Event::PointerMoved(from)]); + self.frame(vec![button(from, true)]); + for k in 1..=8 { + self.frame(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))]); + } + self.frame(vec![button(to, false)]); + self.settle(); + } } fn icon_panel_count() -> usize { @@ -801,6 +868,18 @@ mod tests { assert_eq!(h.app.ui.open_panel, None, "a click in the docked group puts {open:?} away"); } + #[test] + fn dock_tab_strip_drags_reorder_tabs_horizontally() { + let mut h = Harness::new(); + let tabs = h.dock_tabs(); + assert_eq!(tabs.len(), 3); + h.drag(tabs[2].center(), tabs[0].center()); + let order: Vec<_> = floating::docked_tabs(&h.app.ui).map(|t| t.info().0).collect(); + assert_eq!(order, ["libraries", "properties", "layers"]); + let saved: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&h.app.ui).unwrap()).unwrap(); + assert_eq!(saved.dock_tab_order, h.app.ui.dock_tab_order); + } + #[test] fn the_collapse_command_sets_or_toggles_the_dock() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index a302536c5..155a66bd9 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -211,7 +211,9 @@ fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { /// The dock tabs left in the dock, in tab order. pub fn docked_tabs(ui: &UiState) -> impl Iterator + '_ { - DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none()) + let order: Vec = + if ui.dock_tab_order.is_empty() { DockTab::ALL.to_vec() } else { ui.dock_tab_order.clone() }; + order.into_iter().filter(|t| group_of(ui, t.info().0).is_none()) } /// The dock tab the tabbed group shows: the chosen one, or the first left when it floats. @@ -566,6 +568,8 @@ enum Action { Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Reorder tabs in floating group `gi` from index `from` to `to`. + Reorder(usize, usize, usize), /// Float the docked column of this group, its top-left corner this far from the pointer. Undock(usize, Vec2), } @@ -654,6 +658,18 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) } Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Reorder(gi, from, to)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) + && from < g.panels.len() + && to < g.panels.len() + && from != to + { + let active = g.panels.get(g.active).cloned(); + let panel = g.panels.remove(from); + g.panels.insert(to.min(g.panels.len()), panel); + g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); + } + } Some(Action::Undock(gi, grab)) => { let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; @@ -889,12 +905,14 @@ fn draw_groups( let names: Vec<_> = g.ids.iter().zip(&labels).map(|(&id, label)| (id, label.as_str())).collect(); let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0); let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false); - for (j, resp) in out.responses { - let Some(&id) = g.ids.get(j) else { continue }; - if resp.double_clicked() { + for (j, resp) in &out.responses { + let Some(&id) = g.ids.get(*j) else { continue }; + if let Some((from, to)) = dock::tab_reorder_on_release(strip, pointer, resp, &out.responses) { + *action = Some(Action::Reorder(g.gi, from, to)); + } else if resp.double_clicked() { *action = Some(Action::Collapse(g.gi)); } else if resp.clicked() { - *action = Some(Action::Activate(g.gi, j)); + *action = Some(Action::Activate(g.gi, *j)); } else if lone && resp.drag_started() { *action = Some(match alone { Some(pos) => Action::Move(id, pos), @@ -1240,6 +1258,18 @@ mod tests { } } + #[test] + fn floating_group_tabs_reorder_horizontally_within_the_strip() { + let mut h = Harness::new(); + h.drag(h.icons()[2].center(), pos2(500.0, 250.0)); + h.drag(h.icons()[0].center(), h.shared_tab("swatches").center()); + assert_eq!(h.groups(), [["swatches", "color"]]); + let tabs = h.widgets(|r| h.group("swatches").contains_rect(r) && r.height() == 32.0); + assert_eq!(tabs.len(), 2); + h.drag(tabs[1].center(), tabs[0].center()); + assert_eq!(h.groups(), [["color", "swatches"]]); + } + #[test] fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a351a982e..fdda46375 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -857,6 +857,16 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { + legacy_tab_in_strip(app, ui, panel, response, None); +} + +pub(crate) fn legacy_tab_in_strip( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + panel: &str, + response: &egui::Response, + strip: Option, +) { let Some(panel) = normalize(panel) else { return }; response.context_menu(|ui| { if ui.button(tl!("Float Panel")).clicked() { @@ -880,7 +890,17 @@ pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &st } }); }); - if response.drag_started() + let inside_strip = strip.is_some_and(|strip| { + ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p)) + }); + let area = egui::Id::new("vectorcraft-panel-docking"); + let begun = ui.ctx().data(|d| d.get_temp::(area.with("legacy-origin")).is_some()); + let begin = if strip.is_some() { + !inside_strip && !begun && (response.dragged() || response.drag_started()) + } else { + response.drag_started() + }; + if begin && let Some(id) = normalize(panel) { let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 08adf61c7..bda4a23eb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -309,6 +309,9 @@ pub struct UiState { pub legacy_language: Option, pub brightness: Brightness, pub dock_tab: DockTab, + /// Tab order in the dock's tabbed group when it differs from [`DockTab::ALL`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dock_tab_order: Vec, /// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of /// the icon column (the dock's double arrow, `window.collapseDock`). #[serde(default)] @@ -778,6 +781,7 @@ impl Default for UiState { legacy_language: None, brightness: Brightness::MediumDark, dock_tab: DockTab::Properties, + dock_tab_order: Vec::new(), dock_collapsed: false, open_panel: None, control_bar: false,