mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 17:44:20 +00:00
ui: resize iconic flyouts from their free edges (#627)
* ui: resize iconic flyouts from their free edges (#5713) * ui: persist independent flyout widths per group (#5713) * ui: keep constrained flyouts resizable (#5713) * ui: document flyout helpers for storytold/vectorcraft#5713 * ui: honor host header minimums for storytold/vectorcraft#5713 * ui: keep constrained frame axes resizable for storytold/vectorcraft#5713
This commit is contained in:
@@ -13,6 +13,12 @@ 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.
|
||||
An explicit height overrides content fitting; untouched heights continue to fit
|
||||
content, including after a width-only drag. Layout locks and float bounds apply.
|
||||
|
||||
`DockContent` can replace an iconic column or a complete tab group without a
|
||||
second layout tree. `column_rect` reports the fitted column before painting;
|
||||
`owns_iconic_column`/`iconic_column` and `custom_group` suppress the corresponding
|
||||
|
||||
@@ -117,6 +117,7 @@ pub struct FloatingFrame<'a> {
|
||||
limits: PanelLimits,
|
||||
resizable: bool,
|
||||
resize_axes: [bool; 2],
|
||||
resize_edges: [bool; 4],
|
||||
resize_label: &'a str,
|
||||
opacity: f32,
|
||||
reserve_top_resize_band: bool,
|
||||
@@ -146,6 +147,7 @@ impl<'a> FloatingFrame<'a> {
|
||||
limits: PanelLimits::default(),
|
||||
resizable: true,
|
||||
resize_axes: [true, true],
|
||||
resize_edges: [true; 4],
|
||||
resize_label: "Resize window",
|
||||
opacity: 1.0,
|
||||
reserve_top_resize_band: true,
|
||||
@@ -159,13 +161,14 @@ impl<'a> FloatingFrame<'a> {
|
||||
}
|
||||
|
||||
/// Size limits of a resize, top resize band included. The frame itself is drawn at the
|
||||
/// given rect.
|
||||
/// given rect. Minimums are capped by the bounds when the viewport cannot fit them.
|
||||
pub fn limits(mut self, limits: PanelLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reserve a separate top resize gutter; disable when contents already supply a header.
|
||||
/// Hosts supplying their own header also supply its minimum size through [`Self::limits`].
|
||||
pub fn reserve_top_resize_band(mut self, reserve: bool) -> Self {
|
||||
self.reserve_top_resize_band = reserve;
|
||||
self
|
||||
@@ -182,6 +185,13 @@ impl<'a> FloatingFrame<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable the left, right, top and bottom edges. A corner requires both its edges.
|
||||
/// Hosts anchored to a dock can expose only the edges facing away from it.
|
||||
pub fn resize_edges(mut self, edges: [bool; 4]) -> Self {
|
||||
self.resize_edges = edges;
|
||||
self
|
||||
}
|
||||
|
||||
/// Accessibility label of the resize handles.
|
||||
pub fn resize_label(mut self, label: &'a str) -> Self {
|
||||
self.resize_label = label;
|
||||
@@ -242,9 +252,20 @@ impl<'a> FloatingFrame<'a> {
|
||||
let mut resized = None;
|
||||
let mut started = None;
|
||||
let mut responses = Vec::new();
|
||||
let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits };
|
||||
// Only stock chrome needs its title-bar minimum. Host-owned headers use the host's
|
||||
// limits; retain a nonempty floor that cannot exceed an already-constrained rect.
|
||||
let chrome_min = if self.reserve_top_resize_band { vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0) } else { Vec2::splat(1.0).min(rect.size()) };
|
||||
// Bounds may force one axis below its minimum; do not let that block the other axis.
|
||||
let limits = PanelLimits { min: self.limits.min.max(chrome_min).min(self.limits.max).min(bounds.size()), ..self.limits };
|
||||
let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx());
|
||||
for (name, mut handle, mut direction) in handles(rect) {
|
||||
if (direction.x < 0.0 && !self.resize_edges[0])
|
||||
|| (direction.x > 0.0 && !self.resize_edges[1])
|
||||
|| (direction.y < 0.0 && !self.resize_edges[2])
|
||||
|| (direction.y > 0.0 && !self.resize_edges[3])
|
||||
{
|
||||
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());
|
||||
}
|
||||
@@ -525,6 +546,67 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt
|
||||
mod compatibility_tests {
|
||||
use super::*;
|
||||
|
||||
/// Viewport clipping may force one stock-chrome axis below its normal minimum.
|
||||
/// The other axis still resizes while keeping feasible host/chrome limits and its anchor.
|
||||
#[test]
|
||||
fn constrained_stock_frames_keep_the_other_axis_resizable() {
|
||||
use egui::{Event, PointerButton, RawInput};
|
||||
for (size, handle, delta, stock_minimum, host_minimum) in [
|
||||
(vec2(30.0, 300.0), "resize-bottom", vec2(0.0, 20.0), vec2(30.0, 38.0), vec2(30.0, 90.0)),
|
||||
(vec2(300.0, 30.0), "resize-right", vec2(20.0, 0.0), vec2(40.0, 30.0), vec2(80.0, 30.0)),
|
||||
] {
|
||||
for (minimum, expected_minimum) in [(vec2(20.0, 20.0), stock_minimum), (vec2(80.0, 90.0), host_minimum)] {
|
||||
let ctx = Context::default();
|
||||
let id = Id::unique("constrained-stock-frame");
|
||||
let bounds = Rect::from_min_size(Pos2::ZERO, size);
|
||||
let mut rect = Rect::from_min_size(pos2(10.0, 10.0), vec2(100.0, 100.0));
|
||||
let mut time = 0.0;
|
||||
let mut frame = |events| {
|
||||
time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 300.0))),
|
||||
events,
|
||||
time: Some(time),
|
||||
..Default::default()
|
||||
};
|
||||
let mut next = rect;
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let shown = FloatingFrame::new(id, rect).bounds(bounds).limits(PanelLimits { min: minimum, max: Vec2::splat(500.0) }).show(
|
||||
ui.ctx(),
|
||||
&ChromeStyle::from_ui(ui),
|
||||
|_, _| {},
|
||||
);
|
||||
next = shown.resized.unwrap_or(shown.rect);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
rect = next;
|
||||
next
|
||||
};
|
||||
let drag = |frame: &mut dyn FnMut(Vec<Event>) -> Rect, from, to| {
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![Event::PointerMoved(from), button(from, true)]);
|
||||
frame(vec![Event::PointerMoved(to)]);
|
||||
frame(vec![button(to, false)]);
|
||||
frame(vec![])
|
||||
};
|
||||
frame(vec![]);
|
||||
let before = frame(vec![]);
|
||||
let from = ctx.read_response(id.with(handle)).unwrap().rect.center();
|
||||
let grown = drag(&mut frame, from, from + delta);
|
||||
assert_eq!(grown.size(), before.size() + delta, "bounds={size:?}, minimum={minimum:?}");
|
||||
assert_eq!(grown.min, before.min);
|
||||
let from = ctx.read_response(id.with(handle)).unwrap().rect.center();
|
||||
let small = drag(&mut frame, from, from - delta * 100.0);
|
||||
assert_eq!(small.size(), expected_minimum);
|
||||
assert_eq!(small.min, before.min);
|
||||
let from = ctx.read_response(id.with(handle)).unwrap().rect.center();
|
||||
let large = drag(&mut frame, from, from + delta * 100.0);
|
||||
assert!(bounds.contains_rect(large));
|
||||
assert_eq!(large.min, before.min);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_preserve_pre_host_hook_frame_geometry() {
|
||||
let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0));
|
||||
|
||||
@@ -40,11 +40,14 @@ pub struct Group<P> {
|
||||
/// [`DockContent::dock_heights`](crate::docking::DockContent::dock_heights).
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub height: Option<f32>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl<P> Group<P> {
|
||||
pub fn new(panels: Vec<P>, active: usize) -> Self {
|
||||
Self { panels, active, minimized: false, height: None }
|
||||
Self { panels, active, minimized: false, height: None, flyout_width: None }
|
||||
}
|
||||
|
||||
pub fn active_panel(&self) -> Option<&P> {
|
||||
@@ -134,6 +137,8 @@ pub enum PanelState {
|
||||
pub struct GroupLocation {
|
||||
pub minimized: bool,
|
||||
pub height: Option<f32>,
|
||||
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
|
||||
pub flyout_width: Option<f32>,
|
||||
/// The column, when the group was docked.
|
||||
pub column: Option<ColumnLocation>,
|
||||
}
|
||||
@@ -698,6 +703,8 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detach the panel's group, preserving dock dimensions and floating-window geometry.
|
||||
/// A stack entry becomes a single-panel group with its saved height.
|
||||
fn take_group(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
|
||||
let place = self.place(panel).ok_or(DockError::MissingPanel)?;
|
||||
match place {
|
||||
@@ -718,7 +725,7 @@ 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 };
|
||||
let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height, flyout_width: None };
|
||||
let moving = whole(Moving::group(group));
|
||||
return Ok(Moving { rect: None, native: None, id: None, ..moving });
|
||||
}
|
||||
@@ -726,7 +733,13 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))),
|
||||
Some(Node::Stack { mut entries }) if entries.len() == 1 => {
|
||||
let entry = entries.remove(0);
|
||||
Ok(whole(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })))
|
||||
Ok(whole(Moving::group(Group {
|
||||
panels: vec![entry.panel],
|
||||
active: 0,
|
||||
minimized: !entry.open,
|
||||
height: entry.height,
|
||||
flyout_width: None,
|
||||
})))
|
||||
}
|
||||
_ => Err(DockError::MissingPanel),
|
||||
}
|
||||
|
||||
@@ -70,6 +70,244 @@ fn texts(d: &Driver) -> Vec<String> {
|
||||
|
||||
const CANVAS: Pos2 = pos2(600.0, 400.0);
|
||||
|
||||
/// The current flyout rectangle recorded in egui's area memory.
|
||||
fn flyout_rect<C: DockContent<u32>>(d: &DockDriver<u32, C>) -> Rect {
|
||||
d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap()
|
||||
}
|
||||
|
||||
/// Build two iconic groups on either side of the canvas for anchored-resize tests.
|
||||
fn iconic_on_side(right: bool) -> Layout<u32> {
|
||||
let mut layout = docked(&[&[1, 2], &[3]], true);
|
||||
if right {
|
||||
let Some(Node::Split { first, second, size, .. }) = &mut layout.root else { panic!("split") };
|
||||
std::mem::swap(first, second);
|
||||
*size = SplitSize::FixedSecond(240.0);
|
||||
let Node::Dock { side, .. } = second.as_mut() else { panic!("dock") };
|
||||
*side = Side::End;
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
/// Flyouts expose only the canvas edge, bottom, and their shared corner on either dock side.
|
||||
#[test]
|
||||
fn flyout_free_edges_resize_and_keep_the_column_anchor() {
|
||||
for right in [false, true] {
|
||||
let mut d = DockDriver::new(iconic_on_side(right), Content);
|
||||
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"] };
|
||||
for name in chrome::RESIZE_HANDLES {
|
||||
assert_eq!(d.ctx.read_response(id.with(name)).is_some(), enabled.contains(&name), "{name}, right={right}");
|
||||
}
|
||||
d.ctx.memory_mut(|m| m.request_focus(id.with("resize-bottom")));
|
||||
assert!(DockArea::<u32>::new(d.id).chrome_has_focus(&d.ctx, &d.layout));
|
||||
let before = flyout_rect(&d);
|
||||
let from = if right { before.left_bottom() + vec2(2.0, -2.0) } else { before.right_bottom() - vec2(2.0, 2.0) };
|
||||
d.drag(from, from + vec2(if right { -90.0 } else { 90.0 }, 100.0));
|
||||
d.settle();
|
||||
let after = flyout_rect(&d);
|
||||
assert!((after.width() - before.width() - 90.0).abs() < 1.0, "{before:?} -> {after:?}");
|
||||
assert!((after.height() - before.height() - 100.0).abs() < 1.0, "{before:?} -> {after:?}");
|
||||
assert_eq!(after.top(), before.top());
|
||||
assert_eq!(if right { after.right() } else { after.left() }, if right { before.right() } else { before.left() });
|
||||
assert_eq!(open(&d), Some(1));
|
||||
assert!(d.layout.floating.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// User heights override both a host floating-size hint and the last short content height;
|
||||
/// group height and width survive tab switches, closing, and a workspace round trip.
|
||||
#[test]
|
||||
fn resized_flyout_survives_reopening_and_workspace_restore() {
|
||||
struct Short;
|
||||
impl DockContent<u32> for Short {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Short");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn floating_size(&self, _: &u32) -> Option<egui::Vec2> {
|
||||
Some(vec2(200.0, 160.0))
|
||||
}
|
||||
}
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Short);
|
||||
let icon = d.hit(&Hit::Icon(1)).unwrap().center();
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
assert!(before.height() <= 160.0);
|
||||
let from = before.right_bottom() - vec2(2.0, 2.0);
|
||||
d.drag(from, from + vec2(80.0, 170.0));
|
||||
d.settle();
|
||||
let grown = flyout_rect(&d);
|
||||
assert!(grown.height() > 160.0);
|
||||
assert_eq!(first_column(&d.layout).groups[0].height, Some(grown.height()));
|
||||
assert_eq!(first_column(&d.layout).groups[0].flyout_width, Some(grown.width()));
|
||||
assert_eq!(first_column(&d.layout).width, 240.0);
|
||||
assert_eq!(first_column(&d.layout).groups[1].flyout_width, None);
|
||||
assert_eq!(first_column(&d.layout).groups[1].height, None);
|
||||
d.click(d.hit(&Hit::Icon(2)).unwrap().center());
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d).size(), grown.size());
|
||||
d.click(d.hit(&Hit::Icon(3)).unwrap().center());
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d).width(), before.width(), "another group keeps its own width");
|
||||
let from = flyout_rect(&d).right_center() - vec2(2.0, 0.0);
|
||||
d.drag(from, from + vec2(20.0, 0.0));
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d).size(), grown.size(), "resizing another group leaves this one intact");
|
||||
d.click(d.hit(&Hit::Icon(2)).unwrap().center());
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(flyout_rect(&d).size(), grown.size());
|
||||
#[cfg(feature = "serde")]
|
||||
{
|
||||
let saved = serde_json::to_string(&d.layout).unwrap();
|
||||
let mut restored = DockDriver::new(serde_json::from_str(&saved).unwrap(), Short);
|
||||
restored.click(restored.hit(&Hit::Icon(1)).unwrap().center());
|
||||
restored.settle();
|
||||
assert_eq!(flyout_rect(&restored).size(), grown.size());
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal dragging must not pin an automatic height or overwrite a saved height
|
||||
/// temporarily clamped by a smaller viewport. Minimums, float bounds and locks still apply.
|
||||
#[test]
|
||||
fn flyout_resize_respects_limits_and_preserves_height_on_width_only_drags() {
|
||||
for height in [None, Some(480.0)] {
|
||||
let mut d = DockDriver::new(docked(&[&[1]], true), Content);
|
||||
d.layout.apply(Action::ResizeGroup { panel: 1, height }).unwrap();
|
||||
d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 330.0)));
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let before = flyout_rect(&d);
|
||||
let from = before.right_center() - vec2(2.0, 0.0);
|
||||
d.drag(from, from + vec2(70.0, 0.0));
|
||||
d.settle();
|
||||
assert!((flyout_rect(&d).width() - before.width() - 70.0).abs() < 1.0);
|
||||
assert_eq!(first_column(&d.layout).groups[0].height, height);
|
||||
d.float_bounds = None;
|
||||
d.settle();
|
||||
if let Some(height) = height {
|
||||
assert_eq!(flyout_rect(&d).height(), height);
|
||||
}
|
||||
let from = flyout_rect(&d).right_bottom() - vec2(2.0, 2.0);
|
||||
d.drag(from, from - vec2(800.0, 800.0));
|
||||
d.settle();
|
||||
let small = flyout_rect(&d);
|
||||
let style = DockStyle::default();
|
||||
assert_eq!(small.width(), style.min_pane);
|
||||
assert_eq!(small.height(), style.tab_height + style.min_pane);
|
||||
let from = small.right_bottom() - vec2(2.0, 2.0);
|
||||
d.drag(from, from + vec2(1600.0, 1600.0));
|
||||
d.settle();
|
||||
assert!(d.screen.contains_rect(flyout_rect(&d)));
|
||||
let saved = d.layout.clone();
|
||||
d.locked = true;
|
||||
d.settle();
|
||||
let from = flyout_rect(&d).center_bottom() - vec2(0.0, 2.0);
|
||||
d.drag(from, from - vec2(0.0, 60.0));
|
||||
assert_eq!(d.layout, saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted widths use the same finite geometry validation as column widths.
|
||||
#[test]
|
||||
fn flyout_width_rejects_invalid_geometry_without_mutating_the_layout() {
|
||||
let mut layout = docked(&[&[1]], true);
|
||||
let original = layout.clone();
|
||||
for width in [0.0, -1.0, f32::NAN, f32::INFINITY, 2_000_000.0] {
|
||||
assert_eq!(layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(width) }), Err(DockError::InvalidGeometry));
|
||||
assert_eq!(layout, original);
|
||||
}
|
||||
layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(400.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_width, Some(400.0));
|
||||
layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: None }).unwrap();
|
||||
assert_eq!(layout, original);
|
||||
}
|
||||
|
||||
/// Float bounds can be shorter than the normal minimum; that must not disable width drags.
|
||||
#[test]
|
||||
fn a_flyout_constrained_below_minimum_height_can_still_resize_its_width() {
|
||||
for height in [20.0, 50.0] {
|
||||
let mut d = DockDriver::new(docked(&[&[1]], true), Content);
|
||||
d.float_bounds = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, height)));
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let before = d.ctx.read_response(d.id.with("flyout").with("resize")).unwrap().rect.right_bottom();
|
||||
// The side handle needs room above and below its corners, so the bottom-right
|
||||
// corner exercises both axes in the shortest viewport as well.
|
||||
let from = before - vec2(2.0, 2.0);
|
||||
d.drag(from, from + vec2(60.0, 0.0));
|
||||
d.settle();
|
||||
assert_eq!(first_column(&d.layout).groups[0].flyout_width, Some(300.0));
|
||||
assert_eq!(first_column(&d.layout).groups[0].height, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A host-owned header permits smaller flyouts than stock chrome on either dock side.
|
||||
/// Both axes can grow from that size, shrink to the configured minimum, and stay in bounds.
|
||||
#[test]
|
||||
fn custom_small_flyouts_resize_within_their_configured_limits() {
|
||||
for right in [false, true] {
|
||||
let mut d = DockDriver::new(iconic_on_side(right), Content).with_style(|style| {
|
||||
style.min_pane = 20.0;
|
||||
style.tab_height = 10.0;
|
||||
});
|
||||
d.layout.apply(Action::ResizeGroup { panel: 1, height: Some(30.0) }).unwrap();
|
||||
d.layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(32.0) }).unwrap();
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
let id = d.id.with("flyout");
|
||||
let side = if right { "resize-left" } else { "resize-right" };
|
||||
let corner = if right { "resize-bottom-left" } else { "resize" };
|
||||
let handle = |d: &Driver, name| d.ctx.read_response(id.with(name)).unwrap().rect;
|
||||
let saved = |d: &Driver| d.layout.location(&1).unwrap().group.unwrap();
|
||||
let before = handle(&d, side);
|
||||
let anchor = if right { before.left() + 32.0 } else { before.right() - 32.0 };
|
||||
let top = before.top() - 12.0;
|
||||
let direction = if right { -1.0 } else { 1.0 };
|
||||
|
||||
d.drag(before.center(), before.center() + vec2(direction * 20.0, 0.0));
|
||||
d.settle();
|
||||
assert_eq!(saved(&d).flyout_width, Some(52.0), "right={right}");
|
||||
assert_eq!(saved(&d).height, Some(30.0));
|
||||
let after = handle(&d, side);
|
||||
assert_eq!(if right { after.left() + 52.0 } else { after.right() - 52.0 }, anchor);
|
||||
|
||||
let bottom = handle(&d, "resize-bottom");
|
||||
d.drag(bottom.center(), bottom.center() + vec2(0.0, 20.0));
|
||||
d.settle();
|
||||
assert_eq!(saved(&d).height, Some(50.0));
|
||||
assert_eq!(saved(&d).flyout_width, Some(52.0));
|
||||
assert_eq!(handle(&d, side).top() - 12.0, top);
|
||||
|
||||
let from = handle(&d, corner).center();
|
||||
d.drag(from, from - vec2(direction * 1000.0, 1000.0));
|
||||
d.settle();
|
||||
assert_eq!(saved(&d).flyout_width, Some(20.0));
|
||||
assert_eq!(saved(&d).height, Some(30.0));
|
||||
|
||||
let from = handle(&d, corner).center();
|
||||
d.drag(from, from + vec2(direction * 2000.0, 2000.0));
|
||||
d.settle();
|
||||
let side = handle(&d, side);
|
||||
let bottom = handle(&d, corner).bottom();
|
||||
let rect = if right {
|
||||
Rect::from_min_max(pos2(side.left(), top), pos2(anchor, bottom))
|
||||
} else {
|
||||
Rect::from_min_max(pos2(anchor, top), pos2(side.right(), bottom))
|
||||
};
|
||||
assert!(d.screen.contains_rect(rect));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_column_shows_one_icon_per_panel_with_group_separators() {
|
||||
let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
|
||||
@@ -467,6 +467,11 @@ pub enum Action<P> {
|
||||
panel: P,
|
||||
height: Option<f32>,
|
||||
},
|
||||
/// Preferred flyout width of the dock group holding `panel`; `None` uses its column.
|
||||
ResizeFlyoutWidth {
|
||||
panel: P,
|
||||
width: Option<f32>,
|
||||
},
|
||||
ResizeIconic {
|
||||
panel: P,
|
||||
width: Option<f32>,
|
||||
@@ -747,7 +752,7 @@ 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, column });
|
||||
location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, flyout_width: own.flyout_width, column });
|
||||
if location.anchor.is_some() {
|
||||
return Ok(location);
|
||||
}
|
||||
@@ -810,14 +815,21 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
}
|
||||
let state = location.group.as_ref();
|
||||
if state.is_some_and(|s| {
|
||||
!valid_height(s.height) || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w)))
|
||||
!valid_height(s.height)
|
||||
|| s.flyout_width.is_some_and(|w| !valid_width(w))
|
||||
|| s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w)))
|
||||
}) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
// A restore that creates a group, column or dock gives it the saved state.
|
||||
let moving = || {
|
||||
let group =
|
||||
Group { panels: vec![panel.clone()], active: 0, minimized: state.is_some_and(|s| s.minimized), height: state.and_then(|s| s.height) };
|
||||
let group = Group {
|
||||
panels: vec![panel.clone()],
|
||||
active: 0,
|
||||
minimized: state.is_some_and(|s| s.minimized),
|
||||
height: state.and_then(|s| s.height),
|
||||
flyout_width: state.and_then(|s| s.flyout_width),
|
||||
};
|
||||
let column = state.and_then(|s| s.column);
|
||||
Moving {
|
||||
width: column.map(|c| c.width),
|
||||
@@ -1211,6 +1223,15 @@ impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
entry.height = height;
|
||||
Ok(())
|
||||
}
|
||||
Action::ResizeFlyoutWidth { panel, width } => {
|
||||
if width.is_some_and(|w| !valid_width(w)) {
|
||||
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_width = width;
|
||||
Ok(())
|
||||
}
|
||||
Action::SetIconic { panel, iconic } => {
|
||||
self.column_of(&panel)?.iconic = iconic;
|
||||
Ok(())
|
||||
@@ -1395,8 +1416,9 @@ 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) {
|
||||
if group.panels.is_empty() || !valid_height(group.height) || group.flyout_width.is_some_and(|w| !valid_width(w)) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
check_tabs(&group.panels, group.active, seen)
|
||||
|
||||
@@ -9,7 +9,7 @@ use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, U
|
||||
|
||||
use super::{GroupKind, Renderer, dock::Edge};
|
||||
use crate::docking::{
|
||||
Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag,
|
||||
Action, Column, DockStyle, Group, GroupDrag, GroupGesture, PanelLimits, begin_drag,
|
||||
chrome::{ChromeStyle, FloatingFrame},
|
||||
is_dragging,
|
||||
targets::{DockDrop, DockTarget, horizontal_line, usable},
|
||||
@@ -298,8 +298,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
close_flyout::<P>(ctx, self.id);
|
||||
return;
|
||||
}
|
||||
let bounds = self.workspace.intersect(self.float_bounds);
|
||||
let width = request.width.max(self.style.min_pane).min(bounds.width());
|
||||
let mut bounds = self.workspace.intersect(self.float_bounds);
|
||||
// Keep the column-side edge anchored, even when the saved width exceeds the room.
|
||||
match request.edge {
|
||||
Edge::Left => bounds.min.x = bounds.min.x.max(request.column.right()),
|
||||
Edge::Right => bounds.max.x = bounds.max.x.min(request.column.left()),
|
||||
}
|
||||
if !usable(bounds) {
|
||||
return;
|
||||
}
|
||||
let width = request.group.flyout_width.unwrap_or(request.width).max(self.style.min_pane).min(bounds.width());
|
||||
let max = self
|
||||
.content
|
||||
.floating_size(&active)
|
||||
@@ -307,9 +315,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
.or(request.group.height)
|
||||
.filter(|h| h.is_finite() && *h > 0.0)
|
||||
.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)
|
||||
// Only untouched heights fit content: otherwise the next frame undoes a resize.
|
||||
let height = request
|
||||
.group
|
||||
.height
|
||||
.or_else(|| self.fitted_height(&active, self.style.tab_height, max))
|
||||
.unwrap_or(max)
|
||||
.max(self.style.tab_height + self.style.min_pane)
|
||||
.min(bounds.height());
|
||||
@@ -321,9 +331,43 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
let rect = Rect::from_min_size(pos2(left, top), vec2(width, height));
|
||||
let style = ChromeStyle::from_dock(self.style);
|
||||
let group = request.group;
|
||||
let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| {
|
||||
self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock);
|
||||
let movable = group.panels.iter().all(|panel| self.permissions(panel).movable);
|
||||
// In a viewport smaller than the minimum, keep the other axis resizable instead
|
||||
// of asking AnchoredResize to reject an already-constrained starting rectangle.
|
||||
let limits =
|
||||
PanelLimits { min: vec2(self.style.min_pane, self.style.tab_height + self.style.min_pane).min(bounds.size()), max: bounds.size() };
|
||||
let label = self.style.resize_window_label.clone();
|
||||
let response = FloatingFrame::new(id, rect)
|
||||
.bounds(bounds)
|
||||
.limits(limits)
|
||||
.resizable(movable)
|
||||
.resize_edges([matches!(request.edge, Edge::Right), matches!(request.edge, Edge::Left), false, true])
|
||||
.reserve_top_resize_band(false)
|
||||
.resize_label(&label)
|
||||
.show(ctx, &style, |ui, rect| {
|
||||
self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock);
|
||||
});
|
||||
for handle in &response.handles {
|
||||
self.register_interaction(ctx, handle.id);
|
||||
}
|
||||
let down = ctx.input(|input| input.pointer.primary_down());
|
||||
ctx.data_mut(|data| {
|
||||
let key = self.id.with("pointer-resize");
|
||||
if let Some(started) = response.resize_started {
|
||||
data.insert_temp(key, started);
|
||||
} else if !down && data.get_temp::<Id>(key).is_some_and(|id| response.handles.iter().any(|handle| handle.id == id)) {
|
||||
data.remove::<Id>(key);
|
||||
}
|
||||
});
|
||||
if let Some(resized) = response.resized {
|
||||
// Width-only drags preserve automatic fitting and heights clamped by a small viewport.
|
||||
if resized.height() != response.rect.height() {
|
||||
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.iconic.rect = Some(response.rect);
|
||||
}
|
||||
|
||||
|
||||
@@ -1227,6 +1227,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
|
||||
return false;
|
||||
}
|
||||
super::hits::named(self.id, layout).contains_key(&focused)
|
||||
|| (super::flyout::<P>(ctx, self.id).is_some()
|
||||
&& super::chrome::RESIZE_HANDLES.iter().any(|name| self.id.with("flyout").with(name) == focused))
|
||||
}
|
||||
|
||||
/// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate
|
||||
|
||||
Reference in New Issue
Block a user