{
+ /// The layout without its sizes and rectangles: which panels are where, and their state.
+ /// A run of splits along one axis nests one way (`a | (b | c)`), since nesting it the
+ /// other way only moves sizes between its members.
+ pub(super) fn shape(&self) -> Layout
{
+ match n {
+ Node::Split { axis, .. } => {
+ let mut members = Vec::new();
+ run(n, *axis, &mut members);
+ let mut shaped = members.into_iter().rev().map(node);
+ let last = shaped.next().unwrap_or(Node::Tabs { panels: Vec::new(), active: 0 });
+ shaped.fold(last, |second, first| Node::Split {
+ axis: *axis,
+ size: SplitSize::Ratio(0.5),
+ first: Box::new(first),
+ second: Box::new(second),
+ })
+ }
+ Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active },
+ Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| super::StackEntry { height: None, ..e.clone() }).collect() },
+ Node::Dock { side, columns } => Node::Dock {
+ side: *side,
+ columns: columns
+ .iter()
+ .map(|c| Column {
+ groups: c.groups.iter().map(|g| Group { height: None, ..g.clone() }).collect(),
+ width: 0.0,
+ iconic: c.iconic,
+ iconic_width: None,
+ })
+ .collect(),
+ },
+ }
+ }
+ Layout {
+ root: self.root.as_ref().map(node),
+ floating: self
+ .floating
+ .iter()
+ .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() })
+ .collect(),
+ }
+ }
+
+ /// Whether `other` arranges the panels as this layout does: equal apart from split sizes,
+ /// column widths, group heights and window rectangles. A drop that leads from one to the
+ /// other changes nothing the user would see as a move.
+ pub(super) fn same_arrangement(&self, other: &Self) -> bool {
+ self.shape() == other.shape()
+ }
+
pub(super) fn place(&self, panel: &P) -> Option {
for (w, window) in self.floating.iter().enumerate() {
for (leaf, path) in leaves(&window.root) {
diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs
index b6006aa5..0098aeb5 100644
--- a/crates/ui/src/docking/drop_tests.rs
+++ b/crates/ui/src/docking/drop_tests.rs
@@ -315,3 +315,120 @@ fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() {
}
}
}
+
+/// Whether the last frame painted a drop preview: an accent line, outline or fill.
+fn previews(d: &Driver) -> bool {
+ let accent = accent();
+ d.shapes().iter().any(|clipped| match &clipped.shape {
+ egui::Shape::Rect(shape) => shape.fill == accent || shape.fill == accent.gamma_multiply(0.08) || shape.stroke.color == accent,
+ egui::Shape::Path(path) => path.fill != Color32::TRANSPARENT && path.fill == accent.gamma_multiply(0.3),
+ _ => false,
+ })
+}
+
+fn ratio_split(axis: SplitAxis, ratio: f32, first: Node, second: Node) -> Node {
+ Node::Split { axis, size: SplitSize::Ratio(ratio), first: Box::new(first), second: Box::new(second) }
+}
+
+#[test]
+fn a_drop_that_changes_only_sizes_shows_nothing_and_does_nothing() {
+ // CADCraft's sidebar: Layers (2) alone above Properties (3) and a third group (4), beside
+ // the canvas (1); and the same with the column as one two-group split.
+ let nested = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), ratio_split(SplitAxis::Vertical, 0.5, tabs(&[3]), tabs(&[4])));
+ let flat = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), tabs(&[3]));
+ for column in [nested, flat] {
+ let layout = Layout { root: Some(ratio_split(SplitAxis::Horizontal, 0.6, tabs(&[1]), column)), floating: Vec::new() };
+ let start = || DockDriver::new(layout.clone(), Bodies::default());
+ let d = start();
+ let (body, rect) = group(&d, 3);
+ let top = pos2(body.center().x, body.top() + body.height() * 0.2);
+ // Properties' bottom third moves Layers, with a line.
+ let mut d = start();
+ d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2));
+ assert!(previews(&d) && d.filled(line(rect, Zone::Bottom)));
+ // Its top third would put Layers back where it is.
+ let mut d = start();
+ d.hold(tab(&d, 2), top);
+ assert!(!previews(&d), "no line, outline or fill");
+ let out = d.release(top);
+ assert!(out.actions.is_empty(), "{:?}", out.actions);
+ assert_eq!(d.layout, layout);
+ }
+ // A lone frame's tab on the layout edge its frame already holds.
+ let layout = Layout { root: Some(ratio_split(SplitAxis::Vertical, 0.75, tabs(&[1]), tabs(&[4]))), floating: Vec::new() };
+ let mut d = DockDriver::new(layout.clone(), Bodies::default());
+ let at = pos2(400.0, 590.0);
+ d.hold(tab(&d, 4), at);
+ assert!(!previews(&d));
+ let out = d.release(at);
+ assert!(out.actions.is_empty(), "{:?}", out.actions);
+ assert_eq!(d.layout, layout);
+}
+
+#[test]
+fn a_dock_drop_that_changes_only_heights_shows_nothing_and_does_nothing() {
+ // A dock column of a sized lone group (2) above another (3), beside the canvas (1).
+ let column = Column::new(vec![Group { height: Some(150.0), ..Group::new(vec![2], 0) }, Group::new(vec![3], 0)], 240.0);
+ let dock = Node::Dock { side: Side::End, columns: vec![column] };
+ let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(240.0), first: Box::new(tabs(&[1])), second: Box::new(dock) };
+ let layout = Layout { root: Some(root), floating: Vec::new() };
+ let start = || DockDriver::new(layout.clone(), Bodies::default());
+ let d = start();
+ let (body, rect) = group(&d, 3);
+ let (upper, _) = group(&d, 2);
+ // Below the lower group moves the upper one, with a line.
+ let mut d = start();
+ d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2));
+ assert!(previews(&d));
+ // The lower group's top third and the seam between them put the group back where it is.
+ for at in [pos2(body.center().x, body.top() + body.height() * 0.2), pos2(body.center().x, (upper.bottom() + rect.top()) * 0.5)] {
+ let mut d = start();
+ d.hold(tab(&d, 2), at);
+ assert!(!previews(&d), "{at:?}: no line");
+ let out = d.release(at);
+ assert!(out.actions.is_empty(), "{at:?}: {:?}", out.actions);
+ assert_eq!(d.layout, layout);
+ }
+}
+
+#[test]
+fn a_header_drag_of_a_windows_only_group_looks_through_its_window() {
+ // Group 4 floats alone over frame 1's left part.
+ let mut layout = frames();
+ layout.floating.push(Floating::single(vec![4], 0, [40.0, 150.0, 300.0, 260.0]));
+ let start = || DockDriver::new(layout.clone(), Bodies::default());
+ let d = start();
+ let (body, rect) = group(&d, 1);
+ let window = Rect::from_min_size(pos2(40.0, 150.0), vec2(300.0, 260.0));
+ let at = pos2(body.left() + body.width() * 0.25, body.center().y);
+ assert!(window.contains(at));
+ let mut d = start();
+ let bar = d.hit(&Hit::DragBar(4)).unwrap().center();
+ d.hold(bar, at);
+ assert!(d.filled(line(rect, Zone::Left)), "frame 1's left third, behind the window");
+ let out = d.release(at);
+ assert_eq!(out.actions, vec![Action::MoveGroup { panel: 4, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]);
+}
+
+#[test]
+fn a_header_drag_in_a_window_of_two_groups_still_offers_the_other_group() {
+ let mut layout = frames();
+ let tree = ratio_split(SplitAxis::Vertical, 0.5, tabs(&[4]), tabs(&[5]));
+ layout.floating.push(Floating::new(tree, [40.0, 100.0, 300.0, 400.0]));
+ let start = || DockDriver::new(layout.clone(), Bodies::default());
+ let d = start();
+ let (body, rect) = group(&d, 5);
+ let mut d = start();
+ let bar = d.hit(&Hit::DragBar(4)).unwrap().center();
+ d.hold(bar, body.center());
+ assert!(d.outlined(rect.shrink(1.0)), "group 5 joins");
+ let out = d.release(body.center());
+ assert!(
+ matches!(
+ &out.actions[..],
+ [Action::MoveGroup { panel: 4, to: Target::At { anchor: 5, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } }]
+ ),
+ "{:?}",
+ out.actions
+ );
+}
diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs
index fc071e66..8406ec5e 100644
--- a/crates/ui/src/docking/group_tests.rs
+++ b/crates/ui/src/docking/group_tests.rs
@@ -503,7 +503,8 @@ fn release_over_own_position_is_a_noop() {
#[test]
fn floating_border_and_drag_bar_move_and_cancel_as_a_group() {
for name in ["drag-border", "drag-bar"] {
- let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
+ // Nothing docked: a header drag of a window's only group looks through the window.
+ let layout = Layout { root: None, floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout.clone());
let from = d.rect(name, 5).center();
d.hold(from, from + vec2(50.0, 40.0));
@@ -522,7 +523,7 @@ fn floating_border_and_drag_bar_move_and_cancel_as_a_group() {
#[test]
fn floating_border_movement_does_not_accumulate_fractional_step_rounding() {
- let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
+ let layout = Layout { root: None, floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout);
let from = d.rect("drag-border", 5).center();
d.step(vec![Event::PointerMoved(from), button(from, true)]);
diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs
index be0aeee4..19c97607 100644
--- a/crates/ui/src/docking/render/dock.rs
+++ b/crates/ui/src/docking/render/dock.rs
@@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p
use super::{GroupKind, Renderer, RowItem, row_drag, row_range, stripless};
use crate::docking::{
- Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone,
+ Action, Column, DockEvent, DockHeights, DockStyle, DropTarget, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone,
dock_model::Unit,
targets::{
DockDrop, DockTarget, SIDE_EDGE_ZONE_DEPTH, STRIP_REACH, body_zone, column_edge, drop_zone, horizontal_line, resolve_dock, usable,
@@ -636,18 +636,25 @@ impl Renderer<'_, P> {
/// takes tabs: the strip joins the group, and border bands reach into it only
/// [`STRIP_REACH`] from their lines. Not with [`DockStyle::side_edge_zones`].
pub(super) fn in_tab_strip(&self, pointer: Pos2) -> bool {
+ self.in_tab_strip_past(pointer, &[])
+ }
+
+ /// [`Self::in_tab_strip`] past the groups holding any of `through`.
+ fn in_tab_strip_past(&self, pointer: Pos2, through: &[P]) -> bool {
+ let own = |t: &DropTarget
| t.tabs.iter().any(|(p, _)| through.contains(p));
!self.style.side_edge_zones
- && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer)).is_some_and(|t| {
+ && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer) && !own(t)).is_some_and(|t| {
t.accepts_tabs && t.tab_strip.map(|strip| strip.intersect(t.rect)).is_some_and(|strip| usable(strip) && strip.contains(pointer))
})
}
/// The dock target at `pointer`: a band at a border, else a side zone of a dock group's
/// body ([`SIDE_EDGE_ZONE_DEPTH`] deep with [`DockStyle::side_edge_zones`]). The middle of a
- /// group that takes tabs is none: the group's own target joins it.
- fn dock_drop(&self, pointer: Pos2) -> Option> {
+ /// group that takes tabs is none: the group's own target joins it. The groups holding
+ /// `through` (a floating window the drag looks through) hide no strip.
+ fn dock_drop(&self, pointer: Pos2, through: &[P]) -> Option> {
let mut candidates = self.dock_candidates();
- let strip = self.in_tab_strip(pointer);
+ let strip = self.in_tab_strip_past(pointer, through);
if strip {
candidates.retain(|t| t.line.distance_to_pos(pointer) <= STRIP_REACH);
}
@@ -670,11 +677,11 @@ impl Renderer<'_, P> {
/// The dock target a whole group or column drag would take at `pointer`, with the action
/// and its line. A target that leaves the layout unchanged is a no-op: `None` action.
pub(super) fn dock_group_proposal(&self, drag: &GroupDrag
, pointer: Pos2) -> Option<(Option>, Rect)> {
- let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel);
- if self.dock_covered(pointer, moving) {
+ let through = self.looks_through(drag);
+ if self.dock_covered(pointer, through.then_some(&drag.panel)) {
return None;
}
- let drop = self.dock_drop(pointer)?;
+ let drop = self.dock_drop(pointer, if through { &drag.members } else { &[] })?;
let action = group_action(drag, drop.target.to());
self.checked(action).map(|action| (action, drop.line))
}
@@ -686,7 +693,7 @@ impl Renderer<'_, P> {
if self.dock_covered(pointer, None) {
return None;
}
- let drop = self.dock_drop(pointer)?;
+ let drop = self.dock_drop(pointer, &[])?;
let action = match drop.target.to() {
// A target named by the dragged panel is its own group or column: another panel
// there names the same one. With none, the drop leaves the panel where it is.
@@ -707,11 +714,12 @@ impl Renderer<'_, P> {
self.checked(action).map(|action| (action, drop.line))
}
- /// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid.
+ /// `Some(None)` when the action is valid but changes nothing apart from sizes
+ /// ([`crate::docking::Layout::same_arrangement`]), `None` when it is invalid.
fn checked(&self, action: Action