diff --git a/crates/ui/README.md b/crates/ui/README.md index a1073b827..a039e12fe 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -16,7 +16,9 @@ and command handling remain in each application. No icons, fonts or application `DockContent` can replace an iconic column or a complete tab group without a second layout tree. `column_rect` reports the fitted column before painting; `owns_iconic_column`/`iconic_column` and `custom_group` suppress the corresponding -stock chrome, body and input when the host takes ownership. These hooks are also +stock chrome, body and input when the host takes ownership. Docked custom groups +also skip stock body top/bottom drop bands; column-edge targets remain available. +These hooks are also forwarded by `show_customized`'s label/permissions/limits adapter. `resize_handle_style` and `split_separator` let the host paint one seam while diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs index afc02e887..c3fcbee5c 100644 --- a/crates/ui/src/docking/host_hooks_tests.rs +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -58,9 +58,11 @@ fn the_top_gutter_is_reserved_by_default_and_can_be_owned_by_the_contents() { #[derive(Default)] struct Host { exclusive: bool, + stock_group: Option, bodies: usize, columns: Vec, iconic: Vec, + groups: std::collections::HashMap, collapsed: bool, protected: bool, frame_width: bool, @@ -92,8 +94,9 @@ impl DockContent for Host { fn body(&mut self, _: &mut egui::Ui, _: &u32) { self.bodies += 1; } - fn custom_group(&mut self, _: &mut egui::Ui, _: &[u32], _: &u32, _: Rect, _: bool) -> bool { - self.exclusive + fn custom_group(&mut self, _: &mut egui::Ui, _: &[u32], selected: &u32, rect: Rect, _: bool) -> bool { + self.groups.insert(*selected, rect); + self.exclusive && self.stock_group.as_ref() != Some(selected) } fn owns_iconic_column(&self, _: &u32) -> bool { self.exclusive @@ -259,3 +262,59 @@ fn internal_width_input_is_suppressed_only_when_explicitly_owned_through_overrid assert_eq!(hits.iter().any(|(hit, _)| *hit == Hit::Divider(Divider::WindowSplit(1, vec![]))), !owned); } } + +#[test] +fn exclusive_docked_groups_skip_stock_body_bands_but_keep_column_edges() { + for owned in [false, true] { + for zone in [Zone::Top, Zone::Bottom, Zone::Left, Zone::Right] { + let ctx = Context::default(); + let area = Id::unique("owned-dock-targets"); + let layout = Layout { + root: Some(Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![1, 2], 0)], 240.0)] }), + floating: vec![Floating::single(vec![99], 0, [500.0, 100.0, 200.0, 150.0])], + }; + let mut host = Host { exclusive: owned, stock_group: Some(99), ..Default::default() }; + let mut step = |events, start: bool| { + let mut out = DockOutput::default(); + ctx.run_ui(input(events), |ui| { + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + out = DockArea::new(area).show_content(ui, &layout, &style, &mut host); + if start { + assert!(begin_drag(ui.ctx(), area, 99, Rect::from_min_size(pos2(500.0, 100.0), vec2(200.0, 150.0)))); + } + }) + .drop_without_applying_deltas(); + (out, host.groups.get(&1).copied()) + }; + step(vec![], false); + step(vec![], false); + let (_, target) = step(vec![], false); + let target = target.expect("the docked group is drawn"); + let from = pos2(550.0, 140.0); + step(vec![Event::PointerMoved(from), button(from, true)], true); + let to = match zone { + Zone::Top => pos2(target.center().x, target.top() + 50.0), + Zone::Bottom => pos2(target.center().x, target.bottom() - 20.0), + Zone::Left => pos2(target.left() + 20.0, target.center().y), + Zone::Right => pos2(target.right() - 20.0, target.center().y), + Zone::Center => unreachable!(), + }; + step(vec![Event::PointerMoved(to)], false); + let (out, _) = step(vec![Event::PointerMoved(to), button(to, false)], false); + let group_drop = out.actions.iter().any(|a| { + matches!( + a, + Action::MovePanel { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + | Action::MoveGroup { to: Target::At { placement: Placement::Group { .. }, .. }, .. } + ) + }); + if matches!(zone, Zone::Top | Zone::Bottom) { + assert_eq!(group_drop, !owned, "{owned} {zone:?}: {:?}", out.actions); + } else { + assert!(!out.actions.is_empty(), "column edges still work: {owned} {zone:?}"); + assert!(!group_drop, "column edges must not become group bands"); + } + } + } +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 51c6fc3b5..adbfe5bdb 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -509,9 +509,12 @@ impl Renderer<'_, P> { /// 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 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 owned: Vec<_> = column + .groups + .iter() + .zip(&rects) + .map(|(group, rect)| self.group(ui, &group.panels, group.active, group.minimized, *rect, GroupKind::Dock)) + .collect(); let top = rects.last().map_or(area.top(), |r| r.bottom()); let xs = view.x_range(); let half = self.style.seam_band * 0.5; @@ -525,7 +528,7 @@ impl Renderer<'_, P> { } }; let mut previous: Option<&P> = None; - for (group, rect) in column.groups.iter().zip(&rects) { + for ((group, rect), owned) in column.groups.iter().zip(&rects).zip(owned) { let Some(anchor) = group.panels.first() else { continue }; if let Some(above) = previous { let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half)); @@ -541,7 +544,7 @@ impl Renderer<'_, P> { self.dock_groups.push(DockGroup { body, center, - sides: [None, None, Some((above, line(rect.top()))), Some((below, line(rect.bottom())))], + sides: [None, None, (!owned).then(|| (above, line(rect.top()))), (!owned).then(|| (below, line(rect.bottom())))], }); } previous = Some(anchor); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 512f8a969..3164a4626 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -135,7 +135,9 @@ impl Renderer<'_, P> { pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating

, body: Rect, frame: Rect) { let Some(key) = window.panels().next().cloned() else { return }; match (&window.root, window.minimized) { - (Node::Tabs { panels, active }, true) => self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }), + (Node::Tabs { panels, active }, true) => { + self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }); + } (_, true) => {} (root, false) => { let outer = self.tree.replace((key, frame)); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 35f80f548..7259b5349 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -36,7 +36,7 @@ const OVERFLOW_WIDTH: f32 = 22.0; const WHEEL_NOTCH: f32 = 40.0; impl Renderer<'_, P> { - /// Draw one tab group at `rect`. + /// Draw one tab group at `rect`, returning whether the host took exclusive ownership. /// /// An `active` index outside `panels` is a no-op. A lone protected panel is drawn stripless: /// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered @@ -45,15 +45,15 @@ impl Renderer<'_, P> { /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. The host's extra /// tabs follow the panel tabs and fit with them ([`fit_with_extra`]). `minimized` hides the body /// but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left layout. - pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { - let Some(selected) = panels.get(active) else { return }; + pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) -> bool { + let Some(selected) = panels.get(active) else { return false }; let host_collapsed = self.content.group_collapsed(panels); let minimized = minimized || host_collapsed; if !host_collapsed { self.record_float(panels, rect.size(), rect.size()); } if self.content.custom_group(ui, panels, selected, rect, matches!(kind, GroupKind::Floating { .. })) { - return; + return true; } let policy = self.permissions(selected); let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; @@ -71,7 +71,7 @@ impl Renderer<'_, P> { accepts_tabs: policy.accepts_tabs, right_to_left: rtl, }); - return; + return false; } let movable = panels.iter().all(|p| self.permissions(p).movable); let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); @@ -175,6 +175,7 @@ impl Renderer<'_, P> { self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); } ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + false } /// A part of the strip the host draws (`header` or `extra-tabs`) at `rect`. A press in it