{
/// Serde reads the v1 shape `{panels, active, rect}` as one group, and a stack of groups
/// `{groups, rect}` as a vertical split of them, sized by their heights. A window with one
/// group (not minimized, no native rect) writes the v1 shape, so older builds can read files
-/// that use no newer feature. A native window also writes its `id`; any other window gets a
-/// fresh one on load.
+/// that use no newer feature. Every window writes its `id`; a window read without one gets a
+/// fresh one.
///
/// Equality ignores `id`: two windows with the same contents and place are the same layout.
#[derive(Clone, Debug)]
pub struct Floating
{
/// Names the window across panel moves, and its OS window with feature `native-windows`.
- /// Unique within a layout: [`Self::new`] and loading draw fresh ids, [`Self::with_id`]
- /// sets one, and merging windows keeps the receiving window's.
+ /// Unique within a layout: [`Self::new`] draws a fresh id, [`Self::with_id`] sets one,
+ /// loading keeps the saved id (or draws one when it is missing or taken), and merging
+ /// windows keeps the receiving window's.
pub id: u64,
/// `Split`, `Tabs` and `Stack` nodes; a `Dock` is invalid here.
pub root: Node
,
rect: [f32; 4],
#[serde(skip_serializing_if = "Option::is_none")]
@@ -206,10 +207,10 @@ mod floating_serde {
fn serialize(&self, serializer: S) -> Result {
match &self.root {
Node::Tabs { panels, active } if !self.minimized && self.native.is_none() => {
- V1 { panels, active: *active, rect: self.rect }.serialize(serializer)
+ V1 { id: self.id, panels, active: *active, rect: self.rect }.serialize(serializer)
}
root => Tree {
- id: self.native.map(|_| self.id),
+ id: self.id,
root,
rect: self.rect,
native: self.native,
diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs
index 9327fd4d3..c315cdf2d 100644
--- a/crates/ui/src/docking/model_tests.rs
+++ b/crates/ui/src/docking/model_tests.rs
@@ -576,18 +576,32 @@ mod serde_tests {
fn single_plain_floating_window_writes_the_v1_shape() {
let rect = [1.0, 2.0, 300.0, 200.0];
let plain = Floating::single(vec![4u32, 5], 1, rect);
- assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect}));
+ assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"id": plain.id, "panels": [4, 5], "active": 1, "rect": rect}));
let minimized = Floating { minimized: true, ..Floating::single(vec![4u32], 0, rect) };
let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect).unwrap();
let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) };
for window in [minimized, stacked, native] {
let value = serde_json::to_value(&window).unwrap();
assert!(value.get("root").is_some() && value.get("panels").is_none(), "{value}");
+ assert_eq!(value["id"], json!(window.id), "{value}");
assert_eq!(serde_json::from_value::>(value).unwrap(), window);
}
assert!(serde_json::from_value::>(json!({"rect": rect})).is_err());
}
+ #[test]
+ fn in_window_windows_keep_their_ids_through_a_save() {
+ let rect = [1.0, 2.0, 300.0, 200.0];
+ let layout = Layout {
+ root: None,
+ floating: vec![Floating::single(vec![4u32], 0, rect).with_id(7), Floating::stack(vec![g(&[5], 0), g(&[6], 0)], rect).unwrap().with_id(8)],
+ };
+ let saved = serde_json::to_string(&layout).unwrap();
+ let loaded: Layout = serde_json::from_str(&saved).unwrap();
+ assert_eq!(loaded, layout);
+ assert_eq!(loaded.floating.iter().map(|w| w.id).collect::>(), [7, 8]);
+ }
+
#[test]
fn v2_round_trips() {
let mut workspace = Workspace::new(docked());
@@ -668,8 +682,13 @@ mod serde_tests {
}
let layout: super::Layout = serde_json::from_value(layout_value.clone()).unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
layout.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
- // Nothing new is in use, so the layout writes back exactly as read.
- assert_eq!(serde_json::to_value(&layout).unwrap(), layout_value, "{app}{pointer}");
+ // Nothing new is in use, so the layout writes back exactly as read, plus the
+ // fresh id of each floating window.
+ let mut written = serde_json::to_value(&layout).unwrap();
+ for (window, value) in layout.floating.iter().zip(written["floating"].as_array_mut().unwrap()) {
+ assert_eq!(value.as_object_mut().unwrap().remove("id"), Some(json!(window.id)), "{app}{pointer}");
+ }
+ assert_eq!(written, layout_value, "{app}{pointer}");
}
}
}
diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs
index 258af6736..d8711abc8 100644
--- a/crates/ui/src/docking/native_tests.rs
+++ b/crates/ui/src/docking/native_tests.rs
@@ -581,8 +581,9 @@ fn a_native_window_saves_its_id_place_and_monitor() {
let copies: Layout = serde_json::from_str(&serde_json::to_string(&copies).unwrap()).unwrap();
assert_eq!(copies.floating[0].id, layout.floating[0].id);
assert_ne!(copies.floating[1].id, copies.floating[0].id);
- // An in-window window writes no id, as older builds did.
- assert!(!serde_json::to_string(&Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0])).unwrap().contains("id"));
+ // An in-window window writes its id too.
+ let in_window = Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0]).with_id(3);
+ assert!(serde_json::to_string(&in_window).unwrap().contains(r#""id":3"#));
}
/// Height of the bar at the top of a native window.
diff --git a/docs/ui-docking.md b/docs/ui-docking.md
index 39152ee60..c47357e41 100644
--- a/docs/ui-docking.md
+++ b/docs/ui-docking.md
@@ -413,10 +413,11 @@ the main window has never reported its `outer_rect`; an app that forces X11 in c
windows from the first frame. `DockArea::native_windows(bool)` overrides the choice either way.
- **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change,
- including when its first panel moves out. `Floating::new` and loading draw fresh ids, and
- `Floating::with_id` sets one; a layout
- loads with unique ids; a window that merges into another takes the receiver's id, and a whole
- window that moves (floated again, or its only group dragged) keeps its own.
+ including when its first panel moves out. `Floating::new` draws a fresh id and
+ `Floating::with_id` sets one. A saved layout stores every window's id and loads with unique
+ ids: a window saved without an id, or with one an earlier window took, gets a fresh one. A
+ window that merges into another takes the receiver's id, and a whole window that moves
+ (floated again, or its only group dragged) keeps its own.
- **Kind and level.** `DockContent::window_kind` sorts panels into palettes
(`WindowKind::Palette`, the default) and viewers (`WindowKind::Normal`), such as a video
monitor on a second display. A window holding any `Normal` panel is a normal window, whichever
From edef7915d2efdf9904149a0b113df196ad7de8f5 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Sun, 25 Oct 2026 09:59:17 -0400
Subject: [PATCH 2/2] docking: fixes from the first Mac test round (edge band,
drop_edges, no-op drops, narrow frames, pane previews) (#312)
* docking: fade a dragged native window only when the drag can dock
* docking: narrow the outer edge band to the canvas column and add DockStyle::drop_edges
* docking: drop_edges defaults to all sides; the docking profile's outer band takes only its top and bottom
* panes: preview the slot a dragged pane docks in, also while floating
* docking: a drop that changes only sizes is a no-op; a lone floating group's header drag looks through its window
* docking: a drop beside a frame too narrow to halve takes the room from beyond its divider
* panes: the dock preview counts a pane its drop reveals, and a side pane stays inside the host
With SidePolicy::OneSlot a drop can reveal a pane the frame hides, so the landing slot is
worked out from every open pane. A side pane is no wider than the room the panes outside it
leave, as the preview assumes; egui placed a wider one partly beyond the host's edge.
* docking: anchor an outer edge drop on the area it shows
The edge band and window.panel.dock to an outer edge anchor on the canvas only where the edge area narrows to the canvas column, else on a panel that is not protected, so tree order no longer decides the frame. The renderer tries the drop and offers the band only when it wraps the panels the band shows. A top (bottom) edge also narrows a split of rows to its first (last) row when only that row holds the canvas. to_actions takes the permissions, and FloatSpace records side_edge_zones.
* docking: a sized split gives the new frame its minimum or saved width
* docking: keep a native window opaque during a float-only drag
* docking: DockCommand::to_actions keeps its signature; to_actions_with takes the permissions
FilmCraft calls to_actions. Without permissions no panel is protected, so an outer edge dock
wraps the part between the docks, as before.
---
crates/ui/src/docking/command.rs | 36 ++-
crates/ui/src/docking/command_tests.rs | 47 +++-
crates/ui/src/docking/dock_model.rs | 153 +++++++++++-
crates/ui/src/docking/drop_tests.rs | 309 ++++++++++++++++++++++++
crates/ui/src/docking/group_tests.rs | 5 +-
crates/ui/src/docking/mod.rs | 4 +-
crates/ui/src/docking/model.rs | 61 +++--
crates/ui/src/docking/model_tests.rs | 20 ++
crates/ui/src/docking/native_tests.rs | 18 +-
crates/ui/src/docking/render/dock.rs | 30 ++-
crates/ui/src/docking/render/frame.rs | 140 ++++++++---
crates/ui/src/docking/render/mod.rs | 2 +-
crates/ui/src/docking/render/native.rs | 29 ++-
crates/ui/src/docking/render/preview.rs | 200 ++++++++++++---
crates/ui/src/docking/render/sizes.rs | 14 +-
crates/ui/src/docking/split_tests.rs | 81 ++++++-
crates/ui/src/docking/surface.rs | 83 +++----
crates/ui/src/docking/targets.rs | 60 ++++-
crates/ui/src/docking/workspace.rs | 7 +
crates/ui/src/panes/mod.rs | 150 +++++++++---
crates/ui/src/panes/tests.rs | 99 ++++++++
crates/ui/tests/docking_workflows.rs | 3 +-
docs/ui-docking.md | 46 +++-
docs/ui-panes.md | 4 +-
24 files changed, 1368 insertions(+), 233 deletions(-)
diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs
index 99825f1d3..7db19eb40 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
{
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 78d406b10..c6ac3f1d7 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) -> 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 9abd4082d..0c3185009 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
{
+ 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
{
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
{
}
/// 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
{
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 8a7fe6f33..efe44c5d4 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 fc071e669..8406ec5ec 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 f960c3e63..9b0273d9d 100644
--- a/crates/ui/src/docking/mod.rs
+++ b/crates/ui/src/docking/mod.rs
@@ -35,8 +35,8 @@ pub use surface::{
WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag,
};
pub use targets::{
- DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker, zone_rect,
- zone_shape,
+ DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, Sides, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker,
+ zone_rect, zone_shape,
};
pub use workspace::Workspace;
diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs
index 360724ab6..11d0f3e55 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
, 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 c315cdf2d..6bf2553da 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 d8711abc8..63bd9f9e9 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 be0aeee49..19c976071 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