From bab74e1f4c87618dd3841aeec99e3d0be0946d34 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:07:15 -0400 Subject: [PATCH] docking: a canvas body takes no drop in the docking profile (#323) With new_dock_zones (and without side_edge_zones), the body of a frame or floating group that takes no tabs is no drop target, whatever its accepts_splits. A floating window released there stays floating and a docked panel floats as over empty space; the outer edge band, dock columns, new docks and drops beside other groups still dock. Without new_dock_zones the whole canvas body still goes to the nearest of drop_edges. --- crates/ui/src/docking/drop_tests.rs | 21 +++++++--- crates/ui/src/docking/native_tests.rs | 54 +++++++++++++++++++++++++ crates/ui/src/docking/render/preview.rs | 10 ++--- crates/ui/src/docking/surface.rs | 28 +++++++++---- crates/ui/src/docking/targets.rs | 11 +++-- docs/ui-docking.md | 21 ++++++---- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 9d7b4e0f..dc54a154 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -122,16 +122,17 @@ fn a_drop_on_a_groups_outer_third_docks_beside_it_with_its_rect_and_its_middle_j } #[test] -fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { +fn without_new_dock_zones_a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; - let d = driver(&[(1, canvas)]); + let start = || driver(&[(1, canvas)]).with_style(|style| style.new_dock_zones = false); + let d = start(); let (body, rect) = group(&d, 1); for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { - let out = previews_result(driver(&[(1, canvas)]), tab(&d, 3), at, |d| group(d, 3).1); + let out = previews_result(start(), tab(&d, 3), at, |d| group(d, 3).1); assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); } // Its strip joins nothing. - let mut d = driver(&[(1, canvas)]); + let mut d = start(); let out = d.drag(tab(&d, 3), pos2(rect.center().x, rect.top() + 13.0)); assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, placement: Placement::Tab { .. }, .. })), "{:?}", out.actions); } @@ -345,7 +346,7 @@ fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { } #[test] -fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { +fn without_new_dock_zones_a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { let beside = Permissions { accepts_splits: true, ..Permissions::PROTECTED }; let d = canvas_driver(beside, |_| {}); let (body, _) = group(&d, 0); @@ -358,12 +359,20 @@ fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { (Sides::NONE, [None, None]), ] { for (at, zone) in [low, high].into_iter().zip(zones) { - let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let split_frames = move |style: &mut DockStyle| { + style.new_dock_zones = false; + style.drop_edges = edges; + }; + let mut d = canvas_driver(beside, split_frames); let out = d.drag(tab(&d, 3), at); match zone { Some(zone) => assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Split(zone) }], "{edges:?}"), None => assert!(!moves(&out), "{edges:?}: {:?}", out.actions), } + // With new dock zones the canvas body takes no drop on any side. + let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let out = d.drag(tab(&d, 3), at); + assert!(!moves(&out), "{edges:?}: {:?}", out.actions); } } } diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 98a53295..ff49703b 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -17,6 +17,8 @@ const ORIGIN: Pos2 = pos2(100.0, 50.0); struct Content { /// Panels that may not close. fixed: Vec, + /// The protected canvas that takes splits beside it, as PhotoCraft's does. + canvas: Option, /// Panels that stay in-window. in_window: Vec, /// Viewer panels: their windows are normal windows. @@ -121,6 +123,9 @@ impl DockContent for Content { } fn permissions(&self, panel: &u32) -> Permissions { + if self.canvas == Some(*panel) { + return Permissions { accepts_splits: true, ..Permissions::PROTECTED }; + } Permissions { closable: !self.fixed.contains(panel), ..Permissions::default() } } @@ -982,6 +987,55 @@ fn a_native_windows_bar_docks_it_at_frame_edges_and_dock_lines_but_not_a_body_ce assert!(in_root(&d, 5) && in_root(&d, 6)); } +#[test] +fn over_a_canvas_body_a_window_stays_floating_and_a_tab_floats_while_the_edge_bands_dock() { + // PhotoCraft: a dock column of [2] above [3, 4] beside canvas 0, which takes splits, and + // window [5, 6] floating. + let accent = Color32::from_rgb(250, 10, 200); + let dock = Node::Dock { + side: super::Side::Start, + columns: vec![super::Column::new(vec![super::Group::new(vec![2], 0), super::Group::new(vec![3, 4], 0)], 240.0)], + }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[0])) }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 80.0, 240.0, 200.0])] }; + let start = || { + DockDriver::new(layout.clone(), Content { canvas: Some(0), ..Content::default() }) + .with_style(move |s| { + s.accent = accent; + s.float_hint = false; + }) + .with_native_host(ORIGIN) + }; + // The canvas spans x = 244 to 800 and the whole height: no tab strip. + for at in [pos2(520.0, 150.0), pos2(520.0, 450.0)] { + let mut d = start(); + let before = accented(d.shapes(), accent); + let id = window(&d, 5).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let local = d.window_local(id, ORIGIN + at.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + d.step(vec![]); + assert_eq!(accented(d.shapes(), accent), before, "{at:?}: no preview"); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{at:?}: {:?}", out.actions); + assert!(window(&d, 5).contains(&6), "{at:?}"); + } + // A docked tab dropped there floats. + let mut d = start(); + let tab = d.hit(&Hit::Tab(3)).unwrap().center(); + let out = d.drag(tab, pos2(520.0, 450.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); + // The window's right edge, where no dock is yet, and the bottom edge band beside the canvas + // still dock the window. + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(795.0, 300.0)); + assert_eq!(window_move(&out), Some(&Target::NewDock { side: super::Side::End }), "{:?}", out.actions); + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(520.0, 590.0)); + assert!(matches!(window_move(&out), Some(Target::At { placement: Placement::Edge(super::Zone::Bottom), .. })), "{:?}", out.actions); +} + #[test] fn escape_during_a_bar_drag_puts_the_window_back() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 09163c0d..0666d687 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -198,11 +198,11 @@ impl Renderer<'_, P> { } /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`] and the - /// canvas sides of [`crate::docking::DockStyle::drop_edges`], where a split that would leave - /// a group without room becomes a join of the target group, or no target when the group - /// takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). + /// canvas sides of [`crate::docking::DockStyle::drop_edges`] (none in the docking profile), + /// where a split that would leave a group without room becomes a join of the target group, + /// or no target when the group takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { - let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.drop_sides())?; + let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.canvas_sides())?; if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { return Some(self.settled(proposal, action)); } @@ -220,7 +220,7 @@ impl Renderer<'_, P> { pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_rule(), - self.style.drop_sides(), + self.style.canvas_sides(), )?; Some(self.settled(proposal, action)) } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 23b2d344..9a29a2fc 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -64,11 +64,13 @@ pub struct DockStyle { /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its /// body, and every preview is a line or a tab outline. pub side_edge_zones: bool, - /// Which sides of a group that takes no tabs (a canvas), and of the layout's outer edge, take - /// a drop (all four). Without [`Self::side_edge_zones`] the outer left and right edges are - /// new docks, so only top and bottom apply there. A dock-column app whose palettes dock only - /// at the left and right sets [`crate::docking::Sides::NONE`] with [`Self::new_dock_zones`] - /// on. Not used with [`Self::side_edge_zones`]. Groups that take tabs keep all four sides. + /// Which sides of the layout's outer edge, and of a group that takes no tabs (a canvas) + /// without [`Self::new_dock_zones`], take a drop (all four). With [`Self::new_dock_zones`] + /// a canvas body takes no drop, and without [`Self::side_edge_zones`] the outer left and + /// right edges are new docks, so only top and bottom apply there. A dock-column app whose + /// palettes dock only at the left and right sets [`crate::docking::Sides::NONE`] with + /// [`Self::new_dock_zones`] on. Not used with [`Self::side_edge_zones`]. Groups that take + /// tabs keep all four sides. pub drop_edges: super::Sides, /// The share of a group's body, below the tab strip, that means "beside" on each side: of /// its width from the left and right edges, of its height from the top and bottom edges @@ -81,8 +83,10 @@ pub struct DockStyle { /// zero). Other drops beside a group, column, dock or the layout's edge preview the rect the /// moved panels take. Border bands reach 4 pt past this line into a tab strip. pub drop_line_width: f32, - /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new - /// dock (see [`Self::new_dock_band`]). + /// [D] The docking profile. The docked area's left and right edges, where no dock is yet, + /// take a drop as a new dock (see [`Self::new_dock_band`]). Without + /// [`Self::side_edge_zones`], the body of a frame or floating group that takes no tabs (a + /// canvas) takes no drop: a release there floats as over no target. pub new_dock_zones: bool, /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. pub column_bar_height: f32, @@ -237,12 +241,20 @@ impl DockStyle { } } - /// The canvas sides [`Self::drop_edges`] gives in this profile: all four with + /// The sides [`Self::drop_edges`] gives in this profile: all four with /// [`Self::side_edge_zones`]. pub(super) fn drop_sides(&self) -> super::Sides { if self.side_edge_zones { super::Sides::ALL } else { self.drop_edges } } + /// The sides of a frame or floating group that takes no tabs (a canvas) whose body takes a + /// drop: none in the docking profile ([`Self::new_dock_zones`] without + /// [`Self::side_edge_zones`]), where only the bands along borders dock beside it, else + /// [`Self::drop_sides`]. + pub(super) fn canvas_sides(&self) -> super::Sides { + if self.side_edge_zones || !self.new_dock_zones { self.drop_sides() } else { super::Sides::NONE } + } + /// The outer edges that take a drop: all four with [`Self::side_edge_zones`], else the top /// and bottom of [`Self::drop_edges`]. pub(super) fn outer_edges(&self) -> super::Sides { diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 0752cbb6..d18f465c 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -4,6 +4,8 @@ use super::{Placement, Side, Target, Zone}; /// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip /// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. +/// The dock area's docking profile ([`crate::docking::DockStyle::new_dock_zones`]) gives the +/// body of a frame or floating group that rejects tabs no target at all. #[derive(Clone, Debug)] pub struct DropTarget

{ pub anchor: P, @@ -75,9 +77,10 @@ pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Opti /// In the body below it, the outer `fraction` of its width from the left or right edge, or of /// its height from the top or bottom edge, is that side's zone; where two sides' zones meet, the /// side the pointer is relatively nearer wins. The rest is the center. Without `center` (a group -/// that takes no tabs) the whole body goes to the relatively nearest side. A `fraction` of zero -/// or less leaves only the center; more than one half acts as one half. Outside, non-finite, -/// empty and inverted rectangles have no target. +/// that takes no tabs) the whole body goes to the relatively nearest side, as the dock area has +/// it without [`crate::docking::DockStyle::new_dock_zones`]; with it, such a body is no target. +/// A `fraction` of zero or less leaves only the center; more than one half acts as one half. +/// Outside, non-finite, empty and inverted rectangles have no target. pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, pointer: Pos2) -> Option { body_zone_on(rect, tab_height, fraction, center, Sides::ALL, pointer) } @@ -271,7 +274,7 @@ pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropT } /// [`resolve_drop_by`] where a group that takes no tabs splits only on `sides` with -/// [`ZoneRule::Fraction`]. +/// [`ZoneRule::Fraction`], and with none is no target. pub(super) fn resolve_drop_on( panel: &P, pointer: Pos2, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 4baba14e..40e34dac 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -239,8 +239,14 @@ A flyout fits its content the same way, without the title bar. beside the group on that side. Where two sides meet, at a corner, the side the pointer is relatively nearer wins. - The middle third joins the group's tabs. - - A group that takes no tabs, such as a protected canvas, has no middle: every point of its - body docks beside it on the relatively nearest side. Its strip takes nothing. + - A group that takes no tabs, such as a protected canvas, takes nothing on its strip, and its + body has no middle: every point docks beside it on the relatively nearest side. + - In the docking profile (`new_dock_zones` without `side_edge_zones`), the body of a frame or + floating group that takes no tabs takes nothing, whatever its `accepts_splits`: a release + there is a release over no target, so a floating window stays floating and a docked panel + or group floats as `float_on_empty_drop` says. Only the bands along borders (below) dock + beside it, as in Adobe apps, whose document area is no drop zone. A dock column's group + keeps its sides. - Beside a frame is a split of that frame. Beside a group in a dock column, the top and bottom stack a new group above or below it, and the left and right make a new column on that side. - A frame too narrow or short for two halves of `DockStyle::min_pane` still splits when the @@ -276,11 +282,12 @@ A flyout fits its content the same way, without the title bar. `window.panel.dock` to an outer edge picks its anchor the same way, from the permissions `apply` (or `to_actions`) gets and the area's `FloatSpace` (`with_side_edge_zones` before it has drawn). -- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of a group that - takes no tabs (a canvas), and of the layout's outer edge, take a drop. A canvas's whole body - goes to the nearest listed side; with none listed it takes nothing. In the docking profile - the outer left and right edges are new docks, so only top and bottom apply there. Groups - that take tabs keep all four sides. A dock-column app whose palettes dock only at the left +- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of the layout's + outer edge, and of a group that takes no tabs (a canvas) without `new_dock_zones`, take a + drop. There a canvas's whole body goes to the nearest listed side; with none listed it takes + nothing. In the docking profile the canvas body takes no drop and the outer left and right + edges are new docks, so only the outer top and bottom apply. Groups that take tabs keep all + four sides. A dock-column app whose palettes dock only at the left and right, as in AutoCAD, sets `Sides::NONE` with `new_dock_zones` on. Not used with `side_edge_zones`. - With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame