From 2f90228e10951f73396b4146fa3826f0a011d4d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:41:27 +0000 Subject: [PATCH] docking: a narrow group splits when its neighbours have room for both halves A side split that leaves a half at its minimum is taken when the dividers along that half can still travel, the groups beyond giving way as they do for a dragged divider. An 80 pt group, whose halves need 84, splits on all four sides and takes the rest from its neighbour; only when the groups beyond are at their minimums too does the drop join the group's tabs. --- crates/ui/src/docking/render/preview.rs | 39 +++++++++++++-------- crates/ui/src/docking/render/sizes.rs | 32 +++++++++++++++-- crates/ui/src/docking/split_tests.rs | 46 ++++++++++++++++++++++++- docs/ui-docking.md | 5 +-- 4 files changed, 103 insertions(+), 19 deletions(-) diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 33f234cb..e280f5d8 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,6 +8,7 @@ use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, surface::TAB_PADDING, }; +use crate::layout::SplitAxis; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. fn float_only(ctx: &Context) -> bool { @@ -139,31 +140,41 @@ impl Renderer<'_, P> { } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group - /// it makes smaller stays [`DROP_MARGIN`] above its minimum, so each divider can still - /// travel both ways. An action the model rejects is left to the model's check. + /// it makes smaller keeps its minimum, and each divider can still travel both ways. A group + /// within [`DROP_MARGIN`] of its minimum along an axis passes when the dividers along its + /// sides can travel, the groups beyond it giving way as they do for a dragged divider, so a + /// narrow group still splits when its neighbours have room for both halves. An action the + /// model rejects is left to the model's check. pub(super) fn leaves_room(&self, action: Action

) -> bool { let mut candidate = self.layout.clone(); if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { return true; } let sizer = self.sizer(); - let drawn = |layout: &Layout

| { + let drawn = |root: &Node

, area: Rect| { let mut out: Vec> = Vec::new(); - if let Some(root) = &layout.root { - sizer.groups(root, self.workspace, &mut out); - } - for window in layout.floating.iter().filter(|w| !w.minimized) { - sizer.groups(&window.root, self.window_body(window), &mut out); - } - out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect.size(), d.limits.min))).collect::>() + sizer.groups(root, area, &mut out); + out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect, d.limits.min))).collect::>() }; - let before: HashMap = drawn(self.layout).into_iter().map(|(panel, size, _)| (panel, size)).collect(); - drawn(&candidate).into_iter().all(|(panel, size, min)| { - let room = size - min; - (room.x >= DROP_MARGIN && room.y >= DROP_MARGIN) || before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + let before: HashMap = + self.trees(self.layout).into_iter().flat_map(|(root, area)| drawn(root, area)).map(|(panel, rect, _)| (panel, rect.size())).collect(); + self.trees(&candidate).into_iter().all(|(root, area)| { + drawn(root, area).into_iter().all(|(panel, rect, min)| { + let (size, room) = (rect.size(), rect.size() - min); + let fits = |room: f32, axis| room >= DROP_MARGIN || sizer.frees((root, area), rect, axis); + before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + || (room.min_elem() > -0.01 && fits(room.x, SplitAxis::Horizontal) && fits(room.y, SplitAxis::Vertical)) + }) }) } + /// The trees of `layout` as drawn: the docked root in the workspace and each open floating + /// window's tree in its body. + fn trees<'l>(&self, layout: &'l Layout

) -> Vec<(&'l Node

, Rect)> { + let windows = layout.floating.iter().filter(|w| !w.minimized).map(|w| (&w.root, self.window_body(w))); + layout.root.iter().map(|root| (root, self.workspace)).chain(windows).collect() + } + /// Release of a group drag: join or split at the target, or float at the ghost with the /// group's size. Over its own docked position nothing changes; a floating group released /// over itself moves there. diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index 1c44cc1a..4d3fae3e 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -18,8 +18,8 @@ use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect}; /// A split's two children with the rects they are drawn in. type Children<'n, P> = [(&'n Node

, Rect); 2]; -/// Room a group keeps above its minimum after a drop, so each of its dividers can still -/// travel both ways. +/// How far each divider beside a group that a drop makes smaller can still travel both ways: +/// the group keeps this much above its minimum, or the groups beyond it give the room. pub(in crate::docking) const DROP_MARGIN: f32 = 1.0; pub(in crate::docking) struct Sizer<'a, P> { @@ -277,6 +277,34 @@ impl

Sizer<'_, P> { } (-first_in.min(second_out), first_out.min(second_in)) } + + /// Whether every divider of `root` across `axis` that runs along a side of the group drawn + /// at `group` can travel [`DROP_MARGIN`] both ways, by [`Self::range`]: a group at its + /// minimum holds no divider still while the groups beyond it can give way. + pub(in crate::docking) fn frees(&self, root: (&Node

, Rect), group: Rect, axis: SplitAxis) -> bool { + let edges = |rect: Rect| match axis { + SplitAxis::Horizontal => (rect.left(), rect.right()), + SplitAxis::Vertical => (rect.top(), rect.bottom()), + }; + let (start, end) = edges(group); + let beside = |child: Rect, edge: f32, own: f32| child.contains(group.center()) && (edge - own).abs() < 0.5; + let mut nodes = vec![(root.0, root.1, Vec::new())]; + while let Some((node, rect, path)) = nodes.pop() { + let (Node::Split { axis: split_axis, .. }, Some([a, b])) = (node, self.children(node, rect)) else { continue }; + if *split_axis == axis && (beside(a.1, edges(a.1).1, end) || beside(b.1, edges(b.1).0, start)) { + let (lo, hi) = self.range(root, &path); + if -lo < DROP_MARGIN || hi < DROP_MARGIN { + return false; + } + } + for (second, (child, rect)) in [(false, a), (true, b)] { + let mut path = path.clone(); + path.push(second); + nodes.push((child, rect, path)); + } + } + true + } } /// The size of a split at `path` whose first child is `first` of `available` points. diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index ea3283ad..e4b6774f 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -193,8 +193,10 @@ fn groups_have_one_small_minimum_and_no_maximum() { drag_hit(&mut d, divider(&[true, true]), vec2(-2000.0, 0.0)); assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further"); - // A group too narrow for two halves takes a drop on its edge as a tab. + // At its minimum, with Project left of it at its own, it has no room for two halves and + // takes a drop on its edge as a tab. let tools = group(&d, TOOLS); + assert_eq!(group(&d, PROJECT[0]).width(), 40.0); let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0)); assert!( matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]), @@ -801,3 +803,45 @@ fn zones_a_group_does_not_take_are_not_drawn() { d.hold(tab(&d, TIMELINE), timeline.center() + vec2(0.0, 13.0)); assert!(zone_outlines(&d).is_empty()); } + +#[test] +fn a_narrow_group_splits_on_every_side_and_its_neighbours_give_the_room() { + // Tools at 80 pt: two halves need 84, so a side split takes the rest from Timeline. + let narrow = || { + let mut d = film(); + let width = group(&d, TOOLS).width(); + drag_hit(&mut d, divider(&[true, true]), vec2(80.0 - width, 0.0)); + d + }; + let d = narrow(); + let (tools, timeline) = (group(&d, TOOLS), group(&d, TIMELINE)); + assert_eq!(tools.width().round(), 80.0); + let body_top = tools.top() + 26.0; + for (zone, at) in [ + (Zone::Left, pos2(tools.left() + 5.0, tools.center().y)), + (Zone::Right, pos2(tools.right() - 5.0, tools.center().y)), + (Zone::Top, pos2(tools.center().x, body_top + 5.0)), + (Zone::Bottom, pos2(tools.center().x, tools.bottom() - 15.0)), + ] { + let mut d = narrow(); + d.hold(tab(&d, INFO), at); + assert_eq!(zone_outlines(&d).len(), 6, "{zone:?}: four sides, the center and the strip"); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, zone).unwrap()), "{zone:?} is filled"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(zone) }]); + d.settle(); + let (info, split) = (group(&d, INFO), group(&d, TOOLS)); + let beside = match zone { + Zone::Left => info.right() < split.left(), + Zone::Right => info.left() > split.right(), + Zone::Top => info.bottom() < split.top(), + _ => info.top() > split.bottom(), + }; + assert!(beside, "{zone:?}: Info {info:?}, Tools {split:?}"); + if matches!(zone, Zone::Left | Zone::Right) { + assert_eq!((info.width(), split.width()), (40.0, 40.0), "{zone:?}"); + assert!((group(&d, TIMELINE).width() - (timeline.width() - 4.0)).abs() < 1.0, "{zone:?}: Timeline gives the room"); + } + assert_sizes(&d, &format!("{zone:?}")); + } +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 33b27d09..0e039ec1 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -212,8 +212,9 @@ A flyout fits its content the same way, without the title bar. - Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The side zones are trapezoids along the four edges of the body below the tab strip, like a picture frame, all four the same depth: `DockStyle::zone_depth` (40 pt), or a quarter of the body's - smaller side when that is less, so every frame keeps a center. A split that would leave either half without - `DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target. + smaller side when that is less, so every frame keeps a center. A frame too narrow or short for two + halves of `DockStyle::min_pane` still splits when the frames beyond it can give the room, as they + do for a dragged divider; when they cannot, the drop joins the frame's tabs instead. The gap between two frames is no target. - The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of the window and is offered only when every frame keeps its minimum. Past the band, a frame's own