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.
This commit is contained in:
aiongg committed 2026-10-22 20:20:26 +00:00
1 parent 809bf90936
commit 281ea71f5b
6 files changed
+112 -9

No files matched your search

+1 -1
View File
@@ -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};
+25 -2
View File
@@ -220,7 +220,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>, 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"));
+44 -2
View File
@@ -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)));
}
+26 -3
View File
@@ -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<Zone> {
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<Zone> {
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<Vec<Pos2>> {
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<Rect> {
if !usable(rect) {
+12
View File
@@ -215,6 +215,18 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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 {
+4 -1
View File
@@ -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