Move dock tabs between groups individually (#3239)

* Report which dock tab leaves its strip

* Carry dragged dock tab identity

* Expose dock panel name normalization

* Move individual tabs across native dock groups

* Document cross-group dock tab drops

* Test individual tab drops between dock groups

* Resolve a dropped tab by its original index; rustfmt

The strip reports visible indices, so a reordered group moved the wrong
panel. Adds a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
imSlicedBread
2026-10-11 12:10:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 66c0b59a23
commit 28622aabfc
5 changed files with 117 additions and 20 deletions
+40 -4
View File
@@ -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<Action> = Vec::new();
let mut tab_drops: Vec<(Group, usize, Pos2)> = Vec::new();
let mut dragging: Option<Group> = None;
let mut strips: Vec<StripRects> = 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)> {
+64 -3
View File
@@ -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<String>, 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<String>, 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");
+1 -1
View File
@@ -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)
}
+8 -8
View File
@@ -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<usize>)>,
/// 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<usize>,
/// The index of a tab whose drag ended away from this strip.
pub dropped_out: Option<usize>,
}
/// 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
+4 -4
View File
@@ -80,10 +80,10 @@ pub struct CardResponse {
pub chevron: Option<Rect>,
/// A tab dragged along the strip was dropped: `(tab, before)` (#2272).
pub tab_reorder: Option<(usize, Option<usize>)>,
/// 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<usize>,
/// The index of a tab whose drag ended away from the strip.
pub tab_dropped_out: Option<usize>,
}
impl CardResponse {