From 91aaa294d0fdf7b15dc5f4d912de9bdf2beadcab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:18:34 +0000 Subject: [PATCH] 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. --- crates/ui/src/docking/command.rs | 30 +++-- crates/ui/src/docking/command_tests.rs | 46 +++++++- crates/ui/src/docking/dock_model.rs | 44 ++++++-- crates/ui/src/docking/drop_tests.rs | 50 +++++++++ crates/ui/src/docking/model.rs | 5 +- crates/ui/src/docking/render/frame.rs | 145 ++++++++++++++++++------- crates/ui/src/docking/surface.rs | 20 +++- crates/ui/tests/docking_workflows.rs | 3 +- docs/ui-docking.md | 16 ++- 9 files changed, 283 insertions(+), 76 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 99825f1d..103b3331 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -340,7 +340,18 @@ impl DockCommand

{ /// [`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

, right_to_left: bool, space: &FloatSpace

) -> Result>, 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

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> Result>, 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 DockCommand

{ 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 DockCommand

{ // 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 DockCommand

{ 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) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 78d406b1..3801c048 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -45,7 +45,7 @@ fn parse(params: Value) -> Result, serde_json::Error> { /// The actions `params` makes on a fresh workspace. fn actions(params: Value) -> Vec> { 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::

(&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

, panel: &str) -> Vec> { - 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

, panel: &str) -> Option> { + 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. diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 37c6fa07..0c318500 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -543,20 +543,28 @@ impl Layout

{ 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 { + /// 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 { + let holds = |node: &Node

| 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 Layout

{ 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

{ + 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>> { match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { Some(Node::Dock { columns, .. }) => Some(columns), @@ -866,7 +890,7 @@ impl Layout

{ 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); diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 87de29b3..efe44c5d 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -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, panel: u32) -> Option> { + 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; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 31dcfcfa..e5ba3cf8 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -328,8 +328,9 @@ pub enum Placement

{ /// 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), } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 02dd25d1..e1b7c429 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -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

{ paths: Option, Vec>>, /// The same for each floating window's splits, by the window's first shown panel. window_paths: HashMap, Vec>>, - /// The view node whose rect is the outer edge area; none while a frame is maximized. - edge_path: Option>, - pub(super) edge: Option, + /// The outer edge areas of top, bottom, left and right drops; none while a frame is + /// maximized. + edges: Option<[EdgeArea

; 4]>, + /// The panels the view leaves out. + hidden: Vec

, pub(super) maximized: Option

, /// Floating windows as drawn, back to front. pub(super) windows: Vec>, } +/// The view node an outer edge drop wraps ([`Layout::edge_path`]), and its rect once drawn. +struct EdgeArea

{ + path: Vec, + /// The node is narrower than the part between the docks: the canvas column. + narrowed: bool, + panels: Vec

, + rect: Option, +} + +impl EdgeArea

{ + fn of(view: &Layout

, 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(view: &Layout

, canvas: &dyn Fn(&P) -> bool) -> [EdgeArea

; 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(group: &Group

, hidden: &dyn Fn(&P) -> bo impl 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 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 Renderer<'_, P> { Some((anchor, zone, preview)) } + fn edge_area(&self, zone: Zone) -> Option<&EdgeArea

> { + 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

{ + 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) { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 885303a6..5f8afc31 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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

{ /// 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

{ /// 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)>, + /// 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 FloatSpace

{ /// 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(), diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 3f46b521..081fda2e 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -417,7 +417,8 @@ mod v1_fixtures { let float: DockCommand = 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"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 79415dd6..640d879e 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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