docking: a floated group takes its content height

This commit is contained in:
aiongg committed 2026-10-19 14:06:37 +00:00
1 parent e1b7733a1d
commit a014337eb2
9 files changed
+158 -28

No files matched your search

+3 -2
View File
@@ -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<P>, panel: &str) -> Vec<Action<P>> {
+50 -4
View File
@@ -50,11 +50,16 @@ struct TestContent {
floating_size: Option<Vec2>,
host_owned: bool,
whole_group: bool,
/// Height every body takes; `None` draws a one-line label.
body_height: Option<f32>,
}
impl DockContent<u32> 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<Vec2>| {
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::<u32>(&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);
}
+5 -1
View File
@@ -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::<Vec<_>>()), Some(vec![1, 2]));
+6 -2
View File
@@ -299,13 +299,17 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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 {
+1
View File
@@ -218,6 +218,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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());
+41 -11
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) {
let ctx = ui.ctx();
@@ -274,21 +279,30 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<f32> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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
+30 -4
View File
@@ -237,13 +237,16 @@ pub struct FloatSpace<P> {
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<P: PartialEq> FloatSpace<P> {
/// 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<P: PartialEq> FloatSpace<P> {
}
}
impl<P: PartialEq> FloatSpace<P> {
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<P: PartialEq> FloatSpace<P> {
@@ -269,6 +282,10 @@ impl<P: PartialEq> FloatSpace<P> {
Self::find(&self.columns, panel)
}
pub(super) fn content(&self, panel: &P) -> Option<f32> {
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<Vec2> {
sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size)
@@ -445,8 +462,11 @@ pub trait DockContent<P> {
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<Vec2> {
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::<P>(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());
+2 -2
View File
@@ -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));
+20 -2
View File
@@ -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