diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index c05271c5..021b8570 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -87,6 +87,8 @@ pub(super) fn frame_style(style: &mut DockStyle) { style.close_others_item = true; style.maximize_item = true; style.command_press_floats = true; + style.float_on_empty_drop = false; + style.title_bar_docks = false; style.float_hint = true; style.wheel_selects_tabs = true; style.middle_click_closes_tabs = true; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index f8bc435a..a321f3aa 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -323,6 +323,7 @@ impl Renderer<'_, P> { /// group target docks or merges every group in it, else hosts get the release in /// `group_drop`. pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let float_only = float_only || !self.style.title_bar_docks; if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { self.output.actions.extend(action); return; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 9de57d97..a767750a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -60,14 +60,18 @@ impl Renderer<'_, P> { } return; } - // Leaving the window ends a group drag where it is, with no drop. - if !released || !(has_pointer || touch_ended) { + // Leaving the window ends a group drag where it is, with no drop, unless only a + // release outside the window floats. + let gone = !has_pointer && !touch_ended; + let floats_outside = !self.style.float_on_empty_drop && drag.gesture != GroupGesture::Window; + if !(released && !gone) && !(gone && floats_outside) { return; } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; + let outside = gone || !ctx.content_rect().contains(pointer); match drag.gesture { GroupGesture::Window => self.drop_window(&drag, pointer, float_only), - GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only || outside), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); @@ -164,6 +168,9 @@ impl Renderer<'_, P> { if proposal.is_some() && !floating { return; } + if !float_only && !self.style.float_on_empty_drop { + return; + } // 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(drag, pointer); @@ -196,7 +203,7 @@ impl Renderer<'_, P> { x += advance; } } - if float_only { + if float_only || (drag.gesture == GroupGesture::Window && !self.style.title_bar_docks) { return; } if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { @@ -246,8 +253,10 @@ impl Renderer<'_, P> { return; } 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 outside = gone || !ctx.content_rect().contains(pointer); + let empty_drop = self.style.float_on_empty_drop + && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let floatable = self.permissions(&drag.panel).floatable && (float_only || outside || empty_drop); 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 { @@ -255,7 +264,7 @@ impl Renderer<'_, P> { } return; } - let dock = if float_only || gone { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; + let dock = if float_only || outside { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; if let Some((action, line)) = dock { if released { cancel_drag::

(ctx, self.id); @@ -282,10 +291,10 @@ impl Renderer<'_, P> { }; if released { cancel_drag::

(ctx, self.id); - if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { + if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !outside) { return; } - let action = if let Some(proposal) = proposal.filter(|_| !gone) { + let action = if let Some(proposal) = proposal.filter(|_| !outside) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) } else if let Some(rect) = floating_rect { Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 89a94dc9..3354d9f0 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -149,12 +149,12 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let mut d = film(); let out = d.drag(tab(&d, INFO), pos2(timeline.center().x, timeline.top() + 10.0)); assert!(matches!(&out.actions[..], [Action::Move { panel: INFO, placement: Placement::Tab { .. }, .. }]), "{:?}", out.actions); - // The gap between two groups belongs to neither: the tab floats. + // The gap between two groups belongs to neither: the drag cancels. let mut d = film(); let tools = group(&d, TOOLS); let gap = pos2(tools.right() + 2.0, tools.center().y); let out = d.drag(tab(&d, INFO), gap); - assert!(matches!(&out.actions[..], [Action::Float { panel: INFO, .. }]), "{:?}", out.actions); + assert!(out.actions.is_empty(), "{:?}", out.actions); } #[test] @@ -443,3 +443,66 @@ fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); } } + +/// The Editing workspace under a 24 pt menu bar and above a 20 pt status bar. +fn film_in_window() -> Driver { + let mut d = DockDriver::new(editing(), Film::default()).with_style(frame_style); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = Rect::from_min_max(pos2(0.0, 24.0), pos2(SCREEN.x, SCREEN.y - 20.0)); + d.settle(); + d +} + +#[test] +fn a_release_over_no_target_inside_the_window_cancels_the_drag() { + let d = film_in_window(); + let (source, program) = (group(&d, SOURCE[0]), group(&d, PROGRAM)); + let gap = pos2((source.right() + program.left()) * 0.5, source.center().y); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + for to in [pos2(600.0, 10.0), pos2(600.0, SCREEN.y - 8.0), gap] { + let mut d = film_in_window(); + let before = d.layout.clone(); + let out = d.drag(from, to); + d.settle(); + assert!(out.actions.is_empty(), "{from:?} to {to:?}: {:?}", out.actions); + assert_eq!(d.layout, before, "{from:?} to {to:?}"); + } + // Ctrl/Cmd held floats it there. + let mut d = film_in_window(); + d.modifiers = Modifiers::COMMAND; + d.drag(from, pos2(600.0, 10.0)); + d.settle(); + assert!(d.layout.floating.iter().any(|w| w.contains(&PROJECT[0])), "{from:?}: {:?}", d.layout.floating); + } +} + +#[test] +fn a_release_outside_the_window_floats() { + let d = film_in_window(); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + let mut d = film_in_window(); + d.hold(from, pos2(600.0, 10.0)); + d.leave(); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&PROJECT[0])); + assert!(window.is_some(), "{from:?}: {:?}", d.layout); + // A tab takes one panel; a drag bar takes the group. + assert_eq!(window.unwrap().contains(&PROJECT[1]), from == from_bar, "{from:?}"); + } +} + +#[test] +fn the_title_bar_moves_a_floating_window_without_docking() { + let mut d = film(); + let title = d.hit(&Hit::Title(INFO)).unwrap().center(); + let timeline = group(&d, TIMELINE); + d.drag(title, timeline.center()); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&INFO)).expect("still floating"); + assert!(window.rect[0] < 1000.0 && window.rect[1] > 400.0, "moved towards the drop: {:?}", window.rect); + assert_eq!(d.layout.root, editing().root); +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index e4a82b34..1595ef3e 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -101,6 +101,13 @@ pub struct DockStyle { /// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even /// when the key is let go during the drag. Off: only the key held at the drop counts. pub command_press_floats: bool, + /// A drag released over no drop target inside the window floats what it carries, such as + /// a panel dropped on a protected canvas. Off: that release cancels the drag; only + /// Ctrl/Cmd or a release outside the window floats. + pub float_on_empty_drop: bool, + /// Dragging a floating window's title bar onto a drop target docks the whole window there. + /// Off: the title bar only moves the window; tabs and drag bars dock. + pub title_bar_docks: bool, /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, /// Close Other Panels in Group) for closable panels. A host that draws its own Close items /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. @@ -183,6 +190,8 @@ impl DockStyle { float_hint_label: "Hold {key} to undock".into(), float_hint: false, command_press_floats: false, + float_on_empty_drop: true, + title_bar_docks: true, close_menu_items: true, close_button: true, wheel_selects_tabs: false, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 51cfd297..f92d1aa9 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -17,7 +17,7 @@ outside the crate. | Arrangement | Layout | Style settings | Apps | |---|---|---|---| | Docks beside a canvas (the defaults) | `Node::Dock`s on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | `new_dock_zones` on | Creative apps with a canvas and panel docks | -| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | +| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones`, `float_on_empty_drop` and `title_bar_docks` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | Sides are logical: `Side::Start` is the left edge, or the right edge when `DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its @@ -185,7 +185,9 @@ A flyout fits its content the same way, without the title bar. ## Gestures - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags - the whole group. A column bar drags a column. A floating window's title bar drags the window. + the whole group. A column bar drags a column. A floating window's title bar drags the window; + with `title_bar_docks` a release over a target docks the whole window there, else it only + moves. - A movable floating window resizes from every edge and corner. The top edge and corners are a 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on the title bar's buttons. A resize keeps the opposite edges in place and stays within the size @@ -207,6 +209,10 @@ A flyout fits its content the same way, without the title bar. joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. +- With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such + as a panel dropped on a protected canvas. Without it, that release cancels the drag and + nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release + outside the app window floats in both cases. - Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`, the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the pointer names the modifier while a drag offers a docking target: `float_hint_label`, where