diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 944842de14..9d89213a39 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -5,7 +5,7 @@ //! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a //! group to its tab strip, drag a tab strip (outside its tabs, or a tab dragged off the strip) to //! move the group up or down the column, and drag a tab along its strip to reorder the group's -//! tabs (#2272); Window › Workspace › Lock Workspace stops all three moves. +//! tabs (#2272), or drop a tab on another group's strip to move only that tab. Window › Workspace › Lock Workspace stops all these moves. //! //! The layout is [`DockLayout`] in `UiState::dock` (serialisable, drivable with `ui.set`), saved //! with Window › Workspace › New Workspace…, reset by Reset Workspace, and remembered across @@ -13,7 +13,7 @@ use std::collections::BTreeMap; -use egui::{Rect, Sense, Stroke, pos2, vec2}; +use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -428,6 +428,8 @@ fn rects_id() -> egui::Id { #[derive(Clone, Debug, PartialEq)] pub struct StripRects { pub group: Group, + /// The area accepting a tab drop, excluding the group menu. + pub area: Rect, /// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu). pub tabs: Vec<(usize, Rect)>, /// The panel menu button. @@ -456,6 +458,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod let locked = app.session.prefs().workspace_locked; let rects = rects_after_layout(&heights, area); let mut actions: Vec = Vec::new(); + let mut tab_drops: Vec<(Group, usize, Pos2)> = Vec::new(); let mut dragging: Option = None; let mut strips: Vec = Vec::with_capacity(rects.len()); for (i, (g, rect)) in rects.iter().copied().enumerate() { @@ -499,6 +502,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } strips.push(StripRects { group: g, + area: Rect::from_min_max(pos2(rect.left(), resp.menu.rect.top()), pos2(resp.menu.rect.left(), resp.menu.rect.bottom())), tabs: resp.tabs.iter().filter_map(|(i, r)| indices.get(*i).map(|original| (*original, *r))).collect(), menu: resp.menu.rect, chevron: resp.chevron, @@ -530,16 +534,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod actions.push(Action::MoveTab(g, tab, before.and_then(|b| indices.get(b).copied()))); } // A tab dragged off its strip moves the whole group, as the strip itself does. - if !locked && app.ui.docking.is_none() && (resp.strip.dragged() || resp.tab_dragging_out) { + if !locked && app.ui.docking.is_none() && (resp.strip.dragged() || resp.tab_dragging_out.is_some()) { dragging = Some(g); } if !locked && app.ui.docking.is_none() - && (resp.strip.drag_stopped() || resp.tab_dropped_out) + && resp.strip.drag_stopped() && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) { actions.push(Action::Move(g, drop_before(&order, &rects, g, p.y))); } + if !locked + && app.ui.docking.is_none() + && let Some(shown_tab) = resp.tab_dropped_out + // The strip uses visible indices; the drop resolves the panel by its original one. + && let Some(&tab) = indices.get(shown_tab) + && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) + { + tab_drops.push((g, tab, p)); + } if dragging == Some(g) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } @@ -589,6 +602,23 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } } } + let mut panel_moves = Vec::new(); + for (from, tab, position) in tab_drops { + let target = strips.iter().find(|strip| strip.group != from && strip.area.contains(position)); + if let Some(target) = target { + let pro = t.pro; + let source = from.tabs(pro).get(tab).and_then(|label| crate::panel_docking::normalize(label)); + let slot = crate::tab_strip::drop_slot(&target.tabs, target.area, position); + let before = slot.and_then(|slot| target.tabs.get(slot).map(|(index, _)| *index)); + let anchor = before.or_else(|| target.tabs.first().map(|(index, _)| *index)); + let destination = anchor.and_then(|index| target.group.tabs(pro).get(index).and_then(|label| crate::panel_docking::normalize(label))); + if let (Some(source), Some(destination)) = (source, destination) { + panel_moves.push((source, destination, before.is_some())); + } + } else { + actions.push(Action::Move(from, drop_before(&order, &rects, from, position.y))); + } + } // Drop indicator while a group is dragged by its tab strip. if let (Some(g), Some(p)) = (dragging, ui.ctx().pointer_interact_pos()) { let before = drop_before(&order, &rects, g, p.y); @@ -633,6 +663,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } } } + for (panel, anchor, before) in panel_moves { + let params = if before { json!({"panel":panel,"anchor":anchor,"before":anchor}) } else { json!({"panel":panel,"anchor":anchor}) }; + if let Err(error) = app.run("window.panel.move", params) { + app.ui.status = error; + } + } } fn rects_after_layout(heights: &[(Group, f32)], area: Rect) -> Vec<(Group, Rect)> { diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index 063456d375..47623dff96 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -382,6 +382,67 @@ fn double_clicking_a_tab_collapses_and_dragging_a_strip_reorders() { assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers)); } +#[test] +fn dragging_a_tab_onto_another_group_moves_only_that_panel() { + let (app, _, _) = app_with_layers(); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let strips = last_strips(&h.ctx); + let source = strips + .iter() + .find(|strip| strip.group == Group::Color) + .and_then(|strip| strip.tabs.iter().find(|(index, _)| *index == 1)) + .map(|(_, rect)| rect.center()) + .unwrap(); + let destination = strips.iter().find(|strip| strip.group == Group::Layers).and_then(|strip| strip.tabs.first()).map(|(_, rect)| rect.center()).unwrap(); + + drag(&mut h, source, destination); + + let layout = h.state().ui.docking.as_ref().expect("moving one native tab promotes to the shared layout"); + fn tabs<'a>(node: &'a craft_ui::docking::Node, panel: &str) -> Option<&'a [String]> { + match node { + craft_ui::docking::Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => Some(panels), + craft_ui::docking::Node::Split { first, second, .. } => tabs(first, panel).or_else(|| tabs(second, panel)), + _ => None, + } + } + let source_group = tabs(layout.root.as_ref().unwrap(), "swatches").unwrap(); + assert!(source_group.contains(&"layers".to_owned()), "dropped into the destination tab group"); + assert!(!source_group.contains(&"color".to_owned()), "the source group remains intact"); + assert!(crate::panel_docking::valid(layout)); +} + +/// The strip shows reordered tabs by position; the drop moves the panel that was dragged, not +/// the one at that position in the default order. +#[test] +fn dragging_a_reordered_tab_onto_another_group_moves_that_panel() { + let (mut app, _, _) = app_with_layers(); + // Gradients (original tab 2) drawn first: Gradients | Color | Swatches | Patterns. + app.ui.dock.move_tab(Group::Color, 2, Some(0), true); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let strips = last_strips(&h.ctx); + let source = strips + .iter() + .find(|strip| strip.group == Group::Color) + .and_then(|strip| strip.tabs.iter().find(|(index, _)| *index == 2)) + .map(|(_, rect)| rect.center()) + .unwrap(); + let destination = strips.iter().find(|strip| strip.group == Group::Layers).and_then(|strip| strip.tabs.first()).map(|(_, rect)| rect.center()).unwrap(); + + drag(&mut h, source, destination); + + let layout = h.state().ui.docking.as_ref().expect("moving one native tab promotes to the shared layout"); + fn tabs<'a>(node: &'a craft_ui::docking::Node, panel: &str) -> Option<&'a [String]> { + match node { + craft_ui::docking::Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => Some(panels), + craft_ui::docking::Node::Split { first, second, .. } => tabs(first, panel).or_else(|| tabs(second, panel)), + _ => None, + } + } + let moved = tabs(layout.root.as_ref().unwrap(), "gradients").unwrap(); + assert!(moved.contains(&"layers".to_owned()), "the dragged Gradients tab joined Layers: {moved:?}"); + assert!(!moved.contains(&"color".to_owned()), "Color stayed in its group: {moved:?}"); +} + #[test] fn tiny_windows_do_not_panic() { for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { @@ -975,14 +1036,14 @@ fn dragging_a_tab_reorders_the_group_and_the_order_persists() { } } -/// Dragging a tab off its strip moves the whole group (as dragging the strip does); a locked -/// workspace keeps both the tab and group order. +/// Dragging a tab onto another group's body moves the whole group; onto its tab strip moves only +/// the tab. A locked workspace keeps both the tab and group order. #[test] fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() { let (app, _, _) = app_with_layers(); let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); let paths = tab_rect(&h, Group::Layers, 2); - let to = rect_of(&h, Group::Color).left_top() + vec2(120.0, 10.0); + let to = rect_of(&h, Group::Color).left_top() + vec2(120.0, 40.0); drag(&mut h, paths.center(), to); assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers)); assert!(h.state().ui.dock.tab_order.is_empty(), "the tabs kept their order"); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 8c219bdd6c..1c4cd92f9d 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -51,7 +51,7 @@ fn cancel_legacy_drag(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } } -fn normalize(raw: &str) -> Option<&'static str> { +pub(crate) fn normalize(raw: &str) -> Option<&'static str> { PANELS.iter().find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())).map(|p| p.0) } diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index 576bb0cb77..f7c9043821 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -44,10 +44,10 @@ pub struct StripOut { /// A tab was dragged along the strip and dropped: `(tab, before)`, draw `tab` just before /// tab `before` (`None`: last). pub reorder: Option<(usize, Option)>, - /// A tab is being dragged away from the strip (the dock moves the whole group then). - pub dragging_out: bool, - /// A tab drag ended away from the strip. - pub dropped_out: bool, + /// The index of a tab being dragged away from this strip. + pub dragging_out: Option, + /// The index of a tab whose drag ended away from this strip. + pub dropped_out: Option, } /// Width of the » overflow button. @@ -110,8 +110,8 @@ fn tabs_in( responses: Vec::with_capacity(f.shown.len()), chevron: None, reorder: None, - dragging_out: false, - dropped_out: false, + dragging_out: None, + dropped_out: None, }; // Without reordering, drags on a tab fall through to the strip behind it. let sense = if reorderable { Sense::click_and_drag() } else { Sense::click() }; @@ -184,8 +184,8 @@ fn tabs_in( crate::widgets::drop_line(ui, *r, slot >= out.tabs.len(), true, &t); } } - None if released => out.dropped_out = true, - None => out.dragging_out = true, + None if released => out.dropped_out = Some(tab), + None => out.dragging_out = Some(tab), } } out diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d8b85eec5c..a0c9f00708 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -80,10 +80,10 @@ pub struct CardResponse { pub chevron: Option, /// A tab dragged along the strip was dropped: `(tab, before)` (#2272). pub tab_reorder: Option<(usize, Option)>, - /// A tab is being dragged away from the strip. - pub tab_dragging_out: bool, - /// A tab drag ended away from the strip. - pub tab_dropped_out: bool, + /// The index of a tab being dragged away from the strip. + pub tab_dragging_out: Option, + /// The index of a tab whose drag ended away from the strip. + pub tab_dropped_out: Option, } impl CardResponse {