From 1e6f127844ec61b3b8afdcc2bb76e7cfe470153f Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:13:01 -0400
Subject: [PATCH] docking: a drop beside a too-narrow frame at its row's edge
takes room from the other side (#353)
---
crates/ui/src/docking/dock_model.rs | 24 ++++++++++++------
crates/ui/src/docking/model.rs | 7 +++---
crates/ui/src/docking/model_tests.rs | 9 +++++--
crates/ui/src/docking/render/preview.rs | 24 +++++++++---------
crates/ui/src/docking/split_tests.rs | 33 +++++++++++++++++++++++++
5 files changed, 72 insertions(+), 25 deletions(-)
diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs
index 0c318500..12e8468d 100644
--- a/crates/ui/src/docking/dock_model.rs
+++ b/crates/ui/src/docking/dock_model.rs
@@ -326,17 +326,25 @@ pub(super) fn split_frame
(root: &mut Node
, path: &[bool], new: Node
, zo
true
}
-/// [`split_frame`] with the sizes of [`Placement::SplitSized`]: the new split takes `size`, and
-/// the nearest enclosing split whose divider runs along the frame's `zone` side takes `outer`.
-/// False when there is no such split, or a dock is beyond it.
-pub(super) fn split_frame_sized
(root: &mut Node
, path: &[bool], new: Node
, zone: Zone, size: SplitSize, outer: SplitSize) -> bool {
- let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
+/// The depth along the frame `path` of the split whose size a [`Placement::SplitSized`] on
+/// `zone` replaces: the nearest enclosing split along the zone's axis (`on_axis` at a depth)
+/// whose divider runs along the frame's `zone` side, else, for a frame at the edge of its
+/// area on that side, the nearest one along its opposite side.
+pub(super) fn sized_split_depth(path: &[bool], zone: Zone, on_axis: impl Fn(usize) -> bool) -> Option {
// A divider on the frame's start side (left or top) belongs to a split holding the frame
// in its second child.
let start = matches!(zone, Zone::Left | Zone::Top);
- let enclosing = (0..path.len()).rev().find(|&depth| {
- path.get(depth) == Some(&start) && matches!(node_at(root, &path[..depth]), Some(Node::Split { axis: own, .. }) if *own == axis)
- });
+ let nearest = |second: bool| (0..path.len()).rev().find(|&depth| path.get(depth) == Some(&second) && on_axis(depth));
+ nearest(start).or_else(|| nearest(!start))
+}
+
+/// [`split_frame`] with the sizes of [`Placement::SplitSized`]: the new split takes `size`, and
+/// the split [`sized_split_depth`] finds takes `outer`. False when there is no such split, or a
+/// dock is beyond it.
+pub(super) fn split_frame_sized(root: &mut Node
, path: &[bool], new: Node
, zone: Zone, size: SplitSize, outer: SplitSize) -> bool {
+ let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
+ let enclosing =
+ sized_split_depth(path, zone, |depth| matches!(node_at(root, &path[..depth]), Some(Node::Split { axis: own, .. }) if *own == axis));
let Some(depth) = enclosing else { return false };
let Some(Node::Split { size: own, first, second, .. }) = node_at_mut(root, &path[..depth]) else { return false };
if zone == Zone::Center || matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }) {
diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs
index 11d0f3e5..0c90c140 100644
--- a/crates/ui/src/docking/model.rs
+++ b/crates/ui/src/docking/model.rs
@@ -310,9 +310,10 @@ pub enum Placement
{
/// A side [`Placement::Split`] that gives the new frame its own room instead of half the
/// anchor's frame: the new split takes `size`, and `outer` replaces the size of the nearest
/// enclosing split whose divider runs along the anchor frame's `zone` side, so the frames
- /// beyond that divider give the room. The renderer offers it beside a frame too small to
- /// halve. `Center`, and an anchor without such a divider or with a dock beyond it, are
- /// invalid.
+ /// beyond that divider give the room. An anchor at the edge of its area on that side takes
+ /// the nearest such split along its opposite side instead. The renderer offers it beside a
+ /// frame too small to halve. `Center`, and an anchor without either divider or with a dock
+ /// beyond it, are invalid.
SplitSized { zone: Zone, size: SplitSize, outer: SplitSize },
/// A new group above or below the anchor's group in a dock column. On an anchor in a
/// floating window, a split of the anchor's group above or below.
diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs
index 6bf2553d..4df96d21 100644
--- a/crates/ui/src/docking/model_tests.rs
+++ b/crates/ui/src/docking/model_tests.rs
@@ -786,8 +786,13 @@ fn a_sized_split_sets_the_new_split_and_the_enclosing_one_beside_the_anchor() {
moved.apply(Action::Move { panel: 4, anchor: 3, placement: sized(Zone::Left) }).unwrap();
let inner = hsplit(SplitSize::FixedSecond(164.0), tabs(&[2], 0), hsplit(SplitSize::FixedFirst(100.0), tabs(&[4], 0), tabs(&[3], 0)));
assert_eq!(moved.root, Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), inner)));
- // 1 has no divider on its left, and a center is no side.
- for (anchor, zone) in [(1, Zone::Left), (3, Zone::Center)] {
+ // 1 has no divider on its left: the split on its right gives the room.
+ let mut moved = layout.clone();
+ moved.apply(Action::Move { panel: 4, anchor: 1, placement: sized(Zone::Left) }).unwrap();
+ let rest = hsplit(SplitSize::FixedSecond(60.0), tabs(&[2], 0), tabs(&[3], 0));
+ assert_eq!(moved.root, Some(hsplit(SplitSize::FixedSecond(164.0), hsplit(SplitSize::FixedFirst(100.0), tabs(&[4], 0), tabs(&[1], 0)), rest)));
+ // 1 has no divider above or below it, and a center is no side.
+ for (anchor, zone) in [(1, Zone::Top), (3, Zone::Center)] {
let mut moved = layout.clone();
assert_eq!(moved.apply(Action::Move { panel: 4, anchor, placement: sized(zone) }), Err(DockError::InvalidPlacement), "{anchor} {zone:?}");
assert_eq!(moved, layout);
diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs
index 5404984e..b3cb6248 100644
--- a/crates/ui/src/docking/render/preview.rs
+++ b/crates/ui/src/docking/render/preview.rs
@@ -6,7 +6,7 @@ use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action};
use crate::docking::chrome::drop_preview;
use crate::docking::{
Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag,
- dock_model::Place,
+ dock_model::{Place, sized_split_depth},
surface::TAB_PADDING,
targets::{DockDrop, SIDE_EDGE_ZONE_DEPTH, resolve_drop_on},
};
@@ -235,7 +235,8 @@ impl Renderer<'_, P> {
/// The side split of `proposal`'s frame when halving it leaves no room
/// ([`Placement::SplitSized`]): the frame keeps its size, and the frames beyond the divider
- /// on that side give the new frame its minimum (with [`DROP_MARGIN`]). A floating panel
+ /// on that side, or on the opposite side for a frame at the edge of its area, give the new
+ /// frame its minimum (with [`DROP_MARGIN`]). A floating panel
/// keeps its size along the split instead, and one in a dock column its column's width or
/// its group's height, when that fits. `None` without such a divider, or when those frames
/// cannot give the new frame its minimum.
@@ -243,8 +244,6 @@ impl Renderer<'_, P> {
let Placement::Split(zone) = proposal.placement else { return None };
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let along = |v: Vec2| if axis == SplitAxis::Horizontal { v.x } else { v.y };
- // The divider on the frame's start side (left or top) belongs to a split holding the
- // frame in its second child, as `split_frame_sized` finds it.
let start = matches!(zone, Zone::Left | Zone::Top);
let anchor = &proposal.anchor;
let now = self.drawn_groups(self.layout);
@@ -271,19 +270,20 @@ impl Renderer<'_, P> {
}
let &(Node::Split { first, second, .. }, halved) = chain.last()? else { return None };
let new = if start { first } else { second };
- let depth = (0..split_path.len()).rev().find(|&d| {
- split_path.get(d) == Some(&start) && chain.get(d).is_some_and(|(n, _)| matches!(n, Node::Split { axis: own, .. } if *own == axis))
- })?;
+ let depth =
+ sized_split_depth(split_path, zone, |d| chain.get(d).is_some_and(|(n, _)| matches!(n, Node::Split { axis: own, .. } if *own == axis)))?;
+ // The frame is in the outer split's second child when the divider is on its start side.
+ let in_second = *split_path.get(depth)?;
let &(outer, outer_rect) = chain.get(depth)?;
let Node::Split { size: outer_size, first: a, second: b, .. } = outer else { return None };
if matches!(**a, Node::Dock { .. }) || matches!(**b, Node::Dock { .. }) {
return None;
}
let [a, b] = sizer.children(outer, outer_rect)?;
- let far = if start { a } else { b };
- // The far side's edge at the divider is its end edge when the divider is on the start
- // side of the frame.
- let room = sizer.travel(far.0, far.1, axis, start).0 - DROP_MARGIN;
+ let far = if in_second { a } else { b };
+ // The far side's edge at the divider is its end edge when the frame is in the second
+ // child.
+ let room = sizer.travel(far.0, far.1, axis, in_second).0 - DROP_MARGIN;
let least = along(super::node_limits(new, self.style, &*self.content).min) + DROP_MARGIN;
let gap = self.style.gap;
// A split frame's width is its share of the room, not one of its own.
@@ -300,7 +300,7 @@ impl Renderer<'_, P> {
let size = saved.filter(|width| (least..=most).contains(width)).unwrap_or(least);
// The anchor's side of the outer split grows by what the halves did not take already.
let grow = frame + gap + size - along(halved.size());
- let first = along(a.1.size()) + if start { -grow } else { grow };
+ let first = along(a.1.size()) + if in_second { -grow } else { grow };
let outer = super::sizes::resized(*outer_size, first, along(a.1.size()) + along(b.1.size()))?;
let size = super::sizes::resized(SplitSize::Ratio(0.5), if start { size } else { frame }, frame + size)?;
Some(Placement::SplitSized { zone, size, outer })
diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs
index ec2834a7..f003ff40 100644
--- a/crates/ui/src/docking/split_tests.rs
+++ b/crates/ui/src/docking/split_tests.rs
@@ -397,6 +397,39 @@ fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() {
assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room");
}
+/// Tools first in its row, at the area's left edge: its left side has no divider, so the room
+/// comes from beyond its right one.
+#[test]
+fn a_drop_on_the_outer_side_of_a_frame_too_narrow_to_halve_takes_room_from_the_other_side() {
+ let root = Node::Split {
+ axis: SplitAxis::Horizontal,
+ size: SplitSize::FixedFirst(60.0),
+ first: Box::new(tabs(&[TOOLS])),
+ second: Box::new(tabs(&PROPERTIES)),
+ };
+ let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![INFO], 0, [1100.0, 100.0, 280.0, 200.0])] };
+ let mut d = driver(layout, Film::default());
+ d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y));
+ d.settle();
+ let (tools, properties, info) = (group(&d, TOOLS), group(&d, PROPERTIES[0]), group(&d, INFO));
+ // Inside Tools' left zone, past the window edge band.
+ let at = pos2(tools.left() + 12.0, tools.center().y + 13.0);
+ d.hold(tab(&d, INFO), at);
+ assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, Zone::Left).unwrap()), "Tools' left zone is a target");
+ let out = d.release(at);
+ assert!(
+ matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]),
+ "{:?}",
+ out.actions
+ );
+ d.settle();
+ let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0]));
+ assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}");
+ assert!((new_info.width() - info.width()).abs() < 1.0, "Info keeps its floating width: {new_info:?}");
+ assert!(new_info.right() < new_tools.left() && new_tools.right() < new_properties.left());
+ assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room");
+}
+
#[test]
fn a_docked_panel_dropped_beside_a_frame_too_narrow_to_halve_takes_its_minimum_width() {
// Info docked below Properties, as wide as it, beside a narrow Tools frame.