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.
This commit is contained in:
aiongg committed 2026-10-21 16:19:25 +00:00
1 parent a0b923ac1e
commit 2fa4ad79d7
10 files changed
+60 -20

No files matched your search

+20
View File
@@ -388,3 +388,23 @@ fn chrome_has_focus_covers_every_drawn_control() {
assert!(DockArea::<u32>::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:?}");
}
+2 -2
View File
@@ -23,7 +23,7 @@ pub(in crate::docking) struct StackHit<P> {
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating<P>]) {
let screen = ctx.content_rect();
let screen = self.float_bounds;
if !crate::docking::targets::usable(screen) {
return;
}
@@ -139,7 +139,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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() {
+2 -2
View File
@@ -269,7 +269,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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();
}
+1 -2
View File
@@ -296,8 +296,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
close_flyout::<P>(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
+2
View File
@@ -35,6 +35,8 @@ pub(super) struct Renderer<'a, P> {
pub(super) layout: &'a Layout<P>,
/// 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<DropTarget<P>>,
/// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`].
pub(super) dock_targets: Vec<crate::docking::targets::DockDrop<P>>,
+1 -1
View File
@@ -134,7 +134,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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()]
}
};
+11 -11
View File
@@ -34,7 +34,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>, pointer: Pos2, float_only: bool) {
fn drop_group(&mut self, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
if pointer.distance(drag.origin) < 2.0 {
return;
}
@@ -129,7 +129,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<P>(ctx, self.id);
@@ -357,16 +357,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>, pointer: Pos2) -> Rect {
fn group_ghost(&self, drag: &GroupDrag<P>, 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<P>, pointer: Pos2) -> Rect {
fn tab_float_rect(&self, drag: &Drag<P>, 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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 {
+13 -1
View File
@@ -672,11 +672,12 @@ pub struct DockArea<'a, P = ()> {
maximized: Option<P>,
hide: HidePanels<'a, P>,
auto_collapse: bool,
float_bounds: Option<Rect>,
}
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(),
+7 -1
View File
@@ -42,6 +42,8 @@ pub struct DockDriver<P, C> {
pub maximized: Option<P>,
/// Passed to [`DockArea::raise_panel`] on the next frame only.
pub raise: Option<P>,
/// Passed to [`DockArea::float_bounds`].
pub float_bounds: Option<Rect>,
style: Option<StyleFn>,
shapes: Vec<ClippedShape>,
time: f64,
@@ -63,6 +65,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> Do
auto_collapse: true,
maximized: None,
raise: None,
float_bounds: None,
style: None,
shapes: Vec::new(),
time: 0.0,
@@ -97,7 +100,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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);
});
});
+1
View File
@@ -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