diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 99825f1d..7db19eb4 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -340,7 +340,24 @@ 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. + /// + /// An outer edge dock wraps the part between the docks. [`Self::to_actions_with`] also + /// knows which panels are protected, so it wraps the canvas column where a drag would. pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { + self.to_actions_with(workspace, right_to_left, space, |_| Permissions::default()) + } + + /// [`Self::to_actions`] with the `permissions` the actions are applied with. 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. + pub fn to_actions_with( + &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 +435,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 { .. })) { @@ -504,7 +522,7 @@ impl DockCommand

{ /// location, and a panel never laid out opens as [`Action::Open`] without an anchor does /// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. - /// `space` is where `window.panel.float` floats, as for [`Self::to_actions`]. + /// `space` is where `window.panel.float` floats, as for [`Self::to_actions_with`]. pub fn apply( &self, workspace: &mut Workspace

, @@ -526,7 +544,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_with(&shown, right_to_left, space, &permissions)?, &permissions)?; *workspace = shown; return Ok(closed); } @@ -534,7 +552,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_with(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..c6ac3f1d 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_with(&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_with(&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_with(&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,25 @@ 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_with( + &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_with(&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_with(&workspace(), false, space, permissions).unwrap() } #[test] @@ -411,6 +417,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 37eb3f58..9d7b4e0f 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -262,6 +262,59 @@ 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); + let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview")); + assert_eq!(preview.x_range(), canvas.shrink(1.0).x_range(), "the canvas column's width, not the window's"); + let out = d.release(at); + d.settle(); + 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); + assert_eq!(preview, group(&d, 3).1.shrink(1.0)); +} + +#[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); + let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview")); + assert_eq!(preview.x_range(), Rect::from_x_y_ranges(0.0..=800.0, 0.0..=1.0).shrink2(egui::vec2(1.0, 0.0)).x_range(), "the window's width"); + 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/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 5cf3dbfb..064d8486 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -2205,10 +2205,13 @@ fn a_tab_drag_onto_a_native_window_is_no_app_drag() { #[test] fn a_native_window_fades_only_while_its_drag_can_dock() { let drag = |docks| WindowDrag { window: 1, grab: vec2(10.0, 10.0), to: pos2(100.0, 100.0), start: pos2(100.0, 100.0), docks }; - assert_eq!(drag_opacity(None, true), 1.0); + assert_eq!(drag_opacity(None, false, true), 1.0); // A bar drag that only moves the window stays opaque. - assert_eq!(drag_opacity(Some(&drag(false)), false), 1.0); - assert_eq!(drag_opacity(Some(&drag(false)), true), 0.75); + assert_eq!(drag_opacity(Some(&drag(false)), false, false), 1.0); + assert_eq!(drag_opacity(Some(&drag(false)), false, true), 0.75); // A drag by the window's only tab or group docks either way. - assert_eq!(drag_opacity(Some(&drag(true)), false), 0.75); + assert_eq!(drag_opacity(Some(&drag(true)), false, false), 0.75); + // With Cmd/Ctrl held the drag only moves the window. + assert_eq!(drag_opacity(Some(&drag(false)), true, true), 1.0); + assert_eq!(drag_opacity(Some(&drag(true)), true, false), 1.0); } 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/render/native.rs b/crates/ui/src/docking/render/native.rs index 57e494d0..1dd96e49 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -95,11 +95,12 @@ pub(in crate::docking) const OTHER_CLOSE: CloseLayout = const DRAG_OPACITY: f64 = 0.75; /// Opacity of the window `drag` moves: faded while the drag can dock (its only tab or group, or -/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move. +/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move and while +/// Ctrl/Cmd keeps the drop floating (`float_only`). #[cfg(any(target_os = "macos", test))] -pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, title_bar_docks: bool) -> f64 { +pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, float_only: bool, title_bar_docks: bool) -> f64 { match drag { - Some(drag) if drag.docks || title_bar_docks => DRAG_OPACITY, + Some(drag) if !float_only && (drag.docks || title_bar_docks) => DRAG_OPACITY, _ => 1.0, } } @@ -594,7 +595,8 @@ impl Renderer<'_, P> { let drag = ctx.data(|d| d.get_temp::(drag_key)); let dragged = drag.as_ref().and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); let this = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); - let opacity = if this { drag_opacity(drag.as_ref(), self.style.title_bar_docks) } else { 1.0 }; + let float_only = drag.is_some_and(|drag| self.cross.pointer.is_some_and(|p| p.window == Some(drag.window) && p.command)); + let opacity = if this { drag_opacity(drag.as_ref(), float_only, self.style.title_bar_docks) } else { 1.0 }; set_window_opacity(&title, main.as_deref(), opacity); } if let Some(placed) = report.placed.filter(|_| !moving) { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 18e3628a..d0a5a340 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -226,10 +226,11 @@ impl Renderer<'_, P> { } /// The side split of `proposal`'s frame when halving it leaves no room - /// ([`Placement::SplitSized`]): the frame keeps its size, the new frame takes the size the - /// moved panels have now (at least its minimum and [`DROP_MARGIN`]), and the frames beyond - /// the divider on that side give the room. `None` without such a divider, or when those - /// frames cannot give the new frame its minimum. + /// ([`Placement::SplitSized`]): the frame keeps its size, and the frames beyond the divider + /// on that side give the new frame its minimum (with [`DROP_MARGIN`]). A floating panel + /// keeps its size along the split instead, and one in a dock column its column's width or + /// its group's height, when that fits. `None` without such a divider, or when those frames + /// cannot give the new frame its minimum. fn sized_split(&self, panel: &P, proposal: &DropProposal

, action: &impl Fn(Target

) -> Action

) -> Option> { let Placement::Split(zone) = proposal.placement else { return None }; let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; @@ -277,11 +278,18 @@ impl Renderer<'_, P> { let room = sizer.travel(far.0, far.1, axis, start).0 - DROP_MARGIN; let least = along(super::node_limits(new, self.style, &*self.content).min) + DROP_MARGIN; let gap = self.style.gap; - let wanted = now.iter().find(|(node, _)| holds(node, panel)).map_or(least, |(_, rect)| along(rect.size())); - let size = wanted.max(least).min(along(halved.size()) + room - frame - gap); - if size < least { + // A split frame's width is its share of the room, not one of its own. + let saved = match self.place(panel) { + Some(Place::Floating { .. }) => now.iter().find(|(node, _)| holds(node, panel)).map(|(_, rect)| along(rect.size())), + Some(place @ Place::Dock { .. }) if axis == SplitAxis::Horizontal => self.layout.column(place).map(|column| column.width), + Some(place @ Place::Dock { .. }) => self.layout.group(place).and_then(|group| group.height), + _ => None, + }; + let most = along(halved.size()) + room - frame - gap; + if least > most { return None; } + let size = saved.filter(|width| (least..=most).contains(width)).unwrap_or(least); // The anchor's side of the outer split grows by what the halves did not take already. let grow = frame + gap + size - along(halved.size()); let first = along(a.1.size()) + if start { -grow } else { grow }; diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 81b75540..ec2834a7 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -397,6 +397,32 @@ fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room"); } +#[test] +fn a_docked_panel_dropped_beside_a_frame_too_narrow_to_halve_takes_its_minimum_width() { + // Info docked below Properties, as wide as it, beside a narrow Tools frame. + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(60.0), + first: Box::new(split(SplitAxis::Vertical, 0.6, tabs(&PROPERTIES), tabs(&[INFO]))), + second: Box::new(tabs(&[TOOLS])), + }; + let mut d = driver(Layout { root: Some(root), floating: Vec::new() }, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y)); + d.settle(); + let (tools, properties) = (group(&d, TOOLS), group(&d, PROPERTIES[0])); + let out = d.drag(tab(&d, INFO), pos2(tools.left() + 4.0, tools.center().y + 13.0)); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0])); + assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}"); + assert!(new_info.width() < 60.0, "Info takes its minimum, not its docked width: {new_info:?}"); + assert!(new_properties.width() > properties.width() - 60.0, "Properties gives only that: {new_properties:?}"); +} + #[test] fn a_drop_beside_a_narrow_frame_joins_it_when_no_neighbour_has_room() { let mut d = beside_tools(130.0, 60.0); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 4c596934..4b95a1bf 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -366,9 +366,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 @@ -381,13 +381,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 @@ -1189,7 +1199,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..4cfd5ad9 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_with(&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 84818310..a2da1862 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 @@ -400,7 +407,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