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 <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-21 23:38:02 +07:00
committed by bflatastic
co-authored by Cursor
parent 58066428c6
commit 2c715c9fe3
4 changed files with 144 additions and 11 deletions
+84 -5
View File
@@ -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<Pos2>,
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<DockTab> = 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<usize>,
@@ -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<Rect> {
let dock = self.ctx.data(|d| d.get_temp::<Rect>(floating::dock_rect_id())).unwrap();
let mut tabs: Vec<Rect> =
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());
+35 -5
View File
@@ -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<Item = DockTab> + '_ {
DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none())
let order: Vec<DockTab> =
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<Action>)
}
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();
+21 -1
View File
@@ -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<egui::Rect>,
) {
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::<LegacyDrag>(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));
+4
View File
@@ -309,6 +309,9 @@ pub struct UiState {
pub legacy_language: Option<String>,
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<DockTab>,
/// 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,