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 9abd4082..0c318500 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -326,6 +326,35 @@ pub(super) fn split_frame

(root: &mut Node

, path: &[bool], new: Node

, zo true } +/// [`split_frame`] with the sizes of [`Placement::SplitSized`]: the new split takes `size`, and +/// the nearest enclosing split whose divider runs along the frame's `zone` side takes `outer`. +/// False when there is no such split, or a dock is beyond it. +pub(super) fn split_frame_sized

(root: &mut Node

, path: &[bool], new: Node

, zone: Zone, size: SplitSize, outer: SplitSize) -> bool { + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + // A divider on the frame's start side (left or top) belongs to a split holding the frame + // in its second child. + let start = matches!(zone, Zone::Left | Zone::Top); + let enclosing = (0..path.len()).rev().find(|&depth| { + path.get(depth) == Some(&start) && matches!(node_at(root, &path[..depth]), Some(Node::Split { axis: own, .. }) if *own == axis) + }); + let Some(depth) = enclosing else { return false }; + let Some(Node::Split { size: own, first, second, .. }) = node_at_mut(root, &path[..depth]) else { return false }; + if zone == Zone::Center || matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }) { + return false; + } + *own = outer; + if !split_frame(root, path, new, zone) { + return false; + } + match node_at_mut(root, path) { + Some(Node::Split { size: own, .. }) => { + *own = size; + true + } + _ => false, + } +} + /// Insert `new` at `index` and select the member at `index + selected`. fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, selected: usize) { let index = index.min(panels.len()); @@ -335,6 +364,66 @@ fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, } impl Layout

{ + /// The layout without its sizes and rectangles: which panels are where, and their state. + /// A run of splits along one axis nests one way (`a | (b | c)`), since nesting it the + /// other way only moves sizes between its members. + pub(super) fn shape(&self) -> Layout

{ + fn run<'a, P>(node: &'a Node

, axis: SplitAxis, out: &mut Vec<&'a Node

>) { + match node { + Node::Split { axis: own, first, second, .. } if *own == axis => { + run(first, axis, out); + run(second, axis, out); + } + node => out.push(node), + } + } + fn node(n: &Node

) -> Node

{ + match n { + Node::Split { axis, .. } => { + let mut members = Vec::new(); + run(n, *axis, &mut members); + let mut shaped = members.into_iter().rev().map(node); + let last = shaped.next().unwrap_or(Node::Tabs { panels: Vec::new(), active: 0 }); + shaped.fold(last, |second, first| Node::Split { + axis: *axis, + size: SplitSize::Ratio(0.5), + first: Box::new(first), + second: Box::new(second), + }) + } + Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active }, + Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| super::StackEntry { height: None, ..e.clone() }).collect() }, + Node::Dock { side, columns } => Node::Dock { + side: *side, + columns: columns + .iter() + .map(|c| Column { + groups: c.groups.iter().map(|g| Group { height: None, ..g.clone() }).collect(), + width: 0.0, + iconic: c.iconic, + iconic_width: None, + }) + .collect(), + }, + } + } + Layout { + root: self.root.as_ref().map(node), + floating: self + .floating + .iter() + .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() }) + .collect(), + } + } + + /// Whether `other` arranges the panels as this layout does: equal apart from split sizes, + /// column widths, group heights and window rectangles. A drop that leads from one to the + /// other changes nothing the user would see as a move. + pub(super) fn same_arrangement(&self, other: &Self) -> bool { + self.shape() == other.shape() + } + pub(super) fn place(&self, panel: &P) -> Option { for (w, window) in self.floating.iter().enumerate() { for (leaf, path) in leaves(&window.root) { @@ -454,18 +543,32 @@ 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 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, - _ => break, + 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, + }, }; path.push(second_side); node = Some(if second_side { second } else { first }); @@ -473,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), @@ -666,7 +785,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); } @@ -732,10 +852,22 @@ impl Layout

{ let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; if split_frame(root, path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) } } + Placement::SplitSized { zone, size, outer } => { + let (window, path) = place.tree_path().ok_or(DockError::InvalidPlacement)?; + let new = match moving.node { + Some(node) => node, + None => { + let (panels, active) = flatten(moving.groups); + Node::Tabs { panels, active } + } + }; + let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; + if split_frame_sized(root, path, new, zone, size, outer) { Ok(()) } else { Err(DockError::InvalidPlacement) } + } // 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 +889,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(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 8a7fe6f3..efe44c5d 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -195,3 +195,312 @@ 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]))); +} + +/// 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; + 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); + // Without top and bottom, no outer edge takes a drop: left and right are docks here. + for at in [pos2(canvas.center().x, 590.0), pos2(canvas.left() + 5.0, 300.0), pos2(canvas.right() - 5.0, 300.0)] { + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + d.hold(tab(&d, 3), at); + assert!(!d.filled(line(canvas, Zone::Bottom)), "{at:?}"); + let out = d.release(at); + assert!(!moves(&out), "{at:?}: {:?}", out.actions); + } + // 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); + // Nearer the bottom than the left, and right of center nearer the right than the top. + let low = pos2(body.left() + body.width() * 0.3, body.bottom() - 40.0); + let high = pos2(body.right() - body.width() * 0.3, body.top() + 40.0); + for (edges, zones) in [ + (DockStyle::default().drop_edges, [Some(Zone::Bottom), Some(Zone::Top)]), + (Sides::LEFT_RIGHT, [Some(Zone::Left), Some(Zone::Right)]), + (Sides::NONE, [None, None]), + ] { + for (at, zone) in [low, high].into_iter().zip(zones) { + 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), "{edges:?}: {:?}", out.actions), + } + } + } +} + +/// Whether the last frame painted a drop preview: an accent line, outline or fill. +fn previews(d: &Driver) -> bool { + let accent = accent(); + d.shapes().iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.fill == accent || shape.fill == accent.gamma_multiply(0.08) || shape.stroke.color == accent, + egui::Shape::Path(path) => path.fill != Color32::TRANSPARENT && path.fill == accent.gamma_multiply(0.3), + _ => false, + }) +} + +fn ratio_split(axis: SplitAxis, ratio: f32, first: Node, second: Node) -> Node { + Node::Split { axis, size: SplitSize::Ratio(ratio), first: Box::new(first), second: Box::new(second) } +} + +#[test] +fn a_drop_that_changes_only_sizes_shows_nothing_and_does_nothing() { + // CADCraft's sidebar: Layers (2) alone above Properties (3) and a third group (4), beside + // the canvas (1); and the same with the column as one two-group split. + let nested = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), ratio_split(SplitAxis::Vertical, 0.5, tabs(&[3]), tabs(&[4]))); + let flat = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), tabs(&[3])); + for column in [nested, flat] { + let layout = Layout { root: Some(ratio_split(SplitAxis::Horizontal, 0.6, tabs(&[1]), column)), floating: Vec::new() }; + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 3); + let top = pos2(body.center().x, body.top() + body.height() * 0.2); + // Properties' bottom third moves Layers, with a line. + let mut d = start(); + d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2)); + assert!(previews(&d) && d.filled(line(rect, Zone::Bottom))); + // Its top third would put Layers back where it is. + let mut d = start(); + d.hold(tab(&d, 2), top); + assert!(!previews(&d), "no line, outline or fill"); + let out = d.release(top); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, layout); + } + // A lone frame's tab on the layout edge its frame already holds. + let layout = Layout { root: Some(ratio_split(SplitAxis::Vertical, 0.75, tabs(&[1]), tabs(&[4]))), floating: Vec::new() }; + let mut d = DockDriver::new(layout.clone(), Bodies::default()); + let at = pos2(400.0, 590.0); + d.hold(tab(&d, 4), at); + assert!(!previews(&d)); + let out = d.release(at); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, layout); +} + +#[test] +fn a_dock_drop_that_changes_only_heights_shows_nothing_and_does_nothing() { + // A dock column of a sized lone group (2) above another (3), beside the canvas (1). + let column = Column::new(vec![Group { height: Some(150.0), ..Group::new(vec![2], 0) }, Group::new(vec![3], 0)], 240.0); + let dock = Node::Dock { side: Side::End, columns: vec![column] }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(240.0), first: Box::new(tabs(&[1])), second: Box::new(dock) }; + let layout = Layout { root: Some(root), floating: Vec::new() }; + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 3); + let (upper, _) = group(&d, 2); + // Below the lower group moves the upper one, with a line. + let mut d = start(); + d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2)); + assert!(previews(&d)); + // The lower group's top third and the seam between them put the group back where it is. + for at in [pos2(body.center().x, body.top() + body.height() * 0.2), pos2(body.center().x, (upper.bottom() + rect.top()) * 0.5)] { + let mut d = start(); + d.hold(tab(&d, 2), at); + assert!(!previews(&d), "{at:?}: no line"); + let out = d.release(at); + assert!(out.actions.is_empty(), "{at:?}: {:?}", out.actions); + assert_eq!(d.layout, layout); + } +} + +#[test] +fn a_header_drag_of_a_windows_only_group_looks_through_its_window() { + // Group 4 floats alone over frame 1's left part. + let mut layout = frames(); + layout.floating.push(Floating::single(vec![4], 0, [40.0, 150.0, 300.0, 260.0])); + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 1); + let window = Rect::from_min_size(pos2(40.0, 150.0), vec2(300.0, 260.0)); + let at = pos2(body.left() + body.width() * 0.25, body.center().y); + assert!(window.contains(at)); + let mut d = start(); + let bar = d.hit(&Hit::DragBar(4)).unwrap().center(); + d.hold(bar, at); + assert!(d.filled(line(rect, Zone::Left)), "frame 1's left third, behind the window"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 4, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]); +} + +#[test] +fn a_header_drag_in_a_window_of_two_groups_still_offers_the_other_group() { + let mut layout = frames(); + let tree = ratio_split(SplitAxis::Vertical, 0.5, tabs(&[4]), tabs(&[5])); + layout.floating.push(Floating::new(tree, [40.0, 100.0, 300.0, 400.0])); + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 5); + let mut d = start(); + let bar = d.hit(&Hit::DragBar(4)).unwrap().center(); + d.hold(bar, body.center()); + assert!(d.outlined(rect.shrink(1.0)), "group 5 joins"); + let out = d.release(body.center()); + assert!( + matches!( + &out.actions[..], + [Action::MoveGroup { panel: 4, to: Target::At { anchor: 5, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } }] + ), + "{:?}", + out.actions + ); +} + +#[test] +fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { + // Frame 2 is 60 pt wide beside frame 1; group 4 floats over frame 1. + let root = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(60.0), first: Box::new(tabs(&[1])), second: Box::new(tabs(&[2])) }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![4], 0, [100.0, 100.0, 200.0, 200.0])] }; + let mut d = DockDriver::new(layout, Bodies::default()); + let (body, rect) = group(&d, 2); + let at = pos2(body.left() + 8.0, body.center().y); + d.hold(tab(&d, 4), at); + assert!(d.filled(line(rect, Zone::Left)), "a line along frame 2's left edge"); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: 4, anchor: 2, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + assert!((group(&d, 2).1.width() - rect.width()).abs() < 1.0, "frame 2 keeps its width"); + assert!(group(&d, 4).1.width() >= 40.0); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index fc071e66..8406ec5e 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -503,7 +503,8 @@ fn release_over_own_position_is_a_noop() { #[test] fn floating_border_and_drag_bar_move_and_cancel_as_a_group() { for name in ["drag-border", "drag-bar"] { - let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + // Nothing docked: a header drag of a window's only group looks through the window. + let layout = Layout { root: None, floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; let mut d = Driver::new(layout.clone()); let from = d.rect(name, 5).center(); d.hold(from, from + vec2(50.0, 40.0)); @@ -522,7 +523,7 @@ fn floating_border_and_drag_bar_move_and_cancel_as_a_group() { #[test] fn floating_border_movement_does_not_accumulate_fractional_step_rounding() { - let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let layout = Layout { root: None, floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; let mut d = Driver::new(layout); let from = d.rect("drag-border", 5).center(); d.step(vec![Event::PointerMoved(from), button(from, true)]); diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index f960c3e6..9b0273d9 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 360724ab..11d0f3e5 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,7 +1,8 @@ use std::{collections::HashSet, fmt, hash::Hash}; use super::dock_model::{ - Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, tree_panels, valid_column, + Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, split_frame_sized, + tree_panels, valid_column, }; use crate::layout::{SplitAxis, SplitSize}; @@ -306,6 +307,13 @@ pub enum Placement

{ /// Center inserts after the anchor; sides split the anchor's frame into two halves, and /// every other frame keeps its size. Sides apply to `Tabs` and `Stack` nodes only. Split(Zone), + /// A side [`Placement::Split`] that gives the new frame its own room instead of half the + /// anchor's frame: the new split takes `size`, and `outer` replaces the size of the nearest + /// enclosing split whose divider runs along the anchor frame's `zone` side, so the frames + /// beyond that divider give the room. The renderer offers it beside a frame too small to + /// halve. `Center`, and an anchor without such a divider or with a dock beyond it, are + /// invalid. + SplitSized { zone: Zone, size: SplitSize, outer: SplitSize }, /// A new group above or below the anchor's group in a dock column. On an anchor in a /// floating window, a split of the anchor's group above or below. /// `height` is the inserted group's preferred height ([`super::Group::height`]); `None` @@ -319,7 +327,11 @@ 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; + /// for `Top` (`Bottom`), also the first (last) row of a split of rows when only that row + /// holds one. `Center` is invalid. Edge(Zone), } @@ -820,14 +832,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) { @@ -836,7 +848,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) { @@ -863,7 +875,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)); } @@ -900,7 +912,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)); } @@ -988,7 +1000,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) @@ -1018,7 +1030,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 { @@ -1037,7 +1050,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) { @@ -1071,11 +1084,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(()) @@ -1085,14 +1098,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) { @@ -1143,13 +1156,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(|_| ()), @@ -1286,16 +1299,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 @@ -1304,6 +1319,10 @@ impl Layout

{ let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; return if split_frame(root, &path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) }; } + if let Placement::SplitSized { zone, size, outer } = placement { + let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; + return if split_frame_sized(root, &path, new, zone, size, outer) { Ok(()) } else { Err(DockError::InvalidPlacement) }; + } if insert_node(root, &entry, anchor, &placement) { Ok(()) } else { Err(DockError::MissingTarget) } } } @@ -1313,7 +1332,7 @@ impl Layout

{ fn placement_allowed

(placement: &Placement

, anchor: Permissions) -> bool { match placement { Placement::Tab { .. } | Placement::Split(Zone::Center) => anchor.accepts_tabs, - Placement::Split(_) => anchor.accepts_splits, + Placement::Split(_) | Placement::SplitSized { .. } => anchor.accepts_splits, _ => true, } } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index c315cdf2..6bf2553d 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -773,3 +773,23 @@ fn edge_placement_splits_the_part_between_the_docks_or_the_root() { assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement)); assert_eq!(frames, before); } + +#[test] +fn a_sized_split_sets_the_new_split_and_the_enclosing_one_beside_the_anchor() { + // 1 | (2 | 3): 4 goes left of 3, and the split between 2 and 3 gives the room. + let layout = Layout { + root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), hsplit(SplitSize::FixedSecond(60.0), tabs(&[2], 0), tabs(&[3], 0)))), + floating: vec![Floating::single(vec![4], 0, [0.0, 0.0, 100.0, 100.0])], + }; + let sized = |zone| Placement::SplitSized { zone, size: SplitSize::FixedFirst(100.0), outer: SplitSize::FixedSecond(164.0) }; + let mut moved = layout.clone(); + moved.apply(Action::Move { panel: 4, anchor: 3, placement: sized(Zone::Left) }).unwrap(); + let inner = hsplit(SplitSize::FixedSecond(164.0), tabs(&[2], 0), hsplit(SplitSize::FixedFirst(100.0), tabs(&[4], 0), tabs(&[3], 0))); + assert_eq!(moved.root, Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), inner))); + // 1 has no divider on its left, and a center is no side. + for (anchor, zone) in [(1, Zone::Left), (3, Zone::Center)] { + let mut moved = layout.clone(); + assert_eq!(moved.apply(Action::Move { panel: 4, anchor, placement: sized(zone) }), Err(DockError::InvalidPlacement), "{anchor} {zone:?}"); + assert_eq!(moved, layout); + } +} diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index d8711abc..63bd9f9e 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,9 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, wayland}; +use super::render::{ + APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, wayland, +}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, WindowKind, Workspace}; use crate::layout::{SplitAxis, SplitSize}; @@ -2200,3 +2202,17 @@ fn a_tab_drag_onto_a_native_window_is_no_app_drag() { assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 5 | 6, .. }]), "{:?}", out.actions); assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); } + +#[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, false, true), 1.0); + // A bar drag that only moves the window stays opaque. + 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, 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/dock.rs b/crates/ui/src/docking/render/dock.rs index be0aeee4..19c97607 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer, RowItem, row_drag, row_range, stripless}; use crate::docking::{ - Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone, + Action, Column, DockEvent, DockHeights, DockStyle, DropTarget, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone, dock_model::Unit, targets::{ DockDrop, DockTarget, SIDE_EDGE_ZONE_DEPTH, STRIP_REACH, body_zone, column_edge, drop_zone, horizontal_line, resolve_dock, usable, @@ -636,18 +636,25 @@ impl Renderer<'_, P> { /// takes tabs: the strip joins the group, and border bands reach into it only /// [`STRIP_REACH`] from their lines. Not with [`DockStyle::side_edge_zones`]. pub(super) fn in_tab_strip(&self, pointer: Pos2) -> bool { + self.in_tab_strip_past(pointer, &[]) + } + + /// [`Self::in_tab_strip`] past the groups holding any of `through`. + fn in_tab_strip_past(&self, pointer: Pos2, through: &[P]) -> bool { + let own = |t: &DropTarget

| t.tabs.iter().any(|(p, _)| through.contains(p)); !self.style.side_edge_zones - && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer)).is_some_and(|t| { + && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer) && !own(t)).is_some_and(|t| { t.accepts_tabs && t.tab_strip.map(|strip| strip.intersect(t.rect)).is_some_and(|strip| usable(strip) && strip.contains(pointer)) }) } /// The dock target at `pointer`: a band at a border, else a side zone of a dock group's /// body ([`SIDE_EDGE_ZONE_DEPTH`] deep with [`DockStyle::side_edge_zones`]). The middle of a - /// group that takes tabs is none: the group's own target joins it. - fn dock_drop(&self, pointer: Pos2) -> Option> { + /// group that takes tabs is none: the group's own target joins it. The groups holding + /// `through` (a floating window the drag looks through) hide no strip. + fn dock_drop(&self, pointer: Pos2, through: &[P]) -> Option> { let mut candidates = self.dock_candidates(); - let strip = self.in_tab_strip(pointer); + let strip = self.in_tab_strip_past(pointer, through); if strip { candidates.retain(|t| t.line.distance_to_pos(pointer) <= STRIP_REACH); } @@ -670,11 +677,11 @@ impl Renderer<'_, P> { /// The dock target a whole group or column drag would take at `pointer`, with the action /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { - let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel); - if self.dock_covered(pointer, moving) { + let through = self.looks_through(drag); + if self.dock_covered(pointer, through.then_some(&drag.panel)) { return None; } - let drop = self.dock_drop(pointer)?; + let drop = self.dock_drop(pointer, if through { &drag.members } else { &[] })?; let action = group_action(drag, drop.target.to()); self.checked(action).map(|action| (action, drop.line)) } @@ -686,7 +693,7 @@ impl Renderer<'_, P> { if self.dock_covered(pointer, None) { return None; } - let drop = self.dock_drop(pointer)?; + let drop = self.dock_drop(pointer, &[])?; let action = match drop.target.to() { // A target named by the dragged panel is its own group or column: another panel // there names the same one. With none, the drop leaves the panel where it is. @@ -707,11 +714,12 @@ impl Renderer<'_, P> { self.checked(action).map(|action| (action, drop.line)) } - /// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid. + /// `Some(None)` when the action is valid but changes nothing apart from sizes + /// ([`crate::docking::Layout::same_arrangement`]), `None` when it is invalid. fn checked(&self, action: Action

) -> Option>> { let mut candidate = self.layout.clone(); candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?; - Some((candidate != *self.layout).then_some(action)) + Some((!candidate.same_arrangement(self.layout)).then_some(action)) } /// The line a drop beside a group, column, dock or the layout's edge previews with. diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 74ba98a0..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,22 +20,52 @@ 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 layout as shown: a maximized frame alone, and hidden panels left out. +/// 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>( 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), @@ -50,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()); + 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)) => { @@ -77,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()); + state.edges = Some(edge_areas(&view, &canvas)); } state.paths = Some(paths); (Cow::Owned(view), state) @@ -152,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); } } @@ -166,32 +200,40 @@ 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 top and bottom 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 sides = self.style.outer_edges(); + let (distance, zone, area) = EDGES + .into_iter() + .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 }; - 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))?; - if distance > 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(zone).is_some_and(reach) { + if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line.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(); + 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 { @@ -203,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/mod.rs b/crates/ui/src/docking/render/mod.rs index 50525fc7..0f3fd5d1 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -21,7 +21,7 @@ pub(in crate::docking) use native::WindowDrag; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, + native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, session, wayland}, }; #[cfg(all(test, feature = "native-windows"))] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 017add67..1dd96e49 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -88,10 +88,22 @@ pub(in crate::docking) const MACOS_CLOSE: CloseLayout = pub(in crate::docking) const OTHER_CLOSE: CloseLayout = CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 }; -/// Opacity of a native window while its bar or only tab drags it: the window stays above the -/// main window, so the drop preview under the pointer shows only through it. -#[cfg(target_os = "macos")] -const DRAG_OPACITY: f64 = 0.5; +/// Opacity of a native window while its bar or only tab drags it somewhere it can dock: the +/// window stays above the main window, so the drop preview under the pointer shows only +/// through it. +#[cfg(any(target_os = "macos", test))] +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 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>, float_only: bool, title_bar_docks: bool) -> f64 { + match drag { + Some(drag) if !float_only && (drag.docks || title_bar_docks) => DRAG_OPACITY, + _ => 1.0, + } +} /// Sets the opacity of the AppKit windows titled `title`, never one titled `main` (the main /// window's title). egui has no viewport command for a window's opacity, and a transparent @@ -580,9 +592,12 @@ impl Renderer<'_, P> { #[cfg(target_os = "macos")] { let main = ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).and_then(|v| v.title.clone())); - let dragged = ctx.data(|d| d.get_temp::(drag_key)).and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); - let faded = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); - set_window_opacity(&title, main.as_deref(), if faded { DRAG_OPACITY } else { 1.0 }); + 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 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) { let waiting = place.pending.is_some_and(|until| now < until); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 2defec03..f29e77f3 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,9 +8,9 @@ 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; +use crate::layout::{SplitAxis, SplitSize}; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. fn float_only(ctx: &Context) -> bool { @@ -127,24 +127,35 @@ impl Renderer<'_, P> { } /// The valid target under the pointer for a whole group. A target holding the group - /// itself is a no-op proposal; a target the model would reject is no target. A window - /// moving live looks through itself and does not split frames or, moved by its bar, join - /// a body's center. + /// itself is a no-op proposal, and so is a drop that would change only sizes; a target + /// the model would reject is no target. A drag that takes a whole floating window along + /// looks through it ([`Self::looks_through`]). A window moving live does not split frames + /// or, moved by its bar, join a body's center. pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { - let window = drag.gesture == GroupGesture::Window; - if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { - return self.valid_group(drag, proposal); + if let Some(proposal) = self.extra_proposal(&drag.members, pointer, self.looks_through(drag)) { + return self.valid_group(drag, self.settled(proposal, |to| group_action(drag, to))); } let target = self.group_target(drag, pointer)?; self.group_proposal_at(drag, pointer, target) } - /// The group under the pointer that a group drag addresses. A window moving live looks - /// through itself. + /// Whether a group drag looks through the floating window it comes from: the window's + /// title bar moves it, or the drag holds the window's only group and so takes the window + /// along. The window then covers nothing behind it. + pub(super) fn looks_through(&self, drag: &GroupDrag

) -> bool { + match drag.gesture { + GroupGesture::Window => true, + GroupGesture::Group => self.whole_window(&drag.panel), + GroupGesture::Column => false, + } + } + + /// The group under the pointer that a group drag addresses, past the drag's own groups + /// when it looks through its window. fn group_target(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<&DropTarget

> { - let window = drag.gesture == GroupGesture::Window; + let through = self.looks_through(drag); let own = |t: &DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); - self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t))) + self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(through && own(t))) } /// [`Self::group_proposal`] on `target` alone. @@ -171,7 +182,7 @@ impl Renderer<'_, P> { let (edges, joins) = (false, false); let split = match proposal.placement { Placement::Split(Zone::Center) => !joins, - Placement::Split(_) => !edges, + Placement::Split(_) | Placement::SplitSized { .. } => !edges, _ => false, }; if window && !into_window && split { @@ -186,19 +197,130 @@ 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. + /// takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). 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); + return Some(self.settled(proposal, action)); } if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) { - return Some(proposal); + return Some(self.settled(proposal, action)); + } + if let Some(placement) = self.sized_split(panel, &proposal, &action) + && self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: placement.clone() })) + { + return Some(self.settled(DropProposal { placement, ..proposal }, action)); } 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()) + let proposal = resolve_drop_on( + panel, + pointer, + std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), + self.style.zone_rule(), + self.style.drop_sides(), + )?; + Some(self.settled(proposal, action)) + } + + /// The side split of `proposal`'s frame when halving it leaves no room + /// ([`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 }; + let along = |v: Vec2| if axis == SplitAxis::Horizontal { v.x } else { v.y }; + // The divider on the frame's start side (left or top) belongs to a split holding the + // frame in its second child, as `split_frame_sized` finds it. + let start = matches!(zone, Zone::Left | Zone::Top); + let anchor = &proposal.anchor; + let now = self.drawn_groups(self.layout); + let frame = along(now.iter().find(|(node, _)| holds(node, anchor))?.1.size()); + let mut candidate = self.layout.clone(); + let halves = action(Target::At { anchor: anchor.clone(), placement: proposal.placement.clone() }); + candidate.apply_with_permissions(halves, |p| self.permissions(p)).ok()?; + let (root, area, path) = match candidate.place(anchor)? { + Place::Node(path) => (candidate.root.as_ref()?, self.workspace, path), + Place::Floating { window, path } => { + let window = candidate.floating.get(window)?; + (&window.root, self.window_body(window), path) + } + Place::Dock { .. } => return None, + }; + // The splits from the root down to the new one, as drawn with halves. + let sizer = self.sizer(); + let (_, split_path) = path.split_last()?; + let mut chain = vec![(root, area)]; + for &second in split_path { + let &(node, rect) = chain.last()?; + let [a, b] = sizer.children(node, rect)?; + chain.push(if second { b } else { a }); + } + let &(Node::Split { first, second, .. }, halved) = chain.last()? else { return None }; + let new = if start { first } else { second }; + let depth = (0..split_path.len()).rev().find(|&d| { + split_path.get(d) == Some(&start) && chain.get(d).is_some_and(|(n, _)| matches!(n, Node::Split { axis: own, .. } if *own == axis)) + })?; + let &(outer, outer_rect) = chain.get(depth)?; + let Node::Split { size: outer_size, first: a, second: b, .. } = outer else { return None }; + if matches!(**a, Node::Dock { .. }) || matches!(**b, Node::Dock { .. }) { + return None; + } + let [a, b] = sizer.children(outer, outer_rect)?; + let far = if start { a } else { b }; + // The far side's edge at the divider is its end edge when the divider is on the start + // side of the frame. + 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; + // 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 }; + let outer = super::sizes::resized(*outer_size, first, along(a.1.size()) + along(b.1.size()))?; + let size = super::sizes::resized(SplitSize::Ratio(0.5), if start { size } else { frame }, frame + size)?; + Some(Placement::SplitSized { zone, size, outer }) + } + + /// Every group of `layout`'s trees as drawn, with its rect. + fn drawn_groups<'l>(&self, layout: &'l Layout

) -> Vec<(&'l Node

, Rect)> { + let sizer = self.sizer(); + let mut out = Vec::new(); + for (root, area) in self.trees(layout) { + let mut drawn = Vec::new(); + sizer.groups(root, area, &mut drawn); + out.extend(drawn.into_iter().map(|d| (d.node, d.rect))); + } + out + } + + /// `proposal`, marked a no-op when `action` at it would leave every panel where it is, + /// changing only sizes ([`Layout::same_arrangement`]): a lone group dropped beside the + /// group it already borders, or a tab on the edge its frame already holds. Such a drop + /// shows no target and changes nothing. + fn settled(&self, mut proposal: DropProposal

, action: impl Fn(Target

) -> Action

) -> DropProposal

{ + if !proposal.noop { + let mut candidate = self.layout.clone(); + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + proposal.noop = candidate.apply_with_permissions(action(to), |p| self.permissions(p)).is_ok() && candidate.same_arrangement(self.layout); + } + proposal } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group @@ -238,8 +360,9 @@ impl Renderer<'_, P> { } /// Release of a group drag: join or split at the target, or float at the ghost with the - /// group's size. Over its own docked position nothing changes; a floating group released - /// over itself moves there. + /// group's size. Over its own docked position, or where the drop would change only sizes, + /// nothing changes; a floating group released over itself or a window's title bar moves + /// there. fn drop_group(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { if pointer.distance(drag.origin) < 2.0 { return; @@ -255,7 +378,8 @@ impl Renderer<'_, P> { self.output.actions.push(group_action(drag, to)); return; } - if proposal.is_some() && !floating { + // A no-op anchored on another group would change only sizes. + if proposal.is_some_and(|p| !floating || !drag.members.contains(&p.anchor)) { return; } if !float_only && !self.style.float_on_empty_drop { @@ -311,7 +435,7 @@ impl Renderer<'_, P> { if zones { self.paint_marker(&painter, &proposal); } else { - let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } | Placement::Edge(zone) if zone != Zone::Center); self.paint_preview(ctx, &painter, &proposal, split); } if drag.gesture != GroupGesture::Window { @@ -409,12 +533,14 @@ impl Renderer<'_, P> { /// The group target a single tab drag would take at `pointer`. pub(super) fn tab_proposal(&self, panel: &P, pointer: Pos2) -> Option> { - self.extra_proposal(std::slice::from_ref(panel), pointer, false).or_else(|| { - self.resolve_roomy(panel, pointer, &self.targets, |to| match to { - Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to => Action::MovePanel { panel: panel.clone(), to }, - }) - }) + let action = |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }; + match self.extra_proposal(std::slice::from_ref(panel), pointer, false) { + Some(proposal) => Some(self.settled(proposal, action)), + None => self.resolve_roomy(panel, pointer, &self.targets, action), + } } /// The preview of a tab drag's group target (with @@ -449,7 +575,7 @@ impl Renderer<'_, P> { /// region the drop takes, filled for a tab drag (`filled`) or a split. fn paint_preview(&self, ctx: &Context, painter: &egui::Painter, proposal: &DropProposal

, filled: bool) { if crate::docking::targets::usable(proposal.preview) { - let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); + let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } | Placement::Edge(zone) if zone != Zone::Center); if self.style.side_edge_zones { drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled); } else if beside { @@ -493,8 +619,7 @@ impl Renderer<'_, P> { /// [`Self::paint_zones`] for a group drag. pub(super) fn paint_group_zones(&self, painter: &egui::Painter, drag: &GroupDrag

, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { - let window = drag.gesture == GroupGesture::Window; - if !self.style.side_edge_zones || self.extra_proposal(&drag.members, pointer, window).is_some() { + if !self.style.side_edge_zones || self.extra_proposal(&drag.members, pointer, self.looks_through(drag)).is_some() { return false; } let Some(target) = self.group_target(drag, pointer) else { return false }; @@ -516,7 +641,7 @@ impl Renderer<'_, P> { use crate::docking::targets::{header_height, usable, zone_shape}; let zone_of = |proposal: &DropProposal

| match proposal.placement { _ if proposal.noop => None, - Placement::Split(zone) => Some(zone), + Placement::Split(zone) | Placement::SplitSized { zone, .. } => Some(zone), Placement::Tab { .. } => Some(Zone::Center), _ => None, }; @@ -680,6 +805,15 @@ impl Renderer<'_, P> { } } +/// Whether the drawn group `node` holds `panel`. +fn holds(node: &Node

, panel: &P) -> bool { + match node { + Node::Tabs { panels, .. } => panels.contains(panel), + Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel), + Node::Split { .. } | Node::Dock { .. } => false, + } +} + /// The panel that names a drawn group: its first panel; none for a dock or a split. fn lead

(node: &Node

) -> Option<&P> { match node { diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index b1c9fd49..415ecf8d 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -312,16 +312,22 @@ impl

Sizer<'_, P> { /// The size of a split at `path` whose first child is `first` of `available` points, in the /// split's own mode `size`: a fixed side keeps its points, a ratio stays a ratio. fn push_size(path: &[bool], size: SplitSize, first: f32, available: f32, out: &mut Vec<(Vec, SplitSize)>) { + if let Some(size) = resized(size, first, available) { + out.push((path.to_vec(), size)); + } +} + +/// `size` in its own mode for a first child of `first` out of `available` points. +pub(in crate::docking) fn resized(size: SplitSize, first: f32, available: f32) -> Option { if available <= 0.0 || !available.is_finite() || !first.is_finite() { - return; + return None; } let first = first.clamp(0.0, available); - let size = match size { + Some(match size { SplitSize::Ratio(_) => SplitSize::Ratio((first / available).clamp(0.0, 1.0)), SplitSize::FixedFirst(_) => SplitSize::FixedFirst(first), SplitSize::FixedSecond(_) => SplitSize::FixedSecond(available - first), - }; - out.push((path.to_vec(), size)); + }) } /// One group of a flat row, such as the panels of a stack or the groups of a dock column: diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 8a07c80f..fd3070c2 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -194,10 +194,10 @@ fn groups_have_one_small_minimum_and_no_maximum() { assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further"); // At its minimum, with Project left of it at its own, it has no room for two halves and - // takes a drop on its edge as a tab. + // takes a drop on its left edge as a tab. let tools = group(&d, TOOLS); assert_eq!(group(&d, PROJECT[0]).width(), 40.0); - let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0)); + let out = d.drag(tab(&d, INFO), pos2(tools.left() + 4.0, tools.center().y + 30.0)); assert!( matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]), "{:?}", @@ -359,6 +359,83 @@ fn a_drop_beside_a_narrow_group_splits_that_group_only() { assert_eq!((group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)), (project, timeline, meters)); } +/// Properties and a narrow Tools frame side by side, Tools `tools` wide in an area `width` +/// wide, with Info floating at the left. +fn beside_tools(width: f32, tools: f32) -> Driver { + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(tools), + first: Box::new(tabs(&PROPERTIES)), + second: Box::new(tabs(&[TOOLS])), + }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![INFO], 0, [width + 100.0, 100.0, 280.0, 200.0])] }; + let mut d = driver(layout, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(width, SCREEN.y)); + d.settle(); + d +} + +#[test] +fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { + let mut d = beside_tools(1000.0, 60.0); + let (tools, properties, info) = (group(&d, TOOLS), group(&d, PROPERTIES[0]), group(&d, INFO)); + let left = zone_shape(tools, 26.0, 40.0, Zone::Left).unwrap(); + let at = pos2(tools.left() + 4.0, tools.center().y + 13.0); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&left), "Tools' left zone is a target"); + let out = d.release(at); + 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() - info.width()).abs() < 1.0, "Info keeps its floating width: {new_info:?}"); + assert!(new_info.right() < new_tools.left() && new_properties.right() < new_info.left()); + 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); + let tools = group(&d, TOOLS); + let at = pos2(tools.left() + 4.0, tools.center().y + 13.0); + let out = d.drag(tab(&d, INFO), at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) }]), + "{:?}", + out.actions + ); +} + #[test] fn every_divider_travels_after_drops() { let mut d = film(); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 9e330759..6b86c67b 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, + /// Which sides of a group that takes no tabs (a canvas), and of the layout's outer edge, take + /// a drop (all four). Without [`Self::side_edge_zones`] the outer left and right edges are + /// new docks, so only top and bottom apply there. A dock-column app whose palettes dock only + /// at the left and right sets [`crate::docking::Sides::NONE`] with [`Self::new_dock_zones`] + /// on. Not used with [`Self::side_edge_zones`]. 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::ALL, side_zone_fraction: 1.0 / 3.0, drop_line_width: 4.0, new_dock_zones: true, @@ -227,6 +234,19 @@ impl DockStyle { } } + /// The canvas 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 } + } + + /// The outer edges that take a drop: all four with [`Self::side_edge_zones`], else the top + /// and bottom of [`Self::drop_edges`]. + pub(super) fn outer_edges(&self) -> super::Sides { + let sides = self.drop_sides(); + if self.side_edge_zones { sides } else { super::Sides { left: false, right: false, ..sides } } + } + /// 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`]. @@ -343,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 @@ -358,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 @@ -476,54 +506,19 @@ fn cancel_drag_if_replaced }) else { return; }; - let now = shape(layout); - if now == shape(&last.layout) { + let now = layout.shape(); + if now == last.layout.shape() { return; } let mut applied = last.layout; for action in last.actions { let _ = applied.apply(action); } - if now != shape(&applied) { + if now != applied.shape() { cancel_drag::

(ctx, area); } } -/// `layout` without its sizes and rectangles: which panels are where, and their state. -fn shape(layout: &Layout

) -> Layout

{ - use super::{Column, Floating, Group, Node, StackEntry}; - use crate::layout::SplitSize; - fn node(n: &Node

) -> Node

{ - match n { - Node::Split { axis, first, second, .. } => { - Node::Split { axis: *axis, size: SplitSize::Ratio(0.5), first: Box::new(node(first)), second: Box::new(node(second)) } - } - Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active }, - Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| StackEntry { height: None, ..e.clone() }).collect() }, - Node::Dock { side, columns } => Node::Dock { - side: *side, - columns: columns - .iter() - .map(|c| Column { - groups: c.groups.iter().map(|g| Group { height: None, ..g.clone() }).collect(), - width: 0.0, - iconic: c.iconic, - iconic_width: None, - }) - .collect(), - }, - } - } - Layout { - root: layout.root.as_ref().map(node), - floating: layout - .floating - .iter() - .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() }) - .collect(), - } -} - /// Keep what [`cancel_drag_if_replaced`] compares the next frame against, while a drag of this /// area goes on. fn record_drag_frame( @@ -1183,7 +1178,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) @@ -1207,7 +1202,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/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index c9ac64a7..0752cbb6 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -23,7 +23,8 @@ pub struct DropProposal

{ pub placement: Placement

, pub preview: Rect, pub insertion: Option, - /// The pointer addresses the tab's current insertion slot. + /// The pointer addresses the tab's current insertion slot. The renderer also marks a drop + /// that would leave every panel where it is, changing only sizes. pub noop: bool, } @@ -78,6 +79,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 +94,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 +226,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 +267,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 +320,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/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 183af38d..c3711a50 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -312,6 +312,13 @@ fn reversed(hidden: &Location

, location: &Location

, panel: Placement::Split(Zone::Bottom) => (Placement::Split(Zone::Top), location.size), Placement::Group { below, .. } => (Placement::Group { below: !below, height: None }, None), Placement::Column { after } => (Placement::Column { after: !after }, None), + Placement::SplitSized { zone, .. } => match zone { + Zone::Left => (Placement::Split(Zone::Right), location.size), + Zone::Right => (Placement::Split(Zone::Left), location.size), + Zone::Top => (Placement::Split(Zone::Bottom), location.size), + Zone::Bottom => (Placement::Split(Zone::Top), location.size), + Zone::Center => return None, + }, Placement::Edge(_) => return None, }; Some(Location { floating: None, anchor: Some(panel.clone()), placement, size, sibling: Vec::new(), ..hidden.clone() }) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 991d052d..40ef1d7f 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -219,6 +219,7 @@ struct DragOut { canceled: bool, } +#[derive(Clone)] struct Shown

{ pane: P, id: Id, @@ -363,20 +364,9 @@ impl PaneHost { states(pane).or_else(|| ctx.data(|data| data.get_temp::(id.with("state")))).unwrap_or_else(|| content.default_state(pane)); shown.push(Shown { pane: pane.clone(), id, state }); } - if self.policy == SidePolicy::OneSlot { - let mut taken = Vec::new(); - for index in (0..shown.len()).rev() { - if let Place::Docked(edge) = shown[index].state.place - && content.closable(&shown[index].pane) - { - if taken.contains(&edge) { - shown.remove(index); - } else { - taken.push(edge); - } - } - } - } + // Before the one-slot filter: a drop can reveal a pane this frame hides. + let open_panes = shown.clone(); + self.one_slot(&mut shown, content); let previous = memory.active.filter(|active| shown.iter().any(|s| s.id == *active)); memory.active = previous; @@ -405,12 +395,13 @@ impl PaneHost { let mut regions = Vec::new(); for edge in [Edge::Start, Edge::End, Edge::Bottom] { for s in shown.iter().filter(|s| s.state.place == Place::Docked(edge)) { - let rect = self.docked(ui, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); + let rect = + self.docked(ui, &open_panes, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); regions.push(Region { pane: s.id, rect, floating: None }); } } for s in shown.iter().filter(|s| s.state.place == Place::Floating) { - let (rect, layer) = self.floating(&ctx, s, content, &style, focus_target == Some(s.id), bounds, &mut requests); + let (rect, layer) = self.floating(&ctx, &open_panes, s, content, &style, focus_target == Some(s.id), bounds, &preview, &mut requests); regions.push(Region { pane: s.id, rect, floating: Some(layer) }); } @@ -475,10 +466,90 @@ impl PaneHost { (min, max.min(self.max_fraction * extent).max(min)) } + /// With [`SidePolicy::OneSlot`], drop every closable docked pane but the last on its edge. + fn one_slot>(&self, shown: &mut Vec>, content: &C) { + if self.policy != SidePolicy::OneSlot { + return; + } + let mut taken = Vec::new(); + for index in (0..shown.len()).rev() { + if let Place::Docked(edge) = shown[index].state.place + && content.closable(&shown[index].pane) + { + if taken.contains(&edge) { + shown.remove(index); + } else { + taken.push(edge); + } + } + } + } + + /// The width (height at the bottom) of a pane docked at `edge`: its size, else its default, + /// within its limits. + fn docked_size>(&self, content: &C, s: &Shown

, edge: Edge, bounds: Rect) -> f32 { + let extent = if self.side(edge) == Side::Bottom { bounds.height() } else { bounds.width() }; + let (min, max) = self.limits(content, &s.pane, extent); + let default = content.default_state(&s.pane).size; + s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max) + } + + /// The rect `pane` takes on the next frame once it is docked at the side `edge`. Side panels + /// are laid out as [`Self::show`] draws them: `Start` panes, then `End` panes, each in `open` + /// order from the outside in and no wider than the space left. `None` when + /// [`SidePolicy::OneSlot`] would hide the pane there. + fn landing>(&self, shown: &[Shown

], pane: Id, edge: Edge, content: &C, bounds: Rect) -> Option { + let mut after: Vec> = shown + .iter() + .map(|s| { + let place = if s.id == pane { Place::Docked(edge) } else { s.state.place }; + Shown { pane: s.pane.clone(), id: s.id, state: PaneState { place, ..s.state } } + }) + .collect(); + self.one_slot(&mut after, content); + let mut free = bounds; + for side_edge in [Edge::Start, Edge::End] { + for s in after.iter().filter(|s| s.state.place == Place::Docked(side_edge)) { + let size = self.docked_size(content, s, side_edge, bounds).min(free.width()).max(0.0); + let rect = if self.side(side_edge) == Side::Left { + let rect = Rect::from_x_y_ranges(free.left()..=free.left() + size, free.y_range()); + free.min.x = rect.right(); + rect + } else { + let rect = Rect::from_x_y_ranges(free.right() - size..=free.right(), free.y_range()); + free.max.x = rect.left(); + rect + }; + if s.id == pane { + return Some(rect); + } + } + } + None + } + + /// Paints where `pane` lands when it is dropped docked at the side `edge`. + #[allow(clippy::too_many_arguments)] + fn preview_dock>( + &self, + preview: &egui::Painter, + style: &ChromeStyle, + shown: &[Shown

], + pane: Id, + edge: Edge, + content: &C, + bounds: Rect, + ) { + if let Some(rect) = self.landing(shown, pane, edge, content, bounds) { + chrome::drop_preview(preview, rect.shrink(1.0), style.accent, true); + } + } + #[allow(clippy::too_many_arguments)] fn docked>( &self, ui: &mut Ui, + shown: &[Shown

], s: &Shown

, edge: Edge, content: &mut C, @@ -492,8 +563,10 @@ impl PaneHost { let side = self.side(edge); let extent = if side == Side::Bottom { bounds.height() } else { bounds.width() }; let (min, max) = self.limits(content, &s.pane, extent); - let default = content.default_state(&s.pane).size; - let size = s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max); + // A side pane takes no more than the width the panes outside it leave: egui would place + // a wider one partly beyond the host's edge. + let room = if side == Side::Bottom { f32::INFINITY } else { ui.available_rect_before_wrap().width().max(0.0) }; + let size = self.docked_size(content, s, edge, bounds).min(room); let panel_id = s.id.with("panel"); let panel = match side { Side::Left => egui::Panel::left(panel_id), @@ -502,7 +575,7 @@ impl PaneHost { }; let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke)); let title_bar = content.title_bar(&s.pane); - let shown = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| { + let drawn = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| { let top = ui.max_rect(); let (bar, body_top) = if title_bar { let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); @@ -538,8 +611,8 @@ impl PaneHost { ui.allocate_rect(body, Sense::hover()); bar }); - let rect = shown.response.rect; - let bar = shown.inner; + let rect = drawn.response.rect; + let bar = drawn.inner; let (axis, strip) = match side { Side::Left => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.right() - 2.0..=rect.right() + 2.0, rect.y_range())), @@ -575,28 +648,23 @@ impl PaneHost { if content.movable(&s.pane) && let Some(bar) = &bar { - // Docked at the host's left or right edge, the pane gets the width the side limits - // give its size: a bottom pane's height, not its width. - let (side_min, side_max) = self.limits(content, &s.pane, bounds.width()); - let side_width = - s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(side_min).clamp(side_min, side_max); - self.drag_out(ui.ctx(), bar, s, edge, rect, side_width, bounds, memory, preview, style, requests); + self.drag_out(ui.ctx(), bar, shown, s, edge, rect, content, bounds, memory, preview, style, requests); } rect } /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's - /// left or right edge it docks there `side_width` wide, elsewhere it floats under the - /// pointer. + /// left or right edge it docks there, elsewhere it floats under the pointer. #[allow(clippy::too_many_arguments)] - fn drag_out( + fn drag_out>( &self, ctx: &Context, bar: &Response, + shown: &[Shown

], s: &Shown

, edge: Edge, rect: Rect, - side_width: f32, + content: &C, bounds: Rect, memory: &mut Memory, preview: &egui::Painter, @@ -639,23 +707,23 @@ impl PaneHost { if drag.canceled || !outside { return; } - let shape = match target.map(|t| self.side(t)) { - Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + side_width, bounds.y_range()), - Some(_) => Rect::from_x_y_ranges(bounds.right() - side_width..=bounds.right(), bounds.y_range()), - None => float, - }; - chrome::drop_preview(preview, shape.shrink(1.0), style.accent, true); + match target { + Some(target) => self.preview_dock(preview, style, shown, s.id, target, content, bounds), + None => chrome::drop_preview(preview, float.shrink(1.0), style.accent, true), + } } #[allow(clippy::too_many_arguments)] fn floating>( &self, ctx: &Context, + panes: &[Shown

], s: &Shown

, content: &mut C, style: &ChromeStyle, focus: bool, bounds: Rect, + preview: &egui::Painter, requests: &mut Vec>, ) -> (Rect, LayerId) { let (min_width, _) = self.limits(content, &s.pane, bounds.width()); @@ -682,7 +750,13 @@ impl PaneHost { requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(resized))); } if content.movable(&s.pane) { - match chrome::live_move(ctx, &shown.inner, shown.rect, bounds) { + let step = chrome::live_move(ctx, &shown.inner, shown.rect, bounds); + if let Some(MoveStep::Moving(_)) = step + && let Some(edge) = ctx.pointer_latest_pos().and_then(|pointer| self.snap(pointer, bounds)) + { + self.preview_dock(preview, style, panes, s.id, edge, content, bounds); + } + match step { Some(MoveStep::Moving(next) | MoveStep::Canceled(next)) if next != shown.rect => { requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); } diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index 9809adcb..01bd7f69 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -185,6 +185,25 @@ fn drag(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { h.run_steps(2); } +/// The rect a docked pane's panel takes. +fn panel(h: &Harness<'_, App>, pane: Pane) -> Rect { + egui::containers::panel::PanelState::load(&h.ctx, pane_id(pane).with("panel")).unwrap().outer_rect +} + +/// The drop previews painted in the last frame: 2 pt accent outlines with a 15% accent fill. +fn previews(h: &Harness<'_, App>) -> Vec { + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.stroke.width == 2.0 && shape.fill == shape.stroke.color.gamma_multiply(0.15) => { + Some(shape.rect.expand(1.0)) + } + _ => None, + }) + .collect() +} + fn key(h: &mut Harness<'_, App>, key: Key, modifiers: Modifiers) { send(h, vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); h.run_steps(1); @@ -345,6 +364,86 @@ fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { assert_eq!(h.state().states[&Pane::Styles].float_rect.map(|r| [r[2], r[3]]), Some([270.0, 480.0])); } +#[test] +fn a_floating_pane_near_a_side_edge_previews_the_column_it_docks_in() { + let mut app = App::new(&[Pane::Nav, Pane::Styles]); + app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: Some(300.0), float_rect: Some([300.0, 100.0, 270.0, 400.0]) }); + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + hold(&mut h, from, pos2(400.0, 300.0)); + assert!(previews(&h).is_empty(), "away from the edges"); + let to = pos2(L + 10.0, 300.0); + send(&mut h, vec![Event::PointerMoved(to)]); + let landing = Rect::from_x_y_ranges(L + 250.0..=L + 550.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing], "beside Nav, 300 pt wide"); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(h.state().place(Pane::Styles), Place::Docked(Edge::Start)); + assert_eq!(panel(&h, Pane::Styles), landing); +} + +#[test] +fn a_docked_pane_dragged_across_previews_the_column_it_lands_in() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Comments])); + let from = title(&h, Pane::Comments).center(); + let to = pos2(L + 10.0, 300.0); + hold(&mut h, from, to); + let landing = Rect::from_x_y_ranges(L + 250.0..=L + 500.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing], "right of Nav, which comes first in open"); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(h.state().place(Pane::Comments), Place::Docked(Edge::Start)); + assert_eq!(panel(&h, Pane::Comments), landing); + + // Right to left: Start is the right edge, and Nav stays outermost there. + let mut app = App::new(&[Pane::Nav, Pane::Comments]); + app.rtl = true; + let mut h = harness(app); + let from = title(&h, Pane::Comments).center(); + let to = pos2(R - 10.0, 300.0); + hold(&mut h, from, to); + let landing = Rect::from_x_y_ranges(R - 500.0..=R - 250.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing]); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(panel(&h, Pane::Comments), landing); +} + +#[test] +fn one_slot_previews_a_pane_beside_the_context_pane() { + let mut app = App::new(&[Pane::Pivot, Pane::Styles]); + app.policy = SidePolicy::OneSlot; + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + let to = pos2(R - 10.0, 300.0); + hold(&mut h, from, to); + let landing = Rect::from_x_y_ranges(R - 500.0..=R - 250.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing], "inside the Pivot pane, which comes first in open"); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(panel(&h, Pane::Styles), landing); +} + +#[test] +fn one_slot_previews_the_room_a_pane_it_reveals_leaves() { + // Nav and Styles share the start slot, so only Styles shows. Moved to the end, it reveals + // Nav, which takes 500 of the host's 784 pt. + let mut app = App::new(&[Pane::Nav, Pane::Styles]); + app.policy = SidePolicy::OneSlot; + for pane in [Pane::Nav, Pane::Styles] { + app.states.insert(pane, PaneState::docked(Edge::Start, 500.0)); + } + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + let to = pos2(R - 10.0, 300.0); + hold(&mut h, from, to); + let landing = Rect::from_x_y_ranges(L + 500.0..=R, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing]); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(panel(&h, Pane::Styles), landing); +} + #[test] fn escape_and_focus_loss_restore_a_floating_pane_during_a_move() { let mut app = App::new(&[Pane::Styles]); 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 c47357e4..956ae34f 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -85,7 +85,9 @@ layout.apply(Action::Move { that side or a new group above or below in a dock. A floating window takes splits as the docked layout does; `Placement::Group` on a floating anchor splits its group above or below. - A side split zone puts what moves in a new half-size split of the anchor's frame, and no - other frame changes size. The new split has its own divider. + other frame changes size. The new split has its own divider. `Placement::SplitSized` sizes + that split instead (`size`) and resizes the enclosing split whose divider runs along the + anchor frame's side (`outer`), so the frames beyond that divider give the new frame its room. - Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and move a floating window to its own OS window (`SetNative`, and `SetMonitor` for the monitor it @@ -215,7 +217,10 @@ A flyout fits its content the same way, without the title bar. with `title_bar_docks` a release over a target docks the whole window there, else it only moves. - Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout, - into any window. Its groups split, join and resize like frames, with dividers and four-way + into any window. A drag bar or border of a window's only group takes the whole window along, + so, like the title bar, it looks through the window: the groups, docks and windows behind it + take the drop while the pointer is still inside it. In a window of several groups, the other + groups stay targets. Its groups split, join and resize like frames, with dividers and four-way handles (`Divider::WindowSplit` and `Divider::WindowIntersection`, named by the window's first panel). Double-clicking the title bar minimizes the window to its title bar, or to the tab strip of its only group; so does double-clicking that group's strip. The group menu's float item @@ -239,8 +244,11 @@ A flyout fits its content the same way, without the title bar. - Beside a frame is a split of that frame. Beside a group in a dock column, the top and bottom stack a new group above or below it, and the left and right make a new column on that side. - A frame too narrow or short for two halves of `DockStyle::min_pane` still splits when the - frames beyond it can give the room, as they do for a dragged divider. When they cannot, that - side joins the frame's tabs instead, or takes nothing when the frame takes no tabs. The gap + frames beyond it can give the room, as they do for a dragged divider. When they cannot, the + frame keeps its size and the new frame takes the width (or height) the moved panels have now, + at least its minimum, from the frames beyond the frame's divider on that side + (`Placement::SplitSized`). When those have no room either, that side joins the frame's tabs + instead, or takes nothing when the frame takes no tabs. The gap between two frames is no target. - Bands along borders come before the group zones. Each is centred on its border where the pointer can reach both sides: @@ -250,12 +258,31 @@ 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 top and bottom in `drop_edges` (all four sides 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. 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 + the outer left and right edges are new docks, so only top and bottom apply there. Groups + that take tabs keep all four sides. A dock-column app whose palettes dock only at the left + and right, as in AutoCAD, sets `Sides::NONE` with `new_dock_zones` on. Not used with + `side_edge_zones`. - 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 @@ -296,6 +323,12 @@ A flyout fits its content the same way, without the title bar. - A drop that joins a group shows a 2 pt accent outline of the whole group, with a light fill, and on its strip the 2 pt insertion marker. + A drop that would leave every panel where it is, changing only sizes, shows nothing and + changes nothing: a lone group dropped beside the group it already borders, or a frame's tab + on the layout edge the frame already holds. Split sizes, column widths, group heights and + window rectangles are not compared, and a run of splits along one axis is the same however + it nests. + A group drag also shows its ghost at the pointer. With `side_edge_zones`, a drag over a group instead outlines each of its zones that a drop would change something in (the side trapezoids, the center rectangle and the tab strip; `zone_shape` gives the outlines) and @@ -369,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 diff --git a/docs/ui-panes.md b/docs/ui-panes.md index 201d2af1..4828c116 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -60,8 +60,8 @@ egui::CentralPanel::default().show(ui, |ui| { | `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). | | Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The edge takes presses on both sides of its line, over the document's own widgets too, and stays under floating windows. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). | | Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. | -| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. | -| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it. Rects are clamped to the host area. | +| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. While the pointer is there, a preview shows the rect the pane will take: with `Columns`, after the panes on that edge that come before it in `open`. | +| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it, with the same preview as a drag out. Rects are clamped to the host area. | | Focus | `out.focused` is the pane holding keyboard focus or last clicked; a click or focus in the document clears it. F6 and Shift+F6 cycle through the shown panes and then report `out.to_document`: give focus to the document. `.f6(false)` leaves F6 to the app, which calls `panes::focus_next(ctx, id, reverse)` instead. | State: