docking: anchor an outer edge drop on the area it shows

The edge band and window.panel.dock to an outer edge anchor on the canvas only where the edge area narrows to the canvas column, else on a panel that is not protected, so tree order no longer decides the frame. The renderer tries the drop and offers the band only when it wraps the panels the band shows. A top (bottom) edge also narrows a split of rows to its first (last) row when only that row holds the canvas. to_actions takes the permissions, and FloatSpace records side_edge_zones.
This commit is contained in:
aiongg committed 2026-10-25 06:18:34 +00:00
1 parent 4dc543cb44
commit 91aaa294d0
9 files changed
+283 -76

No files matched your search

+21 -9
View File
@@ -340,7 +340,18 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and
/// `height` replace the matching values, and a group or column that is already a whole
/// floating window keeps its rect.
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool, space: &FloatSpace<P>) -> Result<Vec<Action<P>>, DockError> {
///
/// An outer edge dock wraps what a drag to that edge would: the canvas column where the
/// area narrows its edge band to it, else the part between the docks. `space` says whether
/// the area draws with [`super::DockStyle::side_edge_zones`], which never narrows, and
/// `permissions` are those the actions are applied with.
pub fn to_actions(
&self,
workspace: &Workspace<P>,
right_to_left: bool,
space: &FloatSpace<P>,
permissions: impl Fn(&P) -> Permissions,
) -> Result<Vec<Action<P>>, DockError> {
let layout = &workspace.layout;
let zoned = Zoned { layout, right_to_left };
let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) };
@@ -418,12 +429,13 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
if before.is_some() {
return Err(DockError::InvalidPlacement);
}
let anchor = layout
.panels()
.into_iter()
.find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_))))
.cloned()
.ok_or(DockError::MissingTarget)?;
// The model narrows the edge on a protected anchor, as `Workspace::apply` checks it.
let protected = |p: &P| {
let policy = if workspace.locked { permissions(p).locked() } else { permissions(p) };
policy.is_protected()
};
let narrowed = !space.side_edge_zones && layout.edge_narrows(zone, &protected);
let anchor = layout.edge_anchor(narrowed, &protected, &|p| members.contains(p)).ok_or(DockError::MissingTarget)?;
return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?);
}
if !matches!(layout.place(panel), Some(Place::Floating { .. })) {
@@ -526,7 +538,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
// Shown, then moved to the edge, all or nothing.
let mut shown = workspace.clone();
shown.set_visible(panel.clone(), true, None, &permissions)?;
let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?;
let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space, &permissions)?, &permissions)?;
*workspace = shown;
return Ok(closed);
}
@@ -534,7 +546,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
workspace.locked = locked.unwrap_or(!workspace.locked);
return Ok(Vec::new());
}
let actions = self.to_actions(workspace, right_to_left, space)?;
let actions = self.to_actions(workspace, right_to_left, space, &permissions)?;
workspace.apply_all(actions, permissions)
}
}
+40 -6
View File
@@ -45,7 +45,7 @@ fn parse(params: Value) -> Result<DockCommand<P>, serde_json::Error> {
/// The actions `params` makes on a fresh workspace.
fn actions(params: Value) -> Vec<Action<P>> {
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}"))
command.to_actions(&workspace(), false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}"))
}
/// Apply `params` to a fresh workspace; return it and the panels closed.
@@ -294,7 +294,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() {
let command =
|zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap();
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) {
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space(), permissions) {
Ok(actions) => match actions.as_slice() {
[Action::Move { placement, .. }] => Ok(placement.clone()),
other => panic!("{other:?}"),
@@ -338,7 +338,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area);
let space = float_space::<P>(&d.ctx, d.id).expect("the area records its float space");
assert_eq!(space.area, area);
let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap();
let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space, permissions).unwrap();
let panel = float(json!({"command": "window.panel.float", "panel": "layers"}));
// The floating_size width, and an empty body's least fitted height under its 150 points.
@@ -359,19 +359,24 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
// Without recorded sizes a unit floats at the layout's size, shrunk into a small area.
let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0)));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small);
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(
&workspace(),
false,
&small,
permissions,
);
assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }]));
// A host seeds the size of a panel the area never drew; a recorded size still wins.
let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded);
let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded, permissions);
assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }]));
let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]);
}
fn float_with(space: &FloatSpace<P>, panel: &str) -> Vec<Action<P>> {
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap()
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space, permissions).unwrap()
}
#[test]
@@ -411,6 +416,35 @@ fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() {
rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement);
}
/// The frame beside `panel`'s own: what an edge split with `panel` wrapped.
fn wrapped(layout: &Layout<P>, panel: &str) -> Option<Node<P>> {
let Some(dock_model::Place::Node(path)) = layout.place(&s(panel)) else { return None };
let (last, parent) = path.split_last()?;
layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned()
}
#[test]
fn an_outer_edge_dock_wraps_the_canvas_column_whatever_the_tree_order() {
let tabs = |panel: &str| Node::Tabs { panels: vec![s(panel)], active: 0 };
let side =
|first, second| Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
let canvas_first = side(tabs("canvas"), side(tabs("layers"), tabs("history")));
let canvas_later = side(side(tabs("layers"), tabs("canvas")), tabs("history"));
for root in [canvas_first, canvas_later] {
let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])] };
let command = parse(json!({"command": "window.panel.dock", "panel": "color", "zone": "top"})).unwrap();
let mut workspace = Workspace::new(layout.clone());
command.apply(&mut workspace, false, &space(), permissions).unwrap();
assert_eq!(wrapped(&workspace.layout, "color"), Some(tabs("canvas")), "{:?}", workspace.layout);
// An area drawn with side edge zones never narrows its edge to the canvas column.
let mut workspace = Workspace::new(layout.clone());
command.apply(&mut workspace, false, &space().with_side_edge_zones(true), permissions).unwrap();
let mut whole = layout;
whole.floating.clear();
assert_eq!(wrapped(&workspace.layout, "color"), whole.root, "{:?}", workspace.layout);
}
}
#[test]
fn dock_to_the_side_a_panel_already_leads_the_last_column_of() {
// Layers leads the dock's only column: it moves out to a new column after it.
+34 -10
View File
@@ -543,20 +543,28 @@ impl<P: Clone + Eq + Hash> Layout<P> {
None
}
/// The node an edge split wraps: the root, or the part between the docks. Where neither side
/// of a side-by-side split is a dock and only one holds a `canvas` panel, that side: the
/// canvas column, without the side frames.
pub(in crate::docking) fn edge_path(&self, canvas: &dyn Fn(&P) -> bool) -> Vec<bool> {
/// The node an edge split at `zone` wraps: the root, or the part between the docks. Where
/// neither side of a side-by-side split is a dock and only one holds a `canvas` panel,
/// that side: the canvas column, without the side frames. A top (bottom) edge also
/// descends a split of rows into its first (last) row when only that row holds a `canvas`
/// panel, so a full-width row below (above) the canvas keeps its edge.
pub(in crate::docking) fn edge_path(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Vec<bool> {
let holds = |node: &Node<P>| tree_panels(node).into_iter().any(canvas);
let mut path = Vec::new();
let mut node = self.root.as_ref();
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
while let Some(Node::Split { axis, first, second, .. }) = node {
if path.len() > MAX_DEPTH {
break;
}
let second_side = match (&**first, &**second) {
(Node::Dock { .. }, _) => true,
(_, Node::Dock { .. }) => false,
_ => match (tree_panels(first).into_iter().any(canvas), tree_panels(second).into_iter().any(canvas)) {
let second_side = match (axis, &**first, &**second) {
(SplitAxis::Horizontal, Node::Dock { .. }, _) => true,
(SplitAxis::Horizontal, _, Node::Dock { .. }) => false,
(SplitAxis::Vertical, ..) => match (zone, holds(first), holds(second)) {
(Zone::Top, true, false) => false,
(Zone::Bottom, false, true) => true,
_ => break,
},
_ => match (holds(first), holds(second)) {
(true, false) => false,
(false, true) => true,
_ => break,
@@ -568,6 +576,22 @@ impl<P: Clone + Eq + Hash> Layout<P> {
path
}
/// Whether an edge drop at `zone` that narrows by `canvas` wraps less than one that does
/// not: the edge area is narrower than the part between the docks.
pub(in crate::docking) fn edge_narrows(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> bool {
self.edge_path(zone, canvas) != self.edge_path(zone, &|_| false)
}
/// The docked panel, other than `skip`, that a [`Placement::Edge`] anchors on so the drop
/// wraps the edge area it shows: a `protected` one where that area is narrowed to the
/// canvas column (`narrowed`), else one that is not protected, since a protected anchor
/// narrows the model's edge. Any docked panel when every one is protected.
pub(in crate::docking) fn edge_anchor(&self, narrowed: bool, protected: &dyn Fn(&P) -> bool, skip: &dyn Fn(&P) -> bool) -> Option<P> {
let docked: Vec<&P> =
self.panels().into_iter().filter(|p| !skip(p) && matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))).collect();
docked.iter().find(|p| protected(p) == narrowed).or(docked.first()).map(|p| (*p).clone())
}
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
Some(Node::Dock { columns, .. }) => Some(columns),
@@ -866,7 +890,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
return Err(DockError::InvalidPlacement);
}
// A protected anchor narrows the edge to the canvas column.
let path = if canvas(&anchor) { self.edge_path(canvas) } else { self.edge_path(&|_| false) };
let path = if canvas(&anchor) { self.edge_path(zone, canvas) } else { self.edge_path(zone, &|_| false) };
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
if matches!(node, Node::Dock { .. }) {
return Err(DockError::InvalidPlacement);
+50
View File
@@ -263,6 +263,56 @@ fn between_docks_the_outer_edge_band_covers_the_part_between_them() {
assert_eq!(**first, split(SplitAxis::Vertical, SplitSize::Ratio(0.75), middle, tabs(&[3])));
}
/// The frame beside `panel`'s own: what an edge split with `panel` wrapped.
fn wrapped(layout: &Layout<u32>, panel: u32) -> Option<Node<u32>> {
let Some(dock_model::Place::Node(path)) = layout.place(&panel) else { return None };
let (last, parent) = path.split_last()?;
layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned()
}
#[test]
fn over_a_full_width_row_the_top_edge_band_covers_only_the_canvas_column_above_it() {
// SoundCraft's Edit window: side frame 1, canvas 0 and side frame [2, 3] over a full-width
// editor 4.
let Some(upper) = canvas_columns().root else { panic!() };
let root = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), upper.clone(), tabs(&[4]));
let layout = Layout { root: Some(root), floating: Vec::new() };
let driver = || DockDriver::new(layout.clone(), Bodies { rules: [(0, Permissions::PROTECTED)].into(), ..Bodies::default() });
let d = driver();
let (canvas, _) = group(&d, 0);
let at = pos2(canvas.center().x, 5.0);
let mut d = driver();
d.hold(tab(&d, 3), at);
assert!(d.filled(Rect::from_x_y_ranges(canvas.x_range(), 0.0..=4.0)), "a line along the canvas column's top");
assert!(!d.filled(Rect::from_x_y_ranges(0.0..=800.0, 0.0..=4.0)), "no line along the window's top");
let out = d.release(at);
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]);
assert_eq!(wrapped(&d.layout, 3), Some(tabs(&[0])), "{:?}", d.layout);
}
#[test]
fn an_outer_edge_drop_wraps_the_area_it_previews_when_the_app_applies_other_permissions() {
// Frame [2, 3] and canvas 0 beside frame 1, which is protected only while drawing (PDFCraft's
// bare tools mode): drawn, the edge band is not narrowed to the canvas.
let inner = split(SplitAxis::Horizontal, SplitSize::Ratio(0.5), Node::Tabs { panels: vec![2, 3], active: 1 }, tabs(&[0]));
let layout = Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::Ratio(0.7), inner, tabs(&[1]))), floating: Vec::new() };
let drawn = [(0, Permissions::PROTECTED), (1, Permissions::PROTECTED)];
let driver = || DockDriver::new(layout.clone(), Bodies { rules: drawn.into(), ..Bodies::default() });
let d = driver();
let (canvas, _) = group(&d, 0);
let at = pos2(canvas.center().x, 5.0);
let mut d = driver();
d.hold(tab(&d, 3), at);
assert!(d.filled(Rect::from_x_y_ranges(0.0..=800.0, 0.0..=4.0)), "a line along the window's top");
let out = d.release(at);
let mut applied = layout.clone();
let apply = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() };
applied.apply_all(out.actions.clone(), apply).unwrap_or_else(|e| panic!("{e}: {:?}", out.actions));
let mut rest = layout;
rest.apply(Action::Close { panel: 3 }).unwrap();
assert_eq!(wrapped(&applied, 3), rest.root, "{:?}", out.actions);
}
#[test]
fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() {
let left_right = |style: &mut DockStyle| style.drop_edges = Sides::LEFT_RIGHT;
+3 -2
View File
@@ -328,8 +328,9 @@ pub enum Placement<P> {
/// root is split, or in a layout with docks, the part between them. The anchor names any
/// docked panel. A protected anchor ([`Permissions::is_protected`], applied through
/// [`Layout::apply_with_permissions`]) narrows the part to the canvas column: where neither
/// side of a side-by-side split is a dock and only one holds a protected panel, that side.
/// `Center` is invalid.
/// side of a side-by-side split is a dock and only one holds a protected panel, that side;
/// for `Top` (`Bottom`), also the first (last) row of a split of rows when only that row
/// holds one. `Center` is invalid.
Edge(Zone),
}
+106 -39
View File
@@ -7,8 +7,8 @@ use egui::{Context, Pos2, Rect, pos2, vec2};
use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup};
use crate::docking::{
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone,
dock_model::{Place, node_at},
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Placement, Target, Zone,
dock_model::{Place, node_at, tree_panels},
};
/// How far below the pointer the float hint sits, clear of the cursor.
@@ -20,14 +20,40 @@ pub(in crate::docking) struct FrameState<P> {
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
/// The same for each floating window's splits, by the window's first shown panel.
window_paths: HashMap<P, HashMap<Vec<bool>, Vec<bool>>>,
/// The view node whose rect is the outer edge area; none while a frame is maximized.
edge_path: Option<Vec<bool>>,
pub(super) edge: Option<Rect>,
/// The outer edge areas of top, bottom, left and right drops; none while a frame is
/// maximized.
edges: Option<[EdgeArea<P>; 4]>,
/// The panels the view leaves out.
hidden: Vec<P>,
pub(super) maximized: Option<P>,
/// Floating windows as drawn, back to front.
pub(super) windows: Vec<WindowHit<P>>,
}
/// The view node an outer edge drop wraps ([`Layout::edge_path`]), and its rect once drawn.
struct EdgeArea<P> {
path: Vec<bool>,
/// The node is narrower than the part between the docks: the canvas column.
narrowed: bool,
panels: Vec<P>,
rect: Option<Rect>,
}
impl<P: Clone + Eq + Hash> EdgeArea<P> {
fn of(view: &Layout<P>, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Self {
let path = view.edge_path(zone, canvas);
let panels =
view.root.as_ref().and_then(|root| node_at(root, &path)).map_or(Vec::new(), |node| tree_panels(node).into_iter().cloned().collect());
Self { narrowed: view.edge_narrows(zone, canvas), path, panels, rect: None }
}
}
const EDGES: [Zone; 4] = [Zone::Top, Zone::Bottom, Zone::Left, Zone::Right];
fn edge_areas<P: Clone + Eq + Hash>(view: &Layout<P>, canvas: &dyn Fn(&P) -> bool) -> [EdgeArea<P>; 4] {
EDGES.map(|zone| EdgeArea::of(view, zone, canvas))
}
/// The layout as shown: a maximized frame alone, and hidden panels left out. `narrow` narrows
/// the outer edge area to the canvas column ([`Layout::edge_path`]).
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
@@ -54,11 +80,12 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
}
Place::Floating { .. } => None,
});
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() };
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edges: None, hidden: Vec::new(), maximized: None, windows: Vec::new() };
if maximized.is_none() && matches!(hide, HidePanels::None) {
state.edge_path = Some(layout.edge_path(&canvas));
state.edges = Some(edge_areas(layout, &canvas));
return (Cow::Borrowed(layout), state);
}
state.hidden = layout.panels().into_iter().filter(|p| hidden(p)).cloned().collect();
let mut paths = HashMap::new();
let view = match maximized {
Some((panel, path, node)) => {
@@ -81,7 +108,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
}
};
if state.maximized.is_none() {
state.edge_path = Some(view.edge_path(&canvas));
state.edges = Some(edge_areas(&view, &canvas));
}
state.paths = Some(paths);
(Cow::Owned(view), state)
@@ -156,8 +183,11 @@ fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bo
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) {
self.frame.edge = Some(rect);
if self.tree.is_some() {
return;
}
for area in self.frame.edges.iter_mut().flatten().filter(|area| area.path == path) {
area.rect = Some(rect);
}
}
@@ -177,38 +207,33 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// edge zones the quarter the new frame takes. Without them, on a tab strip that takes tabs,
/// the band reaches only [`crate::docking::targets::STRIP_REACH`] from the line.
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band };
let band = band.min(area.width() / 4.0).min(area.height() / 4.0);
let line = |zone| crate::docking::targets::edge_line(area, zone, self.style.drop_line_width);
let sides = self.style.outer_edges();
let edges = [
(pointer.y - area.top(), Zone::Top),
(area.bottom() - pointer.y, Zone::Bottom),
(pointer.x - area.left(), Zone::Left),
(area.right() - pointer.x, Zone::Right),
];
let (distance, zone) = edges.into_iter().filter(|(_, zone)| sides.contains(*zone)).min_by(|a, b| a.0.total_cmp(&b.0))?;
if distance > band {
return None;
}
let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH;
if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line(zone).is_some_and(reach) {
return None;
}
// A protected anchor narrows the model's edge to the canvas column, as `edge_path`
// narrows the band without side edge zones.
let narrow = !self.style.side_edge_zones;
let docked: Vec<&P> = self
.layout
.panels()
let (distance, zone, area) = EDGES
.into_iter()
.filter(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))
.collect();
let anchor = docked.iter().find(|p| self.permissions(p).is_protected() == narrow).or(docked.first())?;
let anchor = (*anchor).clone();
.filter(|zone| sides.contains(*zone))
.filter_map(|zone| {
let area = self.edge_area(zone)?.rect.filter(|area| area.contains(pointer))?;
let distance = match zone {
Zone::Top => pointer.y - area.top(),
Zone::Bottom => area.bottom() - pointer.y,
Zone::Left => pointer.x - area.left(),
Zone::Right | Zone::Center => area.right() - pointer.x,
};
Some((distance, zone, area))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band };
if distance > band.min(area.width() / 4.0).min(area.height() / 4.0) {
return None;
}
let line = crate::docking::targets::edge_line(area, zone, self.style.drop_line_width);
let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH;
if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line.is_some_and(reach) {
return None;
}
let anchor = self.edge_anchor(members, zone)?;
if !self.style.side_edge_zones {
return Some((anchor, zone, line(zone)?));
return Some((anchor, zone, line?));
}
let size = area.size() * 0.25;
let preview = match zone {
@@ -220,6 +245,48 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
Some((anchor, zone, preview))
}
fn edge_area(&self, zone: Zone) -> Option<&EdgeArea<P>> {
self.frame.edges.as_ref()?.get(EDGES.iter().position(|edge| *edge == zone)?)
}
/// The docked anchor, not one of `members`, of an outer edge drop at `zone` that wraps the
/// edge area drawn: protected where that area is the canvas column, as the model narrows on
/// a protected anchor, else not. The drawn area and the model's can still differ (hidden
/// panels, a canvas test that differs from these permissions), so the drop is tried: an
/// anchor whose drop wraps other panels than the area shows gives way to one that is not
/// protected, and then to no edge target.
fn edge_anchor(&self, members: &[P], zone: Zone) -> Option<P> {
let area = self.edge_area(zone)?;
let protected = |p: &P| self.permissions(p).is_protected();
let skip = |p: &P| members.contains(p);
let lead = members.first()?;
let wraps_area = |anchor: &P| {
let to = Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) };
let action = match members {
[_] => Action::Move { panel: lead.clone(), anchor: anchor.clone(), placement: Placement::Edge(zone) },
_ => Action::MoveGroup { panel: lead.clone(), to },
};
let mut candidate = self.layout.clone();
if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() {
// Left to the model's check.
return true;
}
let Some(Place::Node(path)) = candidate.place(lead) else { return false };
let Some((last, parent)) = path.split_last() else { return false };
let beside = [parent, &[!last]].concat();
let Some(wrapped) = candidate.root.as_ref().and_then(|root| node_at(root, &beside)) else { return false };
let shown = |p: &&P| !self.frame.hidden.contains(p) && !members.contains(p);
let wrapped: Vec<&P> = tree_panels(wrapped).into_iter().filter(shown).collect();
let drawn: Vec<&P> = area.panels.iter().filter(shown).collect();
wrapped.len() == drawn.len() && wrapped.iter().all(|p| drawn.contains(p))
};
let anchor = self.layout.edge_anchor(area.narrowed, &protected, &skip)?;
if wraps_area(&anchor) {
return Some(anchor);
}
self.layout.edge_anchor(false, &protected, &skip).filter(|other| *other != anchor && !protected(other) && wraps_area(other))
}
/// Under the pointer while a drag offers a docking target: which modifier floats the drop
/// instead. With native windows, the window under the pointer draws it, beside its preview.
pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) {
+15 -5
View File
@@ -363,9 +363,9 @@ const FLOAT_INSET: f32 = 24.0;
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0);
/// Where a docking command floats a panel, group or column when it gives no rect (see
/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it
/// with [`float_space`].
/// Where a docking command floats a panel, group or column when it gives no rect, and how the
/// area draws its outer edge (see `DockCommand::to_actions`, feature `serde`). [`DockArea`]
/// records one each frame; read it with [`float_space`].
#[derive(Clone, Debug, PartialEq)]
pub struct FloatSpace<P> {
/// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left
@@ -378,13 +378,23 @@ pub struct FloatSpace<P> {
/// The height each panel's content used when it was last drawn, which fits a floated group
/// to its content; `None` for content that filled its body.
pub(super) contents: Vec<(P, Option<f32>)>,
/// The area draws with [`DockStyle::side_edge_zones`]: an outer edge drop wraps the part
/// between the docks, never only the canvas column.
pub(super) side_edge_zones: bool,
}
impl<P: PartialEq> FloatSpace<P> {
/// An area with no recorded sizes: commands float a panel at 260x320, and a group or
/// column at its dock column's width (else 260) by 320.
pub fn new(area: Rect) -> Self {
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() }
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new(), side_edge_zones: false }
}
/// For an area drawn with [`DockStyle::side_edge_zones`] when the area has not drawn yet
/// (a headless run): an outer edge command then wraps what that profile's edge band wraps.
pub fn with_side_edge_zones(mut self, on: bool) -> Self {
self.side_edge_zones = on;
self
}
/// Adds the size a command floats `panel` at alone when the area has not drawn it, such as
@@ -1186,7 +1196,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
output: DockOutput::default(),
frame,
iconic: super::render::Iconic::new(self.auto_collapse),
float_space: FloatSpace { contents, ..FloatSpace::new(rect) },
float_space: FloatSpace { contents, side_edge_zones: style.side_edge_zones, ..FloatSpace::new(rect) },
places,
contents: content_index,
dividers: Vec::new(),
+2 -1
View File
@@ -417,7 +417,8 @@ mod v1_fixtures {
let float: DockCommand<String> =
serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap();
let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space).unwrap();
let actions =
float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space, |p| d.content.permissions(p)).unwrap();
d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}"));
d.settle();
assert!(start_columns(&d.layout).is_empty(), "{at}: floated");
+12 -4
View File
@@ -266,9 +266,16 @@ A flyout fits its content the same way, without the title bar.
edge band is offered only when every frame keeps its minimum.
- The edge area is the root, or the part between the docks. Without `side_edge_zones` it also
narrows to the canvas column: where neither side of a side-by-side split is a dock and only
one side holds a protected panel, that side. Side panels in split frames then get no band
across the whole window, and the drop wraps only the canvas column. A drop on the band
anchors on the canvas, so `Placement::Edge` narrows the same way when applied.
one side holds a protected panel, that side. The top (bottom) band also narrows a split of
rows to its first (last) row when only that row holds the canvas, so a full-width row below
the canvas keeps the bottom band. Side panels in split frames then get no band across the
whole window, and the drop wraps only the canvas column. A drop on a narrowed band anchors
on the canvas, so `Placement::Edge` narrows the same way when applied; on a band that is not
narrowed it anchors on a panel that is not protected. The renderer tries the drop with its
own permissions and offers the band only when the drop wraps the panels the band shows.
`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
@@ -395,7 +402,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan
it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or
a new dock; when it already is that dock's last column, the command does nothing.
- `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a
new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first.
new frame on that outer edge (`Placement::Edge`), wrapping what a drag to that edge would
(see the edge area above). A hidden panel is shown first.
- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden
panel's place; a panel never laid out opens as `Open` without an anchor does.
- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see