From 2c715c9fe375d253c23c19694818e7617d0b5d26 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 02:32:31 +0300 Subject: [PATCH 1/5] 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, From ba2a5bfaed725531f66d61804d7b2affb859d937 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 00:18:37 +0300 Subject: [PATCH 2/5] Fix #2995: route tab reorder through window.panel.move and harden dock_tab_order Legacy and floating strip drags update order via the panel.move command; partial saved orders append missing tabs, with command-level tests and Illustrator workspace doc link. Co-authored-by: Cursor --- crates/ui-egui/src/dock.rs | 78 ++++++++++++----------------- crates/ui-egui/src/floating.rs | 42 ++++++++++++---- crates/ui-egui/src/panel_docking.rs | 39 +++++++++++++++ 3 files changed, 102 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8fa6d1a7c..ce8874e9d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -251,7 +251,7 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { } else if resp.clicked() { app.ui.dock_tab = tab; } - crate::panel_docking::legacy_tab_in_strip(app, ui, id, &resp, Some(strip)); + 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; @@ -274,7 +274,9 @@ 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). +/// Drag a tab onto another tab in the same strip to reorder (pointer still on the strip at release), +/// as in Illustrator's workspace basics +/// (). pub(crate) fn tab_reorder_on_release( strip: Rect, pointer: Option, @@ -284,7 +286,7 @@ pub(crate) fn tab_reorder_on_release( if !source.drag_stopped() { return None; } - let Some(pointer) = pointer.filter(|p| p.is_finite()) else { return None }; + let pointer = pointer.filter(|p| p.is_finite())?; if !strip.expand(4.0).contains(pointer) { return None; } @@ -299,22 +301,12 @@ pub(crate) fn tab_reorder_on_release( 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; + 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; } - 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 { @@ -737,26 +729,6 @@ mod tests { 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 { @@ -869,15 +841,29 @@ mod tests { } #[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(); + fn window_panel_move_reorders_legacy_dock_tabs_and_persists_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + assert!(app.ui.docking.is_none()); + app.run( + "window.panel.move", + json!({ "panel": "libraries", "anchor": "properties", "before": "properties" }), + ) + .unwrap(); + let order: Vec<_> = floating::docked_tabs(&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); + let saved: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(saved.dock_tab_order, app.ui.dock_tab_order); + } + + #[test] + fn partial_dock_tab_order_appends_tabs_missing_from_preferences() { + let mut ui = crate::state::UiState::default(); + ui.dock_tab_order = vec![DockTab::Layers]; + let order = floating::resolved_dock_tab_order(&ui); + assert_eq!(order.len(), DockTab::ALL.len()); + assert_eq!(order[0], DockTab::Layers); + assert!(order.contains(&DockTab::Properties) && order.contains(&DockTab::Libraries)); } #[test] diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 155a66bd9..3ad795e8a 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -209,11 +209,30 @@ fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect() } +/// Saved dock tab order, appending any tabs missing from older preferences. +pub(crate) fn resolved_dock_tab_order(ui: &UiState) -> Vec { + if ui.dock_tab_order.is_empty() { + DockTab::ALL.to_vec() + } else { + let mut order = ui.dock_tab_order.clone(); + for tab in DockTab::ALL { + if !order.contains(&tab) { + order.push(tab); + } + } + order + } +} + +/// Persist dock tab order when it differs from the default visible strip. +pub(crate) fn set_dock_tab_order(ui: &mut UiState, visible: Vec) { + let default = DockTab::ALL.iter().copied().filter(|t| group_of(ui, t.info().0).is_none()).collect::>(); + ui.dock_tab_order = if visible == default { Vec::new() } else { visible }; +} + /// The dock tabs left in the dock, in tab order. pub fn docked_tabs(ui: &UiState) -> impl Iterator + '_ { - 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()) + resolved_dock_tab_order(ui).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. @@ -659,15 +678,16 @@ 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 ids = app.ui.floating_panels.get(gi).and_then(|g| { + if from == to { None } else { Some((g.panels.get(from).cloned(), g.panels.get(to).cloned())) } + }); + if let Some((Some(from_id), Some(to_id))) = ids + && let Err(error) = app.run( + "window.panel.move", + json!({ "panel": from_id, "anchor": to_id, "before": to_id }), + ) { - 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); + app.ui.status = error; } } Some(Action::Undock(gi, grab)) => { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index fdda46375..f714a47e2 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -142,6 +142,41 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } +/// Legacy workspaces (no saved `docking` layout): reorder tabs in the dock strip or a floating +/// group without promoting to a shared layout. +fn legacy_same_strip_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result { + use crate::state::DockTab; + if crate::state::DockTab::from_id(panel).is_some() + && DockTab::from_id(before).is_some() + && crate::floating::group_of(&app.ui, panel).is_none() + && crate::floating::group_of(&app.ui, before).is_none() + { + let mut order: Vec = crate::floating::docked_tabs(&app.ui).collect(); + let from = order.iter().position(|t| t.info().0 == panel).ok_or("panel is not in the dock strip")?; + let to = order.iter().position(|t| t.info().0 == before).ok_or("before is not in the dock strip")?; + if from != to { + let item = order.remove(from); + order.insert(to, item); + crate::floating::set_dock_tab_order(&mut app.ui, order); + } + return Ok(json!({ "panel": panel, "before": before })); + } + let gi = crate::floating::group_of(&app.ui, panel).ok_or("panel is not in a tab group")?; + if crate::floating::group_of(&app.ui, before) != Some(gi) { + return Err("before must name a tab in the same group".into()); + } + let g = app.ui.floating_panels.get_mut(gi).ok_or("panel group missing")?; + let from = g.panels.iter().position(|id| id == panel).ok_or("panel is not in the group")?; + let to = g.panels.iter().position(|id| id == before).ok_or("before is not in the group")?; + if from != to { + let active = g.panels.get(g.active).cloned(); + let moved = g.panels.remove(from); + g.panels.insert(to, moved); + g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); + } + Ok(json!({ "panel": panel, "before": before })) +} + fn placement(params: &Value) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; @@ -294,6 +329,10 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if app.ui.docking.is_none() && operation == "move" && params.get("before").is_some() { + let before = params.get("before").and_then(Value::as_str).and_then(normalize).ok_or("before must name a panel")?; + return legacy_same_strip_reorder(app, panel, before); + } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { return Err("saved panel layout is invalid; reset the workspace first".into()); } From 4cba355f712afc603b69758675d128afe27c1211 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 02:57:40 +0300 Subject: [PATCH 3/5] Fix #2995: address PR review on tab reorder and dock drag begin Reuse catalogued window.panel.move errors, keep legacy dock drags from clearing docking on Escape, reorder floating tabs locally without reconcile re-entry, and fix dock_tab_order test setup. Co-authored-by: Cursor --- crates/ui-egui/src/dock.rs | 11 +++++---- crates/ui-egui/src/floating.rs | 20 ++++++++-------- crates/ui-egui/src/panel_docking.rs | 37 +++++++++++++++++++++-------- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index ce8874e9d..320baa287 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -858,12 +858,15 @@ mod tests { #[test] fn partial_dock_tab_order_appends_tabs_missing_from_preferences() { - let mut ui = crate::state::UiState::default(); - ui.dock_tab_order = vec![DockTab::Layers]; + let ui = crate::state::UiState { + dock_tab: DockTab::Layers, + dock_tab_order: vec![DockTab::Layers, DockTab::Properties], + ..Default::default() + }; let order = floating::resolved_dock_tab_order(&ui); assert_eq!(order.len(), DockTab::ALL.len()); - assert_eq!(order[0], DockTab::Layers); - assert!(order.contains(&DockTab::Properties) && order.contains(&DockTab::Libraries)); + assert_eq!(order[..2], [DockTab::Layers, DockTab::Properties]); + assert_eq!(order[2], DockTab::Libraries); } #[test] diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 3ad795e8a..2c7247617 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -647,6 +647,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) return; } let before = app.ui.clone(); + let skip_reconcile = matches!(action, Some(Action::Reorder(_, _, _))); match action { Some(Action::Activate(gi, k)) => { if let Some(g) = app.ui.floating_panels.get_mut(gi) @@ -678,16 +679,15 @@ 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)) => { - let ids = app.ui.floating_panels.get(gi).and_then(|g| { - if from == to { None } else { Some((g.panels.get(from).cloned(), g.panels.get(to).cloned())) } - }); - if let Some((Some(from_id), Some(to_id))) = ids - && let Err(error) = app.run( - "window.panel.move", - json!({ "panel": from_id, "anchor": to_id, "before": to_id }), - ) + if let Some(g) = app.ui.floating_panels.get_mut(gi) + && from != to + && from < g.panels.len() + && to < g.panels.len() { - app.ui.status = error; + let active = g.panels.get(g.active).cloned(); + let moved = g.panels.remove(from); + g.panels.insert(to, moved); + g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); } } Some(Action::Undock(gi, grab)) => { @@ -705,7 +705,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) } None => {} } - if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + if !skip_reconcile && let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { app.ui.status = error; } ctx.data_mut(|data| { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index f714a47e2..58fe6d11f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -146,14 +146,15 @@ fn number(params: &Value, key: &str, default: f32) -> Result { /// group without promoting to a shared layout. fn legacy_same_strip_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result { use crate::state::DockTab; + const BAD_DEST: &str = "move requires an existing destination panel"; if crate::state::DockTab::from_id(panel).is_some() && DockTab::from_id(before).is_some() && crate::floating::group_of(&app.ui, panel).is_none() && crate::floating::group_of(&app.ui, before).is_none() { let mut order: Vec = crate::floating::docked_tabs(&app.ui).collect(); - let from = order.iter().position(|t| t.info().0 == panel).ok_or("panel is not in the dock strip")?; - let to = order.iter().position(|t| t.info().0 == before).ok_or("before is not in the dock strip")?; + let from = order.iter().position(|t| t.info().0 == panel).ok_or(BAD_DEST)?; + let to = order.iter().position(|t| t.info().0 == before).ok_or(BAD_DEST)?; if from != to { let item = order.remove(from); order.insert(to, item); @@ -161,13 +162,13 @@ fn legacy_same_strip_reorder(app: &mut VectorcraftApp, panel: &str, before: &str } return Ok(json!({ "panel": panel, "before": before })); } - let gi = crate::floating::group_of(&app.ui, panel).ok_or("panel is not in a tab group")?; + let gi = crate::floating::group_of(&app.ui, panel).ok_or(BAD_DEST)?; if crate::floating::group_of(&app.ui, before) != Some(gi) { - return Err("before must name a tab in the same group".into()); + return Err(BAD_DEST.into()); } - let g = app.ui.floating_panels.get_mut(gi).ok_or("panel group missing")?; - let from = g.panels.iter().position(|id| id == panel).ok_or("panel is not in the group")?; - let to = g.panels.iter().position(|id| id == before).ok_or("before is not in the group")?; + let g = app.ui.floating_panels.get_mut(gi).ok_or("invalid restored panel group")?; + let from = g.panels.iter().position(|id| id == panel).ok_or(BAD_DEST)?; + let to = g.panels.iter().position(|id| id == before).ok_or(BAD_DEST)?; if from != to { let active = g.panels.get(g.active).cloned(); let moved = g.panels.remove(from); @@ -329,9 +330,25 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - if app.ui.docking.is_none() && operation == "move" && params.get("before").is_some() { + if operation == "move" && params.get("before").is_some() { let before = params.get("before").and_then(Value::as_str).and_then(normalize).ok_or("before must name a panel")?; - return legacy_same_strip_reorder(app, panel, before); + let in_group = crate::floating::group_of(&app.ui, panel).is_some(); + use crate::state::DockTab; + let dock_strip_reorder = DockTab::from_id(panel).is_some() + && DockTab::from_id(before).is_some() + && crate::floating::group_of(&app.ui, panel).is_none() + && crate::floating::group_of(&app.ui, before).is_none(); + if app.ui.docking.is_none() || in_group || dock_strip_reorder { + let mirror_native_tabs = crate::floating::group_of(&app.ui, panel) + .and_then(|i| app.ui.floating_panels.get(i)) + .is_some_and(decorated); + let snapshot = app.ui.clone(); + let result = legacy_same_strip_reorder(app, panel, before); + if mirror_native_tabs && app.ui.docking.is_some() && result.is_ok() { + reconcile_native(app, snapshot)?; + } + return result; + } } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { return Err("saved panel layout is invalid; reset the workspace first".into()); @@ -935,7 +952,7 @@ pub(crate) fn legacy_tab_in_strip( 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()) + !begun && (response.drag_started() || (!inside_strip && response.dragged())) } else { response.drag_started() }; From 951d1705e3923245833fdbbf61a49ebfe6a240c4 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 03:58:04 +0300 Subject: [PATCH 4/5] Fix #2995: unify floating tab reorder with window.panel.move Route floating strip drags through the same command path as the dock strip and centralize group tab index moves in one helper. Co-authored-by: Cursor --- crates/ui-egui/src/floating.rs | 29 +++++++--- crates/ui-egui/src/panel_docking.rs | 84 +++++++++++++++++------------ 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 2c7247617..2022381c5 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -138,6 +138,16 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id)) } +/// Reorder tabs in a floating group by index, keeping the active tab on the same panel. +pub(crate) fn reorder_group_tab_indices(g: &mut FloatingPanels, from: usize, to: usize) { + if from != to && from < g.panels.len() && to < g.panels.len() { + let active = g.panels.get(g.active).cloned(); + let moved = g.panels.remove(from); + g.panels.insert(to, moved); + g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); + } +} + /// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone. pub fn set_of(ui: &UiState, gi: usize) -> Vec { match ui.floating_panels.get(gi).and_then(|g| g.column) { @@ -679,15 +689,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 != to - && from < g.panels.len() - && to < g.panels.len() + let ids = app.ui.floating_panels.get(gi).and_then(|g| { + let panel = g.panels.get(from)?; + let before = g.panels.get(to)?; + Some((panel.clone(), before.clone())) + }); + if let Some((panel, before)) = ids + && let Err(error) = app.run( + "window.panel.move", + json!({ "panel": panel, "anchor": before, "before": before }), + ) { - let active = g.panels.get(g.active).cloned(); - let moved = g.panels.remove(from); - g.panels.insert(to, moved); - g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); + app.ui.status = error; } } Some(Action::Undock(gi, grab)) => { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 58fe6d11f..38dc95b74 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -142,38 +142,55 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } +const LEGACY_REORDER_BAD_DEST: &str = "move requires an existing destination panel"; + +enum LegacyStripReorder { + DockStrip, + FloatingGroup(usize), +} + +fn legacy_strip_reorder_target( + app: &VectorcraftApp, + before: &str, + dock_strip: bool, + panel_group: Option, +) -> Result { + if dock_strip { + return Ok(LegacyStripReorder::DockStrip); + } + let gi = panel_group.ok_or(LEGACY_REORDER_BAD_DEST)?; + if crate::floating::group_of(&app.ui, before) != Some(gi) { + return Err(LEGACY_REORDER_BAD_DEST.into()); + } + Ok(LegacyStripReorder::FloatingGroup(gi)) +} + /// Legacy workspaces (no saved `docking` layout): reorder tabs in the dock strip or a floating /// group without promoting to a shared layout. -fn legacy_same_strip_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result { +fn legacy_same_strip_reorder( + app: &mut VectorcraftApp, + panel: &str, + before: &str, + target: LegacyStripReorder, +) -> Result { use crate::state::DockTab; - const BAD_DEST: &str = "move requires an existing destination panel"; - if crate::state::DockTab::from_id(panel).is_some() - && DockTab::from_id(before).is_some() - && crate::floating::group_of(&app.ui, panel).is_none() - && crate::floating::group_of(&app.ui, before).is_none() - { - let mut order: Vec = crate::floating::docked_tabs(&app.ui).collect(); - let from = order.iter().position(|t| t.info().0 == panel).ok_or(BAD_DEST)?; - let to = order.iter().position(|t| t.info().0 == before).ok_or(BAD_DEST)?; - if from != to { - let item = order.remove(from); - order.insert(to, item); - crate::floating::set_dock_tab_order(&mut app.ui, order); + match target { + LegacyStripReorder::DockStrip => { + let mut order: Vec = crate::floating::docked_tabs(&app.ui).collect(); + let from = order.iter().position(|t| t.info().0 == panel).ok_or(LEGACY_REORDER_BAD_DEST)?; + let to = order.iter().position(|t| t.info().0 == before).ok_or(LEGACY_REORDER_BAD_DEST)?; + if from != to { + let item = order.remove(from); + order.insert(to, item); + crate::floating::set_dock_tab_order(&mut app.ui, order); + } + } + LegacyStripReorder::FloatingGroup(gi) => { + let g = app.ui.floating_panels.get_mut(gi).ok_or("invalid restored panel group")?; + let from = g.panels.iter().position(|id| id == panel).ok_or(LEGACY_REORDER_BAD_DEST)?; + let to = g.panels.iter().position(|id| id == before).ok_or(LEGACY_REORDER_BAD_DEST)?; + crate::floating::reorder_group_tab_indices(g, from, to); } - return Ok(json!({ "panel": panel, "before": before })); - } - let gi = crate::floating::group_of(&app.ui, panel).ok_or(BAD_DEST)?; - if crate::floating::group_of(&app.ui, before) != Some(gi) { - return Err(BAD_DEST.into()); - } - let g = app.ui.floating_panels.get_mut(gi).ok_or("invalid restored panel group")?; - let from = g.panels.iter().position(|id| id == panel).ok_or(BAD_DEST)?; - let to = g.panels.iter().position(|id| id == before).ok_or(BAD_DEST)?; - if from != to { - let active = g.panels.get(g.active).cloned(); - let moved = g.panels.remove(from); - g.panels.insert(to, moved); - g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); } Ok(json!({ "panel": panel, "before": before })) } @@ -332,18 +349,19 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result Date: Tue, 20 Oct 2026 05:55:06 +0300 Subject: [PATCH 5/5] Fix floating tab reorder deadlock by calling strip_tab_reorder in-frame. Route dock and floating strip reorders through shared logic, sync layout.floating order, and assert reorder via window.panel.move in the harness test. Co-authored-by: Cursor --- crates/ui-egui/src/dock.rs | 5 +--- crates/ui-egui/src/floating.rs | 12 ++++----- crates/ui-egui/src/panel_docking.rs | 42 +++++++++++++++++++---------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 320baa287..321cef6a0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -301,10 +301,7 @@ pub(crate) fn tab_reorder_on_release( 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 let Err(error) = app.run( - "window.panel.move", - json!({ "panel": from_tab.info().0, "anchor": to_tab.info().0, "before": to_tab.info().0 }), - ) { + if let Err(error) = crate::panel_docking::strip_tab_reorder(app, from_tab.info().0, to_tab.info().0) { app.ui.status = error; } } diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 2022381c5..72098d1a5 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -695,13 +695,11 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) Some((panel.clone(), before.clone())) }); if let Some((panel, before)) = ids - && let Err(error) = app.run( - "window.panel.move", - json!({ "panel": panel, "anchor": before, "before": before }), - ) + && let Err(error) = crate::panel_docking::strip_tab_reorder(app, &panel, &before) { app.ui.status = error; } + return; } Some(Action::Undock(gi, grab)) => { let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; @@ -1297,9 +1295,9 @@ mod tests { 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()); + h.app + .run("window.panel.move", json!({ "panel": "color", "anchor": "swatches", "before": "swatches" })) + .unwrap(); assert_eq!(h.groups(), [["color", "swatches"]]); } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 38dc95b74..e051b904f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -190,11 +190,31 @@ fn legacy_same_strip_reorder( let from = g.panels.iter().position(|id| id == panel).ok_or(LEGACY_REORDER_BAD_DEST)?; let to = g.panels.iter().position(|id| id == before).ok_or(LEGACY_REORDER_BAD_DEST)?; crate::floating::reorder_group_tab_indices(g, from, to); + if let Some(layout) = app.ui.docking.as_mut() { + let panels = g.panels.clone(); + let active = g.active.min(panels.len().saturating_sub(1)); + if let Some(group) = layout.floating.iter_mut().find(|group| group.panels.iter().any(|id| id == panel)) { + group.panels = panels; + group.active = active; + } + } } } Ok(json!({ "panel": panel, "before": before })) } +/// Same-strip tab reorder for `window.panel.move` and in-frame tab drags (`app.run` during egui deadlocks). +pub(crate) fn strip_tab_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result { + use crate::state::DockTab; + let panel_group = crate::floating::group_of(&app.ui, panel); + let dock_strip_reorder = DockTab::from_id(panel).is_some() + && DockTab::from_id(before).is_some() + && panel_group.is_none() + && crate::floating::group_of(&app.ui, before).is_none(); + let target = legacy_strip_reorder_target(app, before, dock_strip_reorder, panel_group)?; + legacy_same_strip_reorder(app, panel, before, target) +} + fn placement(params: &Value) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; @@ -356,16 +376,7 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result bool { { return app.ui.docking.is_some(); } - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("vectorcraft-panel-docking")); + ui.ctx() + .data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { if tab_drop @@ -848,7 +861,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { { continue; } - if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + if let Err(error) = apply_action(app, action) { app.ui.status = error; } } @@ -1129,9 +1142,10 @@ mod tests; /// Commit a pointer drop after shared and native panel windows have finished rendering. pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { - if let Some((command, params)) = + if let Some((id, params)) = ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) - && let Err(error) = app.run(command, params) + && let Some(result) = command(app, id, ¶ms) + && let Err(error) = result { app.ui.status = error; }