From 02d7d92a3e60ea6cc5ad0f158e74c7ac721596b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:18:09 +0000 Subject: [PATCH 01/13] docking: fade a dragged native window only when the drag can dock --- crates/ui/src/docking/native_tests.rs | 15 +++++++++++++- crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 27 +++++++++++++++++++------- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 258af673..5cf3dbfb 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}; @@ -2199,3 +2201,14 @@ 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, true), 1.0); + // A bar drag that only moves the window stays opaque. + assert_eq!(drag_opacity(Some(&drag(false)), false), 1.0); + assert_eq!(drag_opacity(Some(&drag(false)), true), 0.75); + // A drag by the window's only tab or group docks either way. + assert_eq!(drag_opacity(Some(&drag(true)), false), 0.75); +} 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..57e494d0 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -88,10 +88,21 @@ 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. +#[cfg(any(target_os = "macos", test))] +pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, title_bar_docks: bool) -> f64 { + match drag { + Some(drag) if 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 +591,11 @@ 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 opacity = if this { drag_opacity(drag.as_ref(), 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); From 889f0367f290cefddbe894da84a5b8fd180fc1a4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:18:09 +0000 Subject: [PATCH 02/13] docking: narrow the outer edge band to the canvas column and add DockStyle::drop_edges --- crates/ui/src/docking/dock_model.rs | 20 ++-- crates/ui/src/docking/drop_tests.rs | 131 +++++++++++++++++++++++- crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/model.rs | 44 ++++---- crates/ui/src/docking/render/frame.rs | 45 +++++--- crates/ui/src/docking/render/preview.rs | 15 ++- crates/ui/src/docking/surface.rs | 15 ++- crates/ui/src/docking/targets.rs | 57 ++++++++++- docs/ui-docking.md | 13 ++- 9 files changed, 290 insertions(+), 54 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 9abd4082..3c0918e6 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -454,8 +454,10 @@ impl Layout

{ None } - /// The node an edge split wraps: the root, or the part between the docks. - pub(in crate::docking) fn edge_path(&self) -> Vec { + /// The node an edge split wraps: the root, or the part between the docks. Where neither side + /// of a side-by-side split is a dock and only one holds a `canvas` panel, that side: the + /// canvas column, without the side frames. + pub(in crate::docking) fn edge_path(&self, canvas: &dyn Fn(&P) -> bool) -> Vec { let mut path = Vec::new(); let mut node = self.root.as_ref(); while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node { @@ -465,7 +467,11 @@ impl Layout

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

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

, to: Target

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

, to: Target

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

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

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

{ Column { after: bool }, /// A new frame along one edge of the docked layout, a quarter of its width or height: the /// root is split, or in a layout with docks, the part between them. The anchor names any - /// docked panel. `Center` is invalid. + /// docked panel. A protected anchor ([`Permissions::is_protected`], applied through + /// [`Layout::apply_with_permissions`]) narrows the part to the canvas column: where neither + /// side of a side-by-side split is a dock and only one holds a protected panel, that side. + /// `Center` is invalid. Edge(Zone), } @@ -819,14 +822,14 @@ impl Layout

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

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

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

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

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

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

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

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

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

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

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

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

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

, anchor: &P, placement: Placement

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

, anchor: &P, placement: Placement

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

{ pub(super) windows: Vec>, } -/// The layout as shown: a maximized frame alone, and hidden panels left out. +/// The layout as shown: a maximized frame alone, and hidden panels left out. `narrow` narrows +/// the outer edge area to the canvas column ([`Layout::edge_path`]). pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( layout: &'a Layout

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

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

>, FrameState

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

], action: impl Fn(Target

) -> Action

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

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

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

], + rule: ZoneRule, + sides: Sides, +) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -270,7 +319,7 @@ pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropT let zone = match rule { _ if !target.allow_splits => Zone::Center, ZoneRule::Depth(depth) => drop_zone(target.rect, header, depth, pointer)?, - ZoneRule::Fraction { fraction, .. } => body_zone(target.rect, header, fraction, target.accepts_tabs, pointer)?, + ZoneRule::Fraction { fraction, .. } => body_zone_on(target.rect, header, fraction, target.accepts_tabs, sides, pointer)?, }; if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { return Some(DropProposal { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 39152ee6..e2ff84a7 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -250,12 +250,23 @@ A flyout fits its content the same way, without the title bar. | The seam between two stacked groups of a dock column | A new group between them | `seam_band` (28 pt) | | Each edge of a dock column | A new column on that side | `column_edge_band` (28 pt) | | The docked area's left or right edge without a dock, and an empty dock | A new dock | `new_dock_band` (28 pt) | - | The layout's top and bottom edges (and left and right with `side_edge_zones`) | A new frame across the whole window side, a quarter of the window | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the layout) | + | The edge area's sides in `drop_edges` (top and bottom; all four with `side_edge_zones`) | A new frame along that whole side, a quarter of the edge area | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the edge area) | Where a band overlaps a group's outer third, both mean the same side. Without `side_edge_zones`, a band reaches only 4 pt past its line into a tab strip that takes tabs; the rest of the strip joins. The outer edge band is offered only when every frame keeps its minimum. +- The edge area is the root, or the part between the docks. Without `side_edge_zones` it also + narrows to the canvas column: where neither side of a side-by-side split is a dock and only + one side holds a protected panel, that side. Side panels in split frames then get no band + across the whole window, and the drop wraps only the canvas column. A drop on the band + anchors on the canvas, so `Placement::Edge` narrows the same way when applied. +- `DockStyle::drop_edges` (a `Sides`) lists the edge area's sides that take a drop, and the + sides of a group that takes no tabs (a canvas) that take a split; there, the whole body goes + to the nearest listed side, and with none listed the canvas takes nothing. Groups that take + tabs keep all four sides. The default is top and bottom. An app whose palettes dock only + through the dock bands sets `Sides::NONE`; one whose palettes sit left and right of the + canvas sets `Sides::LEFT_RIGHT`. `side_edge_zones` uses all four and ignores it. - With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame instead: trapezoids along the four edges of the body below the tab strip, all four the same depth, `docking::SIDE_EDGE_ZONE_DEPTH` (40 pt), or a quarter of the body's smaller side when From 876d2539f9d588259d9a12f47acde91c152bc4c6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:20:27 +0000 Subject: [PATCH 03/13] docking: drop_edges defaults to all sides; the docking profile's outer band takes only its top and bottom --- crates/ui/src/docking/drop_tests.rs | 57 ++++++++++++--------------- crates/ui/src/docking/render/frame.rs | 4 +- crates/ui/src/docking/surface.rs | 21 ++++++---- docs/ui-docking.md | 15 +++---- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 8e74ad59..b6006aa5 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -133,13 +133,8 @@ fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; let d = driver(&[(1, canvas)]); let (body, rect) = group(&d, 1); - // By default only its top and bottom: left of center goes to the nearer of those. - for (zone, at, edges) in [ - (Zone::Left, body.center() - vec2(10.0, 2.0), Sides::ALL), - (Zone::Top, body.center() - vec2(10.0, 2.0), DockStyle::default().drop_edges), - (Zone::Bottom, body.center() + vec2(-10.0, 2.0), DockStyle::default().drop_edges), - ] { - let mut d = driver(&[(1, canvas)]).with_style(move |style| style.drop_edges = edges); + for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { + let mut d = driver(&[(1, canvas)]); d.hold(tab(&d, 3), at); assert!(d.filled(line(rect, zone)), "{zone:?}"); let out = d.release(at); @@ -171,7 +166,7 @@ fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { let out = d.drag(tab(&d, 3), top); assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Top) }]); // A group that takes no tabs offers nothing there. - let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]).with_style(|style| style.drop_edges = Sides::ALL); + let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]); d.hold(tab(&d, 3), left); assert!(!d.outlined(rect.shrink(1.0))); let out = d.release(left); @@ -275,22 +270,14 @@ fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { // The canvas has no tab strip: its body is its frame. let (canvas, _) = group(&d, 0); let (side, _) = group(&d, 1); - // The bottom edge takes nothing. - let at = pos2(canvas.center().x, 590.0); - let mut d = canvas_driver(Permissions::PROTECTED, left_right); - d.hold(tab(&d, 3), at); - assert!(!d.filled(line(canvas, Zone::Bottom))); - assert!(!moves(&d.release(at))); - // The left edge splits around the canvas column. - let at = pos2(canvas.left() + 5.0, canvas.center().y); - let mut d = canvas_driver(Permissions::PROTECTED, left_right); - d.hold(tab(&d, 3), at); - assert!(d.filled(line(canvas, Zone::Left)), "a line along the canvas column's left edge"); - let out = d.release(at); - assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Left) }]); - let column = split(SplitAxis::Horizontal, SplitSize::Ratio(0.25), tabs(&[3]), tabs(&[0])); - let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), column, tabs(&[2])); - assert_eq!(d.layout.root, Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides))); + // 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); @@ -310,13 +297,21 @@ fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { let beside = Permissions { accepts_splits: true, ..Permissions::PROTECTED }; let d = canvas_driver(beside, |_| {}); let (body, _) = group(&d, 0); - let at = pos2(body.left() + body.width() * 0.3, body.bottom() - 40.0); - for (edges, zone) in [(DockStyle::default().drop_edges, Some(Zone::Bottom)), (Sides::LEFT_RIGHT, Some(Zone::Left)), (Sides::NONE, None)] { - let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); - let out = d.drag(tab(&d, 3), at); - match zone { - Some(zone) => assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Split(zone) }], "{edges:?}"), - None => assert!(!moves(&out), "{:?}", out.actions), + // 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), + } } } } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 72071eaf..02dd25d1 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -170,7 +170,7 @@ 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): the sides of + /// 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 edge area's whole edge, or with side @@ -181,7 +181,7 @@ impl Renderer<'_, P> { let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band }; let band = band.min(area.width() / 4.0).min(area.height() / 4.0); let line = |zone| crate::docking::targets::edge_line(area, zone, self.style.drop_line_width); - let sides = self.style.drop_sides(); + let sides = self.style.outer_edges(); let edges = [ (pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom), diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index a4a41f2e..01e9a838 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -64,11 +64,11 @@ pub struct DockStyle { /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its /// body, and every preview is a line or a tab outline. pub side_edge_zones: bool, - /// The layout's outer edges that take a drop, a new frame along that edge, and the sides of - /// a group that takes no tabs (a canvas) that take a split (top and bottom; the left and - /// right window edges are new docks). With [`crate::docking::Sides::NONE`], palettes dock - /// only through the dock bands. Not used with [`Self::side_edge_zones`], which takes all - /// four. Groups that take tabs keep all four sides. + /// 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 @@ -196,7 +196,7 @@ impl DockStyle { title_height: super::chrome::TITLE_HEIGHT, edge_band: 28.0, side_edge_zones: false, - drop_edges: super::Sides::TOP_BOTTOM, + drop_edges: super::Sides::ALL, side_zone_fraction: 1.0 / 3.0, drop_line_width: 4.0, new_dock_zones: true, @@ -234,12 +234,19 @@ impl DockStyle { } } - /// The sides [`Self::drop_edges`] gives in this profile: all four with + /// 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`]. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e2ff84a7..93a42930 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -250,7 +250,7 @@ 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 edge area's sides in `drop_edges` (top and bottom; all four with `side_edge_zones`) | A new frame along that whole side, a quarter of the edge area | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the edge area) | + | 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; @@ -261,12 +261,13 @@ A flyout fits its content the same way, without the title bar. one side holds a protected panel, that side. Side panels in split frames then get no band across the whole window, and the drop wraps only the canvas column. A drop on the band anchors on the canvas, so `Placement::Edge` narrows the same way when applied. -- `DockStyle::drop_edges` (a `Sides`) lists the edge area's sides that take a drop, and the - sides of a group that takes no tabs (a canvas) that take a split; there, the whole body goes - to the nearest listed side, and with none listed the canvas takes nothing. Groups that take - tabs keep all four sides. The default is top and bottom. An app whose palettes dock only - through the dock bands sets `Sides::NONE`; one whose palettes sit left and right of the - canvas sets `Sides::LEFT_RIGHT`. `side_edge_zones` uses all four and ignores it. +- `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 From f268d210556b6946adc5e499f5d3ff6d5ad81dd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:11:45 +0000 Subject: [PATCH 04/13] panes: preview the slot a dragged pane docks in, also while floating --- crates/ui/src/panes/mod.rs | 144 ++++++++++++++++++++++++++--------- crates/ui/src/panes/tests.rs | 79 +++++++++++++++++++ docs/ui-panes.md | 4 +- 3 files changed, 187 insertions(+), 40 deletions(-) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index f0eb6676..618678f2 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -349,20 +349,7 @@ 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); - } - } - } - } + self.one_slot(&mut shown, content); let previous = memory.active.filter(|active| shown.iter().any(|s| s.id == *active)); memory.active = previous; @@ -391,12 +378,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, &shown, 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, &shown, s, content, &style, focus_target == Some(s.id), bounds, &preview, &mut requests); regions.push(Region { pane: s.id, rect, floating: Some(layer) }); } @@ -461,10 +449,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, @@ -478,8 +546,7 @@ 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); + let size = self.docked_size(content, s, edge, bounds); let panel_id = s.id.with("panel"); let panel = match side { Side::Left => egui::Panel::left(panel_id), @@ -487,7 +554,7 @@ impl PaneHost { Side::Bottom => egui::Panel::bottom(panel_id), }; let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke)); - 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 title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); ui.allocate_rect(title, Sense::hover()); @@ -505,8 +572,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())), @@ -534,28 +601,23 @@ impl PaneHost { } if content.movable(&s.pane) { - // 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, @@ -598,23 +660,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()); @@ -641,7 +703,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 bb433521..eac2fce4 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -133,6 +133,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); @@ -220,6 +239,66 @@ 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 escape_and_focus_loss_restore_a_floating_pane_during_a_move() { let mut app = App::new(&[Pane::Styles]); diff --git a/docs/ui-panes.md b/docs/ui-panes.md index b9c859ab..45272d7a 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -59,8 +59,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 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: From 63b3e4e2e85ae1716651b4240ea966bec3b947fb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:19:39 +0000 Subject: [PATCH 05/13] docking: a drop that changes only sizes is a no-op; a lone floating group's header drag looks through its window --- crates/ui/src/docking/dock_model.rs | 60 ++++++++++++ crates/ui/src/docking/drop_tests.rs | 117 ++++++++++++++++++++++++ crates/ui/src/docking/group_tests.rs | 5 +- crates/ui/src/docking/render/dock.rs | 30 +++--- crates/ui/src/docking/render/preview.rs | 80 ++++++++++------ crates/ui/src/docking/surface.rs | 41 +-------- crates/ui/src/docking/targets.rs | 3 +- docs/ui-docking.md | 11 ++- 8 files changed, 268 insertions(+), 79 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 3c0918e6..27390e09 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -335,6 +335,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) { diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index b6006aa5..0098aeb5 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -315,3 +315,120 @@ fn a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { } } } + +/// 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 + ); +} 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/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/preview.rs b/crates/ui/src/docking/render/preview.rs index a8cd1e6f..75b9365f 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -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. @@ -189,23 +200,37 @@ impl Renderer<'_, P> { /// [`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_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)); } let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; - resolve_drop_on( + 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)) + } + + /// `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 @@ -245,8 +270,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; @@ -262,7 +288,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 { @@ -416,12 +443,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 @@ -500,8 +529,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 }; diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 01e9a838..885303a6 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -496,54 +496,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( diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index a3dc3b2a..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, } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 93a42930..4ad822a4 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -215,7 +215,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 @@ -308,6 +311,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 From 950b9644e8f357a04bd6ed3797c605fa84518c47 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:29:44 +0000 Subject: [PATCH 06/13] docking: a drop beside a frame too narrow to halve takes the room from beyond its divider --- crates/ui/src/docking/dock_model.rs | 41 ++++++++++ crates/ui/src/docking/drop_tests.rs | 22 ++++++ crates/ui/src/docking/model.rs | 16 +++- crates/ui/src/docking/model_tests.rs | 20 +++++ crates/ui/src/docking/render/preview.rs | 101 ++++++++++++++++++++++-- crates/ui/src/docking/render/sizes.rs | 14 +++- crates/ui/src/docking/split_tests.rs | 55 ++++++++++++- crates/ui/src/docking/workspace.rs | 7 ++ docs/ui-docking.md | 11 ++- 9 files changed, 271 insertions(+), 16 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 27390e09..37c6fa07 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()); @@ -799,6 +828,18 @@ 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 }; diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 0098aeb5..87de29b3 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -432,3 +432,25 @@ fn a_header_drag_in_a_window_of_two_groups_still_offers_the_other_group() { 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/model.rs b/crates/ui/src/docking/model.rs index 6bbf15ad..31dcfcfa 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}; @@ -305,6 +306,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` @@ -1309,6 +1317,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) } } } @@ -1318,7 +1330,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 9327fd4d..d510939b 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -754,3 +754,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/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 75b9365f..740665c2 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -10,7 +10,7 @@ use crate::docking::{ surface::TAB_PADDING, 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 { @@ -182,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 { @@ -209,6 +209,11 @@ impl Renderer<'_, P> { if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) { 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))?; let proposal = resolve_drop_on( panel, @@ -220,6 +225,83 @@ impl Renderer<'_, P> { 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, the new frame takes the size the + /// moved panels have now (at least its minimum and [`DROP_MARGIN`]), and the frames beyond + /// the divider on that side give the room. `None` without such a divider, or when those + /// frames cannot give the new frame its minimum. + 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; + let wanted = now.iter().find(|(node, _)| holds(node, panel)).map_or(least, |(_, rect)| along(rect.size())); + let size = wanted.max(least).min(along(halved.size()) + room - frame - gap); + if size < least { + return None; + } + // 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 @@ -345,7 +427,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 { @@ -485,7 +567,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 { @@ -551,7 +633,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, }; @@ -715,6 +797,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..232ed9d8 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,57 @@ 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_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/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/docs/ui-docking.md b/docs/ui-docking.md index 4ad822a4..79415dd6 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 @@ -242,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: From 4dc543cb449cc232e4c3d58f78a05c1ab169db9e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:50:40 +0000 Subject: [PATCH 07/13] 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. --- crates/ui/src/panes/mod.rs | 12 +++++++++--- crates/ui/src/panes/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 618678f2..28124fa7 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -205,6 +205,7 @@ struct DragOut { canceled: bool, } +#[derive(Clone)] struct Shown

{ pane: P, id: Id, @@ -349,6 +350,8 @@ 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 }); } + // 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)); @@ -379,12 +382,12 @@ impl PaneHost { 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, &shown, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); + 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, &shown, s, content, &style, focus_target == Some(s.id), bounds, &preview, &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) }); } @@ -546,7 +549,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 size = self.docked_size(content, s, edge, bounds); + // 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), diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index eac2fce4..25622e63 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -299,6 +299,26 @@ fn one_slot_previews_a_pane_beside_the_context_pane() { 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]); From d004f40c25633ae9d2d71e3d5ec5a50916ecaabd Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:55:58 +0000 Subject: [PATCH 08/13] docking: preview a drop beside something with the rect the panel takes --- crates/ui/src/docking/dock_tests.rs | 82 ++++++++--- crates/ui/src/docking/drop_tests.rs | 171 ++++++++++++++++------- crates/ui/src/docking/render/dock.rs | 108 ++++++++++---- crates/ui/src/docking/render/floating.rs | 4 +- crates/ui/src/docking/render/preview.rs | 122 +++++++++++++--- crates/ui/src/docking/split_tests.rs | 16 ++- crates/ui/src/docking/surface.rs | 7 +- docs/ui-docking.md | 23 +-- 8 files changed, 398 insertions(+), 135 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 85f47251..5168c244 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -178,6 +178,35 @@ impl Driver { } } + /// Whether the last frame painted the result preview of `rect`: a 2 pt outline with a light + /// fill, inset 1 pt. + fn previewed(&self, rect: Rect) -> bool { + let rect = rect.shrink(1.0); + let shapes = || { + self.shapes.iter().filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.rect == rect => Some(r), + _ => None, + }) + }; + shapes() + .filter(|r| r.fill == egui::Color32::TRANSPARENT && r.stroke.width == 2.0) + .any(|outline| shapes().any(|r| r.stroke.width == 0.0 && r.fill == outline.stroke.color.gamma_multiply(0.08))) + } + + /// The rect of the group whose strip starts with `panel`'s tab, in the column whose bar + /// names `column`: down to the next group's tab, `next`, or the window's bottom. + fn group_rect(&self, panel: u32, next: Option, column: u32) -> Rect { + let tab = self.tab(panel); + let bottom = next.map_or(600.0, |next| self.tab(next).top()); + Rect::from_x_y_ranges(self.column_of(column).x_range(), tab.top()..=bottom) + } + + /// The rect of the dock column whose bar names `panel`. + fn column_of(&self, panel: u32) -> Rect { + let bar = self.rect("column-bar", panel).union(self.rect("column-collapse", panel)); + Rect::from_x_y_ranges(bar.x_range(), bar.top()..=600.0) + } + /// Whether the last frame painted the filled insertion line `line`. fn painted(&self, line: Rect) -> bool { self.shapes.iter().any(|clipped| match &clipped.shape { @@ -206,10 +235,6 @@ fn hline(y: f32, x: std::ops::RangeInclusive) -> Rect { Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0)) } -fn vline(x: f32, y: std::ops::RangeInclusive) -> Rect { - Rect::from_x_y_ranges((x - 2.0)..=(x + 2.0), y) -} - /// A floating group's drag bar. fn bar(d: &Driver, panel: u32) -> Pos2 { d.rect("drag-bar", panel).center() @@ -219,14 +244,18 @@ fn bar(d: &Driver, panel: u32) -> Pos2 { // groups share 14..600, meeting at y = 307. #[test] -fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() { +fn group_dropped_on_a_seam_inserts_between_and_previews_the_rect_it_takes() { let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); let mut d = Driver::new(layout); assert_eq!(d.tab(3).top(), 307.0); let seam = pos2(120.0, 309.0); d.hold(bar(&d, 5), seam); - assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam"); + let held = d.shapes.clone(); let out = d.step(vec![button(seam, false)]); + d.settle(); + let rect = d.group_rect(5, Some(3), 2); + d.shapes = held; + assert!(d.previewed(rect), "group 5's rect between groups 2 and 3: {rect:?}"); assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } }))); assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]); assert!(d.layout.floating.is_empty()); @@ -261,22 +290,29 @@ fn group_dropped_on_a_top_or_bottom_band_stacks_above_or_below() { #[test] fn a_dock_groups_top_and_bottom_thirds_stack_its_sides_make_columns_and_its_middle_joins() { - // Group 3's body is 0..240 across and 333..600 down. - for (at, expected, line) in [ - (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]], Some(hline(307.0, 0.0..=240.0))), - (pos2(120.0, 580.0), vec![vec![vec![2], vec![3], vec![5]]], Some(hline(598.0, 0.0..=240.0))), - (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]], Some(vline(0.0, 0.0..=600.0))), - (pos2(200.0, 466.0), vec![vec![vec![2], vec![3]], vec![vec![5]]], Some(vline(240.0, 0.0..=600.0))), + // Group 3's body is 0..240 across and 333..600 down. Each drop beside previews the rect + // group 5 then takes: in the column, below the group `Some` names, or a whole column. + for (at, expected, result) in [ + (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]], Some(Some(3))), + (pos2(120.0, 580.0), vec![vec![vec![2], vec![3], vec![5]]], Some(None)), + (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]], None), + (pos2(200.0, 466.0), vec![vec![vec![2], vec![3]], vec![vec![5]]], None), (pos2(120.0, 466.0), vec![vec![vec![2], vec![3, 5]]], None), ] { let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); let mut d = Driver::new(layout); d.hold(bar(&d, 5), at); - if let Some(line) = line { - assert!(d.painted(line), "{at:?}: the line of the edge it docks to"); - } + let held = d.shapes.clone(); d.step(vec![button(at, false)]); assert_eq!(columns(&d.layout, Side::Start), expected, "{at:?}"); + d.settle(); + let rect = match result { + Some(next) => d.group_rect(5, next, 2), + None if expected.len() == 2 => d.column_of(5), + None => continue, + }; + d.shapes = held; + assert!(d.previewed(rect), "{at:?}: the rect group 5 takes, {rect:?}"); } } @@ -368,8 +404,14 @@ fn group_dropped_on_a_column_edge_makes_a_new_column() { let mut d = Driver::new(layout.clone()); let edge = pos2(246.0, 200.0); d.hold(bar(&d, 5), edge); - assert!(d.painted(vline(240.0, 0.0..=600.0)), "a vertical line the full height of the dock"); + let held = d.shapes.clone(); let out = d.step(vec![button(edge, false)]); + d.settle(); + let rect = d.column_of(5); + assert_eq!((rect.left(), rect.top(), rect.bottom()), (240.0, 0.0, 600.0)); + let shapes = std::mem::replace(&mut d.shapes, held); + assert!(d.previewed(rect), "the new column's rect, the full height of the dock"); + d.shapes = shapes; assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: true } }))); assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![5]]]); assert_eq!(column(&d.layout, 1).width, 150.0, "the new column keeps the window's width"); @@ -386,9 +428,13 @@ fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() { let layout = with_floating(docked(side, &[&[&[2]]]), &[(5, [300.0, 300.0, 150.0, 150.0])]); let mut d = Driver::new(layout); d.hold(bar(&d, 5), at); - let x = if new == Side::End { 598.0 } else { 2.0 }; - assert!(d.painted(vline(x, 0.0..=600.0)), "{new:?}: a vertical line at the window edge"); + let held = d.shapes.clone(); let out = d.step(vec![button(at, false)]); + d.settle(); + let rect = d.column_of(5); + let shapes = std::mem::replace(&mut d.shapes, held); + assert!(d.previewed(rect), "{new:?}: the new dock's column at the window edge, {rect:?}"); + d.shapes = shapes; assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: new }))); assert_eq!(columns(&d.layout, new), vec![vec![vec![5]]]); assert_eq!(columns(&d.layout, side), vec![vec![vec![2]]]); diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 87de29b3..37eb3f58 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -81,7 +81,7 @@ fn accent() -> Color32 { } #[test] -fn a_drop_on_a_groups_outer_third_docks_beside_it_with_a_line_and_its_middle_joins_with_an_outline() { +fn a_drop_on_a_groups_outer_third_docks_beside_it_with_its_rect_and_its_middle_joins_with_an_outline() { let d = driver(&[]); let (body, rect) = group(&d, 1); let (w, h) = (body.width(), body.height()); @@ -96,11 +96,9 @@ fn a_drop_on_a_groups_outer_third_docks_beside_it_with_a_line_and_its_middle_joi ] { let mut d = driver(&[]); d.hold(tab(&d, 3), at); - assert!(d.filled(line(zone)), "{zone:?}: a line along the group's whole edge"); - assert!(!d.filled(zone_rect(rect, zone).unwrap().shrink(1.0)), "{zone:?}: no filled half"); assert!(!d.outlined(rect.shrink(1.0)), "{zone:?}"); - let out = d.release(at); - assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + let out = previews_result(driver(&[]), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); } // The middle and the tab strip join: the group's outline with a light fill, no line. for at in [body.center(), pos2(rect.center().x, rect.top() + 13.0)] { @@ -119,11 +117,6 @@ fn a_drop_on_a_groups_outer_third_docks_beside_it_with_a_line_and_its_middle_joi // A group's drag bar previews with the same painter. let mut d = driver(&[]); let bar = d.hit(&Hit::DragBar(2)).unwrap().center(); - let at = pos2(body.left() + w * 0.3, body.center().y); - d.hold(bar, at); - assert!(d.filled(line(Zone::Left))); - assert!(!d.filled(zone_rect(rect, Zone::Left).unwrap().shrink(1.0))); - let mut d = driver(&[]); d.hold(bar, body.center()); assert!(d.outlined(rect.shrink(1.0))); } @@ -134,11 +127,8 @@ fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { let d = driver(&[(1, canvas)]); let (body, rect) = group(&d, 1); for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { - let mut d = driver(&[(1, canvas)]); - d.hold(tab(&d, 3), at); - assert!(d.filled(line(rect, zone)), "{zone:?}"); - let out = d.release(at); - assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + let out = previews_result(driver(&[(1, canvas)]), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); } // Its strip joins nothing. let mut d = driver(&[(1, canvas)]); @@ -174,15 +164,17 @@ fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { } #[test] -fn the_root_edge_band_is_28_pt_and_previews_a_line_along_the_layouts_edge() { +fn the_root_edge_band_is_28_pt_and_previews_the_new_frame_along_the_layouts_edge() { let d = driver(&[]); let (body, _) = group(&d, 1); let x = body.center().x; - let mut d = driver(&[]); - d.hold(tab(&d, 3), pos2(x, 573.0)); - assert!(d.filled(Rect::from_x_y_ranges(0.0..=800.0, 596.0..=600.0)), "a line along the layout's whole bottom edge"); - let out = d.release(pos2(x, 573.0)); - assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{:?}", out.actions); + let along_the_bottom = |d: &Driver| { + let (_, rect) = group(d, 3); + assert_eq!((rect.left(), rect.right(), rect.bottom()), (0.0, 800.0, 600.0), "along the layout's whole bottom edge"); + rect + }; + let out = previews_result(driver(&[]), tab(&d, 3), pos2(x, 573.0), along_the_bottom); + assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); // Past the band, the group's own bottom third. let mut d = driver(&[]); let out = d.drag(tab(&d, 3), pos2(x, 571.0)); @@ -225,11 +217,14 @@ fn between_side_frames_the_outer_edge_band_covers_only_the_canvas_column() { 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) }]); + let under_the_canvas = |d: &Driver| { + let (_, rect) = group(d, 3); + assert_eq!(rect.x_range(), canvas.x_range(), "the canvas column's width"); + rect + }; + let out = previews_result(canvas_driver(Permissions::PROTECTED, |_| {}), tab(&d, 3), at, under_the_canvas); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Bottom) }]); + d.drag(tab(&d, 3), at); // 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])); @@ -253,11 +248,15 @@ fn between_docks_the_outer_edge_band_covers_the_part_between_them() { 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 between_the_docks = |d: &Driver| { + let (_, rect) = group(d, 3); + assert_eq!((rect.x_range(), rect.bottom()), (area.x_range(), area.bottom()), "along the bottom between the docks"); + rect + }; + let out = previews_result(driver(), tab(&d, 3), at, between_the_docks); + assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); 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); + d.drag(tab(&d, 3), at); 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]))); @@ -340,16 +339,15 @@ fn a_drop_that_changes_only_sizes_shows_nothing_and_does_nothing() { 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 (body, _) = 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))); + // Properties' bottom third moves Layers, with the rect it takes. + let below = pos2(body.center().x, body.bottom() - body.height() * 0.2); + previews_result(start(), tab(&d, 2), below, |d| group(d, 2).1); // 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"); + assert!(!previews(&d), "no outline or fill"); let out = d.release(top); assert!(out.actions.is_empty(), "{:?}", out.actions); assert_eq!(d.layout, layout); @@ -376,7 +374,7 @@ fn a_dock_drop_that_changes_only_heights_shows_nothing_and_does_nothing() { 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. + // Below the lower group moves the upper one, with the rect it takes. let mut d = start(); d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2)); assert!(previews(&d)); @@ -384,7 +382,7 @@ fn a_dock_drop_that_changes_only_heights_shows_nothing_and_does_nothing() { 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"); + assert!(!previews(&d), "{at:?}: no preview"); let out = d.release(at); assert!(out.actions.is_empty(), "{at:?}: {:?}", out.actions); assert_eq!(d.layout, layout); @@ -402,12 +400,14 @@ fn a_header_drag_of_a_windows_only_group_looks_through_its_window() { 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) } }]); + // Frame 1's left third, behind the window. + let out = previews_result(start(), bar, at, |d| { + let (_, group) = group(d, 4); + assert_eq!(group.left(), rect.left()); + group + }); + assert_eq!(out, vec![Action::MoveGroup { panel: 4, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]); } #[test] @@ -439,18 +439,85 @@ fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { 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 start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); 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 - ); + // The preview is the rect the group takes left of frame 2. + let out = previews_result(start(), tab(&d, 4), at, |d| group(d, 4).1); + assert!(matches!(&out[..], [Action::Move { panel: 4, anchor: 2, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), "{out:?}"); + let mut d = start(); + d.drag(tab(&d, 4), at); 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); } + +/// The rect of the result preview the last frame painted: a 2 pt accent outline with a light +/// fill. +fn result_preview(d: &Driver) -> Option { + let fill = accent().gamma_multiply(0.08); + d.shapes() + .iter() + .find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.fill == fill && shape.stroke.width == 0.0 => Some(shape.rect), + _ => None, + }) + .filter(|rect| d.outlined(*rect)) +} + +/// Drags `from` to `at` and back to rest: the preview while held is the rect `rect_of` reads +/// once the drop is applied, inset by its 1 pt margin. Returns the drop's actions. +fn previews_result(mut d: Driver, from: Pos2, at: Pos2, rect_of: impl Fn(&Driver) -> Rect) -> Vec> { + d.hold(from, at); + let preview = result_preview(&d); + let out = d.release(at); + d.settle(); + assert_eq!(preview, Some(rect_of(&d).shrink(1.0)), "{:?}", out.actions); + out.actions +} + +#[test] +fn a_drop_beside_a_frame_previews_the_rect_the_group_takes() { + let d = driver(&[]); + let (body, _) = group(&d, 1); + let at = pos2(body.left() + body.width() * 0.2, body.center().y); + let out = previews_result(driver(&[]), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Left) }]); + // A whole group the same. + let bar = d.hit(&Hit::DragBar(2)).unwrap().center(); + // Panel 3 is the group's selected tab, the one it draws. + let out = previews_result(driver(&[]), bar, at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::MoveGroup { panel: 2, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]); +} + +/// A 240 pt dock column at the start, group `[2]` above `[3, 4]`, beside frame 1. +fn dock_driver() -> Driver { + let column = Column::new(vec![Group::new(vec![2], 0), Group::new(vec![3, 4], 0)], 240.0); + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[1])) }; + DockDriver::new(Layout { root: Some(root), floating: Vec::new() }, Bodies::default()) +} + +/// The rect of the dock column of `panel`'s group, its bar included, when that group fills it. +fn column_of(d: &Driver, panel: u32) -> Rect { + let bar = d.hit(&Hit::ColumnBar(panel)).unwrap_or_else(|| panic!("column bar {panel}")); + let (body, _) = group(d, panel); + Rect::from_x_y_ranges(body.x_range(), bar.top()..=body.bottom()) +} + +#[test] +fn a_dock_drop_previews_the_rect_the_group_or_column_takes() { + let d = dock_driver(); + let (upper, _) = group(&d, 2); + // Above group 2: a new group at the column's top. + let at = pos2(upper.center().x, upper.top() + 10.0); + let out = previews_result(dock_driver(), tab(&d, 4), at, |d| group(d, 4).1); + assert_eq!(out, vec![Action::Move { panel: 4, anchor: 2, placement: Placement::Group { below: false, height: None } }]); + // The column's inner edge: a new column after it, the full height. + let out = previews_result(dock_driver(), tab(&d, 4), pos2(upper.right() + 6.0, 200.0), |d| column_of(d, 4)); + assert_eq!(out, vec![Action::Move { panel: 4, anchor: 2, placement: Placement::Column { after: true } }]); + // The window's right edge: a new dock. + let out = previews_result(dock_driver(), tab(&d, 4), pos2(795.0, 200.0), |d| column_of(d, 4)); + assert_eq!(out, vec![Action::MovePanel { panel: 4, to: Target::NewDock { side: Side::End } }]); +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 19c97607..fcf6ace0 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -97,6 +97,29 @@ pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width } } +/// [D] A dock's columns as drawn in its rect: each column's width bounds, natural width with +/// the floor it shrinks to, and the width [`fit_widths`] gives it, and each column with groups +/// as `(index, width, rect)`, from the window edge inwards. +pub(in crate::docking) struct DockColumns { + bounds: Vec<(f32, f32)>, + natural: Vec<(f32, Option)>, + widths: Vec, + pub(in crate::docking) rects: Vec<(usize, f32, Rect)>, +} + +/// The rects of groups `heights` high, stacked from the top of `area`. +pub(in crate::docking) fn stacked(area: Rect, heights: &[f32]) -> Vec { + let mut top = area.top(); + heights + .iter() + .map(|height| { + let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height)); + top += height; + rect + }) + .collect() +} + /// A group of a column, as [`group_heights`] sizes it. Heights include the tab strip. #[derive(Clone, Copy, Debug, Default)] pub(in crate::docking) struct GroupSize { @@ -270,21 +293,17 @@ impl Renderer<'_, P> { if usable(rect) { ui.painter().rect_filled(rect, 0.0, self.style.background); } - let bounds: Vec<(f32, f32)> = - columns.iter().map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }).collect(); - let natural: Vec<(f32, Option)> = - columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect(); - let widths = fit_widths(&natural, rect.width()); + let DockColumns { bounds, natural, widths, rects } = self.dock_columns(side, columns, rect); let scaled = widths.iter().zip(&natural).any(|(w, (n, _))| w != n); let total: f32 = widths.iter().sum(); - let mut x = outer; let mut drawn = Vec::new(); - for (index, column) in columns.iter().enumerate() { + for (index, width, column_rect) in rects { + let Some(column) = columns.get(index) else { continue }; let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; - let fitted = widths.get(index).copied().unwrap_or_default(); - let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0)); - let inner = x + inward * width; - let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); + let (x, inner) = match edge { + Edge::Left => (column_rect.left(), column_rect.right()), + Edge::Right => (column_rect.right(), column_rect.left()), + }; let groups = self.dock_groups.len(); self.column(ui, column, first, column_rect, edge); let (outer_band, outer_line) = column_edge(x, rect.y_range(), band_width, line_width); @@ -304,7 +323,6 @@ impl Renderer<'_, P> { } self.dock_targets.push(DockDrop { target: after.0, band: inner_band, line: after.1 }); drawn.push((index, column, first, width, inner)); - x = inner; } // Registered after the columns, so the narrow hit areas win at the edges. for (index, column, first, width, inner) in drawn { @@ -348,6 +366,49 @@ impl Renderer<'_, P> { } } + /// The columns of a dock drawn in `rect` ([`DockColumns`]). + pub(in crate::docking) fn dock_columns(&self, side: Side, columns: &[Column

], rect: Rect) -> DockColumns { + let (outer, inward) = match edge_of(side, self.style.right_to_left) { + Edge::Left => (rect.left(), 1.0), + Edge::Right => (rect.right(), -1.0), + }; + let bounds: Vec<(f32, f32)> = + columns.iter().map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }).collect(); + let natural: Vec<(f32, Option)> = + columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect(); + let widths = fit_widths(&natural, rect.width()); + let mut x = outer; + let mut rects = Vec::new(); + for (index, column) in columns.iter().enumerate() { + if column.groups.first().and_then(|g| g.panels.first()).is_none() { + continue; + } + let fitted = widths.get(index).copied().unwrap_or_default(); + let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0)); + let inner = x + inward * width; + rects.push((index, width, Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()))); + x = inner; + } + DockColumns { bounds, natural, widths, rects } + } + + /// A column drawn at `rect`: its bar, the body below it, and the heights of its groups in + /// the body, top to bottom (none when the column is iconic or its body is empty). Groups + /// taller than the body together scroll. + pub(in crate::docking) fn column_layout(&self, column: &Column

, rect: Rect) -> (Rect, Rect, Vec) { + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); + let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic || !usable(body) { + return (bar, body, Vec::new()); + } + let sizes: Vec<_> = column + .groups + .iter() + .map(|g| GroupSize { minimized: g.minimized, saved: g.height, min: self.group_min(g), content: self.dock_heights(g) }) + .collect(); + (bar, body, group_heights(&sizes, self.style.tab_height, body.height())) + } + fn handle_style(&self) -> ResizeHandleStyle { ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent } } @@ -390,9 +451,8 @@ impl Renderer<'_, P> { return; } self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect); - let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); + let (bar, body, heights) = self.column_layout(column, rect); self.column_bar(ui, column, first, bar, rect, edge); - let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); if column.iconic && usable(body) { self.iconic_column(ui, column, body, edge); } @@ -400,12 +460,6 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); return; } - let sizes: Vec<_> = column - .groups - .iter() - .map(|g| GroupSize { minimized: g.minimized, saved: g.height, min: self.group_min(g), content: self.dock_heights(g) }) - .collect(); - let heights = group_heights(&sizes, self.style.tab_height, body.height()); let total: f32 = heights.iter().sum(); let start = self.targets.len(); if total > body.height() + 0.5 { @@ -437,14 +491,11 @@ impl Renderer<'_, P> { /// Groups top to bottom in `area` (the scrolled content when the column scrolls), their /// seams and bands clipped to the visible `view`, and the height dividers. fn column_groups(&mut self, ui: &mut Ui, column: &Column

, heights: &[f32], area: Rect, view: Rect) { - let mut top = area.top(); - let mut rects = Vec::new(); - for (group, height) in column.groups.iter().zip(heights) { - let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height)); - self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock); - rects.push(rect); - top += height; + let rects = stacked(area, heights); + for (group, rect) in column.groups.iter().zip(&rects) { + self.group(ui, &group.panels, group.active, group.minimized, *rect, GroupKind::Dock); } + let top = rects.last().map_or(area.top(), |r| r.bottom()); let xs = view.x_range(); let half = self.style.seam_band * 0.5; let width = self.style.drop_line_width; @@ -722,7 +773,8 @@ impl Renderer<'_, P> { 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. + /// The line of a dock drop that shows no result rect: into an iconic column, or any dock + /// drop with [`DockStyle::side_edge_zones`]. pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect) { if usable(line) { let painter = self.preview_painter(ctx); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index b1a2a419..fded5aff 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -11,6 +11,8 @@ pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES; /// A floating window as drawn: what covers the layout behind it. pub(in crate::docking) struct WindowHit

{ pub(super) window: Rect, + /// Where the window's tree is drawn. + pub(super) body: Rect, pub(super) members: Vec

, } @@ -120,7 +122,7 @@ impl Renderer<'_, P> { self.tree = outer; } } - self.frame.windows.push(WindowHit { window: frame, members: window.panels().cloned().collect() }); + self.frame.windows.push(WindowHit { window: frame, body, members: window.panels().cloned().collect() }); } /// Drag moves the whole window live, double-click minimizes or expands it, × closes every diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 740665c2..18e3628a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -20,8 +20,8 @@ fn float_only(ctx: &Context) -> bool { /// Share of the accent that fills a tab drop's outline: light, so it does not read as a line. const TAB_FILL: f32 = 0.08; -/// The preview of a drop that joins a group as a tab: a 2 pt accent outline inside `rect`, with -/// a light fill. +/// The preview of a drop that joins a group as a tab, or of the rect a drop beside something +/// gives the moved panels: a 2 pt accent outline inside `rect`, with a light fill. fn tab_outline(painter: &egui::Painter, rect: Rect, accent: egui::Color32) { painter.rect_filled(rect, 0.0, accent.gamma_multiply(TAB_FILL)); painter.rect_stroke(rect, 0.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Inside); @@ -413,8 +413,8 @@ impl Renderer<'_, P> { return; } if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { - if action.is_some() { - self.paint_drop_line(ctx, line); + if let Some(action) = action { + self.paint_dock_preview(ctx, &painter, action, &drag.members, line); if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); } @@ -428,7 +428,7 @@ impl Renderer<'_, P> { self.paint_marker(&painter, &proposal); } else { 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); + self.paint_preview(ctx, &painter, &proposal, split, &drag.members, |to| group_action(drag, to)); } if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); @@ -484,8 +484,9 @@ impl Renderer<'_, P> { self.output.actions.push(action); self.output.focused = Some(drag.panel); } - } else if action.is_some() { - self.paint_drop_line(ctx, line); + } else if let Some(action) = action { + let painter = self.preview_painter(ctx); + self.paint_dock_preview(ctx, &painter, action, std::slice::from_ref(&drag.panel), line); self.paint_float_hint(ctx, pointer); } return; @@ -525,10 +526,7 @@ 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> { - let action = |to| match to { - Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to => Action::MovePanel { panel: panel.clone(), to }, - }; + let action = |to| tab_action(panel, 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), @@ -552,7 +550,7 @@ impl Renderer<'_, P> { let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); if !same_group && !proposal.noop && !zones { - self.paint_preview(ctx, painter, proposal, true); + self.paint_preview(ctx, painter, proposal, true, std::slice::from_ref(&drag.panel), |to| tab_action(&drag.panel, to)); } else { self.paint_marker(painter, proposal); } @@ -561,17 +559,30 @@ impl Renderer<'_, P> { } } - /// The preview of a group target, for tab and group drags: a line along the edge of a drop - /// beside the group or the layout, else the tab outline of the whole group, and the - /// insertion marker on a strip. With [`crate::docking::DockStyle::side_edge_zones`], the - /// 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) { + /// The preview of a group target, for tab and group drags of `members`, which `action` moves + /// there: for a drop beside the group or the layout, the rect the members then take + /// ([`Self::result_rect`]), else the tab outline of the whole group, and the insertion marker + /// on a strip. With [`crate::docking::DockStyle::side_edge_zones`], the 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, + members: &[P], + action: impl Fn(Target

) -> Action

, + ) { if crate::docking::targets::usable(proposal.preview) { 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 { - self.paint_drop_line(ctx, proposal.preview); + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + match self.result_rect(action(to), members) { + Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), + None => self.paint_drop_line(ctx, proposal.preview), + } } else { tab_outline(painter, proposal.preview.shrink(1.0), self.style.accent); } @@ -579,6 +590,68 @@ impl Renderer<'_, P> { self.paint_marker(painter, proposal); } + /// The preview of a dock drop that `action` makes of `members`: the rect they then take + /// ([`Self::result_rect`]). A drop into an iconic column, or any dock drop with + /// [`crate::docking::DockStyle::side_edge_zones`], shows its `line`. + fn paint_dock_preview(&self, ctx: &Context, painter: &egui::Painter, action: Action

, members: &[P], line: Rect) { + match (!self.style.side_edge_zones).then_some(action).and_then(|action| self.result_rect(action, members)) { + Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), + None => self.paint_drop_line(ctx, line), + } + } + + /// Where `members` are drawn once `action` is applied, laid out by the code that draws the + /// layout: the union of the rects of the groups that hold them, or the rect of the dock + /// column they make up alone. A floating window's tree is laid out in its body as drawn + /// this frame. In a dock column that scrolls, a group's rect is clipped to the column's + /// body. `None` when the model rejects `action`, or when the members join an iconic column, + /// whose icons show no rect. + pub(super) fn result_rect(&self, action: Action

, members: &[P]) -> Option { + let mut candidate = self.layout.clone(); + candidate.apply_with_permissions(action, |p| self.permissions(p)).ok()?; + let moved = |panels: &[P]| panels.iter().any(|p| members.contains(p)); + let sizer = self.sizer(); + let mut rects = Vec::new(); + for (root, area) in self.drawn_trees(&candidate, members) { + let mut drawn = Vec::new(); + sizer.groups(root, area, &mut drawn); + for Drawn { node, rect, .. } in drawn { + let Node::Dock { side, columns } = node else { + if members.iter().any(|p| holds(node, p)) { + rects.push(rect); + } + continue; + }; + for (index, _, rect) in self.dock_columns(*side, columns, rect).rects { + let Some(column) = columns.get(index).filter(|c| c.groups.iter().any(|g| moved(&g.panels))) else { continue }; + if column.groups.iter().flat_map(|g| &g.panels).all(|p| members.contains(p)) { + rects.push(rect); + continue; + } + if column.iconic { + return None; + } + let (_, body, heights) = self.column_layout(column, rect); + let groups = column.groups.iter().zip(super::dock::stacked(body, &heights)); + rects.extend(groups.filter(|(g, _)| moved(&g.panels)).map(|(_, rect)| rect.intersect(body))); + } + } + } + rects.into_iter().filter(|r| crate::docking::targets::usable(*r)).reduce(Rect::union) + } + + /// [`Self::trees`] of `layout`, where a floating window that holds a panel other than + /// `members` is laid out in the body that window is drawn in this frame. + fn drawn_trees<'l>(&self, layout: &'l Layout

, members: &[P]) -> Vec<(&'l Node

, Rect)> { + let body = |window: &crate::docking::Floating

| { + let stays = |p: &&P| !members.contains(*p); + let hit = self.frame.windows.iter().find(|hit| window.panels().filter(stays).any(|p| hit.members.contains(p))); + hit.map_or_else(|| self.window_body(window), |hit| hit.body) + }; + let windows = layout.floating.iter().filter(|w| !w.minimized).map(|w| (&w.root, body(w))); + layout.root.iter().map(|root| (root, self.workspace)).chain(windows).collect() + } + /// The insertion marker of a drop on a tab strip. fn paint_marker(&self, painter: &egui::Painter, proposal: &DropProposal

) { if let Some(marker) = proposal.insertion { @@ -597,10 +670,7 @@ impl Renderer<'_, P> { }; let own = target.tabs.iter().any(|(p, _)| p == panel); let at = |at: Pos2| { - let move_to = |to| match to { - Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to => Action::MovePanel { panel: panel.clone(), to }, - }; + let move_to = |to| tab_action(panel, to); let proposal = self.resolve_roomy(panel, at, std::slice::from_ref(target), move_to)?; let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; let joins = matches!(proposal.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); @@ -797,6 +867,14 @@ impl Renderer<'_, P> { } } +/// The move a single tab's drag makes. +fn tab_action(panel: &P, to: Target

) -> Action

{ + match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + } +} + /// Whether the drawn group `node` holds `panel`. fn holds(node: &Node

, panel: &P) -> bool { match node { diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 232ed9d8..81b75540 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -831,16 +831,26 @@ fn the_preview_fills_the_zone_under_the_pointer_and_no_result_rect() { assert_eq!(zone_outlines(&d).len(), 6); d.key(egui::Key::Escape); - // The dock-and-canvas arrangement shows a line along the group's left edge instead. + // The dock-and-canvas arrangement shows the rect the panel takes there instead. let mut d = DockDriver::new(editing(), Film::default()); d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); d.area = d.screen; d.settle(); - d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + let at = pos2(timeline.left() + 8.0, timeline.center().y + 13.0); + d.hold(tab(&d, INFO), at); assert!(!d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); assert!(zone_outlines(&d).is_empty()); assert!(!d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); - assert!(d.filled(Rect::from_x_y_ranges(timeline.left()..=(timeline.left() + 4.0), timeline.y_range()))); + let shapes = d.shapes().to_vec(); + d.release(at); + d.settle(); + let info = group(&d, INFO); + assert_eq!(info.left(), timeline.left()); + let painted = |fill: egui::Color32, stroke: f32| { + shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.rect == info.shrink(1.0) && r.fill == fill && r.stroke.width == stroke)) + }; + let accent = DockStyle::default().accent; + assert!(painted(accent.gamma_multiply(0.08), 0.0) && painted(egui::Color32::TRANSPARENT, 2.0), "Info's rect beside the timeline"); } #[test] diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 885303a6..4c596934 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -75,8 +75,11 @@ pub struct DockStyle { /// (one third, at most one half). The middle joins the group as a tab. Not used with /// [`Self::side_edge_zones`]. pub side_zone_fraction: f32, - /// Width of the line a drop beside a group, column, dock or the layout's edge previews with - /// (4 pt, more than zero). + /// Width of the line that a drop between the icon groups of an iconic column, and every + /// dock drop with [`Self::side_edge_zones`], previews with, and of the line + /// [`crate::docking::ZoneRule::Fraction`] gives hosts as a side's preview (4 pt, more than + /// zero). Other drops beside a group, column, dock or the layout's edge preview the rect the + /// moved panels take. Border bands reach 4 pt past this line into a tab strip. pub drop_line_width: f32, /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new /// dock (see [`Self::new_dock_band`]). diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 79415dd6..84818310 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -308,13 +308,18 @@ A flyout fits its content the same way, without the title bar. way. Towards the minimums, groups give way in proportion to their room. A group takes the largest default, compact and fill height of its panels. All three are body heights, without the tab strip, like `PanelLimits`. -- Drop previews have two shapes, the same for tab and group drags: - - A drop beside something shows a `DockStyle::drop_line_width` (4 pt) accent line along the - whole edge it docks to: a group's edge for a split or a new group above or below it, the - column's width for a seam, the dock's height for a new column or dock, and the layout's - whole edge for the outer edge band. The line's length shows what the drop docks beside. - - 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. +- Drop previews have three shapes, the same for tab and group drags: + - A drop beside something shows the rect the dragged panel, group or column will take, at its + real position and size, as a 2 pt accent outline with a light fill. The renderer applies + the drop to a copy of the layout and lays it out with the code that draws it, so the rect + is the one the panel's group gets on release: the new frame for a split (including + `Placement::SplitSized`) or the outer edge band, the new group for a stack above or below + a dock group or on a seam, and the whole column, bar included, for a new column or dock. + A drop between the icon groups of an iconic column shows a `DockStyle::drop_line_width` + (4 pt) accent line there instead. + - A drop that joins a group shows the same outline and fill around the whole group, and on + its strip the 2 pt insertion marker. + - A drop that floats shows the floating rect. 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 @@ -328,7 +333,7 @@ A flyout fits its content the same way, without the title bar. fills the zone under the pointer. Sides the group does not take, by its permissions or because a split leaves no room, are not drawn; nor is the center of the group a tab comes from. A drop there that the zones do not show, such as the outer edge band, fills the region - it takes. Escape or losing window focus cancels a drag. + it takes, and dock drops show the line along the edge they dock to. Escape or losing window focus cancels a drag. - A drag in progress (a tab, group, column, window bar, native window bar or divider, in any of the app's windows) also ends when the layout passed to the area is not the one it drew last frame, with or without its returned actions applied: the app switched workspaces, ran Reset @@ -591,7 +596,7 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho behind it. A window moving by its bar is never its own target. A point in no window is on the desktop. - *Targets and previews.* Each window draws its drop targets in its own coordinates, and the - window under the pointer draws the drop preview (zone, dock line, ghost) in its own frame. + window under the pointer draws the drop preview (zone, result rect, ghost) in its own frame. The source window shows only the "Hold {key} to undock" hint. The release is resolved at the end of the main window's frame, after every native window has drawn. - *Drops.* Over a window, a tab or group docks or joins there with the usual targets: dock lines From 91aaa294d0fdf7b15dc5f4d912de9bdf2beadcab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:18:34 +0000 Subject: [PATCH 09/13] 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. --- crates/ui/src/docking/command.rs | 30 +++-- crates/ui/src/docking/command_tests.rs | 46 +++++++- crates/ui/src/docking/dock_model.rs | 44 ++++++-- crates/ui/src/docking/drop_tests.rs | 50 +++++++++ crates/ui/src/docking/model.rs | 5 +- crates/ui/src/docking/render/frame.rs | 145 ++++++++++++++++++------- crates/ui/src/docking/surface.rs | 20 +++- crates/ui/tests/docking_workflows.rs | 3 +- docs/ui-docking.md | 16 ++- 9 files changed, 283 insertions(+), 76 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 99825f1d..103b3331 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -340,7 +340,18 @@ impl DockCommand

{ /// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and /// `height` replace the matching values, and a group or column that is already a whole /// floating window keeps its rect. - pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { + /// + /// An outer edge dock wraps what a drag to that edge would: the canvas column where the + /// area narrows its edge band to it, else the part between the docks. `space` says whether + /// the area draws with [`super::DockStyle::side_edge_zones`], which never narrows, and + /// `permissions` are those the actions are applied with. + pub fn to_actions( + &self, + workspace: &Workspace

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> Result>, DockError> { let layout = &workspace.layout; let zoned = Zoned { layout, right_to_left }; let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) }; @@ -418,12 +429,13 @@ impl DockCommand

{ if before.is_some() { return Err(DockError::InvalidPlacement); } - let anchor = layout - .panels() - .into_iter() - .find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_)))) - .cloned() - .ok_or(DockError::MissingTarget)?; + // The model narrows the edge on a protected anchor, as `Workspace::apply` checks it. + let protected = |p: &P| { + let policy = if workspace.locked { permissions(p).locked() } else { permissions(p) }; + policy.is_protected() + }; + let narrowed = !space.side_edge_zones && layout.edge_narrows(zone, &protected); + let anchor = layout.edge_anchor(narrowed, &protected, &|p| members.contains(p)).ok_or(DockError::MissingTarget)?; return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?); } if !matches!(layout.place(panel), Some(Place::Floating { .. })) { @@ -526,7 +538,7 @@ impl DockCommand

{ // Shown, then moved to the edge, all or nothing. let mut shown = workspace.clone(); shown.set_visible(panel.clone(), true, None, &permissions)?; - let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?; + let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space, &permissions)?, &permissions)?; *workspace = shown; return Ok(closed); } @@ -534,7 +546,7 @@ impl DockCommand

{ workspace.locked = locked.unwrap_or(!workspace.locked); return Ok(Vec::new()); } - let actions = self.to_actions(workspace, right_to_left, space)?; + let actions = self.to_actions(workspace, right_to_left, space, &permissions)?; workspace.apply_all(actions, permissions) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 78d406b1..3801c048 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -45,7 +45,7 @@ fn parse(params: Value) -> Result, serde_json::Error> { /// The actions `params` makes on a fresh workspace. fn actions(params: Value) -> Vec> { let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}")) + command.to_actions(&workspace(), false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}")) } /// Apply `params` to a fresh workspace; return it and the panels closed. @@ -294,7 +294,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() { let command = |zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap(); - let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) { + let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space(), permissions) { Ok(actions) => match actions.as_slice() { [Action::Move { placement, .. }] => Ok(placement.clone()), other => panic!("{other:?}"), @@ -338,7 +338,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area); let space = float_space::

(&d.ctx, d.id).expect("the area records its float space"); assert_eq!(space.area, area); - let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap(); + let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space, permissions).unwrap(); let panel = float(json!({"command": "window.panel.float", "panel": "layers"})); // The floating_size width, and an empty body's least fitted height under its 150 points. @@ -359,19 +359,24 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { // Without recorded sizes a unit floats at the layout's size, shrunk into a small area. let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0))); - let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small); + let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions( + &workspace(), + false, + &small, + permissions, + ); assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }])); // A host seeds the size of a panel the area never drew; a recorded size still wins. let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); - let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded); + let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded, permissions); assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }])); let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]); } fn float_with(space: &FloatSpace

, panel: &str) -> Vec> { - parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap() + parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space, permissions).unwrap() } #[test] @@ -411,6 +416,35 @@ fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() { rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement); } +/// The frame beside `panel`'s own: what an edge split with `panel` wrapped. +fn wrapped(layout: &Layout

, panel: &str) -> Option> { + let Some(dock_model::Place::Node(path)) = layout.place(&s(panel)) else { return None }; + let (last, parent) = path.split_last()?; + layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned() +} + +#[test] +fn an_outer_edge_dock_wraps_the_canvas_column_whatever_the_tree_order() { + let tabs = |panel: &str| Node::Tabs { panels: vec![s(panel)], active: 0 }; + let side = + |first, second| Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; + let canvas_first = side(tabs("canvas"), side(tabs("layers"), tabs("history"))); + let canvas_later = side(side(tabs("layers"), tabs("canvas")), tabs("history")); + for root in [canvas_first, canvas_later] { + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])] }; + let command = parse(json!({"command": "window.panel.dock", "panel": "color", "zone": "top"})).unwrap(); + let mut workspace = Workspace::new(layout.clone()); + command.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert_eq!(wrapped(&workspace.layout, "color"), Some(tabs("canvas")), "{:?}", workspace.layout); + // An area drawn with side edge zones never narrows its edge to the canvas column. + let mut workspace = Workspace::new(layout.clone()); + command.apply(&mut workspace, false, &space().with_side_edge_zones(true), permissions).unwrap(); + let mut whole = layout; + whole.floating.clear(); + assert_eq!(wrapped(&workspace.layout, "color"), whole.root, "{:?}", workspace.layout); + } +} + #[test] fn dock_to_the_side_a_panel_already_leads_the_last_column_of() { // Layers leads the dock's only column: it moves out to a new column after it. diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 37c6fa07..0c318500 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -543,20 +543,28 @@ impl Layout

{ None } - /// The node an edge split wraps: the root, or the part between the docks. Where neither side - /// of a side-by-side split is a dock and only one holds a `canvas` panel, that side: the - /// canvas column, without the side frames. - pub(in crate::docking) fn edge_path(&self, canvas: &dyn Fn(&P) -> bool) -> Vec { + /// The node an edge split at `zone` wraps: the root, or the part between the docks. Where + /// neither side of a side-by-side split is a dock and only one holds a `canvas` panel, + /// that side: the canvas column, without the side frames. A top (bottom) edge also + /// descends a split of rows into its first (last) row when only that row holds a `canvas` + /// panel, so a full-width row below (above) the canvas keeps its edge. + pub(in crate::docking) fn edge_path(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Vec { + let holds = |node: &Node

| tree_panels(node).into_iter().any(canvas); let mut path = Vec::new(); let mut node = self.root.as_ref(); - while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node { + while let Some(Node::Split { axis, first, second, .. }) = node { if path.len() > MAX_DEPTH { break; } - let second_side = match (&**first, &**second) { - (Node::Dock { .. }, _) => true, - (_, Node::Dock { .. }) => false, - _ => match (tree_panels(first).into_iter().any(canvas), tree_panels(second).into_iter().any(canvas)) { + let second_side = match (axis, &**first, &**second) { + (SplitAxis::Horizontal, Node::Dock { .. }, _) => true, + (SplitAxis::Horizontal, _, Node::Dock { .. }) => false, + (SplitAxis::Vertical, ..) => match (zone, holds(first), holds(second)) { + (Zone::Top, true, false) => false, + (Zone::Bottom, false, true) => true, + _ => break, + }, + _ => match (holds(first), holds(second)) { (true, false) => false, (false, true) => true, _ => break, @@ -568,6 +576,22 @@ impl Layout

{ path } + /// Whether an edge drop at `zone` that narrows by `canvas` wraps less than one that does + /// not: the edge area is narrower than the part between the docks. + pub(in crate::docking) fn edge_narrows(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> bool { + self.edge_path(zone, canvas) != self.edge_path(zone, &|_| false) + } + + /// The docked panel, other than `skip`, that a [`Placement::Edge`] anchors on so the drop + /// wraps the edge area it shows: a `protected` one where that area is narrowed to the + /// canvas column (`narrowed`), else one that is not protected, since a protected anchor + /// narrows the model's edge. Any docked panel when every one is protected. + pub(in crate::docking) fn edge_anchor(&self, narrowed: bool, protected: &dyn Fn(&P) -> bool, skip: &dyn Fn(&P) -> bool) -> Option

{ + let docked: Vec<&P> = + self.panels().into_iter().filter(|p| !skip(p) && matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))).collect(); + docked.iter().find(|p| protected(p) == narrowed).or(docked.first()).map(|p| (*p).clone()) + } + pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec>> { match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { Some(Node::Dock { columns, .. }) => Some(columns), @@ -866,7 +890,7 @@ impl Layout

{ return Err(DockError::InvalidPlacement); } // A protected anchor narrows the edge to the canvas column. - let path = if canvas(&anchor) { self.edge_path(canvas) } else { self.edge_path(&|_| false) }; + let path = if canvas(&anchor) { self.edge_path(zone, canvas) } else { self.edge_path(zone, &|_| false) }; let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; if matches!(node, Node::Dock { .. }) { return Err(DockError::InvalidPlacement); diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs index 87de29b3..efe44c5d 100644 --- a/crates/ui/src/docking/drop_tests.rs +++ b/crates/ui/src/docking/drop_tests.rs @@ -263,6 +263,56 @@ fn between_docks_the_outer_edge_band_covers_the_part_between_them() { assert_eq!(**first, split(SplitAxis::Vertical, SplitSize::Ratio(0.75), middle, tabs(&[3]))); } +/// The frame beside `panel`'s own: what an edge split with `panel` wrapped. +fn wrapped(layout: &Layout, panel: u32) -> Option> { + let Some(dock_model::Place::Node(path)) = layout.place(&panel) else { return None }; + let (last, parent) = path.split_last()?; + layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned() +} + +#[test] +fn over_a_full_width_row_the_top_edge_band_covers_only_the_canvas_column_above_it() { + // SoundCraft's Edit window: side frame 1, canvas 0 and side frame [2, 3] over a full-width + // editor 4. + let Some(upper) = canvas_columns().root else { panic!() }; + let root = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), upper.clone(), tabs(&[4])); + let layout = Layout { root: Some(root), floating: Vec::new() }; + let driver = || DockDriver::new(layout.clone(), Bodies { rules: [(0, Permissions::PROTECTED)].into(), ..Bodies::default() }); + let d = driver(); + let (canvas, _) = group(&d, 0); + let at = pos2(canvas.center().x, 5.0); + let mut d = driver(); + d.hold(tab(&d, 3), at); + assert!(d.filled(Rect::from_x_y_ranges(canvas.x_range(), 0.0..=4.0)), "a line along the canvas column's top"); + assert!(!d.filled(Rect::from_x_y_ranges(0.0..=800.0, 0.0..=4.0)), "no line along the window's top"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]); + assert_eq!(wrapped(&d.layout, 3), Some(tabs(&[0])), "{:?}", d.layout); +} + +#[test] +fn an_outer_edge_drop_wraps_the_area_it_previews_when_the_app_applies_other_permissions() { + // Frame [2, 3] and canvas 0 beside frame 1, which is protected only while drawing (PDFCraft's + // bare tools mode): drawn, the edge band is not narrowed to the canvas. + let inner = split(SplitAxis::Horizontal, SplitSize::Ratio(0.5), Node::Tabs { panels: vec![2, 3], active: 1 }, tabs(&[0])); + let layout = Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::Ratio(0.7), inner, tabs(&[1]))), floating: Vec::new() }; + let drawn = [(0, Permissions::PROTECTED), (1, Permissions::PROTECTED)]; + let driver = || DockDriver::new(layout.clone(), Bodies { rules: drawn.into(), ..Bodies::default() }); + let d = driver(); + let (canvas, _) = group(&d, 0); + let at = pos2(canvas.center().x, 5.0); + let mut d = driver(); + d.hold(tab(&d, 3), at); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=800.0, 0.0..=4.0)), "a line along the window's top"); + let out = d.release(at); + let mut applied = layout.clone(); + let apply = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }; + applied.apply_all(out.actions.clone(), apply).unwrap_or_else(|e| panic!("{e}: {:?}", out.actions)); + let mut rest = layout; + rest.apply(Action::Close { panel: 3 }).unwrap(); + assert_eq!(wrapped(&applied, 3), rest.root, "{:?}", out.actions); +} + #[test] fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { let left_right = |style: &mut DockStyle| style.drop_edges = Sides::LEFT_RIGHT; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 31dcfcfa..e5ba3cf8 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -328,8 +328,9 @@ pub enum Placement

{ /// root is split, or in a layout with docks, the part between them. The anchor names any /// docked panel. A protected anchor ([`Permissions::is_protected`], applied through /// [`Layout::apply_with_permissions`]) narrows the part to the canvas column: where neither - /// side of a side-by-side split is a dock and only one holds a protected panel, that side. - /// `Center` is invalid. + /// side of a side-by-side split is a dock and only one holds a protected panel, that side; + /// for `Top` (`Bottom`), also the first (last) row of a split of rows when only that row + /// holds one. `Center` is invalid. Edge(Zone), } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 02dd25d1..e1b7c429 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -7,8 +7,8 @@ use egui::{Context, Pos2, Rect, pos2, vec2}; use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup}; use crate::docking::{ - Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone, - dock_model::{Place, node_at}, + Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Placement, Target, Zone, + dock_model::{Place, node_at, tree_panels}, }; /// How far below the pointer the float hint sits, clear of the cursor. @@ -20,14 +20,40 @@ pub(in crate::docking) struct FrameState

{ paths: Option, Vec>>, /// The same for each floating window's splits, by the window's first shown panel. window_paths: HashMap, Vec>>, - /// The view node whose rect is the outer edge area; none while a frame is maximized. - edge_path: Option>, - pub(super) edge: Option, + /// The outer edge areas of top, bottom, left and right drops; none while a frame is + /// maximized. + edges: Option<[EdgeArea

; 4]>, + /// The panels the view leaves out. + hidden: Vec

, pub(super) maximized: Option

, /// Floating windows as drawn, back to front. pub(super) windows: Vec>, } +/// The view node an outer edge drop wraps ([`Layout::edge_path`]), and its rect once drawn. +struct EdgeArea

{ + path: Vec, + /// The node is narrower than the part between the docks: the canvas column. + narrowed: bool, + panels: Vec

, + rect: Option, +} + +impl EdgeArea

{ + fn of(view: &Layout

, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Self { + let path = view.edge_path(zone, canvas); + let panels = + view.root.as_ref().and_then(|root| node_at(root, &path)).map_or(Vec::new(), |node| tree_panels(node).into_iter().cloned().collect()); + Self { narrowed: view.edge_narrows(zone, canvas), path, panels, rect: None } + } +} + +const EDGES: [Zone; 4] = [Zone::Top, Zone::Bottom, Zone::Left, Zone::Right]; + +fn edge_areas(view: &Layout

, canvas: &dyn Fn(&P) -> bool) -> [EdgeArea

; 4] { + EDGES.map(|zone| EdgeArea::of(view, zone, canvas)) +} + /// The layout as shown: a maximized frame alone, and hidden panels left out. `narrow` narrows /// the outer edge area to the canvas column ([`Layout::edge_path`]). pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( @@ -54,11 +80,12 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( } Place::Floating { .. } => None, }); - let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() }; + let mut state = FrameState { paths: None, window_paths: HashMap::new(), edges: None, hidden: Vec::new(), maximized: None, windows: Vec::new() }; if maximized.is_none() && matches!(hide, HidePanels::None) { - state.edge_path = Some(layout.edge_path(&canvas)); + state.edges = Some(edge_areas(layout, &canvas)); return (Cow::Borrowed(layout), state); } + state.hidden = layout.panels().into_iter().filter(|p| hidden(p)).cloned().collect(); let mut paths = HashMap::new(); let view = match maximized { Some((panel, path, node)) => { @@ -81,7 +108,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( } }; if state.maximized.is_none() { - state.edge_path = Some(view.edge_path(&canvas)); + state.edges = Some(edge_areas(&view, &canvas)); } state.paths = Some(paths); (Cow::Owned(view), state) @@ -156,8 +183,11 @@ fn prune_group(group: &Group

, hidden: &dyn Fn(&P) -> bo impl Renderer<'_, P> { pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) { - if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) { - self.frame.edge = Some(rect); + if self.tree.is_some() { + return; + } + for area in self.frame.edges.iter_mut().flatten().filter(|area| area.path == path) { + area.rect = Some(rect); } } @@ -177,38 +207,33 @@ impl Renderer<'_, P> { /// edge zones the quarter the new frame takes. Without them, on a tab strip that takes tabs, /// the band reaches only [`crate::docking::targets::STRIP_REACH`] from the line. pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> { - let area = self.frame.edge.filter(|area| area.contains(pointer))?; - let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band }; - let band = band.min(area.width() / 4.0).min(area.height() / 4.0); - let line = |zone| crate::docking::targets::edge_line(area, zone, self.style.drop_line_width); let sides = self.style.outer_edges(); - let edges = [ - (pointer.y - area.top(), Zone::Top), - (area.bottom() - pointer.y, Zone::Bottom), - (pointer.x - area.left(), Zone::Left), - (area.right() - pointer.x, Zone::Right), - ]; - let (distance, zone) = edges.into_iter().filter(|(_, zone)| sides.contains(*zone)).min_by(|a, b| a.0.total_cmp(&b.0))?; - if distance > band { - return None; - } - let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH; - if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line(zone).is_some_and(reach) { - return None; - } - // A protected anchor narrows the model's edge to the canvas column, as `edge_path` - // narrows the band without side edge zones. - let narrow = !self.style.side_edge_zones; - let docked: Vec<&P> = self - .layout - .panels() + let (distance, zone, area) = EDGES .into_iter() - .filter(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None)) - .collect(); - let anchor = docked.iter().find(|p| self.permissions(p).is_protected() == narrow).or(docked.first())?; - let anchor = (*anchor).clone(); + .filter(|zone| sides.contains(*zone)) + .filter_map(|zone| { + let area = self.edge_area(zone)?.rect.filter(|area| area.contains(pointer))?; + let distance = match zone { + Zone::Top => pointer.y - area.top(), + Zone::Bottom => area.bottom() - pointer.y, + Zone::Left => pointer.x - area.left(), + Zone::Right | Zone::Center => area.right() - pointer.x, + }; + Some((distance, zone, area)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0))?; + let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band }; + if distance > band.min(area.width() / 4.0).min(area.height() / 4.0) { + return None; + } + let line = crate::docking::targets::edge_line(area, zone, self.style.drop_line_width); + let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH; + if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line.is_some_and(reach) { + return None; + } + let anchor = self.edge_anchor(members, zone)?; if !self.style.side_edge_zones { - return Some((anchor, zone, line(zone)?)); + return Some((anchor, zone, line?)); } let size = area.size() * 0.25; let preview = match zone { @@ -220,6 +245,48 @@ impl Renderer<'_, P> { Some((anchor, zone, preview)) } + fn edge_area(&self, zone: Zone) -> Option<&EdgeArea

> { + self.frame.edges.as_ref()?.get(EDGES.iter().position(|edge| *edge == zone)?) + } + + /// The docked anchor, not one of `members`, of an outer edge drop at `zone` that wraps the + /// edge area drawn: protected where that area is the canvas column, as the model narrows on + /// a protected anchor, else not. The drawn area and the model's can still differ (hidden + /// panels, a canvas test that differs from these permissions), so the drop is tried: an + /// anchor whose drop wraps other panels than the area shows gives way to one that is not + /// protected, and then to no edge target. + fn edge_anchor(&self, members: &[P], zone: Zone) -> Option

{ + let area = self.edge_area(zone)?; + let protected = |p: &P| self.permissions(p).is_protected(); + let skip = |p: &P| members.contains(p); + let lead = members.first()?; + let wraps_area = |anchor: &P| { + let to = Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) }; + let action = match members { + [_] => Action::Move { panel: lead.clone(), anchor: anchor.clone(), placement: Placement::Edge(zone) }, + _ => Action::MoveGroup { panel: lead.clone(), to }, + }; + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { + // Left to the model's check. + return true; + } + let Some(Place::Node(path)) = candidate.place(lead) else { return false }; + let Some((last, parent)) = path.split_last() else { return false }; + let beside = [parent, &[!last]].concat(); + let Some(wrapped) = candidate.root.as_ref().and_then(|root| node_at(root, &beside)) else { return false }; + let shown = |p: &&P| !self.frame.hidden.contains(p) && !members.contains(p); + let wrapped: Vec<&P> = tree_panels(wrapped).into_iter().filter(shown).collect(); + let drawn: Vec<&P> = area.panels.iter().filter(shown).collect(); + wrapped.len() == drawn.len() && wrapped.iter().all(|p| drawn.contains(p)) + }; + let anchor = self.layout.edge_anchor(area.narrowed, &protected, &skip)?; + if wraps_area(&anchor) { + return Some(anchor); + } + self.layout.edge_anchor(false, &protected, &skip).filter(|other| *other != anchor && !protected(other) && wraps_area(other)) + } + /// Under the pointer while a drag offers a docking target: which modifier floats the drop /// instead. With native windows, the window under the pointer draws it, beside its preview. pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 885303a6..5f8afc31 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -363,9 +363,9 @@ const FLOAT_INSET: f32 = 24.0; #[cfg_attr(not(feature = "serde"), allow(dead_code))] pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0); -/// Where a docking command floats a panel, group or column when it gives no rect (see -/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it -/// with [`float_space`]. +/// Where a docking command floats a panel, group or column when it gives no rect, and how the +/// area draws its outer edge (see `DockCommand::to_actions`, feature `serde`). [`DockArea`] +/// records one each frame; read it with [`float_space`]. #[derive(Clone, Debug, PartialEq)] pub struct FloatSpace

{ /// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left @@ -378,13 +378,23 @@ pub struct FloatSpace

{ /// The height each panel's content used when it was last drawn, which fits a floated group /// to its content; `None` for content that filled its body. pub(super) contents: Vec<(P, Option)>, + /// The area draws with [`DockStyle::side_edge_zones`]: an outer edge drop wraps the part + /// between the docks, never only the canvas column. + pub(super) side_edge_zones: bool, } impl FloatSpace

{ /// An area with no recorded sizes: commands float a panel at 260x320, and a group or /// column at its dock column's width (else 260) by 320. pub fn new(area: Rect) -> Self { - Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() } + Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new(), side_edge_zones: false } + } + + /// For an area drawn with [`DockStyle::side_edge_zones`] when the area has not drawn yet + /// (a headless run): an outer edge command then wraps what that profile's edge band wraps. + pub fn with_side_edge_zones(mut self, on: bool) -> Self { + self.side_edge_zones = on; + self } /// Adds the size a command floats `panel` at alone when the area has not drawn it, such as @@ -1186,7 +1196,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { output: DockOutput::default(), frame, iconic: super::render::Iconic::new(self.auto_collapse), - float_space: FloatSpace { contents, ..FloatSpace::new(rect) }, + float_space: FloatSpace { contents, side_edge_zones: style.side_edge_zones, ..FloatSpace::new(rect) }, places, contents: content_index, dividers: Vec::new(), diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 3f46b521..081fda2e 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -417,7 +417,8 @@ mod v1_fixtures { let float: DockCommand = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap(); - let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space).unwrap(); + let actions = + float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space, |p| d.content.permissions(p)).unwrap(); d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); d.settle(); assert!(start_columns(&d.layout).is_empty(), "{at}: floated"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 79415dd6..640d879e 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -266,9 +266,16 @@ A flyout fits its content the same way, without the title bar. edge band is offered only when every frame keeps its minimum. - The edge area is the root, or the part between the docks. Without `side_edge_zones` it also narrows to the canvas column: where neither side of a side-by-side split is a dock and only - one side holds a protected panel, that side. Side panels in split frames then get no band - across the whole window, and the drop wraps only the canvas column. A drop on the band - anchors on the canvas, so `Placement::Edge` narrows the same way when applied. + one side holds a protected panel, that side. The top (bottom) band also narrows a split of + rows to its first (last) row when only that row holds the canvas, so a full-width row below + the canvas keeps the bottom band. Side panels in split frames then get no band across the + whole window, and the drop wraps only the canvas column. A drop on a narrowed band anchors + on the canvas, so `Placement::Edge` narrows the same way when applied; on a band that is not + narrowed it anchors on a panel that is not protected. The renderer tries the drop with its + own permissions and offers the band only when the drop wraps the panels the band shows. + `window.panel.dock` to an outer edge picks its anchor the same way, from the permissions + `apply` (or `to_actions`) gets and the area's `FloatSpace` (`with_side_edge_zones` before it + has drawn). - `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of a group that takes no tabs (a canvas), and of the layout's outer edge, take a drop. A canvas's whole body goes to the nearest listed side; with none listed it takes nothing. In the docking profile @@ -395,7 +402,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or a new dock; when it already is that dock's last column, the command does nothing. - `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a - new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. + new frame on that outer edge (`Placement::Edge`), wrapping what a drag to that edge would + (see the edge area above). A hidden panel is shown first. - Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden panel's place; a panel never laid out opens as `Open` without an anchor does. - `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see From bd185872b2cc0d03d29757f7026a86fcc5e94157 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:18:38 +0000 Subject: [PATCH 10/13] docking: a sized split gives the new frame its minimum or saved width --- crates/ui/src/docking/render/preview.rs | 22 ++++++++++++++------- crates/ui/src/docking/split_tests.rs | 26 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 740665c2..f29e77f3 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -226,10 +226,11 @@ impl Renderer<'_, P> { } /// The side split of `proposal`'s frame when halving it leaves no room - /// ([`Placement::SplitSized`]): the frame keeps its size, the new frame takes the size the - /// moved panels have now (at least its minimum and [`DROP_MARGIN`]), and the frames beyond - /// the divider on that side give the room. `None` without such a divider, or when those - /// frames cannot give the new frame its minimum. + /// ([`Placement::SplitSized`]): the frame keeps its size, and the frames beyond the divider + /// on that side give the new frame its minimum (with [`DROP_MARGIN`]). A floating panel + /// keeps its size along the split instead, and one in a dock column its column's width or + /// its group's height, when that fits. `None` without such a divider, or when those frames + /// cannot give the new frame its minimum. fn sized_split(&self, panel: &P, proposal: &DropProposal

, action: &impl Fn(Target

) -> Action

) -> Option> { let Placement::Split(zone) = proposal.placement else { return None }; let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; @@ -277,11 +278,18 @@ impl Renderer<'_, P> { let room = sizer.travel(far.0, far.1, axis, start).0 - DROP_MARGIN; let least = along(super::node_limits(new, self.style, &*self.content).min) + DROP_MARGIN; let gap = self.style.gap; - let wanted = now.iter().find(|(node, _)| holds(node, panel)).map_or(least, |(_, rect)| along(rect.size())); - let size = wanted.max(least).min(along(halved.size()) + room - frame - gap); - if size < least { + // A split frame's width is its share of the room, not one of its own. + let saved = match self.place(panel) { + Some(Place::Floating { .. }) => now.iter().find(|(node, _)| holds(node, panel)).map(|(_, rect)| along(rect.size())), + Some(place @ Place::Dock { .. }) if axis == SplitAxis::Horizontal => self.layout.column(place).map(|column| column.width), + Some(place @ Place::Dock { .. }) => self.layout.group(place).and_then(|group| group.height), + _ => None, + }; + let most = along(halved.size()) + room - frame - gap; + if least > most { return None; } + let size = saved.filter(|width| (least..=most).contains(width)).unwrap_or(least); // The anchor's side of the outer split grows by what the halves did not take already. let grow = frame + gap + size - along(halved.size()); let first = along(a.1.size()) + if start { -grow } else { grow }; diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 232ed9d8..fd3070c2 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -397,6 +397,32 @@ fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room"); } +#[test] +fn a_docked_panel_dropped_beside_a_frame_too_narrow_to_halve_takes_its_minimum_width() { + // Info docked below Properties, as wide as it, beside a narrow Tools frame. + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(60.0), + first: Box::new(split(SplitAxis::Vertical, 0.6, tabs(&PROPERTIES), tabs(&[INFO]))), + second: Box::new(tabs(&[TOOLS])), + }; + let mut d = driver(Layout { root: Some(root), floating: Vec::new() }, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y)); + d.settle(); + let (tools, properties) = (group(&d, TOOLS), group(&d, PROPERTIES[0])); + let out = d.drag(tab(&d, INFO), pos2(tools.left() + 4.0, tools.center().y + 13.0)); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0])); + assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}"); + assert!(new_info.width() < 60.0, "Info takes its minimum, not its docked width: {new_info:?}"); + assert!(new_properties.width() > properties.width() - 60.0, "Properties gives only that: {new_properties:?}"); +} + #[test] fn a_drop_beside_a_narrow_frame_joins_it_when_no_neighbour_has_room() { let mut d = beside_tools(130.0, 60.0); From 87d8781fa2ffc023ecf83bb6dd4d499f3b7a6660 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:18:38 +0000 Subject: [PATCH 11/13] docking: keep a native window opaque during a float-only drag --- crates/ui/src/docking/native_tests.rs | 11 +++++++---- crates/ui/src/docking/render/native.rs | 10 ++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 5cf3dbfb..064d8486 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -2205,10 +2205,13 @@ fn a_tab_drag_onto_a_native_window_is_no_app_drag() { #[test] fn a_native_window_fades_only_while_its_drag_can_dock() { let drag = |docks| WindowDrag { window: 1, grab: vec2(10.0, 10.0), to: pos2(100.0, 100.0), start: pos2(100.0, 100.0), docks }; - assert_eq!(drag_opacity(None, true), 1.0); + assert_eq!(drag_opacity(None, false, true), 1.0); // A bar drag that only moves the window stays opaque. - assert_eq!(drag_opacity(Some(&drag(false)), false), 1.0); - assert_eq!(drag_opacity(Some(&drag(false)), true), 0.75); + assert_eq!(drag_opacity(Some(&drag(false)), false, false), 1.0); + assert_eq!(drag_opacity(Some(&drag(false)), false, true), 0.75); // A drag by the window's only tab or group docks either way. - assert_eq!(drag_opacity(Some(&drag(true)), false), 0.75); + assert_eq!(drag_opacity(Some(&drag(true)), false, false), 0.75); + // With Cmd/Ctrl held the drag only moves the window. + assert_eq!(drag_opacity(Some(&drag(false)), true, true), 1.0); + assert_eq!(drag_opacity(Some(&drag(true)), true, false), 1.0); } diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 57e494d0..1dd96e49 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -95,11 +95,12 @@ pub(in crate::docking) const OTHER_CLOSE: CloseLayout = const DRAG_OPACITY: f64 = 0.75; /// Opacity of the window `drag` moves: faded while the drag can dock (its only tab or group, or -/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move. +/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move and while +/// Ctrl/Cmd keeps the drop floating (`float_only`). #[cfg(any(target_os = "macos", test))] -pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, title_bar_docks: bool) -> f64 { +pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, float_only: bool, title_bar_docks: bool) -> f64 { match drag { - Some(drag) if drag.docks || title_bar_docks => DRAG_OPACITY, + Some(drag) if !float_only && (drag.docks || title_bar_docks) => DRAG_OPACITY, _ => 1.0, } } @@ -594,7 +595,8 @@ impl Renderer<'_, P> { let drag = ctx.data(|d| d.get_temp::(drag_key)); let dragged = drag.as_ref().and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); let this = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); - let opacity = if this { drag_opacity(drag.as_ref(), self.style.title_bar_docks) } else { 1.0 }; + let float_only = drag.is_some_and(|drag| self.cross.pointer.is_some_and(|p| p.window == Some(drag.window) && p.command)); + let opacity = if this { drag_opacity(drag.as_ref(), float_only, self.style.title_bar_docks) } else { 1.0 }; set_window_opacity(&title, main.as_deref(), opacity); } if let Some(placed) = report.placed.filter(|_| !moving) { From b6d35e28f4d19145c9c7595742cc3ea6fb8352d5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:59:54 +0000 Subject: [PATCH 12/13] 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 | 22 ++++++++++++++-------- crates/ui/src/docking/command_tests.rs | 13 +++++++------ crates/ui/tests/docking_workflows.rs | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 103b3331..7db19eb4 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -341,11 +341,17 @@ impl DockCommand

{ /// `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 what a drag to that edge would: the canvas column where the - /// area narrows its edge band to it, else the part between the docks. `space` says whether - /// the area draws with [`super::DockStyle::side_edge_zones`], which never narrows, and - /// `permissions` are those the actions are applied with. - pub fn to_actions( + /// 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, @@ -516,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

, @@ -538,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)?, &permissions)?; + let closed = shown.apply_all(self.to_actions_with(&shown, right_to_left, space, &permissions)?, &permissions)?; *workspace = shown; return Ok(closed); } @@ -546,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, &permissions)?; + 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 3801c048..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(), permissions).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(), permissions) { + 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, permissions).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,7 +359,7 @@ 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( + let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions_with( &workspace(), false, &small, @@ -369,14 +369,15 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { // 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, permissions); + 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, permissions).unwrap() + parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions_with(&workspace(), false, space, permissions).unwrap() } #[test] diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 081fda2e..4cfd5ad9 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -418,7 +418,7 @@ mod v1_fixtures { 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, |p| d.content.permissions(p)).unwrap(); + 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"); From b2928ca9ceaadd70ec038e9910c373098159d9a0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 14:20:24 +0000 Subject: [PATCH 13/13] docking: a dock column's result preview follows its scroll offset --- crates/ui/src/docking/dock_tests.rs | 25 +++++++++++++++++++++++++ crates/ui/src/docking/render/dock.rs | 3 ++- crates/ui/src/docking/render/mod.rs | 2 ++ crates/ui/src/docking/render/preview.rs | 10 +++++++--- crates/ui/src/docking/surface.rs | 3 ++- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 5168c244..e3926f92 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -349,6 +349,31 @@ fn a_group_edge_scrolled_out_of_view_previews_its_line_at_the_views_edge() { assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![5], vec![4]]]); } +#[test] +fn in_a_scrolled_column_a_stack_previews_the_rect_at_the_scroll_offset() { + // Groups 306 pt high from y = 14: group 3 spans 320..626 before scrolling. + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3], &[4]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::with(layout, TestContent { min_height: 280.0 }, false); + let over = d.tab(2).center(); + d.step(vec![ + Event::PointerMoved(over), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -200.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + d.wait(); + let group = d.tab(3); + assert!(group.top() < 250.0, "scrolled up: {group:?}"); + // Group 3's top third stacks 5 above it, where 3 is now. + let at = pos2(120.0, group.bottom() + 40.0); + d.hold(bar(&d, 5), at); + let held = d.shapes.clone(); + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3], vec![4]]]); + d.settle(); + let rect = d.group_rect(5, Some(3), 2); + d.shapes = held; + assert!(d.previewed(rect), "the rect group 5 takes, {rect:?}"); +} + #[test] fn a_stripless_dock_groups_top_third_stacks_above_it() { // Canvas 9 in the middle; protected panel 0 alone in the lower group draws no tab strip, so diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index fcf6ace0..edde25f7 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -466,7 +466,7 @@ impl Renderer<'_, P> { let mut child = ui.new_child(UiBuilder::new().id_salt(("dock-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(body.intersect(ui.clip_rect())); - egui::ScrollArea::vertical() + let scrolled = egui::ScrollArea::vertical() .id_salt(self.id.with(("column-scroll", first))) .auto_shrink([false, false]) .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL) @@ -474,6 +474,7 @@ impl Renderer<'_, P> { let (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); self.column_groups(ui, column, &heights, content, body); }); + self.column_scroll.insert(first.clone(), scrolled.state.offset.y); } else { self.column_groups(ui, column, &heights, body, body); } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 0f3fd5d1..eafc74c7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -104,6 +104,8 @@ pub(super) struct Renderer<'a, P> { pub(super) dock_targets: Vec>, /// [D] The open groups of dock columns, whose body zones stack or make columns. pub(super) dock_groups: Vec>, + /// [D] The scroll offset of each dock column that scrolls this frame, by its first panel. + pub(super) column_scroll: HashMap, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, pub(super) frame: FrameState

, diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index d0a5a340..09163c0d 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -611,8 +611,8 @@ impl Renderer<'_, P> { /// Where `members` are drawn once `action` is applied, laid out by the code that draws the /// layout: the union of the rects of the groups that hold them, or the rect of the dock /// column they make up alone. A floating window's tree is laid out in its body as drawn - /// this frame. In a dock column that scrolls, a group's rect is clipped to the column's - /// body. `None` when the model rejects `action`, or when the members join an iconic column, + /// this frame. In a dock column that scrolls, a group's rect sits at the column's scroll + /// offset this frame and is clipped to the column's body. `None` when the model rejects `action`, or when the members join an iconic column, /// whose icons show no rect. pub(super) fn result_rect(&self, action: Action

, members: &[P]) -> Option { let mut candidate = self.layout.clone(); @@ -640,7 +640,11 @@ impl Renderer<'_, P> { return None; } let (_, body, heights) = self.column_layout(column, rect); - let groups = column.groups.iter().zip(super::dock::stacked(body, &heights)); + // The column keeps the scroll state of its first panel, as drawn. + let overflow = (heights.iter().sum::() - body.height()).max(0.0); + let scroll = column.groups.first().and_then(|g| g.panels.first()).and_then(|p| self.column_scroll.get(p)); + let area = body.translate(vec2(0.0, -scroll.copied().unwrap_or(0.0).min(overflow))); + let groups = column.groups.iter().zip(super::dock::stacked(area, &heights)); rects.extend(groups.filter(|(g, _)| moved(&g.panels)).map(|(_, rect)| rect.intersect(body))); } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ae5760dc..23b2d344 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -1,4 +1,4 @@ -use std::{fmt::Debug, hash::Hash}; +use std::{collections::HashMap, fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; @@ -1201,6 +1201,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { targets: Vec::new(), dock_targets: Vec::new(), dock_groups: Vec::new(), + column_scroll: HashMap::new(), owned_interactions: Vec::new(), output: DockOutput::default(), frame,