diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 307d8a86..78d46dd4 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -340,7 +340,8 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap(); let panel = float(json!({"command": "window.panel.float", "panel": "layers"})); - assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }], "floating_size, inset in the area"); + // The floating_size width, and an empty body's least fitted height under its 150 points. + assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }], "floating_size, inset in the area"); let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] = float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..] else { @@ -365,7 +366,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded); assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }])); let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); - assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }]); + assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]); } fn float_with(space: &FloatSpace

, panel: &str) -> Vec> { diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 0577a628..71c9f113 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -50,11 +50,16 @@ struct TestContent { floating_size: Option, host_owned: bool, whole_group: bool, + /// Height every body takes; `None` draws a one-line label. + body_height: Option, } impl DockContent for TestContent { fn body(&mut self, ui: &mut egui::Ui, _: &u32) { - ui.label("Body"); + match self.body_height { + Some(height) => _ = ui.allocate_exact_size(vec2(10.0, height), egui::Sense::hover()), + None => _ = ui.label("Body"), + } } fn logical_label(&self, panel: &u32) -> String { format!("Panel {panel}") @@ -349,12 +354,12 @@ fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_un } #[test] -fn floated_panel_uses_floating_size_not_the_column_height() { +fn floated_panel_fits_its_content_up_to_floating_size_not_the_column_height() { for size in [Some(vec2(200.0, 150.0)), None] { let mut layout = docked(); let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; - let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(200.0), ..Default::default() }; let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0)); let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { @@ -363,9 +368,10 @@ fn floated_panel_uses_floating_size_not_the_column_height() { }) else { panic!("{:?}", out.actions) }; + let fitted = crate::docking::chrome::TITLE_HEIGHT + d.rect("drag-bar", 2).height() + 200.0; match size { Some(size) => assert_eq!((width, height), (size.x, size.y)), - None => assert!(height <= 320.0, "{height}"), + None => assert_eq!(height, fitted), } } } @@ -542,3 +548,43 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); } + +#[test] +fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() { + let title = crate::docking::chrome::TITLE_HEIGHT; + let float = |body: f32, floating_size: Option| { + let content = TestContent { body_height: Some(body), floating_size, ..Default::default() }; + let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let strip = d.rect("drag-bar", 4).height(); + let recorded = crate::docking::float_space::(&d.ctx, Id::new(AREA)).unwrap().panel(&4).unwrap(); + let target = pos2(400.0, 300.0); + d.hold(d.tab(4).center(), target); + d.modifiers = Modifiers::COMMAND; + let out = d.step(vec![button(target, false)]); + let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { + Action::Float { panel: 4, rect } => Some(*rect), + _ => None, + }) else { + panic!("{:?}", out.actions) + }; + assert_eq!(recorded, vec2(width, height), "a command floats at the size a drag does"); + (strip, vec2(width, height)) + }; + let (strip, size) = float(150.0, None); + assert_eq!(size.y, title + strip + 150.0); + assert_eq!(float(0.0, None).1.y, 120.0, "an empty panel floats at the least fitted height"); + assert_eq!(float(600.0, None).1.y, 320.0, "a tall docked panel keeps the docked cap"); + // The host's floating size gives the width and the most height. + assert_eq!(float(150.0, Some(vec2(300.0, 360.0))).1, vec2(300.0, title + strip + 150.0)); + assert_eq!(float(150.0, Some(vec2(300.0, 160.0))).1, vec2(300.0, 160.0)); + + // A group takes its active panel's content height. + let content = TestContent { body_height: Some(150.0), ..Default::default() }; + let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let target = pos2(400.0, 300.0); + d.hold(d.rect("drag-bar", 2).center(), target); + d.modifiers = Modifiers::COMMAND; + let out = d.step(vec![button(target, false)]); + let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + assert_eq!(*height, title + strip + 150.0); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index b9ad4a88..a90cc298 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -193,9 +193,13 @@ fn dragging_a_flyout_strip_floats_its_group() { let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); d.click(hit(&d, Hit::Icon(1)).center()); d.settle(); + // A one-line body: the flyout and the floated group take the least fitted height. + let flyout = d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap(); + assert_eq!(flyout.height(), 120.0); let bar = hit(&d, Hit::DragBar(1)); let out = d.drag(bar.center(), CANVAS); - assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions); + let [Action::MoveGroup { panel: 1, to: Target::Float { rect: Some(rect) } }] = out.actions.as_slice() else { panic!("{:?}", out.actions) }; + assert_eq!(rect[3], 120.0); assert_eq!(groups(&d.layout), vec![vec![3]]); assert_eq!(d.layout.floating.len(), 1); assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::>()), Some(vec![1, 2])); diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 4b7b3a04..1be3872f 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -299,13 +299,17 @@ impl Renderer<'_, P> { let screen = ctx.content_rect(); let bounds = self.workspace.intersect(screen); let width = request.width.max(self.style.min_pane).min(bounds.width()); - let height = self + let max = self .content .floating_size(&active) .map(|size| size.y) .or(request.group.height) .filter(|h| h.is_finite() && *h > 0.0) - .unwrap_or(FLYOUT_HEIGHT) + .unwrap_or(FLYOUT_HEIGHT); + // A flyout fits the content its panel last drew, up to that height. + let height = self + .fitted_height(&active, self.style.tab_height, max) + .unwrap_or(max) .max(self.style.tab_height + self.style.min_pane) .min(bounds.height()); let left = match request.edge { diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index d5cdd4b9..d099edeb 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -218,6 +218,7 @@ impl Renderer<'_, P> { ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); + self.float_space.record_content(panel.clone(), child.min_rect().height()); // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 5d662980..13f36e5a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -5,7 +5,9 @@ use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2}; use super::{Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ - Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, + Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Node, Placement, Target, Zone, cancel_drag, + dock_model::{Place, node_at}, + resolve_drop, surface::TAB_PADDING, }; @@ -17,6 +19,9 @@ fn float_only(ctx: &Context) -> bool { /// Height of a panel floated out of a dock column when the host gives no floating size. const DOCKED_FLOAT_HEIGHT: f32 = 320.0; +/// Least height of a floating window or flyout fitted to a short panel's content. +pub(super) const MIN_FITTED_HEIGHT: f32 = 120.0; + impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { let ctx = ui.ctx(); @@ -274,21 +279,30 @@ impl Renderer<'_, P> { } /// Size of a panel floated by a drag or the Float menu: the host's floating size, else the - /// source size within the panel limits. A panel leaving a dock column does not take the - /// column's height. + /// source size within the panel limits. Out of a dock column, or with a floating size, the + /// height fits the content the panel last drew, up to that height (320 in a dock). pub(super) fn float_size(&self, panel: &P, source: Vec2) -> Vec2 { + let chrome = self.style.title_height + self.style.tab_height; if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { - return size; + return vec2(size.x, self.fitted_height(panel, chrome, size.y).unwrap_or(size.y)); } let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { - size.y = size.y.min(DOCKED_FLOAT_HEIGHT); + size.y = self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT).unwrap_or(size.y.min(DOCKED_FLOAT_HEIGHT)); } let limits = self.limits(panel); let header = vec2(0.0, self.style.tab_height); size.max(limits.min + header).min(limits.max + header) } + /// Height of a window around `panel`'s group: `chrome` (title bar and tab strip) and the + /// content height the panel last drew, at least [`MIN_FITTED_HEIGHT`] and at most `max`. + /// `None` for a panel the area has not drawn. + pub(super) fn fitted_height(&self, panel: &P, chrome: f32, max: f32) -> Option { + let content = self.float_space.content(panel)?; + Some((chrome + content).max(MIN_FITTED_HEIGHT).min(max)) + } + /// Record what a drag floats from `panels`, one group drawn at `group` whose panels drag /// from `panel` rects, for commands that float without a rect. pub(super) fn record_float(&mut self, panels: &[P], panel: Vec2, group: Vec2) { @@ -307,17 +321,33 @@ impl Renderer<'_, P> { } /// Size of a group or column floated by a drag or Float Tab Group: the source width, and the - /// tallest member's floating size, else the source height capped like a panel leaving a - /// dock column. A group that already floats keeps its size. + /// tallest member's floating height, else the source height capped like a panel leaving a + /// dock column. A group fits that height to its active panel's content. A group that already + /// floats keeps its size. pub(super) fn group_float_size(&self, members: &[P], source: Vec2) -> Vec2 { - let place = members.first().and_then(|panel| self.layout.place(panel)); - if matches!(place, Some(Place::Floating { .. }) | None) { + let Some(place) = members.first().and_then(|panel| self.layout.place(panel)) else { return source }; + if matches!(place, Place::Floating { .. }) { return source; } let valid = |s: &Vec2| s.is_finite() && s.x > 0.0 && s.y > 0.0; let host = members.iter().filter_map(|panel| self.content.floating_size(panel).filter(valid)).map(|s| s.y).reduce(f32::max); - let docked = matches!(place, Some(Place::Dock { .. })); - vec2(source.x, host.unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + let docked = matches!(place, Place::Dock { .. }); + let chrome = self.style.title_height + self.style.tab_height; + let max = host.or(docked.then_some(DOCKED_FLOAT_HEIGHT)); + let fitted = max.and_then(|max| self.group_active(members, &place).and_then(|active| self.fitted_height(active, chrome, max))); + vec2(source.x, fitted.or(host).unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + } + + /// The active panel of the group at `place` when `members` are that whole group. + fn group_active<'a>(&'a self, members: &[P], place: &Place) -> Option<&'a P> { + let (panels, active) = match place { + Place::Node(path) => match self.layout.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Tabs { panels, active }) => (panels, *active), + _ => return None, + }, + _ => self.layout.group(place).map(|group| (&group.panels, group.active))?, + }; + (panels.len() == members.len() && members.iter().all(|m| panels.contains(m))).then(|| panels.get(active)).flatten() } /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c50bf0db..f53707f0 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -237,13 +237,16 @@ pub struct FloatSpace

{ pub(super) panels: Vec<(P, Vec2)>, pub(super) groups: Vec<(P, Vec2)>, pub(super) columns: Vec<(P, Vec2)>, + /// The height each panel's content used when it was last drawn, which fits a floated group + /// to its content. + pub(super) contents: Vec<(P, f32)>, } impl FloatSpace

{ /// An area with no recorded sizes: commands float a panel at 260x320, and a group or /// column at its dock column's width (else 260) by 320. pub fn new(area: Rect) -> Self { - Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() } + Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() } } /// Adds the size a command floats `panel` at alone when the area has not drawn it, such as @@ -254,6 +257,16 @@ impl FloatSpace

{ } } +impl FloatSpace

{ + pub(super) fn record_content(&mut self, panel: P, height: f32) { + let height = if height.is_finite() { height.max(0.0) } else { 0.0 }; + match self.contents.iter_mut().find(|(p, _)| *p == panel) { + Some((_, recorded)) => *recorded = height, + None => self.contents.push((panel, height)), + } + } +} + /// Lookups for `DockCommand`, which needs feature `serde`. #[cfg_attr(not(feature = "serde"), allow(dead_code))] impl FloatSpace

{ @@ -269,6 +282,10 @@ impl FloatSpace

{ Self::find(&self.columns, panel) } + pub(super) fn content(&self, panel: &P) -> Option { + self.contents.iter().find(|(p, _)| p == panel).map(|(_, height)| *height) + } + /// The first record wins: an iconic panel is recorded at its icon before its flyout. fn find(sizes: &[(P, Vec2)], panel: &P) -> Option { sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size) @@ -445,8 +462,11 @@ pub trait DockContent

{ fn limits(&self, _panel: &P) -> PanelLimits { PanelLimits::default() } - /// Preferred floating size, independent of the current dock geometry. `None` falls back - /// to the panel limits. + /// Preferred floating size, independent of the current dock geometry: the width of a + /// floated panel and the most height it takes. A floated panel or group, and a flyout, fit + /// their height to the content the active panel last drew (at least 120 points), up to this + /// height, or up to 320 out of a dock when this is `None`. A rect a command gives and a + /// window the user resized keep their size. fn floating_size(&self, _panel: &P) -> Option { None } @@ -759,7 +779,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { output: DockOutput::default(), frame, iconic: super::render::Iconic::new(self.auto_collapse), - float_space: FloatSpace::new(rect), + float_space: FloatSpace { + // A panel keeps the content height it last drew while hidden or minimized. + contents: float_space::

(ui.ctx(), self.id) + .map(|space| space.contents.into_iter().filter(|(panel, _)| layout.contains(panel)).collect()) + .unwrap_or_default(), + ..FloatSpace::new(rect) + }, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 9338199b..584f28e9 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -50,14 +50,14 @@ fn a_full_height_docked_group_floats_at_the_host_or_capped_height_inside_the_win let mut layout = docked(); let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; - let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(600.0), ..Default::default() }; let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); let bar = d.rect("drag-bar", 2); let out = d.drag(bar.center(), pos2(420.0, 300.0)); let Some(Target::Float { rect: Some([x, y, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; match size { Some(size) => assert_eq!(*height, size.y), - None => assert!(*height <= 320.0, "{height}"), + None => assert_eq!(*height, 320.0), } assert!(*width > 100.0 && *width <= 240.0, "{width}"); let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 8a01aa5f..c7643033 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -146,6 +146,24 @@ the last of them. Only an active tab wider than the strip alone is cut with "… names it in full. The drag bar keeps its reserved width until the tabs overflow. A tab dropped on the "…" button goes to the end of the group. +## Floating size + +A panel or group floated by a drag, a menu or a command without a rect takes its content height: +the title bar, the tab strip and the height the active panel's content used when it was last +drawn (the content ui's `min_rect`), at least 120 points. The most it takes is the height of +`DockContent::floating_size`, or 320 points for a panel or group leaving a dock. A short panel +therefore floats as a short window. A panel's width is its `floating_size` width, else its docked +width; a group keeps its docked width. +A flyout fits its content the same way, without the title bar. + +- A panel the area has not drawn floats at the `floating_size` height, else the docked height + capped at 320, or at a `FloatSpace::with_panel_size` size for commands. +- A group from a frame without a `floating_size` keeps its frame size. A dock column of several + groups floats at the tallest `floating_size` height, else its column height capped at 320. +- A rect from a command and a window the user resized keep their size; floating windows are not + refitted while open. +- Docked layout does not change: a column's last group still fills the column. + ## Gestures - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags @@ -195,8 +213,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when it floats. `side` is `Start` or `End`. -- `window.panel.float` without a rect floats what it moves at the size a drag floats it at: the - panel's `floating_size`, else its docked group or column size, capped like a drag. It goes +- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see + Floating size). It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats