From dcd94c27e79e4795441339527df5bef64654b4f2 Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:57:49 +0300 Subject: [PATCH 1/6] feat(ui): add host-owned docking surfaces and fitted resize chrome --- crates/ui/README.md | 22 ++++ crates/ui/src/docking/chrome.rs | 42 ++++++- crates/ui/src/docking/host_hooks_tests.rs | 147 ++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/render/dock.rs | 19 ++- crates/ui/src/docking/render/floating.rs | 17 +-- crates/ui/src/docking/render/group.rs | 36 +++++- crates/ui/src/docking/render/mod.rs | 18 ++- crates/ui/src/docking/surface.rs | 61 +++++++++ 9 files changed, 341 insertions(+), 23 deletions(-) create mode 100644 crates/ui/src/docking/host_hooks_tests.rs diff --git a/crates/ui/README.md b/crates/ui/README.md index 3906429e..c96349a3 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -11,6 +11,28 @@ The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoC records their scope and acceptance limits. Assets (font files included), complete themes, localization and command handling remain in each application. No icons, fonts or application engines are bundled. +## Host-owned docking chrome + +`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 +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 +`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 +`None`, preserving the ordinary split gap; setting it affects side-dock seams +only. `FloatingFrame::resize_axes` defaults to both axes and preserves an enabled +axis at corners when the other is disabled. A host with its own title bar can +disable the separate top gutter through `reserve_top_resize_band(false)`. + +Exhaustive `DockStyle` struct literals need `dock_gap: None`; constructors such +as `from_ui` and `from_visuals` supply the compatible default. + ## SVG catalogs ```rust diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 9e3aa2bc..4a2b6da7 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -116,8 +116,10 @@ pub struct FloatingFrame<'a> { bounds: Option, limits: PanelLimits, resizable: bool, + resize_axes: [bool; 2], resize_label: &'a str, opacity: f32, + reserve_top_resize_band: bool, } /// What a [`FloatingFrame`] did this frame. @@ -137,7 +139,17 @@ pub struct FrameResponse { impl<'a> FloatingFrame<'a> { pub fn new(id: Id, rect: Rect) -> Self { - Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window", opacity: 1.0 } + Self { + id, + rect, + bounds: None, + limits: PanelLimits::default(), + resizable: true, + resize_axes: [true, true], + resize_label: "Resize window", + opacity: 1.0, + reserve_top_resize_band: true, + } } /// The frame stays inside `bounds`; the default is the viewport's content rect. @@ -153,11 +165,23 @@ impl<'a> FloatingFrame<'a> { self } + /// Reserve a separate top resize gutter; disable when contents already supply a header. + pub fn reserve_top_resize_band(mut self, reserve: bool) -> Self { + self.reserve_top_resize_band = reserve; + self + } + pub fn resizable(mut self, resizable: bool) -> Self { self.resizable = resizable; self } + /// Restrict the stock edge handles when a host owns resizing on one axis. + pub fn resize_axes(mut self, axes: [bool; 2]) -> Self { + self.resize_axes = axes; + self + } + /// Accessibility label of the resize handles. pub fn resize_label(mut self, label: &'a str) -> Self { self.resize_label = label; @@ -192,8 +216,11 @@ impl<'a> FloatingFrame<'a> { ui.set_min_size(rect.size()); ui.allocate_rect(rect, Sense::hover()); ui.painter().rect_filled(rect, 0.0, style.background); - let contents = - if self.resizable { Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) } else { rect }; + let contents = if self.resizable && self.reserve_top_resize_band { + Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) + } else { + rect + }; let inner = add_contents(ui, contents); ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; @@ -217,7 +244,14 @@ impl<'a> FloatingFrame<'a> { let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); - for (name, handle, direction) in handles(rect) { + for (name, mut handle, mut direction) in handles(rect) { + if !self.reserve_top_resize_band && direction.y < 0.0 { + handle.max.y = (rect.top() + ui.ctx().pixels_per_point().recip()).min(rect.bottom()); + } + direction = vec2(if self.resize_axes[0] { direction.x } else { 0.0 }, if self.resize_axes[1] { direction.y } else { 0.0 }); + if direction == egui::Vec2::ZERO { + continue; + } if !super::targets::usable(handle) { continue; } diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs new file mode 100644 index 00000000..b2a0bb9c --- /dev/null +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -0,0 +1,147 @@ +//! Host-owned chrome uses the same fitted geometry and preserves stock defaults. +use super::*; +use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, pos2, vec2}; + +fn input(events: Vec) -> RawInput { + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), focused: true, events, ..Default::default() } +} +fn button(at: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn corner_resize_keeps_each_enabled_axis_including_single_axis_hosts() { + for axes in [[true, true], [false, true], [true, false], [false, false]] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let frame = |events| { + let mut resized = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + resized = chrome::FloatingFrame::new(Id::unique("axes"), rect).resize_axes(axes).show(ui.ctx(), &style, |_, _| ()).resized; + }) + .drop_without_applying_deltas(); + resized + }; + frame(vec![]); + frame(vec![]); + let from = rect.right_bottom() - vec2(2.0, 2.0); + frame(vec![Event::PointerMoved(from), button(from, true)]); + frame(vec![Event::PointerMoved(from + vec2(10.0, 10.0))]); + let result = frame(vec![Event::PointerMoved(from + vec2(40.0, 35.0))]); + if axes == [false, false] { + assert!(result.is_none()); + } else { + let expected = rect.size() + vec2(if axes[0] { 40.0 } else { 0.0 }, if axes[1] { 35.0 } else { 0.0 }); + assert_eq!(result, Some(Rect::from_min_size(rect.min, expected)), "{axes:?}"); + } + } +} + +#[test] +fn the_top_gutter_is_reserved_by_default_and_can_be_owned_by_the_contents() { + for reserve in [true, false] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let mut inner = Rect::NOTHING; + ctx.run_ui(input(vec![]), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + inner = chrome::FloatingFrame::new(Id::unique("gutter"), rect).reserve_top_resize_band(reserve).show(ui.ctx(), &style, |_, r| r).inner; + }) + .drop_without_applying_deltas(); + assert_eq!(inner.left(), rect.left()); + assert_eq!(inner.max, rect.max); + assert_eq!(inner.top(), rect.top() + if reserve { chrome::RESIZE_BAND } else { 0.0 }); + } +} + +#[derive(Default)] +struct Host { + exclusive: bool, + bodies: usize, + columns: Vec, + iconic: Vec, +} +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 owns_iconic_column(&self, _: &u32) -> bool { + self.exclusive + } + fn column_rect(&mut self, _: &Context, _: &Column, rect: Rect, _: Side) { + self.columns.push(rect); + } + fn iconic_column(&mut self, _: &mut egui::Ui, _: &Column, rect: Rect, _: Side) { + self.iconic.push(rect); + } +} +fn render(layout: &Layout, host: &mut Host) -> Vec<(Hit, Rect)> { + let ctx = Context::default(); + for _ in 0..3 { + ctx.run_ui(input(vec![]), |ui| { + host.bodies = 0; + host.columns.clear(); + host.iconic.clear(); + let mut style = DockStyle::from_ui(ui); + super::floating_tests::frame_style(&mut style); + let shown = DockArea::new(Id::unique("host")).show_customized( + ui, + layout, + &style, + |id| format!("Pane {id}"), + |_| Permissions::default(), + |_| PanelLimits::default(), + host, + ); + assert!(shown.error.is_none(), "{:?}", shown.error); + }) + .drop_without_applying_deltas(); + } + DockArea::hit_rects(&ctx, Id::unique("host")) +} + +#[test] +fn an_exclusive_group_replaces_the_stock_body_and_tab_targets_through_overrides() { + let layout = Layout { root: Some(Node::Tabs { panels: vec![1, 2], active: 0 }), floating: vec![] }; + for exclusive in [false, true] { + let mut host = Host { exclusive, ..Default::default() }; + let hits = render(&layout, &mut host); + assert_eq!(host.bodies, usize::from(!exclusive)); + assert_eq!(hits.iter().any(|(hit, _)| matches!(hit, Hit::Tab(_))), !exclusive); + } +} + +#[test] +fn an_iconic_host_receives_the_fitted_column_rect_without_a_second_stock_body() { + let mut column = Column::new(vec![Group::new(vec![1, 2], 0)], 900.0); + column.iconic = true; + let layout = Layout { root: Some(Node::Dock { side: Side::Start, columns: vec![column] }), floating: vec![] }; + let mut host = Host { exclusive: true, ..Default::default() }; + render(&layout, &mut host); + assert_eq!(host.columns.len(), 1); + assert_eq!(host.columns, host.iconic); + assert_eq!(host.bodies, 0); + let rect = host.columns[0]; + assert!(rect.width() > 0.0 && rect.right() <= 800.0 && rect.bottom() <= 600.0); +} + +#[test] +fn invalid_side_dock_gaps_are_rejected_before_host_painting() { + for gap in [f32::NAN, f32::INFINITY, -1.0, 4097.0] { + let ctx = Context::default(); + let layout = Layout { root: Some(Node::Tabs { panels: vec![1], active: 0 }), floating: vec![] }; + let mut host = Host::default(); + ctx.run_ui(input(vec![]), |ui| { + let mut style = DockStyle::from_ui(ui); + style.dock_gap = Some(gap); + let shown = DockArea::new(Id::unique("invalid-gap")).show_content(ui, &layout, &style, &mut host); + assert!(shown.error.is_some(), "{gap}"); + assert_eq!(host.bodies, 0); + }) + .drop_without_applying_deltas(); + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b7985b82..df3349c2 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -53,6 +53,8 @@ mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] +mod host_hooks_tests; +#[cfg(test)] mod iconic_tests; #[cfg(all(test, feature = "native-windows"))] mod ime_tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 878d909e..9a4cd036 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -77,7 +77,7 @@ pub(in crate::docking) fn dock_rects

( style: &DockStyle, content: &dyn crate::docking::DockContent

, ) -> (Rect, Rect, f32) { - let gap = style.gap; + let gap = style.dock_gap.unwrap_or(style.gap); let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width()); let width = columns.iter().map(|c| column_width(c, style)).sum::().clamp(0.0, room); let (dock, rest) = match edge_of(side, style.right_to_left) { @@ -266,10 +266,12 @@ impl Renderer<'_, P> { _ => return false, }; let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content); - self.dock(ui, side, columns, dock, room); path.push(dock_first); self.node(ui, other, rest, path, root); path.pop(); + // The divider's hit margin reaches into the sibling (often the canvas). Register + // the dock after that sibling so a seam press belongs to the resize handle. + self.dock(ui, side, columns, dock, room); true } @@ -328,6 +330,9 @@ impl Renderer<'_, P> { } // Registered after the columns, so the narrow hit areas win at the edges. for (index, column, first, width, inner) in drawn { + if column.iconic && self.content.owns_iconic_column(first) { + continue; + } if width < 1.0 { continue; } @@ -412,7 +417,7 @@ impl Renderer<'_, P> { } fn handle_style(&self) -> ResizeHandleStyle { - 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 }) } /// Width bounds of an expanded column from its panels' limits. @@ -452,6 +457,14 @@ impl Renderer<'_, P> { if !usable(rect) { return; } + let side = side_at(edge, self.style.right_to_left); + self.content.column_rect(ui.ctx(), column, rect, side); + 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); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 256b128f..512f8a96 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -54,7 +54,7 @@ impl Renderer<'_, P> { /// The title bar and, for a movable (resizable) window, the resize band above it. fn window_chrome(&self, window: &Floating

) -> f32 { - let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; + let band = if self.style.title_height > 0.0 && window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; band + self.style.title_height } @@ -81,12 +81,15 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let resize_label = self.style.resize_window_label.clone(); let opacity = self.drag_opacity(ctx, first); - let response = - FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).opacity(opacity).show( - ctx, - &style, - |ui, contents| self.window_contents(ui, window, contents, rect, &style), - ); + let response = FloatingFrame::new(id, rect) + .bounds(screen) + .limits(limits) + .reserve_top_resize_band(self.style.title_height > 0.0) + .resizable(movable) + .resize_axes([!self.content.owns_floating_width(first), true]) + .resize_label(&resize_label) + .opacity(opacity) + .show(ctx, &style, |ui, contents| self.window_contents(ui, window, contents, rect, &style)); for handle in &response.handles { self.register_interaction(ctx, handle.id); } diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index e4e04cda..c7c815e5 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -47,7 +47,14 @@ 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 }; - self.record_float(panels, rect.size(), rect.size()); + 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()); + } let policy = self.permissions(selected); let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; let rtl = self.style.right_to_left; @@ -201,7 +208,21 @@ impl Renderer<'_, P> { if self.minimizes(owner) && response.double_clicked() { self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); } - let active = response.has_focus() || response.hovered(); + let modality = self.id.with("keyboard-chrome-focus"); + let keyboard_input = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + let pointer_input = ui.input(|i| i.pointer.any_pressed()); + let keyboard_focus = ui.ctx().data_mut(|d| { + let on = if pointer_input { + false + } else if keyboard_input { + true + } else { + d.get_temp::(modality).unwrap_or(false) + }; + d.insert_temp(modality, on); + on + }); + let active = response.has_focus() && keyboard_focus; if active { ui.painter().with_clip_rect(hit).rect_stroke( hit.shrink(1.0), @@ -392,17 +413,22 @@ impl Renderer<'_, P> { let minimized = owner.filter(|owner| self.minimizes(owner)).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); - let color = if active { self.style.text } else { self.style.inactive_text }; + let visual_active = active && !owner.is_some_and(|owner| self.content.group_collapsed(owner.panels)); + let color = if visual_active { self.style.text } else { self.style.inactive_text }; let galley = self.tab_label(ui, panel, &label, color, (rect.width() - TAB_PADDING).max(0.0)); let elided = galley.elided; let tab_id = self.id.with(("tab", panel)); let pointer_focus = self.pointer_focus(ui, tab_id); - let response = tabs::Tab::new(tab_id, &label, active) + let response = tabs::Tab::new(tab_id, &label, visual_active) .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) .focus_stroke(if pointer_focus { Stroke::NONE } else { Stroke::new(1.0, self.style.accent) }) .show_at(ui, rect, |ui, response| { let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); + painter.rect_filled( + rect, + 0.0, + if visual_active || response.hovered() { self.style.active_background } else { self.style.tab_background }, + ); let at = pos2(rect.left() + TAB_PADDING * 0.5, rect.center().y - galley.size().y * 0.5); painter.galley(at, galley.clone(), color); }); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 751e17b4..4d71c71f 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -212,6 +212,12 @@ impl Renderer<'_, P> { // The divider's inner-edge hit margin must win over child scrollbars, just // as accordion dividers win over the adjacent panel's content. // 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)); + if host_width { + return; + } let layout_path = self.original_path(path); let response = ResizeHandle::new( self.split_id(&layout_path), @@ -490,11 +496,15 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont Node::Split { axis, first, second, .. } => { let a = node_limits(first, style, content); let b = node_limits(second, style, content); + let gap = if matches!(first.as_ref(), Node::Dock { .. }) || matches!(second.as_ref(), Node::Dock { .. }) { + style.dock_gap.unwrap_or(style.gap) + } else { + style.gap + }; let limits = match axis { - SplitAxis::Horizontal => PanelLimits { - min: vec2(a.min.x + b.min.x + style.gap, a.min.y.max(b.min.y)), - max: vec2(a.max.x + b.max.x + style.gap, a.max.y.min(b.max.y)), - }, + SplitAxis::Horizontal => { + PanelLimits { min: vec2(a.min.x + b.min.x + gap, a.min.y.max(b.min.y)), max: vec2(a.max.x + b.max.x + gap, a.max.y.min(b.max.y)) } + } SplitAxis::Vertical => PanelLimits { min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + style.gap), max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + style.gap), diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 8d2a1444..4a636969 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -13,6 +13,8 @@ pub(super) const TAB_PADDING: f32 = 24.0; pub struct DockStyle { pub tab_height: f32, pub gap: f32, + /// Optional gap beside attached side docks. Other split/row dividers retain `gap`. + pub dock_gap: Option, pub min_pane: f32, pub background: Color32, pub tab_background: Color32, @@ -175,6 +177,7 @@ impl DockStyle { Self { tab_height: 26.0, gap: 4.0, + dock_gap: None, min_pane: 40.0, background: visuals.panel_fill, tab_background: visuals.extreme_bg_color, @@ -304,6 +307,7 @@ impl DockStyle { && self.font.size > 0.0 && self.drop_line_width > 0.0 && self.side_zone_fraction <= 0.5 + && self.dock_gap.is_none_or(|gap| gap.is_finite() && (0.0..=4096.0).contains(&gap)) } } @@ -832,6 +836,39 @@ pub fn send_keys(ctx: &Context, viewport: egui::ViewportId, events: Vec { fn body(&mut self, ui: &mut Ui, panel: &P); + + /// Observe the fitted rectangle of each attached column, before its chrome is drawn. + fn column_rect(&mut self, _ctx: &Context, _column: &super::Column

, _rect: Rect, _side: super::Side) {} + + /// An exclusive host surface replaces stock iconic painting, focus, drag and resize. + /// Placement and column-edge drop targets still belong to the workspace renderer. + fn owns_iconic_column(&self, _first: &P) -> bool { + false + } + + /// Draw the complete iconic column inside its fitted rectangle when owned by the host. + fn iconic_column(&mut self, _ui: &mut Ui, _column: &super::Column

, _rect: Rect, _side: super::Side) {} + + /// Return true after painting an exclusive group surface; stock tabs/body/targets are skipped. + /// The host supplies that group's size limits through `limits`. + fn custom_group(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _rect: Rect, _floating: bool) -> bool { + false + } + + /// Hosts which paint shared seams can keep the stock resize input without a second fill. + fn resize_handle_style(&self, default: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + default + } + /// Presentation-only collapse of a group; its selected tab remains model state. + fn group_collapsed(&self, _panels: &[P]) -> bool { + false + } + /// 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. + fn owns_floating_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 @@ -968,6 +1005,30 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn body(&mut self, ui: &mut Ui, panel: &P) { self.inner.body(ui, panel); } + fn column_rect(&mut self, ctx: &Context, column: &super::Column

, rect: Rect, side: super::Side) { + self.inner.column_rect(ctx, column, rect, side); + } + fn owns_iconic_column(&self, first: &P) -> bool { + self.inner.owns_iconic_column(first) + } + fn iconic_column(&mut self, ui: &mut Ui, column: &super::Column

, rect: Rect, side: super::Side) { + self.inner.iconic_column(ui, column, rect, side); + } + fn custom_group(&mut self, ui: &mut Ui, panels: &[P], active: &P, rect: Rect, floating: bool) -> bool { + self.inner.custom_group(ui, panels, active, rect, floating) + } + fn resize_handle_style(&self, default: crate::layout::ResizeHandleStyle) -> crate::layout::ResizeHandleStyle { + self.inner.resize_handle_style(default) + } + fn group_collapsed(&self, panels: &[P]) -> bool { + self.inner.group_collapsed(panels) + } + fn split_separator(&mut self, ui: &mut Ui, rect: Rect) { + self.inner.split_separator(ui, rect); + } + fn owns_floating_width(&self, panel: &P) -> bool { + self.inner.owns_floating_width(panel) + } fn logical_label(&self, panel: &P) -> String { (self.label)(panel) } From 235fffbdbe79f42028ec414e304f1a10b0b1b07f Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:02:55 +0300 Subject: [PATCH 2/6] fix(ui): preserve host docking geometry and split ownership --- crates/ui/README.md | 4 +- crates/ui/src/docking/host_hooks_tests.rs | 116 +++++++++++++++++++++- crates/ui/src/docking/render/dock.rs | 2 +- crates/ui/src/docking/render/group.rs | 10 +- crates/ui/src/docking/render/mod.rs | 8 +- crates/ui/src/docking/surface.rs | 11 +- 6 files changed, 141 insertions(+), 10 deletions(-) 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) } From 4f263edcdd41daf344d8052f88e4282e5c98d83e Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:31:53 +0300 Subject: [PATCH 3/6] fix(ui): omit stock body bands for host-owned dock groups --- crates/ui/README.md | 4 +- crates/ui/src/docking/host_hooks_tests.rs | 63 ++++++++++++++++++++++- crates/ui/src/docking/render/dock.rs | 13 +++-- crates/ui/src/docking/render/floating.rs | 4 +- crates/ui/src/docking/render/group.rs | 11 ++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/ui/README.md b/crates/ui/README.md index a1073b82..a039e12f 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 afc02e88..c3fcbee5 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 51c6fc3b..adbfe5bd 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 512f8a96..3164a462 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 35f80f54..7259b534 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 From 9a9755568f3c0334f2f332f5c499455017f53b60 Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:45:06 +0300 Subject: [PATCH 4/6] test(ui): enumerate dock edges without unreachable branches --- crates/ui/src/docking/host_hooks_tests.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs index c3fcbee5..d3585733 100644 --- a/crates/ui/src/docking/host_hooks_tests.rs +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -266,7 +266,12 @@ fn internal_width_input_is_suppressed_only_when_explicitly_owned_through_overrid #[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] { + for (zone, anchor, inset) in [ + (Zone::Top, egui::Align2::CENTER_TOP, vec2(0.0, 50.0)), + (Zone::Bottom, egui::Align2::CENTER_BOTTOM, vec2(0.0, -20.0)), + (Zone::Left, egui::Align2::LEFT_CENTER, vec2(20.0, 0.0)), + (Zone::Right, egui::Align2::RIGHT_CENTER, vec2(-20.0, 0.0)), + ] { let ctx = Context::default(); let area = Id::unique("owned-dock-targets"); let layout = Layout { @@ -293,13 +298,7 @@ fn exclusive_docked_groups_skip_stock_body_bands_but_keep_column_edges() { 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!(), - }; + let to = anchor.pos_in_rect(&target) + inset; 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| { From 3685bae8987d5fc05fa6d2c9681efbae7a68af18 Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:09:38 +0300 Subject: [PATCH 5/6] fix(ui): suppress trailing drops for host-owned groups --- crates/ui/src/docking/host_hooks_tests.rs | 46 +++++++++++++++++++++++ crates/ui/src/docking/render/dock.rs | 5 ++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs index d3585733..ba6cb41f 100644 --- a/crates/ui/src/docking/host_hooks_tests.rs +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -317,3 +317,49 @@ fn exclusive_docked_groups_skip_stock_body_bands_but_keep_column_edges() { } } } + +#[test] +fn owned_minimized_groups_skip_the_trailing_stock_target() { + for owned in [false, true] { + let ctx = Context::default(); + let area = Id::unique("owned-dock-targets"); + let mut 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])], + }; + if let Some(Node::Dock { columns, .. }) = &mut layout.root { + columns[0].groups[0].minimized = true; + } + 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 = pos2(target.center().x, target.bottom() + 60.0); + 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 { .. }, .. }, .. } + ) + }); + assert_eq!(group_drop, !owned, "owned={owned}: {:?}", out.actions); + } +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index adbfe5bd..7805ff0d 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -528,7 +528,7 @@ impl Renderer<'_, P> { } }; let mut previous: Option<&P> = None; - for ((group, rect), owned) in column.groups.iter().zip(&rects).zip(owned) { + 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)); @@ -544,13 +544,14 @@ impl Renderer<'_, P> { self.dock_groups.push(DockGroup { body, center, - sides: [None, None, (!owned).then(|| (above, line(rect.top()))), (!owned).then(|| (below, line(rect.bottom())))], + sides: [None, None, (!*owned).then(|| (above, line(rect.top()))), (!*owned).then(|| (below, line(rect.bottom())))], }); } previous = Some(anchor); } // The room under the last group, when every group is minimized, stacks below it. if let Some(last) = previous + && !owned.last().copied().unwrap_or(false) && top < view.bottom() { let band = Rect::from_x_y_ranges(xs, top..=view.bottom()); From 71e5ecf285e1fbfe60e0826bd6824d3ce1b81e89 Mon Sep 17 00:00:00 2001 From: KotovGGG <162052215+KotovGGG@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:39:28 +0300 Subject: [PATCH 6/6] test(ui): verify floating frame compatibility and resize constraints --- crates/ui/README.md | 13 +++++ crates/ui/src/docking/chrome.rs | 30 ++++++++++++ crates/ui/src/docking/host_hooks_tests.rs | 60 +++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/crates/ui/README.md b/crates/ui/README.md index a039e12f..16bb8d95 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -34,6 +34,19 @@ only. `FloatingFrame::resize_axes` defaults to both axes and preserves an enable axis at corners when the other is disabled. A host with its own title bar can disable the separate top gutter through `reserve_top_resize_band(false)`. +Compatibility and target-size audit: before these hooks, resizable frames already +reserved a 4 pt top band. That remains the default; `false` is the new host opt-out. +The unchanged stock edge hit bands are 4 pt thick; corners are at most 12 pt. +They do **not** meet a 24-by-24 logical-pixel target at 1x. This PR preserves +those hit rectangles and does not claim accessibility compliance. Focused +resize handles also support arrow keys (8 pt steps). Enlarging pointer targets +needs separate overlap, title-bar and viewport-edge acceptance checks. + +`chrome::compatibility_tests::defaults_preserve_pre_host_hook_frame_geometry` +locks down the pre-hook hit rectangles/defaults. `host_hooks_tests` exercises all +four axis settings with actual corner and side pointer drags, minimum/maximum +sizes, the viewport edge, and top-gutter opt-out geometry. + Exhaustive `DockStyle` struct literals need `dock_gap: None`; constructors such as `from_ui` and `from_visuals` supply the compatible default. diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 4a2b6da7..3841c256 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -520,3 +520,33 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt forget(); None } + +#[cfg(test)] +mod compatibility_tests { + use super::*; + + #[test] + fn defaults_preserve_pre_host_hook_frame_geometry() { + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let frame = FloatingFrame::new(Id::unique("defaults"), rect); + assert_eq!(frame.resize_axes, [true, true]); + assert!(frame.reserve_top_resize_band); + assert!(frame.resizable); + // Literal pre-PR hit rectangles: preserve these rather than silently enlarging them. + let expected = [ + [388.0, 288.0, 400.0, 300.0], + [100.0, 112.0, 104.0, 288.0], + [396.0, 112.0, 400.0, 288.0], + [112.0, 296.0, 388.0, 300.0], + [100.0, 288.0, 112.0, 300.0], + [112.0, 100.0, 388.0, 104.0], + [100.0, 100.0, 112.0, 104.0], + [388.0, 100.0, 400.0, 104.0], + ]; + for ((_, hit, _), [x0, y0, x1, y1]) in handles(rect).into_iter().zip(expected) { + assert_eq!(hit, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))); + // Audit result: legacy targets do NOT meet a 24-by-24 logical-pixel target. + assert!(hit.width() < 24.0 || hit.height() < 24.0); + } + } +} diff --git a/crates/ui/src/docking/host_hooks_tests.rs b/crates/ui/src/docking/host_hooks_tests.rs index ba6cb41f..8415b76a 100644 --- a/crates/ui/src/docking/host_hooks_tests.rs +++ b/crates/ui/src/docking/host_hooks_tests.rs @@ -363,3 +363,63 @@ fn owned_minimized_groups_skip_the_trailing_stock_target() { assert_eq!(group_drop, !owned, "owned={owned}: {:?}", out.actions); } } + +#[test] +fn disabled_edge_axes_ignore_pointer_drags_and_enabled_edges_obey_limits() { + for axes in [[true, true], [true, false], [false, true], [false, false]] { + for horizontal in [true, false] { + for grow in [true, false] { + let ctx = Context::default(); + let rect = Rect::from_min_size(pos2(400.0, 300.0), vec2(300.0, 200.0)); + let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(720.0, 520.0)); + let frame = |events| { + let mut resized = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + resized = chrome::FloatingFrame::new(Id::unique("edge-axis"), rect) + .resize_axes(axes) + .bounds(bounds) + .limits(PanelLimits { min: vec2(160.0, 120.0), max: vec2(310.0, 210.0) }) + .show(ui.ctx(), &style, |_, _| ()) + .resized; + }) + .drop_without_applying_deltas(); + resized + }; + frame(vec![]); + frame(vec![]); + let from = if horizontal { pos2(rect.right() - 2.0, rect.center().y) } else { pos2(rect.center().x, rect.bottom() - 2.0) }; + frame(vec![Event::PointerMoved(from), button(from, true)]); + let delta = if horizontal { vec2(10.0, 0.0) } else { vec2(0.0, 10.0) }; + frame(vec![Event::PointerMoved(from + delta)]); + let result = frame(vec![Event::PointerMoved(from + delta * if grow { 100.0 } else { -100.0 })]); + let enabled = if horizontal { axes[0] } else { axes[1] }; + let expected = if horizontal { vec2(if grow { 310.0 } else { 160.0 }, 200.0) } else { vec2(300.0, if grow { 210.0 } else { 120.0 }) }; + assert_eq!(result, enabled.then(|| Rect::from_min_size(rect.min, expected)), "{axes:?} horizontal={horizontal} grow={grow}"); + } + } + } +} + +#[test] +fn frame_at_viewport_edge_cannot_resize_beyond_it() { + let ctx = Context::default(); + let rect = Rect::from_min_max(pos2(400.0, 300.0), pos2(800.0, 600.0)); + let frame = |events| { + let mut result = None; + ctx.run_ui(input(events), |ui| { + let style = chrome::ChromeStyle::from_ui(ui); + let response = chrome::FloatingFrame::new(Id::unique("viewport-edge"), rect).show(ui.ctx(), &style, |_, _| ()); + assert_eq!(response.rect, rect); + result = response.resized; + }) + .drop_without_applying_deltas(); + result + }; + frame(vec![]); + frame(vec![]); + let from = rect.max - vec2(2.0, 2.0); + frame(vec![Event::PointerMoved(from), button(from, true)]); + frame(vec![Event::PointerMoved(from + vec2(10.0, 10.0))]); + assert_eq!(frame(vec![Event::PointerMoved(from + vec2(100.0, 100.0))]), None); +}