diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8e0c63dcf..321cef6a0 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,38 @@ 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), +/// as in Illustrator's workspace basics +/// (). +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 pointer = pointer.filter(|p| p.is_finite())?; + 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 let Err(error) = crate::panel_docking::strip_tab_reorder(app, from_tab.info().0, to_tab.info().0) { + app.ui.status = error; + } +} + pub(crate) struct PanelTabs { pub responses: Vec<(usize, egui::Response)>, pub picked: Option, @@ -690,6 +725,7 @@ mod tests { fn arrow(&self) -> egui::Pos2 { egui::pos2(SCREEN.x - 10.0, HEADER / 2.0) } + } fn icon_panel_count() -> usize { @@ -801,6 +837,35 @@ mod tests { assert_eq!(h.app.ui.open_panel, None, "a click in the docked group puts {open:?} away"); } + #[test] + 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(&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 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[..2], [DockTab::Layers, DockTab::Properties]); + assert_eq!(order[2], DockTab::Libraries); + } + #[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..72098d1a5 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) { @@ -209,9 +219,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 + '_ { - DockTab::ALL.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. @@ -566,6 +597,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), } @@ -624,6 +657,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) @@ -654,6 +688,19 @@ 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| { + 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) = 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 }; let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; @@ -669,7 +716,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| { @@ -889,12 +936,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 +1289,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"]]); + h.app + .run("window.panel.move", json!({ "panel": "color", "anchor": "swatches", "before": "swatches" })) + .unwrap(); + 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..e051b904f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -142,6 +142,79 @@ 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, + target: LegacyStripReorder, +) -> Result { + use crate::state::DockTab; + 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); + 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")?; @@ -294,6 +367,18 @@ 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 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")?; + 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(); + if app.ui.docking.is_none() || panel_group.is_some() || dock_strip_reorder { + return strip_tab_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()); } @@ -766,7 +851,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> 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 @@ -774,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; } } @@ -857,6 +944,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 +977,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() { + !begun && (response.drag_started() || (!inside_strip && response.dragged())) + } else { + response.drag_started() + }; + if begin && let Some(id) = normalize(panel) { let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); @@ -1035,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; } 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,