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.