From 281ea71f5b55258bcd3b768eb5b3b6cbcd20ab24 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:20:26 +0000 Subject: [PATCH] docking: the drop preview shows the zone under the pointer With side_edge_zones, a drop on a group fills the zone the pointer is in (a side's trapezoid, or the center rectangle with the tab strip) and outlines the rect the drop gives what moves. zone_shape gives a zone's outline; DockDriver::filled_polygon checks it. The dock-and-canvas arrangement keeps its filled half and insertion lines. --- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/render/preview.rs | 27 +++++++++++++-- crates/ui/src/docking/split_tests.rs | 46 +++++++++++++++++++++++-- crates/ui/src/docking/targets.rs | 29 ++++++++++++++-- crates/ui/src/docking/testing.rs | 12 +++++++ docs/ui-docking.md | 5 ++- 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 2587dca0..dd4b50bc 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -30,7 +30,7 @@ pub use surface::{ DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, float_space, is_dragging, }; -pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; +pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0189f810..cabb8cdc 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -220,7 +220,7 @@ impl Renderer<'_, P> { } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); - if crate::docking::targets::usable(proposal.preview) { + if crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); } if let Some(marker) = proposal.insertion { @@ -322,7 +322,7 @@ impl Renderer<'_, P> { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) { + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, true); } if let Some(marker) = proposal.insertion { @@ -336,6 +336,29 @@ impl Renderer<'_, P> { } } + /// With [`crate::docking::DockStyle::side_edge_zones`], a drop on a group shows the zone + /// under the pointer (a side's trapezoid, or the center rectangle with the tab strip) + /// filled, and the rect the drop gives what moves outlined. False when it painted nothing. + fn paint_zone(&self, painter: &egui::Painter, proposal: &DropProposal

, pointer: Pos2) -> bool { + let zone = match proposal.placement { + Placement::Split(zone) => zone, + Placement::Tab { .. } => Zone::Center, + _ => return false, + }; + let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer)); + let (Some(target), true) = (target, self.style.side_edge_zones) else { return false }; + let Some(points) = crate::docking::targets::zone_shape(target.rect, zone) else { return false }; + let fill = self.style.accent.gamma_multiply(0.25); + painter.add(egui::Shape::convex_polygon(points, fill, egui::Stroke::NONE)); + if zone == Zone::Center + && let Some(strip) = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|r| crate::docking::targets::usable(*r)) + { + painter.rect_filled(strip, 0.0, fill); + } + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, false); + true + } + /// The single drop preview layer, above every floating window. pub(super) fn preview_painter(&self, ctx: &Context) -> egui::Painter { let layer = egui::LayerId::new(egui::Order::Tooltip, self.id.with("drop-preview")); diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 69e1313d..21bfcc16 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -133,7 +133,7 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let at = inset(zone); d.hold(tab(&d, INFO), at); let half = zone_rect(timeline, zone).unwrap(); - assert!(d.filled(half.shrink(1.0)), "{zone:?}: the preview is that half of the group"); + assert!(d.outlined(half.shrink(1.0)), "{zone:?}: the preview outlines that half of the group"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}"); d.settle(); @@ -275,7 +275,7 @@ fn a_drop_beside_a_narrow_group_splits_that_group_only() { assert!(tools.width() > 120.0, "{tools:?}"); let at = pos2(tools.right() - 8.0, tools.center().y + 13.0); d.hold(tab(&d, INFO), at); - assert!(d.filled(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview is Tools' right half"); + assert!(d.outlined(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview outlines Tools' right half"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]); d.settle(); @@ -610,3 +610,45 @@ fn a_closed_floating_window_closes_its_panels_and_each_reopens_where_it_was() { let window = &d.layout.floating[0]; assert_eq!((&window.root, window.rect), (&tree, [700.0, 100.0, 600.0, 400.0])); } + +#[test] +fn the_preview_fills_the_zone_under_the_pointer_and_outlines_the_result() { + let d = film(); + let timeline = group(&d, TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + // A side band: its trapezoid, and the half the panel takes. + for (zone, at) in [ + (Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), + (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0)), + ] { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, zone).unwrap()), "{zone:?}"); + assert!(d.outlined(zone_rect(timeline, zone).unwrap().shrink(1.0)), "{zone:?}"); + d.key(egui::Key::Escape); + } + // The middle and the tab row: the center rectangle and the strip, and the whole group. + for at in [timeline.center() + vec2(0.0, 13.0), strip.center()] { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, Zone::Center).unwrap()), "{at:?}"); + assert!(d.filled(strip), "{at:?}"); + assert!(d.outlined(timeline.shrink(1.0)), "{at:?}"); + d.key(egui::Key::Escape); + } + // A group's drag bar shows the same. + let mut d = film(); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + d.hold(bar, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + d.key(egui::Key::Escape); + + // The dock-and-canvas arrangement keeps its filled half. + let mut d = DockDriver::new(editing(), Film::default()); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(!d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + assert!(d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); +} diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index ff515ec0..7a816f1a 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,4 +1,4 @@ -use egui::{Pos2, Rangef, Rect, pos2}; +use egui::{Pos2, Rangef, Rect, Vec2, pos2, vec2}; use super::{Placement, Side, Target, Zone}; @@ -45,8 +45,7 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if pointer.y < rect.top() + tab_height.min(rect.height()) { return Some(Zone::Center); } - let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); - let (x, y) = (depth(rect.width()), depth(rect.height())); + let Vec2 { x, y } = side_depths(rect); let distances = [ ((pointer.x - rect.left()) / x, Zone::Left), ((rect.right() - pointer.x) / x, Zone::Right), @@ -57,6 +56,30 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { Some(if distance > 1.0 { Zone::Center } else { zone }) } +/// How deep the side zones of a group drawn at `rect` reach in from its left and right edges +/// (`x`) and its top and bottom edges (`y`). +fn side_depths(rect: Rect) -> Vec2 { + let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); + vec2(depth(rect.width()), depth(rect.height())) +} + +/// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it: a side's +/// trapezoid (its outer edge the whole side, its inner edge on the center rectangle), or the +/// center rectangle. The tab strip is a grouping zone too. +pub fn zone_shape(rect: Rect, zone: Zone) -> Option> { + if !usable(rect) { + return None; + } + let inner = rect.shrink2(side_depths(rect)); + Some(match zone { + Zone::Left => vec![rect.left_top(), inner.left_top(), inner.left_bottom(), rect.left_bottom()], + Zone::Right => vec![inner.right_top(), rect.right_top(), rect.right_bottom(), inner.right_bottom()], + Zone::Top => vec![rect.left_top(), rect.right_top(), inner.right_top(), inner.left_top()], + Zone::Bottom => vec![inner.left_bottom(), inner.right_bottom(), rect.right_bottom(), rect.left_bottom()], + Zone::Center => vec![inner.left_top(), inner.right_top(), inner.right_bottom(), inner.left_bottom()], + }) +} + /// Preview of the region occupied after a split or group merge. pub fn zone_rect(rect: Rect, zone: Zone) -> Option { if !usable(rect) { diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 868a6c4c..81db4769 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -215,6 +215,18 @@ impl> Do }) } + /// Whether the last frame filled a polygon with these corners, each within half a point. + pub fn filled_polygon(&self, corners: &[Pos2]) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Path(path) => { + path.fill != egui::Color32::TRANSPARENT + && path.points.len() == corners.len() + && path.points.iter().zip(corners).all(|(a, b)| a.distance(*b) <= 0.5) + } + _ => false, + }) + } + /// Whether the last frame outlined `rect` without filling it. pub fn outlined(&self, rect: Rect) -> bool { self.shapes.iter().any(|clipped| match &clipped.shape { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 26b58eae..adaec4ab 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -224,7 +224,10 @@ A flyout fits its content the same way, without the title bar. for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, - a filled half for a split. Escape or losing window focus cancels a drag. + a filled half for a split. With `side_edge_zones`, a drop on a group instead fills the zone + under the pointer (a side's trapezoid, or the center rectangle and the tab strip; `zone_shape` + gives the outline) and outlines the rect the drop gives what moves. Escape or losing window + focus cancels a drag. - With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such as a panel dropped on a protected canvas. Without it, that release cancels the drag and nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release