diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 9abd4082d..3c0918e63 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -454,8 +454,10 @@ impl Layout

{ None } - /// The node an edge split wraps: the root, or the part between the docks. - pub(in crate::docking) fn edge_path(&self) -> Vec { + /// 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 { let mut path = Vec::new(); let mut node = self.root.as_ref(); while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node { @@ -465,7 +467,11 @@ impl Layout

{ let second_side = match (&**first, &**second) { (Node::Dock { .. }, _) => true, (_, Node::Dock { .. }) => false, - _ => break, + _ => match (tree_panels(first).into_iter().any(canvas), tree_panels(second).into_iter().any(canvas)) { + (true, false) => false, + (false, true) => true, + _ => break, + }, }; path.push(second_side); node = Some(if second_side { second } else { first }); @@ -666,7 +672,8 @@ impl Layout

{ } /// Insert groups taken by [`Self::take_unit`] (or a new single-panel group). - pub(super) fn insert_moving(&mut self, moving: Moving

, to: Target

) -> Result<(), DockError> { + /// `canvas` is the protected-panel test an outer edge drop narrows by ([`Self::edge_path`]). + pub(super) fn insert_moving(&mut self, moving: Moving

, to: Target

, canvas: &dyn Fn(&P) -> bool) -> Result<(), DockError> { if moving.groups.is_empty() || moving.groups.iter().any(|g| g.panels.is_empty()) { return Err(DockError::MissingPanel); } @@ -735,7 +742,7 @@ impl Layout

{ // A floating window is a tree: a group above or below splits the anchor's group. Placement::Group { below, .. } if matches!(place, Place::Floating { .. }) => { let zone = if below { Zone::Bottom } else { Zone::Top }; - self.insert_moving(moving, Target::At { anchor, placement: Placement::Split(zone) }) + self.insert_moving(moving, Target::At { anchor, placement: Placement::Split(zone) }, canvas) } Placement::Group { below, height } => { if !valid_height(height) { @@ -757,7 +764,8 @@ impl Layout

{ if zone == Zone::Center || matches!(place, Place::Floating { .. }) { return Err(DockError::InvalidPlacement); } - let path = self.edge_path(); + // 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 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 8a7fe6f33..8e74ad59e 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -133,8 +133,13 @@ fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; let d = driver(&[(1, canvas)]); let (body, rect) = group(&d, 1); - for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { - let mut d = driver(&[(1, canvas)]); + // By default only its top and bottom: left of center goes to the nearer of those. + for (zone, at, edges) in [ + (Zone::Left, body.center() - vec2(10.0, 2.0), Sides::ALL), + (Zone::Top, body.center() - vec2(10.0, 2.0), DockStyle::default().drop_edges), + (Zone::Bottom, body.center() + vec2(-10.0, 2.0), DockStyle::default().drop_edges), + ] { + let mut d = driver(&[(1, canvas)]).with_style(move |style| style.drop_edges = edges); d.hold(tab(&d, 3), at); assert!(d.filled(line(rect, zone)), "{zone:?}"); let out = d.release(at); @@ -166,7 +171,7 @@ fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { let out = d.drag(tab(&d, 3), top); assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Top) }]); // A group that takes no tabs offers nothing there. - let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]); + let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]).with_style(|style| style.drop_edges = Sides::ALL); d.hold(tab(&d, 3), left); assert!(!d.outlined(rect.shrink(1.0))); let out = d.release(left); @@ -195,3 +200,123 @@ fn the_root_edge_band_is_28_pt_and_previews_a_line_along_the_layouts_edge() { let out = d.drag(tab(&d, 3), pos2(x, 7.0)); assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Top), .. }]), "{:?}", out.actions); } + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +/// Side frame `[1]`, canvas 0 and side frame `[2, 3]` side by side, 200, 400 and 200 pt wide. +fn canvas_columns() -> Layout { + let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), tabs(&[0]), Node::Tabs { panels: vec![2, 3], active: 1 }); + Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides)), floating: Vec::new() } +} + +fn canvas_driver(canvas: Permissions, style: impl Fn(&mut DockStyle) + 'static) -> Driver { + DockDriver::new(canvas_columns(), Bodies { rules: [(0, canvas)].into(), ..Bodies::default() }).with_style(style) +} + +fn moves(out: &DockOutput) -> bool { + out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MovePanel { .. })) +} + +#[test] +fn between_side_frames_the_outer_edge_band_covers_only_the_canvas_column() { + let d = canvas_driver(Permissions::PROTECTED, |_| {}); + let (canvas, _) = group(&d, 0); + let (side, _) = group(&d, 1); + let at = pos2(canvas.center().x, 590.0); + let mut d = canvas_driver(Permissions::PROTECTED, |_| {}); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(canvas, Zone::Bottom)), "a line along the canvas column's bottom"); + assert!(!d.filled(Rect::from_x_y_ranges(0.0..=800.0, 596.0..=600.0)), "no line along the window's bottom"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Bottom) }]); + // The side frames keep their full height. + let column = split(SplitAxis::Vertical, SplitSize::Ratio(0.75), tabs(&[0]), tabs(&[3])); + let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), column, tabs(&[2])); + assert_eq!(d.layout.root, Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides))); + // Under a side frame, its own bottom third. + let mut d = canvas_driver(Permissions::PROTECTED, |_| {}); + let out = d.drag(tab(&d, 3), pos2(side.center().x, 590.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Bottom) }]); +} + +#[test] +fn between_docks_the_outer_edge_band_covers_the_part_between_them() { + let dock = |side, panels: &[u32]| Node::Dock { side, columns: vec![Column::new(vec![Group::new(panels.to_vec(), 0)], 200.0)] }; + let middle = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), tabs(&[0]), tabs(&[4])); + let inner = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), middle.clone(), dock(Side::End, &[2, 3])); + let layout = + Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), dock(Side::Start, &[1]), inner)), 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 (timeline, _) = group(&d, 4); + let area = Rect::from_x_y_ranges(canvas.x_range(), (canvas.top() - 26.0)..=timeline.bottom()); + let at = pos2(canvas.center().x, 590.0); + let mut d = driver(); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(area, Zone::Bottom)), "a line along the bottom between the docks"); + let out = d.release(at); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{:?}", out.actions); + let Some(Node::Split { second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + let Node::Split { first, .. } = &**second else { panic!("{:?}", d.layout) }; + assert_eq!(**first, split(SplitAxis::Vertical, SplitSize::Ratio(0.75), middle, tabs(&[3]))); +} + +#[test] +fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { + let left_right = |style: &mut DockStyle| style.drop_edges = Sides::LEFT_RIGHT; + let d = canvas_driver(Permissions::PROTECTED, left_right); + // The canvas has no tab strip: its body is its frame. + let (canvas, _) = group(&d, 0); + let (side, _) = group(&d, 1); + // The bottom edge takes nothing. + let at = pos2(canvas.center().x, 590.0); + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + d.hold(tab(&d, 3), at); + assert!(!d.filled(line(canvas, Zone::Bottom))); + assert!(!moves(&d.release(at))); + // The left edge splits around the canvas column. + let at = pos2(canvas.left() + 5.0, canvas.center().y); + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(canvas, Zone::Left)), "a line along the canvas column's left edge"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Left) }]); + let column = split(SplitAxis::Horizontal, SplitSize::Ratio(0.25), tabs(&[3]), tabs(&[0])); + let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), column, tabs(&[2])); + assert_eq!(d.layout.root, Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides))); + // A side frame keeps all four sides. + for (zone, y) in [(Zone::Bottom, 590.0), (Zone::Top, side.top() + side.height() * 0.2)] { + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + let out = d.drag(tab(&d, 3), pos2(side.center().x, y)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // No drop edges: no outer edge takes a drop. + for at in [pos2(canvas.center().x, 590.0), pos2(canvas.center().x, 5.0), pos2(canvas.left() + 5.0, 300.0), pos2(canvas.right() - 5.0, 300.0)] { + let mut d = canvas_driver(Permissions::PROTECTED, |style| style.drop_edges = Sides::NONE); + let out = d.drag(tab(&d, 3), at); + assert!(!moves(&out), "{at:?}: {:?}", out.actions); + } +} + +#[test] +fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { + let beside = Permissions { accepts_splits: true, ..Permissions::PROTECTED }; + let d = canvas_driver(beside, |_| {}); + let (body, _) = group(&d, 0); + let at = pos2(body.left() + body.width() * 0.3, body.bottom() - 40.0); + for (edges, zone) in [(DockStyle::default().drop_edges, Some(Zone::Bottom)), (Sides::LEFT_RIGHT, Some(Zone::Left)), (Sides::NONE, None)] { + let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let out = d.drag(tab(&d, 3), at); + match zone { + Some(zone) => assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Split(zone) }], "{edges:?}"), + None => assert!(!moves(&out), "{:?}", out.actions), + } + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index f960c3e63..9b0273d9d 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -35,8 +35,8 @@ pub use surface::{ WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, }; pub use targets::{ - DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker, zone_rect, - zone_shape, + DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, Sides, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker, + zone_rect, zone_shape, }; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index be2ae3a37..6bbf15ad2 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -318,7 +318,10 @@ pub enum Placement

{ Column { after: bool }, /// A new frame along one edge of the docked layout, a quarter of its width or height: the /// root is split, or in a layout with docks, the part between them. The anchor names any - /// docked panel. `Center` is invalid. + /// 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. Edge(Zone), } @@ -819,14 +822,14 @@ impl Layout

{ let window_open = location.anchor.as_ref().is_some_and(|anchor| proposed.floating.iter().any(|w| w.contains(anchor))); if let Some(rect) = location.floating.filter(|_| !window_open) { // A window that closed as a whole reopens at its last rect. - proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; + proposed.insert_moving(moving(), Target::Float { rect: Some(rect) }, &|_| false)?; } else if let Some(anchor) = &location.anchor && matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) { if !proposed.contains(anchor) { return Err(DockError::MissingTarget); } - proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() }, &|_| false)?; } else if let Some(column) = state.and_then(|s| s.column).filter(|_| location.anchor.is_none()) { // Alone in its dock: back at that edge, as the outermost column if a dock exists. match proposed.dock_path(column.side) { @@ -835,7 +838,7 @@ impl Layout

{ let Moving { groups, .. } = moving(); columns.insert(0, Column { groups, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }); } - None => proposed.insert_moving(moving(), Target::NewDock { side: column.side })?, + None => proposed.insert_moving(moving(), Target::NewDock { side: column.side }, &|_| false)?, } } else if let Some(anchor) = &location.anchor { if !proposed.contains(anchor) { @@ -862,7 +865,7 @@ impl Layout

{ Placement::Tab { before: Some(before) } if !proposed.same_group(before, anchor) => Placement::Tab { before: None }, placement => placement.clone(), }; - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement })?; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement }, &|_| false)?; if let Some(size) = location.size { proposed.trees_mut().any(|root| restore_parent_size(root, &panel, size)); } @@ -899,7 +902,7 @@ impl Layout

{ .find(|p| matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))) .cloned() .ok_or(DockError::MissingTarget)?; - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor, placement: Placement::Edge(Zone::Right) })?; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor, placement: Placement::Edge(Zone::Right) }, &|_| false)?; if let Some(root) = proposed.root.as_mut() { restore_parent_size(root, &panel, SplitSize::Ratio(0.25)); } @@ -987,7 +990,7 @@ impl Layout

{ } let closed = self.closed_by(&action)?; let mut proposed = self.clone(); - proposed.apply_inner(action)?; + proposed.apply_inner(action, &|p| permissions(p).is_protected())?; proposed.validate()?; *self = proposed; Ok(closed) @@ -1017,7 +1020,8 @@ impl Layout

{ }) } - fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { + /// `canvas` is the protected-panel test an outer edge drop narrows by. + fn apply_inner(&mut self, action: Action

, canvas: &dyn Fn(&P) -> bool) -> Result<(), DockError> { match action { Action::Move { panel, anchor, placement } => { if panel == anchor { @@ -1036,7 +1040,7 @@ impl Layout

{ } } let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.insert(entry, &anchor, placement) + self.insert(entry, &anchor, placement, canvas) } Action::Float { panel, rect } => { if !valid_rect(rect) { @@ -1070,11 +1074,11 @@ impl Layout

{ } Action::Open { panel, anchor } => { if self.contains(&panel) { - return self.apply_inner(Action::Activate { panel }); + return self.apply_inner(Action::Activate { panel }, canvas); } let entry = StackEntry { panel, open: true, height: None }; if let Some(anchor) = anchor { - self.insert(entry, &anchor, Placement::Tab { before: None }) + self.insert(entry, &anchor, Placement::Tab { before: None }, canvas) } else if self.root.is_none() { self.root = Some(Node::Tabs { panels: vec![entry.panel], active: 0 }); Ok(()) @@ -1084,14 +1088,14 @@ impl Layout

{ } Action::OpenAt { panel, anchor, placement } => { if self.contains(&panel) { - return self.apply_inner(Action::Move { panel, anchor, placement }); + return self.apply_inner(Action::Move { panel, anchor, placement }, canvas); } if let Placement::Tab { before: Some(before) } = &placement && !self.same_group(before, &anchor) { return Err(DockError::MissingTarget); } - self.insert(StackEntry { panel, open: true, height: None }, &anchor, placement) + self.insert(StackEntry { panel, open: true, height: None }, &anchor, placement, canvas) } Action::MoveFloating { panel, rect } => { if !valid_rect(rect) { @@ -1142,13 +1146,13 @@ impl Layout

{ return Ok(()); } let moving = self.take_unit(panel, unit)?; - self.insert_moving(moving, to.clone()) + self.insert_moving(moving, to.clone(), canvas) } Action::MovePanel { panel, to } => match to { - Target::At { anchor, placement } => self.apply_inner(Action::Move { panel, anchor, placement }), + Target::At { anchor, placement } => self.apply_inner(Action::Move { panel, anchor, placement }, canvas), to => { let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.insert_moving(Moving::group(Group::new(vec![entry.panel], 0)), to) + self.insert_moving(Moving::group(Group::new(vec![entry.panel], 0)), to, canvas) } }, Action::CloseGroup { ref panel } | Action::CloseWindow { ref panel } => self.take_unit(panel, unit_of(&action)).map(|_| ()), @@ -1285,16 +1289,18 @@ impl Layout

{ if let Some((panel, _)) = groups.clone().find(|(_, place)| side(place) == Some(Side::End)).or_else(|| groups.clone().next()) { return Some(((*panel).clone(), Placement::Tab { before: None })); } - docked.first().map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) + // A canvas anchor would narrow the edge to the canvas column. + let edge = docked.iter().find(|(p, _)| !permissions(p).is_protected()).or(docked.first()); + edge.map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) } - fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { + fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

, canvas: &dyn Fn(&P) -> bool) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; let tree = place.tree_path().map(|(window, path)| (window, path.to_vec())); let Some((window, path)) = tree.filter(|_| !matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_))) else { let group = Group::new(vec![entry.panel], 0); - return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); + return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }, canvas); }; let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; if let Placement::Split(zone) = placement diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 74ba98a0a..72071eafc 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -28,14 +28,18 @@ pub(in crate::docking) struct FrameState

{ pub(super) windows: Vec>, } -/// The layout as shown: a maximized frame alone, and hidden panels left out. +/// 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>( layout: &'a Layout

, maximized: Option<&P>, hide: HidePanels<'_, P>, content: &dyn DockContent

, + narrow: bool, ) -> (Cow<'a, Layout

>, FrameState

) { let protected = |p: &P| content.permissions(p).is_protected(); + // The edge area narrows to the canvas column without side edge zones. + let canvas = |p: &P| narrow && protected(p); let hidden = |p: &P| match hide { HidePanels::None => false, HidePanels::All => !protected(p), @@ -52,7 +56,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( }); let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() }; if maximized.is_none() && matches!(hide, HidePanels::None) { - state.edge_path = Some(layout.edge_path()); + state.edge_path = Some(layout.edge_path(&canvas)); return (Cow::Borrowed(layout), state); } let mut paths = HashMap::new(); @@ -77,7 +81,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( } }; if state.maximized.is_none() { - state.edge_path = Some(view.edge_path()); + state.edge_path = Some(view.edge_path(&canvas)); } state.paths = Some(paths); (Cow::Owned(view), state) @@ -166,21 +170,25 @@ impl Renderer<'_, P> { paths.and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) } - /// The outer edge band under the pointer (P8): top and bottom, and left and right with + /// The outer edge band under the pointer (P8): the sides of + /// [`crate::docking::DockStyle::drop_edges`], all four with /// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not - /// moving, the zone and the preview: a line along the layout's whole edge, or with side 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. + /// moving, the zone and the preview: a line along the edge area's whole edge, or with side + /// 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 mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)]; - if self.style.side_edge_zones { - edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]); - } - let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + let sides = self.style.drop_sides(); + 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; } @@ -188,8 +196,17 @@ impl Renderer<'_, P> { if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line(zone).is_some_and(reach) { return None; } - let anchor = - self.layout.panels().into_iter().find(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))?.clone(); + // 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() + .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(); if !self.style.side_edge_zones { return Some((anchor, zone, line(zone)?)); } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 2defec03c..a8cd1e6ff 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,7 +8,7 @@ use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, dock_model::Place, surface::TAB_PADDING, - targets::{SIDE_EDGE_ZONE_DEPTH, resolve_drop_by}, + targets::{SIDE_EDGE_ZONE_DEPTH, resolve_drop_on}, }; use crate::layout::SplitAxis; @@ -186,11 +186,12 @@ impl Renderer<'_, P> { (proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) } - /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`], where a split that would leave + /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`] and the + /// canvas sides of [`crate::docking::DockStyle::drop_edges`], where a split that would leave /// a group without room becomes a join of the target group, or no target when the group /// takes no tabs. fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { - let proposal = resolve_drop_by(panel, pointer, targets, self.style.zone_rule())?; + let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.drop_sides())?; if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { return Some(proposal); } @@ -198,7 +199,13 @@ impl Renderer<'_, P> { return Some(proposal); } let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; - resolve_drop_by(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_rule()) + resolve_drop_on( + panel, + pointer, + std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), + self.style.zone_rule(), + self.style.drop_sides(), + ) } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 0b036b2f5..a4a41f2e9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -64,6 +64,12 @@ pub struct DockStyle { /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its /// body, and every preview is a line or a tab outline. pub side_edge_zones: bool, + /// The layout's outer edges that take a drop, a new frame along that edge, and the sides of + /// a group that takes no tabs (a canvas) that take a split (top and bottom; the left and + /// right window edges are new docks). With [`crate::docking::Sides::NONE`], palettes dock + /// only through the dock bands. Not used with [`Self::side_edge_zones`], which takes all + /// four. Groups that take tabs keep all four sides. + pub drop_edges: super::Sides, /// The share of a group's body, below the tab strip, that means "beside" on each side: of /// its width from the left and right edges, of its height from the top and bottom edges /// (one third, at most one half). The middle joins the group as a tab. Not used with @@ -190,6 +196,7 @@ impl DockStyle { title_height: super::chrome::TITLE_HEIGHT, edge_band: 28.0, side_edge_zones: false, + drop_edges: super::Sides::TOP_BOTTOM, side_zone_fraction: 1.0 / 3.0, drop_line_width: 4.0, new_dock_zones: true, @@ -227,6 +234,12 @@ impl DockStyle { } } + /// The sides [`Self::drop_edges`] gives in this profile: all four with + /// [`Self::side_edge_zones`]. + pub(super) fn drop_sides(&self) -> super::Sides { + if self.side_edge_zones { super::Sides::ALL } else { self.drop_edges } + } + /// How a group's body divides into drop zones: [`crate::docking::SIDE_EDGE_ZONE_DEPTH`] deep /// with [`Self::side_edge_zones`], else [`Self::side_zone_fraction`] of it, with /// [`Self::drop_line_width`] previews. Pass it to [`crate::docking::resolve_drop_by`]. @@ -1177,7 +1190,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { ui.ctx().data_mut(|d| d.insert_temp(blocked_key, self.input_blocked)); cancel_drag_if_replaced(ui.ctx(), self.id, layout); release_moved_tab_focus(ui.ctx(), self.id, layout); - let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); + let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content, !style.side_edge_zones); let places = layout.places(); // A panel keeps the content height it last drew while hidden or minimized. let contents: Vec<(P, Option)> = float_space::

(ui.ctx(), self.id) diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index c9ac64a71..a3dc3b2a8 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -78,6 +78,12 @@ pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Opti /// or less leaves only the center; more than one half acts as one half. Outside, non-finite, /// empty and inverted rectangles have no target. pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, pointer: Pos2) -> Option { + body_zone_on(rect, tab_height, fraction, center, Sides::ALL, pointer) +} + +/// [`body_zone`] where a body without `center` maps only to the nearest of `sides`, and to no +/// target without any. +pub(super) fn body_zone_on(rect: Rect, tab_height: f32, fraction: f32, center: bool, sides: Sides, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; } @@ -87,13 +93,16 @@ pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, point } let fraction = fraction.min(0.5); let (w, h) = (body.width(), body.height()); - let sides = [ + let allowed = |zone: &Zone| center || sides.contains(*zone); + let (distance, zone) = [ ((pointer.x - body.left()) / w, Zone::Left), ((body.right() - pointer.x) / w, Zone::Right), ((pointer.y - body.top()) / h, Zone::Top), ((body.bottom() - pointer.y) / h, Zone::Bottom), - ]; - let (distance, zone) = sides.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + ] + .into_iter() + .filter(|(_, zone)| allowed(zone)) + .min_by(|a, b| a.0.total_cmp(&b.0))?; Some(if center && distance > fraction { Zone::Center } else { zone }) } @@ -216,6 +225,34 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg resolve_drop_by(panel, pointer, targets, ZoneRule::Depth(zone_depth)) } +/// A set of sides: of the layout's outer edge, or of a group's body +/// ([`crate::docking::DockStyle::drop_edges`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sides { + pub left: bool, + pub right: bool, + pub top: bool, + pub bottom: bool, +} + +impl Sides { + pub const ALL: Self = Self { left: true, right: true, top: true, bottom: true }; + pub const NONE: Self = Self { left: false, right: false, top: false, bottom: false }; + pub const LEFT_RIGHT: Self = Self { left: true, right: true, top: false, bottom: false }; + pub const TOP_BOTTOM: Self = Self { left: false, right: false, top: true, bottom: true }; + + /// Whether `zone`'s side is in the set; never the center. + pub fn contains(self, zone: Zone) -> bool { + match zone { + Zone::Left => self.left, + Zone::Right => self.right, + Zone::Top => self.top, + Zone::Bottom => self.bottom, + Zone::Center => false, + } + } +} + /// How [`resolve_drop_by`] divides a group's body. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ZoneRule { @@ -229,6 +266,18 @@ pub enum ZoneRule { /// [`resolve_drop`] with the body zones of `rule`: [`crate::docking::DockStyle::zone_rule`] /// gives the ones the dock area uses. pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropTarget

], rule: ZoneRule) -> Option> { + resolve_drop_on(panel, pointer, targets, rule, Sides::ALL) +} + +/// [`resolve_drop_by`] where a group that takes no tabs splits only on `sides` with +/// [`ZoneRule::Fraction`]. +pub(super) fn resolve_drop_on( + panel: &P, + pointer: Pos2, + targets: &[DropTarget

], + rule: ZoneRule, + sides: Sides, +) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -270,7 +319,7 @@ pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropT let zone = match rule { _ if !target.allow_splits => Zone::Center, ZoneRule::Depth(depth) => drop_zone(target.rect, header, depth, pointer)?, - ZoneRule::Fraction { fraction, .. } => body_zone(target.rect, header, fraction, target.accepts_tabs, pointer)?, + ZoneRule::Fraction { fraction, .. } => body_zone_on(target.rect, header, fraction, target.accepts_tabs, sides, pointer)?, }; if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { return Some(DropProposal { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 39152ee60..e2ff84a76 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -250,12 +250,23 @@ A flyout fits its content the same way, without the title bar. | The seam between two stacked groups of a dock column | A new group between them | `seam_band` (28 pt) | | Each edge of a dock column | A new column on that side | `column_edge_band` (28 pt) | | The docked area's left or right edge without a dock, and an empty dock | A new dock | `new_dock_band` (28 pt) | - | The layout's top and bottom edges (and left and right with `side_edge_zones`) | A new frame across the whole window side, a quarter of the window | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the layout) | + | The edge area's sides in `drop_edges` (top and bottom; all four with `side_edge_zones`) | A new frame along that whole side, a quarter of the edge area | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the edge area) | Where a band overlaps a group's outer third, both mean the same side. Without `side_edge_zones`, a band reaches only 4 pt past its line into a tab strip that takes tabs; the rest of the strip joins. The outer 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. +- `DockStyle::drop_edges` (a `Sides`) lists the edge area's sides that take a drop, and the + sides of a group that takes no tabs (a canvas) that take a split; there, the whole body goes + to the nearest listed side, and with none listed the canvas takes nothing. Groups that take + tabs keep all four sides. The default is top and bottom. An app whose palettes dock only + through the dock bands sets `Sides::NONE`; one whose palettes sit left and right of the + canvas sets `Sides::LEFT_RIGHT`. `side_edge_zones` uses all four and ignores it. - With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame instead: trapezoids along the four edges of the body below the tab strip, all four the same depth, `docking::SIDE_EDGE_ZONE_DEPTH` (40 pt), or a quarter of the body's smaller side when