From 2fa4ad79d7dcde5e6ad671d36083dee367a1b788 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:19:25 +0000 Subject: [PATCH] docking: floating windows stay inside the app's float bounds DockArea::float_bounds sets where floating windows, flyouts and drag ghosts may go; the default is the whole viewport. An app with its own title bar under a transparent macOS title bar passes the viewport below it, so a floating window can't be dropped, dragged or restored where pressing it moves the app window. --- crates/ui/src/docking/iconic_tests.rs | 20 ++++++++++++++++++++ crates/ui/src/docking/render/floating.rs | 4 ++-- crates/ui/src/docking/render/group.rs | 4 ++-- crates/ui/src/docking/render/iconic.rs | 3 +-- crates/ui/src/docking/render/mod.rs | 2 ++ crates/ui/src/docking/render/native.rs | 2 +- crates/ui/src/docking/render/preview.rs | 22 +++++++++++----------- crates/ui/src/docking/surface.rs | 14 +++++++++++++- crates/ui/src/docking/testing.rs | 8 +++++++- docs/ui-docking.md | 1 + 10 files changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 1d372ad5..825a2687 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -388,3 +388,23 @@ fn chrome_has_focus_covers_every_drawn_control() { assert!(DockArea::::new(d.id).chrome_has_focus(&d.ctx, &d.layout), "{hit:?}"); } } + +#[test] +fn floating_windows_stay_inside_the_float_bounds() { + // The app's title bar strip (top 30 pt) is outside the bounds: a window saved there is + // drawn below it, and a title bar drag towards it stops at its edge. Command keeps the + // drag from docking. + let mut layout = docked(&[&[1]], false); + layout.floating.push(Floating::single(vec![5], 0, [400.0, 0.0, 240.0, 200.0])); + let mut d = DockDriver::new(layout, Content); + d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 600.0))); + d.settle(); + let title = hit(&d, Hit::Title(5)); + assert!(title.top() >= 30.0, "{title:?}"); + d.modifiers = egui::Modifiers::COMMAND; + d.drag(title.center(), pos2(title.center().x, 2.0)); + d.modifiers = egui::Modifiers::NONE; + d.settle(); + let title = hit(&d, Hit::Title(5)); + assert!(title.top() >= 30.0, "{title:?}"); +} diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 25ab4101..85282ca0 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -23,7 +23,7 @@ pub(in crate::docking) struct StackHit

{ impl Renderer<'_, P> { pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating

]) { - let screen = ctx.content_rect(); + let screen = self.float_bounds; if !crate::docking::targets::usable(screen) { return; } @@ -139,7 +139,7 @@ impl Renderer<'_, P> { && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) { // From the press origin, so fractional pointer steps do not accumulate rounding. - let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds); self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); } if response.bar.double_clicked() { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 28061ff5..b84cde4a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -269,7 +269,7 @@ impl Renderer<'_, P> { } if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { let size = self.group_float_size(owner.panels, owner.rect.size()); - let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, @@ -410,7 +410,7 @@ impl Renderer<'_, P> { response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); if response.clicked() { let size = self.float_size(panel, group.size()); - let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); + let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); ui.close(); } diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 2abe22d7..2bb3e235 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -296,8 +296,7 @@ impl Renderer<'_, P> { close_flyout::

(ctx, self.id); return; } - let screen = ctx.content_rect(); - let bounds = self.workspace.intersect(screen); + let bounds = self.workspace.intersect(self.float_bounds); let width = request.width.max(self.style.min_pane).min(bounds.width()); let max = self .content diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b1d24a77..badd4678 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -35,6 +35,8 @@ pub(super) struct Renderer<'a, P> { pub(super) layout: &'a Layout

, /// The docked area, for host drop requests. pub(super) workspace: Rect, + /// Where floating windows, flyouts and drag ghosts go: [`crate::docking::DockArea::float_bounds`]. + pub(super) float_bounds: Rect, pub(super) targets: Vec>, /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. pub(super) dock_targets: Vec>, diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 94627bd1..349dcbf2 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -134,7 +134,7 @@ impl Renderer<'_, P> { Some(window) => window.rect, None => { let size = self.float_size(panel, owner.rect.size()); - let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); [rect.left(), rect.top(), rect.width(), rect.height()] } }; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 72c50019..4349e14d 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -34,7 +34,7 @@ impl Renderer<'_, P> { let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); let pointer = pointer.filter(|p| p.is_finite()); - let ghost = pointer.map(|pointer| self.group_ghost(ctx, &drag, pointer)); + let ghost = pointer.map(|pointer| self.group_ghost(&drag, pointer)); if !escape && focused && (down || released) @@ -67,7 +67,7 @@ impl Renderer<'_, P> { let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { GroupGesture::Window => self.drop_window(&drag, pointer, float_only), - GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); @@ -109,7 +109,7 @@ 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. - fn drop_group(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + fn drop_group(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { if pointer.distance(drag.origin) < 2.0 { return; } @@ -129,7 +129,7 @@ impl Renderer<'_, P> { } // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); - let rect = self.group_ghost(ctx, drag, pointer); + let rect = self.group_ghost(drag, pointer); let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); @@ -142,7 +142,7 @@ impl Renderer<'_, P> { let ctx = ui.ctx(); let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { - let ghost = self.group_ghost(ctx, drag, pointer); + let ghost = self.group_ghost(drag, pointer); drop_preview(&painter, ghost, self.style.accent, true); let strip = Rect::from_min_size(ghost.min, vec2(ghost.width(), self.style.tab_height.min(ghost.height()))); let labels = painter.with_clip_rect(strip.shrink(2.0)); @@ -207,7 +207,7 @@ impl Renderer<'_, P> { let float_only = float_only(ctx) || drag.undock; let floatable = self.permissions(&drag.panel).floatable && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); - let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); + let floating_rect = floatable.then(|| self.tab_float_rect(&drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { if released { cancel_drag::

(ctx, self.id); @@ -357,16 +357,16 @@ impl Renderer<'_, P> { /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab /// offset, inside the window (Z2). - fn group_ghost(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Rect { + fn group_ghost(&self, drag: &GroupDrag

, pointer: Pos2) -> Rect { let size = if drag.gesture == GroupGesture::Window { drag.source.size() } else { self.group_float_size(&drag.members, drag.source.size()) }; let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size); - clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) } - fn tab_float_rect(&self, ctx: &Context, drag: &Drag

, pointer: Pos2) -> Rect { + fn tab_float_rect(&self, drag: &Drag

, pointer: Pos2) -> Rect { let size = self.float_size(&drag.panel, drag.source.size()); let grab = drag.grab.min(vec2(size.x, self.style.tab_height)); - clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) } /// Offer the gesture to [`crate::docking::DockContent::resolve_drag`]. True when the host @@ -388,7 +388,7 @@ impl Renderer<'_, P> { whole_group, released, workspace: self.workspace, - screen: ctx.content_rect(), + screen: self.float_bounds, }; let Some(feedback) = self.content.resolve_drag(ctx, self.style, &request) else { return false }; if !released { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index d8812080..03aae03f 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -672,11 +672,12 @@ pub struct DockArea<'a, P = ()> { maximized: Option

, hide: HidePanels<'a, P>, auto_collapse: bool, + float_bounds: Option, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true, float_bounds: None } } /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press @@ -720,6 +721,16 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). + /// An app whose own title bar sits under a transparent OS title bar (macOS full-size + /// content) passes the viewport below that strip: a window drawn there would take presses + /// that the OS or the app turns into moving the whole app window. Windows outside the + /// bounds, such as ones restored from a saved layout, are drawn moved inside. + pub fn float_bounds(mut self, bounds: Rect) -> Self { + self.float_bounds = Some(bounds); + self + } + /// Host overlays that must reject docking gestures, including floating fallback. pub fn blocked_rects(mut self, rects: &[Rect]) -> Self { self.blockers = rects.to_vec(); @@ -801,6 +812,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { blockers: self.blockers, layout, workspace: rect, + float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())), targets: Vec::new(), dock_targets: Vec::new(), owned_interactions: Vec::new(), diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index e34cad2f..868a6c4c 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -42,6 +42,8 @@ pub struct DockDriver { pub maximized: Option

, /// Passed to [`DockArea::raise_panel`] on the next frame only. pub raise: Option

, + /// Passed to [`DockArea::float_bounds`]. + pub float_bounds: Option, style: Option, shapes: Vec, time: f64, @@ -63,6 +65,7 @@ impl> Do auto_collapse: true, maximized: None, raise: None, + float_bounds: None, style: None, shapes: Vec::new(), time: 0.0, @@ -97,7 +100,7 @@ impl> Do ..Default::default() }; let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); - let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse); + let (style_fn, maximized, raise, auto, bounds) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if let Some(adjust) = style_fn { @@ -108,6 +111,9 @@ impl> Do if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } + if let Some(bounds) = bounds { + dock = dock.float_bounds(bounds); + } output = dock.show_content(ui, layout, &style, content); }); }); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e2b0607a..79a0ec71 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -133,6 +133,7 @@ Builder options on `DockArea`: | `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | | `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. | | `blocked_rects(rects)` | App overlays that docking gestures must ignore. | +| `float_bounds(rect)` | Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). Pass the viewport below a title bar drawn under a transparent OS title bar, so no floating window sits where a press moves the app window. | `DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items and the floating window ×. They hide controls only: a panel that stays `closable` still closes