From ba2a5bfaed725531f66d61804d7b2affb859d937 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 00:18:37 +0300 Subject: [PATCH] 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()); }