diff --git a/crates/ui/README.md b/crates/ui/README.md index c96349a3..a1073b82 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -21,7 +21,9 @@ forwarded by `show_customized`'s label/permissions/limits adapter. `resize_handle_style` and `split_separator` let the host paint one seam while retaining stock resize input. `owns_floating_width` delegates horizontal floating -handles to the host; stock vertical handles remain available. Presentation-only +frame-edge handles to the host; internal dividers and vertical handles remain available. +`owns_floating_split_width` separately delegates internal horizontal dividers when +the host supplies their replacement input. Presentation-only `group_collapsed` does not change the selected tab in the shared model. All ownership hooks default to false/no-op. `DockStyle::dock_gap` defaults to diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs index b2a0bb9c..afc02e88 100644 --- a/crates/ui/src/docking/host_hooks_tests.rs +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -61,8 +61,34 @@ struct Host { bodies: usize, columns: Vec, iconic: Vec, + collapsed: bool, + protected: bool, + frame_width: bool, + split_width: bool, + space: Option>, + style_calls: std::cell::Cell, } impl DockContent for Host { + fn group_collapsed(&self, _: &[u32]) -> bool { + self.collapsed + } + fn permissions(&self, _: &u32) -> Permissions { + if self.protected { Permissions::PROTECTED } else { Permissions::default() } + } + fn owns_floating_split_width(&self, _: &u32) -> bool { + self.split_width + } + fn owns_floating_width(&self, _: &u32) -> bool { + self.frame_width + } + fn resize_handle_style(&self, _: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + self.style_calls.set(self.style_calls.get() + 1); + crate::layout::ResizeHandleStyle { + hovered: egui::Color32::TRANSPARENT, + dragged: egui::Color32::TRANSPARENT, + focused: egui::Color32::TRANSPARENT, + } + } fn body(&mut self, _: &mut egui::Ui, _: &u32) { self.bodies += 1; } @@ -88,12 +114,13 @@ fn render(layout: &Layout, host: &mut Host) -> Vec<(Hit, Rect)> { host.iconic.clear(); let mut style = DockStyle::from_ui(ui); super::floating_tests::frame_style(&mut style); + let protected = host.protected; let shown = DockArea::new(Id::unique("host")).show_customized( ui, layout, &style, |id| format!("Pane {id}"), - |_| Permissions::default(), + |_| if protected { Permissions::PROTECTED } else { Permissions::default() }, |_| PanelLimits::default(), host, ); @@ -101,6 +128,7 @@ fn render(layout: &Layout, host: &mut Host) -> Vec<(Hit, Rect)> { }) .drop_without_applying_deltas(); } + host.space = float_space(&ctx, Id::unique("host")); DockArea::hit_rects(&ctx, Id::unique("host")) } @@ -145,3 +173,89 @@ fn invalid_side_dock_gaps_are_rejected_before_host_painting() { .drop_without_applying_deltas(); } } + +#[test] +fn host_owned_surfaces_record_fitted_float_geometry() { + let mut host = Host { exclusive: true, ..Default::default() }; + let layout = Layout { root: Some(Node::Tabs { panels: vec![1, 2], active: 0 }), floating: vec![] }; + render(&layout, &mut host); + let space = host.space.as_ref().unwrap(); + assert!(space.panel(&1).is_some_and(|s| s.x > 700.0 && s.y > 0.0)); + assert!(space.group(&2).is_some_and(|s| s.x > 700.0 && s.y > 0.0)); +} + +#[test] +fn host_owned_iconic_columns_record_fitted_float_geometry() { + let mut host = Host { exclusive: true, ..Default::default() }; + let mut column = Column::new(vec![Group::new(vec![1, 2], 0)], 90.0); + column.iconic = true; + let layout = Layout { root: Some(Node::Dock { side: Side::Start, columns: vec![column] }), floating: vec![] }; + render(&layout, &mut host); + let size = host.space.as_ref().unwrap().column(&1).expect("host column is recorded"); + assert_eq!(size.x, host.columns[0].width()); + assert!(size.y > 0.0); +} + +#[test] +fn a_host_collapsed_protected_group_keeps_its_selection_without_drawing_a_body() { + let layout = Layout { root: Some(Node::Tabs { panels: vec![1], active: 0 }), floating: vec![] }; + let mut host = Host { protected: true, collapsed: true, ..Default::default() }; + render(&layout, &mut host); + assert_eq!(host.bodies, 0); + assert!(matches!(layout.root, Some(Node::Tabs { active: 0, .. }))); + assert!(host.space.as_ref().unwrap().panel(&1).is_none()); +} + +#[test] +fn frame_width_ownership_keeps_internal_split_input_and_host_style() { + use crate::layout::{SplitAxis, SplitSize}; + let ctx = Context::default(); + let mut window = Floating::single(vec![1], 0, [100.0, 100.0, 500.0, 350.0]); + window.root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2], active: 0 }), + }; + let layout = Layout { root: None, floating: vec![window] }; + let mut host = Host { frame_width: true, ..Default::default() }; + let mut step = |events| { + 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(Id::unique("owned-width")).show_content(ui, &layout, &style, &mut host); + }) + .drop_without_applying_deltas(); + out + }; + step(vec![]); + step(vec![]); + step(vec![]); + let hit = Hit::Divider(Divider::WindowSplit(1, vec![])); + let hits = DockArea::::hit_rects(&ctx, Id::unique("owned-width")); + let at = hits.iter().find(|(h, _)| *h == hit).expect("internal divider remains interactive").1.center(); + step(vec![Event::PointerMoved(at), button(at, true)]); + step(vec![Event::PointerMoved(at + vec2(10.0, 0.0))]); + let out = step(vec![Event::PointerMoved(at + vec2(40.0, 0.0))]); + assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeWindowSplit { .. })), "{:?}", out.actions); + assert!(host.style_calls.get() > 0); +} + +#[test] +fn internal_width_input_is_suppressed_only_when_explicitly_owned_through_overrides() { + use crate::layout::{SplitAxis, SplitSize}; + let mut window = Floating::single(vec![1], 0, [100.0, 100.0, 500.0, 350.0]); + window.root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2], active: 0 }), + }; + let layout = Layout { root: None, floating: vec![window] }; + for owned in [false, true] { + let mut host = Host { frame_width: true, split_width: owned, ..Default::default() }; + let hits = render(&layout, &mut host); + assert_eq!(hits.iter().any(|(hit, _)| *hit == Hit::Divider(Divider::WindowSplit(1, vec![]))), !owned); + } +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 9a4cd036..51c6fc3b 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -459,13 +459,13 @@ impl Renderer<'_, P> { } let side = side_at(edge, self.style.right_to_left); self.content.column_rect(ui.ctx(), column, rect, side); + self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect); if column.iconic && self.content.owns_iconic_column(first) { let mut child = ui.new_child(UiBuilder::new().id_salt(("host-iconic", first)).max_rect(rect)); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.iconic_column(&mut child, column, rect, side); return; } - self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect); let (bar, body, heights) = self.column_layout(column, rect); self.column_bar(ui, column, first, bar, rect, edge); if column.iconic && usable(body) { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index c7c815e5..35f80f54 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -47,19 +47,21 @@ impl Renderer<'_, P> { /// 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 }; - if self.content.custom_group(ui, panels, selected, rect, matches!(kind, GroupKind::Floating { .. })) { - return; - } 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; + } let policy = self.permissions(selected); let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; let rtl = self.style.right_to_left; if stripless(panels, &*self.content) { - self.contents(ui, selected, rect); + if !minimized { + self.contents(ui, selected, rect); + } self.targets.push(DropTarget { anchor: selected.clone(), rect, diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 4d71c71f..256edccd 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -214,7 +214,7 @@ impl Renderer<'_, P> { // Named by its layout path, which `ResizeSplit` takes and hits report. self.content.split_separator(ui, split.divider); let host_width = - *axis == SplitAxis::Horizontal && self.tree.as_ref().is_some_and(|(panel, _)| self.content.owns_floating_width(panel)); + *axis == SplitAxis::Horizontal && self.tree.as_ref().is_some_and(|(panel, _)| self.content.owns_floating_split_width(panel)); if host_width { return; } @@ -224,7 +224,11 @@ impl Renderer<'_, P> { *axis, split.divider, &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + self.content.resize_handle_style(ResizeHandleStyle { + hovered: self.style.accent, + dragged: self.style.accent, + focused: self.style.accent, + }), ) .value(split.first_size(), (split.first_size() + lo)..=(split.first_size() + hi)) .show(ui); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 4a636969..2dc5b7e9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -865,10 +865,16 @@ pub trait DockContent

{ } /// Paint a fitted split gap in the owning surface, without another resize target. fn split_separator(&mut self, _ui: &mut Ui, _rect: Rect) {} - /// The host supplies exclusive horizontal resize input for this floating tree. + /// The host supplies horizontal resize input at the floating frame edges. + /// Internal split dividers retain stock input unless `owns_floating_split_width` is true. fn owns_floating_width(&self, _panel: &P) -> bool { false } + /// The host supplies horizontal resize input for every internal split in this floating tree. + /// This is separate from frame-edge ownership; only return true with replacement input. + fn owns_floating_split_width(&self, _panel: &P) -> bool { + false + } /// Plain text name for accessibility, menus and the default visual label. fn logical_label(&self, panel: &P) -> String where @@ -1029,6 +1035,9 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn owns_floating_width(&self, panel: &P) -> bool { self.inner.owns_floating_width(panel) } + fn owns_floating_split_width(&self, panel: &P) -> bool { + self.inner.owns_floating_split_width(panel) + } fn logical_label(&self, panel: &P) -> String { (self.label)(panel) }