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