mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
ui: resize iconic flyouts from their top edge and free top corner
* ui: resize iconic flyouts from their top edge and free top corner (#1094) A flyout's top edge and its canvas-side top corner now resize it; the column-side edge and its corners stay anchored. A top-edge resize keeps the bottom and saves the top as `Group::flyout_offset`, its distance from the group's icons, through the new `Action::MoveFlyoutTop`. The offset is serde-defaulted and kept in close/restore locations, so older workspaces align flyouts with their icons as before. Saved offsets are finite and bounded, and placement clamps them inside the float bounds. * ui: test flyout top drags past the minimum, short viewports and older workspaces (#1094) * ui: give the flyout's top resize handle a 4 pt band (#1094) A one-pixel top handle was hard to grab with a real mouse. Frames whose contents supply their own header can now set `FloatingFrame::top_handle_height`; the default stays one physical pixel. Iconic flyouts use the stock resize band's 4 pt, over the top of the tab strip, and the strip below it still drags the group. --------- Co-authored-by: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com>
This commit is contained in:
1 parent
86d6e8c5b0
commit
c4b8405d5f
6 files changed
+289
-13
No files matched your search
+8
-3
@@ -14,9 +14,14 @@ and command handling remain in each application. No icons, fonts or application
|
||||
|
||||
## Host-owned docking chrome
|
||||
|
||||
Iconic flyouts resize from their canvas-side edge, bottom and shared corner, keeping
|
||||
the edge against the icon column anchored. Resizing saves the group's `height`
|
||||
and `flyout_width`, so workspaces retain independent sizes for each group.
|
||||
Iconic flyouts resize from their canvas-side edge, top, bottom and the two
|
||||
canvas-side corners, keeping the edge against the icon column anchored. Resizing
|
||||
saves the group's `height` and `flyout_width`, and a top-edge resize saves
|
||||
`flyout_offset`, the top's distance from the group's icons, so workspaces retain
|
||||
independent sizes and placement for each group. Without an offset a flyout aligns
|
||||
with its icons, and a saved one stays inside the float bounds. The top handle is a
|
||||
4 pt band over the top of the tab strip (`FloatingFrame::top_handle_height`), so it is
|
||||
easy to grab while the rest of the strip keeps its clicks and drags.
|
||||
An explicit height overrides content fitting; untouched heights continue to fit
|
||||
content, including after a width-only drag. Layout locks and float bounds apply.
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ pub struct FloatingFrame<'a> {
|
||||
resize_label: &'a str,
|
||||
opacity: f32,
|
||||
reserve_top_resize_band: bool,
|
||||
top_handle_height: Option<f32>,
|
||||
}
|
||||
|
||||
/// What a [`FloatingFrame`] did this frame.
|
||||
@@ -151,6 +152,7 @@ impl<'a> FloatingFrame<'a> {
|
||||
resize_label: "Resize window",
|
||||
opacity: 1.0,
|
||||
reserve_top_resize_band: true,
|
||||
top_handle_height: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +176,13 @@ impl<'a> FloatingFrame<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Without a reserved band, how far the top handles reach into the contents, in points.
|
||||
/// The default is one physical pixel, so a host-owned header keeps nearly all its clicks.
|
||||
pub fn top_handle_height(mut self, height: f32) -> Self {
|
||||
self.top_handle_height = Some(height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||
self.resizable = resizable;
|
||||
self
|
||||
@@ -267,7 +276,8 @@ impl<'a> FloatingFrame<'a> {
|
||||
continue;
|
||||
}
|
||||
if !self.reserve_top_resize_band && direction.y < 0.0 {
|
||||
handle.max.y = (rect.top() + ui.ctx().pixels_per_point().recip()).min(rect.bottom());
|
||||
let reach = self.top_handle_height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or_else(|| ui.ctx().pixels_per_point().recip());
|
||||
handle.max.y = (rect.top() + reach).min(rect.bottom());
|
||||
}
|
||||
direction = vec2(if self.resize_axes[0] { direction.x } else { 0.0 }, if self.resize_axes[1] { direction.y } else { 0.0 });
|
||||
if direction == egui::Vec2::ZERO {
|
||||
|
||||
@@ -43,11 +43,15 @@ pub struct Group<P> {
|
||||
/// User-set flyout width; absent in older workspaces, which use the column width.
|
||||
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
|
||||
pub flyout_width: Option<f32>,
|
||||
/// User-set offset of the flyout's top from its icons' top, in points (negative is above);
|
||||
/// absent in older workspaces, which align the flyout with its icons.
|
||||
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
|
||||
pub flyout_offset: Option<f32>,
|
||||
}
|
||||
|
||||
impl<P> Group<P> {
|
||||
pub fn new(panels: Vec<P>, active: usize) -> Self {
|
||||
Self { panels, active, minimized: false, height: None, flyout_width: None }
|
||||
Self { panels, active, minimized: false, height: None, flyout_width: None, flyout_offset: None }
|
||||
}
|
||||
|
||||
pub fn active_panel(&self) -> Option<&P> {
|
||||
@@ -139,6 +143,8 @@ pub struct GroupLocation {
|
||||
pub height: Option<f32>,
|
||||
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
|
||||
pub flyout_width: Option<f32>,
|
||||
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
|
||||
pub flyout_offset: Option<f32>,
|
||||
/// The column, when the group was docked.
|
||||
pub column: Option<ColumnLocation>,
|
||||
}
|
||||
@@ -725,7 +731,14 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
&& let Some(index) = entries.iter().position(|e| &e.panel == panel)
|
||||
{
|
||||
let entry = entries.remove(index);
|
||||
let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height, flyout_width: None };
|
||||
let group = Group {
|
||||
panels: vec![entry.panel],
|
||||
active: 0,
|
||||
minimized: !entry.open,
|
||||
height: entry.height,
|
||||
flyout_width: None,
|
||||
flyout_offset: None,
|
||||
};
|
||||
let moving = whole(Moving::group(group));
|
||||
return Ok(Moving { rect: None, native: None, id: None, ..moving });
|
||||
}
|
||||
@@ -739,6 +752,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
minimized: !entry.open,
|
||||
height: entry.height,
|
||||
flyout_width: None,
|
||||
flyout_offset: None,
|
||||
})))
|
||||
}
|
||||
_ => Err(DockError::MissingPanel),
|
||||
|
||||
@@ -88,7 +88,8 @@ fn iconic_on_side(right: bool) -> Layout<u32> {
|
||||
layout
|
||||
}
|
||||
|
||||
/// Flyouts expose only the canvas edge, bottom, and their shared corner on either dock side.
|
||||
/// Flyouts expose the canvas edge, top, bottom and the two corners on the canvas side, on
|
||||
/// either dock side; the column-side edge and its corners stay fixed.
|
||||
#[test]
|
||||
fn flyout_free_edges_resize_and_keep_the_column_anchor() {
|
||||
for right in [false, true] {
|
||||
@@ -96,7 +97,11 @@ fn flyout_free_edges_resize_and_keep_the_column_anchor() {
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let id = d.id.with("flyout");
|
||||
let enabled = if right { ["resize-left", "resize-bottom", "resize-bottom-left"] } else { ["resize-right", "resize-bottom", "resize"] };
|
||||
let enabled = if right {
|
||||
["resize-left", "resize-bottom", "resize-bottom-left", "resize-top", "resize-top-left"]
|
||||
} else {
|
||||
["resize-right", "resize-bottom", "resize", "resize-top", "resize-top-right"]
|
||||
};
|
||||
for name in chrome::RESIZE_HANDLES {
|
||||
assert_eq!(d.ctx.read_response(id.with(name)).is_some(), enabled.contains(&name), "{name}, right={right}");
|
||||
}
|
||||
@@ -214,6 +219,208 @@ fn flyout_resize_respects_limits_and_preserves_height_on_width_only_drags() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dock column on either side of the canvas.
|
||||
fn dock_column(layout: &Layout<u32>) -> Column<u32> {
|
||||
match &layout.root {
|
||||
Some(Node::Split { first, second, .. }) => [first, second]
|
||||
.into_iter()
|
||||
.find_map(|node| match node.as_ref() {
|
||||
Node::Dock { columns, .. } => columns.first().cloned(),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap(),
|
||||
other => panic!("no split: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A point just inside the flyout's top edge, `x` along it.
|
||||
fn top_edge(rect: Rect, x: f32) -> Pos2 {
|
||||
pos2(x, rect.top() + 0.5)
|
||||
}
|
||||
|
||||
/// The top edge and the free top corner grow the flyout upward on either dock side: the
|
||||
/// bottom and the column-side edge stay, and the new top is saved as an offset from the
|
||||
/// icons, so it survives reopening, another group's flyout and a workspace round trip.
|
||||
#[test]
|
||||
fn flyout_top_edge_and_free_top_corner_grow_it_upward() {
|
||||
for right in [false, true] {
|
||||
let mut d = DockDriver::new(iconic_on_side(right), Content);
|
||||
let icon = hit(&d, Hit::Icon(3)).center();
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
assert!(before.top() > 40.0, "room above the second group's flyout: {before:?}");
|
||||
let from = top_edge(before, before.center().x);
|
||||
d.drag(from, from - vec2(0.0, 30.0));
|
||||
d.settle();
|
||||
let raised = flyout_rect(&d);
|
||||
assert!((raised.top() - (before.top() - 30.0)).abs() < 1.0, "right={right}: {before:?} -> {raised:?}");
|
||||
assert_eq!(raised.bottom(), before.bottom());
|
||||
assert_eq!(raised.width(), before.width());
|
||||
let group = dock_column(&d.layout).groups[1].clone();
|
||||
assert_eq!(group.height, Some(raised.height()));
|
||||
assert!((group.flyout_offset.unwrap() + 30.0).abs() < 1.0, "{:?}", group.flyout_offset);
|
||||
assert_eq!(group.flyout_width, None, "a vertical drag leaves the width automatic");
|
||||
assert_eq!(dock_column(&d.layout).groups[0].flyout_offset, None);
|
||||
|
||||
// The free top corner moves the canvas-side edge and the top together.
|
||||
let corner = if right { raised.left_top() + vec2(2.0, 0.5) } else { raised.right_top() + vec2(-2.0, 0.5) };
|
||||
let direction = if right { -1.0 } else { 1.0 };
|
||||
d.drag(corner, corner + vec2(direction * 40.0, -10.0));
|
||||
d.settle();
|
||||
let grown = flyout_rect(&d);
|
||||
assert!((grown.width() - raised.width() - 40.0).abs() < 1.0, "right={right}: {raised:?} -> {grown:?}");
|
||||
assert!((grown.top() - (raised.top() - 10.0)).abs() < 1.0, "right={right}: {raised:?} -> {grown:?}");
|
||||
assert_eq!(grown.bottom(), before.bottom());
|
||||
assert_eq!(if right { grown.right() } else { grown.left() }, if right { before.right() } else { before.left() });
|
||||
|
||||
// Another group's flyout keeps its own placement; this one comes back where it was.
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d).top(), hit(&d, Hit::Icon(1)).top(), "another group stays aligned with its icons");
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d), grown);
|
||||
let location = d.layout.location(&3).unwrap();
|
||||
assert_eq!(location.group.as_ref().unwrap().flyout_offset, dock_column(&d.layout).groups[1].flyout_offset);
|
||||
#[cfg(feature = "serde")]
|
||||
{
|
||||
let saved = serde_json::to_string(&d.layout).unwrap();
|
||||
assert!(saved.contains("flyout_offset"));
|
||||
let mut restored = DockDriver::new(serde_json::from_str(&saved).unwrap(), Content);
|
||||
restored.click(hit(&restored, Hit::Icon(3)).center());
|
||||
restored.settle();
|
||||
assert_eq!(flyout_rect(&restored), grown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The top handle is a band as tall as stock chrome's, so a press a few points below the
|
||||
/// top edge resizes; a press below the band reaches the tab strip and floats the group.
|
||||
#[test]
|
||||
fn the_flyout_top_handle_is_a_band_that_leaves_the_strip_its_drags() {
|
||||
let mut d = DockDriver::new(docked(&[&[1], &[3]], true), Content);
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
let from = pos2(before.center().x, before.top() + chrome::RESIZE_BAND - 1.0);
|
||||
d.drag(from, from - vec2(0.0, 20.0));
|
||||
d.settle();
|
||||
let raised = flyout_rect(&d);
|
||||
assert!((raised.top() - (before.top() - 20.0)).abs() < 1.0, "{before:?} -> {raised:?}");
|
||||
assert_eq!(raised.bottom(), before.bottom());
|
||||
let bar = hit(&d, Hit::DragBar(3));
|
||||
assert!(bar.top() >= raised.top(), "{bar:?} in {raised:?}");
|
||||
let below = pos2(bar.center().x, (raised.top() + chrome::RESIZE_BAND + 2.0).max(bar.top() + 1.0));
|
||||
let out = d.drag(below, CANVAS);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
/// A saved offset that would put the flyout above the window or below it is clamped there,
|
||||
/// and an older workspace without one aligns the flyout with its icons as before.
|
||||
#[test]
|
||||
fn a_saved_flyout_offset_stays_inside_the_bounds() {
|
||||
let mut d = DockDriver::new(docked(&[&[1], &[3]], true), Content);
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
let aligned = flyout_rect(&d);
|
||||
assert_eq!(aligned.top(), hit(&d, Hit::Icon(3)).top());
|
||||
for offset in [-100_000.0, 100_000.0] {
|
||||
d.layout.apply(Action::MoveFlyoutTop { panel: 3, offset: Some(offset) }).unwrap();
|
||||
d.settle();
|
||||
let rect = flyout_rect(&d);
|
||||
assert!(d.screen.contains_rect(rect), "{offset}: {rect:?} in {:?}", d.screen);
|
||||
assert_eq!(rect.height(), aligned.height());
|
||||
}
|
||||
d.layout.apply(Action::MoveFlyoutTop { panel: 3, offset: None }).unwrap();
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d), aligned);
|
||||
#[cfg(feature = "serde")]
|
||||
{
|
||||
let saved = serde_json::to_string(&d.layout).unwrap();
|
||||
assert!(!saved.contains("flyout_offset"), "an unset offset is not written");
|
||||
// A workspace saved before offsets existed has no field: it opens aligned.
|
||||
let mut older = DockDriver::new(serde_json::from_str(&saved).unwrap(), Content);
|
||||
older.click(hit(&older, Hit::Icon(3)).center());
|
||||
older.settle();
|
||||
assert_eq!(flyout_rect(&older), aligned);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging the top edge down past the minimum stops at the minimum height with the bottom
|
||||
/// fixed; the saved offset matches where the top stopped, and reopening keeps that rectangle.
|
||||
#[test]
|
||||
fn a_top_edge_drag_down_stops_at_the_minimum_height() {
|
||||
let mut d = DockDriver::new(docked(&[&[1], &[3]], true), Content);
|
||||
let icon = hit(&d, Hit::Icon(3)).center();
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
let from = top_edge(before, before.center().x);
|
||||
d.drag(from, from + vec2(0.0, 2000.0));
|
||||
d.settle();
|
||||
let small = flyout_rect(&d);
|
||||
let style = DockStyle::default();
|
||||
assert_eq!(small.height(), style.tab_height + style.min_pane);
|
||||
assert_eq!(small.bottom(), before.bottom());
|
||||
let group = first_column(&d.layout).groups[1].clone();
|
||||
assert_eq!(group.flyout_offset, Some(small.top() - hit(&d, Hit::Icon(3)).top()));
|
||||
assert_eq!(group.height, Some(small.height()));
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d), small);
|
||||
}
|
||||
|
||||
/// In float bounds shorter than the minimum, a flyout with a saved offset stays inside them,
|
||||
/// a top drag cannot push it out, and its width still resizes.
|
||||
#[test]
|
||||
fn a_flyout_with_an_offset_in_a_short_viewport_stays_inside_and_resizes_its_width() {
|
||||
for height in [20.0, 50.0] {
|
||||
let mut d = DockDriver::new(docked(&[&[1]], true), Content);
|
||||
d.layout.apply(Action::MoveFlyoutTop { panel: 1, offset: Some(-300.0) }).unwrap();
|
||||
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(800.0, height));
|
||||
d.float_bounds = Some(bounds);
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
assert!(bounds.contains_rect(before), "{height}: {before:?}");
|
||||
let from = top_edge(before, before.center().x);
|
||||
d.drag(from, from - vec2(0.0, 500.0));
|
||||
d.settle();
|
||||
assert!(bounds.contains_rect(flyout_rect(&d)), "{height}: {:?}", flyout_rect(&d));
|
||||
let corner = d.ctx.read_response(d.id.with("flyout").with("resize")).unwrap().rect.right_bottom() - vec2(2.0, 2.0);
|
||||
d.drag(corner, corner + vec2(60.0, 0.0));
|
||||
d.settle();
|
||||
let after = flyout_rect(&d);
|
||||
assert!((after.width() - before.width() - 60.0).abs() < 1.0, "{height}: {before:?} -> {after:?}");
|
||||
assert!(bounds.contains_rect(after));
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted offsets are finite and bounded, survive close and restore, and clear with `None`.
|
||||
#[test]
|
||||
fn flyout_offset_rejects_invalid_geometry_without_mutating_the_layout() {
|
||||
let mut layout = docked(&[&[1]], true);
|
||||
let original = layout.clone();
|
||||
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2_000_000.0, -2_000_000.0] {
|
||||
assert_eq!(layout.apply(Action::MoveFlyoutTop { panel: 1, offset: Some(offset) }), Err(DockError::InvalidGeometry));
|
||||
assert_eq!(layout, original);
|
||||
}
|
||||
layout.apply(Action::MoveFlyoutTop { panel: 1, offset: Some(-120.0) }).unwrap();
|
||||
let location = layout.location(&1).unwrap();
|
||||
layout.apply(Action::Close { panel: 1 }).unwrap();
|
||||
layout.restore(1, &location).unwrap();
|
||||
assert_eq!(first_column(&layout).groups[0].flyout_offset, Some(-120.0));
|
||||
let mut bad = location.clone();
|
||||
bad.group.as_mut().unwrap().flyout_offset = Some(f32::NAN);
|
||||
layout.apply(Action::Close { panel: 1 }).unwrap();
|
||||
assert_eq!(layout.restore(1, &bad), Err(DockError::InvalidGeometry));
|
||||
layout.restore(1, &location).unwrap();
|
||||
layout.apply(Action::MoveFlyoutTop { panel: 1, offset: None }).unwrap();
|
||||
assert_eq!(layout, original);
|
||||
}
|
||||
|
||||
/// Persisted widths use the same finite geometry validation as column widths.
|
||||
#[test]
|
||||
fn flyout_width_rejects_invalid_geometry_without_mutating_the_layout() {
|
||||
|
||||
@@ -472,6 +472,12 @@ pub enum Action<P> {
|
||||
panel: P,
|
||||
width: Option<f32>,
|
||||
},
|
||||
/// Offset of the flyout's top from its icons' top, for the dock group holding `panel`;
|
||||
/// `None` aligns the flyout with its icons.
|
||||
MoveFlyoutTop {
|
||||
panel: P,
|
||||
offset: Option<f32>,
|
||||
},
|
||||
ResizeIconic {
|
||||
panel: P,
|
||||
width: Option<f32>,
|
||||
@@ -585,6 +591,11 @@ pub(super) fn valid_width(width: f32) -> bool {
|
||||
width.is_finite() && width > 0.0 && width <= MAX_EXTENT
|
||||
}
|
||||
|
||||
/// A flyout's top offset from its icons: finite and within the largest extent either way.
|
||||
pub(super) fn valid_offset(offset: f32) -> bool {
|
||||
offset.is_finite() && offset.abs() <= MAX_EXTENT
|
||||
}
|
||||
|
||||
fn valid_size(size: SplitSize) -> bool {
|
||||
match size {
|
||||
SplitSize::Ratio(r) => r.is_finite() && (0.0..=1.0).contains(&r),
|
||||
@@ -752,7 +763,13 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
};
|
||||
let own = groups.get(group).ok_or(DockError::MissingPanel)?;
|
||||
let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?;
|
||||
location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, flyout_width: own.flyout_width, column });
|
||||
location.group = Some(GroupLocation {
|
||||
minimized: own.minimized,
|
||||
height: own.height,
|
||||
flyout_width: own.flyout_width,
|
||||
flyout_offset: own.flyout_offset,
|
||||
column,
|
||||
});
|
||||
if location.anchor.is_some() {
|
||||
return Ok(location);
|
||||
}
|
||||
@@ -817,6 +834,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
if state.is_some_and(|s| {
|
||||
!valid_height(s.height)
|
||||
|| s.flyout_width.is_some_and(|w| !valid_width(w))
|
||||
|| s.flyout_offset.is_some_and(|o| !valid_offset(o))
|
||||
|| s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w)))
|
||||
}) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
@@ -829,6 +847,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
minimized: state.is_some_and(|s| s.minimized),
|
||||
height: state.and_then(|s| s.height),
|
||||
flyout_width: state.and_then(|s| s.flyout_width),
|
||||
flyout_offset: state.and_then(|s| s.flyout_offset),
|
||||
};
|
||||
let column = state.and_then(|s| s.column);
|
||||
Moving {
|
||||
@@ -1232,6 +1251,15 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
group.flyout_width = width;
|
||||
Ok(())
|
||||
}
|
||||
Action::MoveFlyoutTop { panel, offset } => {
|
||||
if offset.is_some_and(|o| !valid_offset(o)) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
let place = self.place(&panel).ok_or(DockError::MissingPanel)?;
|
||||
let group = self.group_mut(&place).ok_or(DockError::InvalidPlacement)?;
|
||||
group.flyout_offset = offset;
|
||||
Ok(())
|
||||
}
|
||||
Action::SetIconic { panel, iconic } => {
|
||||
self.column_of(&panel)?.iconic = iconic;
|
||||
Ok(())
|
||||
@@ -1418,7 +1446,11 @@ fn check_leaf<'a, P: Eq + Hash>(node: &'a Node<P>, seen: &mut HashSet<&'a P>) ->
|
||||
|
||||
/// Validate group dimensions and nonempty membership, then check the active tab and uniqueness.
|
||||
fn check_group<'a, P: Eq + Hash>(group: &'a Group<P>, seen: &mut HashSet<&'a P>) -> Result<(), DockError> {
|
||||
if group.panels.is_empty() || !valid_height(group.height) || group.flyout_width.is_some_and(|w| !valid_width(w)) {
|
||||
if group.panels.is_empty()
|
||||
|| !valid_height(group.height)
|
||||
|| group.flyout_width.is_some_and(|w| !valid_width(w))
|
||||
|| group.flyout_offset.is_some_and(|o| !valid_offset(o))
|
||||
{
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
check_tabs(&group.panels, group.active, seen)
|
||||
|
||||
@@ -327,7 +327,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
Edge::Left => request.column.right(),
|
||||
Edge::Right => request.column.left() - width,
|
||||
};
|
||||
let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top());
|
||||
// A saved offset keeps a top-edge resize; without one the flyout aligns with its icons.
|
||||
let offset = request.group.flyout_offset.unwrap_or(0.0);
|
||||
let top = (request.icons.top() + offset).min(bounds.bottom() - height).max(bounds.top());
|
||||
let rect = Rect::from_min_size(pos2(left, top), vec2(width, height));
|
||||
let style = ChromeStyle::from_dock(self.style);
|
||||
let group = request.group;
|
||||
@@ -341,8 +343,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
.bounds(bounds)
|
||||
.limits(limits)
|
||||
.resizable(movable)
|
||||
.resize_edges([matches!(request.edge, Edge::Right), matches!(request.edge, Edge::Left), false, true])
|
||||
.resize_edges([matches!(request.edge, Edge::Right), matches!(request.edge, Edge::Left), true, true])
|
||||
.reserve_top_resize_band(false)
|
||||
// A band as tall as stock chrome's, over the top of the tab strip, is easy to grab.
|
||||
.top_handle_height(crate::docking::chrome::RESIZE_BAND)
|
||||
.resize_label(&label)
|
||||
.show(ctx, &style, |ui, rect| {
|
||||
self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock);
|
||||
@@ -365,7 +369,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
self.output.actions.push(Action::ResizeGroup { panel: active.clone(), height: Some(resized.height()) });
|
||||
}
|
||||
if resized.width() != response.rect.width() {
|
||||
self.output.actions.push(Action::ResizeFlyoutWidth { panel: active, width: Some(resized.width()) });
|
||||
self.output.actions.push(Action::ResizeFlyoutWidth { panel: active.clone(), width: Some(resized.width()) });
|
||||
}
|
||||
// A top-edge resize moves the top and keeps the bottom: save where the top now sits.
|
||||
if resized.top() != response.rect.top() {
|
||||
self.output.actions.push(Action::MoveFlyoutTop { panel: active, offset: Some(resized.top() - request.icons.top()) });
|
||||
}
|
||||
}
|
||||
self.iconic.rect = Some(response.rect);
|
||||
|
||||
Reference in new issue
Block a user