1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Drag-to-close panels (#8182)

* Closes https://github.com/emilk/egui/pull/7254

You can now drag-to-close a panel. Also drag-to-expand panels.

This is a breaking change: the animated panel functions now take a
`open: &mut bool` instead of `open: bool`.

This is only enabled for resizable panels
This commit is contained in:
Emil Ernerfeldt
2026-05-22 16:05:39 +02:00
committed by GitHub
parent dcefb2e3b8
commit 3cf844c542
10 changed files with 463 additions and 53 deletions

View File

@@ -31,6 +31,9 @@ fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PanelState {
/// The _outer_ rect of the panel, i.e. including the [`Frame`] margin & border.
///
/// When animating, this will be a shifted in the animation direction,
/// so it is really only the size that you can count on.
#[cfg_attr(feature = "serde", serde(alias = "rect"))]
pub outer_rect: Rect,
}
@@ -186,6 +189,21 @@ pub struct Panel {
/// Used by [`Self::show_animated_inside`] to animate a panel sliding in/out.
/// While `slide_fraction != 1.0` the panel does _not_ persist its [`PanelState`].
slide_fraction: f32,
/// Override for the [`Id`] under which the resize-handle widget is registered.
///
/// Used by [`Self::show_animated_between_inside`] so the collapsed and
/// expanded panels share a single resize widget — that way a drag on either
/// one can flip `is_expanded` and the gesture survives the swap.
resize_id_source: Option<Id>,
/// Size below which drag-to-collapse fires, when set.
///
/// Defaults to `outer_size_range.min`. Used by
/// [`Self::show_animated_between_inside`] to set the threshold at the
/// collapsed panel's size, so the swap happens exactly when the slide
/// matches the collapsed size visually.
collapse_threshold: Option<f32>,
}
impl Panel {
@@ -244,6 +262,8 @@ impl Panel {
default_outer_size,
outer_size_range,
slide_fraction: 1.0,
resize_id_source: None,
collapse_threshold: None,
}
}
@@ -336,38 +356,56 @@ impl Panel {
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.show_inside_dyn(ui, Box::new(add_contents))
self.show_inside_dyn(ui, None, Box::new(add_contents))
}
/// Show the panel if `is_expanded` is `true`,
/// Show the panel if `*is_expanded` is `true`,
/// otherwise hide it, with a slide animation in between.
///
/// During the animation `add_contents` runs against the real panel, and the
/// panel slides off-screen toward its fixed edge (clipped against the parent).
/// The parent only reserves the _visible_ portion, so neighboring widgets follow.
///
/// `is_expanded` is taken by `&mut` so the panel can flip it to `false` when
/// the user drags the resize handle past the panel's minimum size, and back
/// to `true` if the user drags the handle outward while the panel is closed.
pub fn show_animated_inside<R>(
self,
ui: &mut Ui,
is_expanded: bool,
is_expanded: &mut bool,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let how_expanded = animate_expansion(ui, self.id.with("animation"), is_expanded);
let how_expanded = animate_expansion(ui, self.id.with("animation"), *is_expanded);
if how_expanded == 0.0 {
// Panel is fully closed. If the user is still dragging the resize handle
// from a previous frame, keep its widget id alive so they can drag the
// panel back out without releasing.
self.keep_drag_alive_for_reopen(ui, is_expanded);
// Make sure the ids of the next widgets are the same whether we show the panel or not:
ui.skip_ahead_auto_ids(1);
return None;
}
// Don't lose the drag during the slide-back-open animation:
let drag_in_progress = ui
.read_response(self.id.with("__resize"))
.is_some_and(|r| r.dragged());
let panel = if how_expanded < 1.0 {
// Mid-animation: slide the panel toward its fixed edge.
// Resizing a moving boundary is too awkward, so disable it during the slide.
self.with_slide_fraction(how_expanded).resizable(false)
if drag_in_progress {
// Mid-animation but the user is dragging — keep resize live so the
// drag-to-reopen gesture flows straight into a normal resize.
self.with_slide_fraction(how_expanded)
} else {
self.with_slide_fraction(how_expanded).resizable(false) // avoid flicker when the handle moved under the pointer during the animation
}
} else {
self
};
Some(panel.show_inside(ui, add_contents))
Some(panel.show_inside_dyn(ui, Some(is_expanded), Box::new(add_contents)))
}
/// Show either a collapsed or expanded panel, with a nice slide animation between.
@@ -378,19 +416,38 @@ impl Panel {
/// `add_contents` receives `expanded = true` whenever the expanded panel is
/// rendered (including mid-animation), and `false` for the collapsed view.
///
/// Give the two panels distinct ids so their persisted sizes don't
/// **Give the two panels distinct ids** so their persisted sizes don't
/// overwrite each other.
///
/// # Drag-to-collapse / drag-to-expand
///
/// The user can resize the panel by dragging its edge. Pulling that edge
/// past the size limits flips `*is_expanded`:
///
/// * `.resizable(true)` on the **expanded** panel enables **drag-to-collapse**:
/// shrinking past `min_size` sets `*is_expanded = false`.
/// * `.resizable(true)` on the **collapsed** panel enables **drag-to-expand**:
/// growing past `max_size` sets `*is_expanded = true`. (Use
/// [`Self::exact_size`] or [`Self::max_size`] to set a tight cap so a small
/// outward drag is enough to trigger the swap.)
///
/// Both panels share a single resize-handle widget under the hood (keyed to
/// the expanded panel's id), so a single uninterrupted drag can collapse and
/// re-expand the panel without releasing.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let mut is_expanded = true;
/// let collapsed = egui::Panel::top("top_collapsed").exact_size(28.0);
/// // `.resizable(true)` on both panels enables drag-to-collapse + drag-to-expand:
/// let collapsed = egui::Panel::top("top_collapsed")
/// .resizable(true)
/// .exact_size(20.0);
/// let expanded = egui::Panel::top("top_expanded")
/// .resizable(true)
/// .default_size(120.0);
/// egui::Panel::show_animated_between_inside(
/// ui,
/// is_expanded,
/// &mut is_expanded,
/// collapsed,
/// expanded,
/// |ui, expanded| {
@@ -407,43 +464,88 @@ impl Panel {
/// ```
pub fn show_animated_between_inside<R>(
ui: &mut Ui,
is_expanded: bool,
is_expanded: &mut bool,
collapsed_panel: Self,
expanded_panel: Self,
add_contents: impl FnOnce(&mut Ui, bool) -> R,
) -> InnerResponse<R> {
let how_expanded = animate_expansion(ui, expanded_panel.id.with("animation"), is_expanded);
debug_assert!(
collapsed_panel.id != expanded_panel.id,
"show_animated_between_inside: the collapsed and expanded panels must have distinct ids \
(their persisted sizes are stored per-id, and sharing one id would let the collapsed \
size overwrite the expanded size)."
);
// Share one resize-handle widget across the collapsed and expanded panels
// by routing both through the expanded panel's id. A drag that starts on
// either panel survives the swap to the other view.
let resize_id_source = expanded_panel.id;
// Drag-to-collapse fires when the drag crosses the collapsed panel's
// size, so the swap lines up with the visual size at that moment.
let collapse_threshold = collapsed_panel.outer_size(ui);
// Is the resize handle currently being dragged?
let drag_in_progress = ui
.read_response(resize_id_source.with("__resize"))
.is_some_and(|r| r.dragged());
let animation_id = expanded_panel.id.with("animation");
// While the user is dragging, snap the animation to the target so the
// drag (which sets `outer_size` directly from the pointer) doesn't fight
// a simultaneous slide. Without this, drag-to-expand visibly jumps as
// the slide animation tries to grow from 0 while the pointer is already
// at the expanded size.
let how_expanded = if drag_in_progress {
ui.animate_bool_with_time(animation_id, *is_expanded, 0.0)
} else {
animate_expansion(ui, animation_id, *is_expanded)
};
// When expanding, the user sees the expanded content the moment animation starts.
// When collapsing, keep showing the expanded content until past the midpoint,
// then swap to the collapsed content for the rest of the slide-out.
let show_expanded_contents = if is_expanded {
true
} else {
0.5 < how_expanded
};
let show_expanded_contents = *is_expanded || 0.5 < how_expanded;
if how_expanded == 0.0 {
collapsed_panel.show_inside(ui, |ui| add_contents(ui, false))
// Fully collapsed. The collapsed panel registers the shared resize
// widget so drag-to-expand works, and `is_expanded` is flipped to
// `true` when the user drags past its `max_size`.
collapsed_panel
.with_resize_id_source(resize_id_source)
.show_inside_dyn(
ui,
Some(is_expanded),
Box::new(|ui| add_contents(ui, false)),
)
} else {
let expanded_panel = expanded_panel.with_collapse_threshold(collapse_threshold);
let panel = if how_expanded < 1.0 {
// Animate the visible size from collapsed_size to expanded_size,
// so the slide picks up where the collapsed panel left off.
let collapsed_size = collapsed_panel.outer_size(ui);
let expanded_size = expanded_panel.outer_size(ui);
let visible_size = lerp(collapsed_size..=expanded_size, how_expanded);
let visible_size = lerp(collapse_threshold..=expanded_size, how_expanded);
let slide_fraction = if 0.0 < expanded_size {
visible_size / expanded_size
} else {
1.0
};
expanded_panel
.with_slide_fraction(slide_fraction)
.resizable(false)
let panel = expanded_panel.with_slide_fraction(slide_fraction);
// Keep the resize handle live during the slide if the drag is
// ongoing — otherwise disabling it would kill the gesture.
if drag_in_progress {
panel
} else {
panel.resizable(false) // avoid flicker when the handle moved under the pointer during the animation
}
} else {
expanded_panel
};
panel.show_inside(ui, |ui| add_contents(ui, show_expanded_contents))
// Pass `is_expanded` so dragging the resize handle past the
// collapsed panel's size collapses to `collapsed_panel`.
panel.show_inside_dyn(
ui,
Some(is_expanded),
Box::new(|ui| add_contents(ui, show_expanded_contents)),
)
}
}
}
@@ -451,9 +553,15 @@ impl Panel {
// Private methods to support the various show methods
impl Panel {
/// Show the panel inside a [`Ui`].
///
/// `is_expanded` is `Some` for the animated entry points
/// ([`Self::show_animated_inside`], [`Self::show_animated_between_inside`]);
/// when present, dragging the resize handle past the minimum size collapses
/// the panel by setting `*is_expanded = false`.
fn show_inside_dyn<'c, R>(
self,
parent_ui: &mut Ui,
is_expanded: Option<&mut bool>,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> InnerResponse<R> {
let side = self.side;
@@ -470,18 +578,56 @@ impl Panel {
// Check for duplicate id
parent_ui.check_for_id_clash(id, outer_rect, "Panel");
// True iff the user is currently dragging the resize handle (set in the block below).
let mut resize_drag_in_progress = false;
if resizable {
// Resolve the resize interaction first to avoid frame latency in the resize.
let resize_id = id.with("__resize");
// We also recompute the size on the release frame (`drag_stopped`) so the
// released size gets persisted into [`PanelState`] — without this the
// store-skipped-during-drag rule would leave the stored size at the
// pre-drag value.
let resize_id = self.resize_id_source.unwrap_or(id).with("__resize");
if let Some(resize_response) = parent_ui.read_response(resize_id)
&& resize_response.dragged()
&& (resize_response.dragged() || resize_response.drag_stopped())
&& let Some(pointer) = resize_response.interact_pointer_pos()
{
resize_drag_in_progress = resize_response.dragged();
let axis = side.axis();
outer_size = (pointer[axis] - side.fixed_pos(outer_rect)).abs();
outer_size = clamp_to_range(outer_size, outer_size_range)
let prev_outer_size = outer_size;
// Signed distance from the fixed edge to the pointer along the
// panel's axis. Going past the fixed edge yields a negative size,
// which `clamp_to_range` then snaps up to `min` — DON'T use
// `.abs()` here, that would mirror the drag and spuriously
// trigger drag-to-expand once the pointer crosses the edge.
let raw_outer_size = -side.sign() * (pointer[axis] - side.fixed_pos(outer_rect));
outer_size = clamp_to_range(raw_outer_size, outer_size_range)
.at_most(available_rect.size_along(axis));
side.set_rect_size(&mut outer_rect, outer_size);
if let Some(is_expanded) = is_expanded {
// Drag-to-collapse: shrink past the threshold → close.
// The threshold defaults to `min_size`, but
// `show_animated_between_inside` overrides it to the
// collapsed panel's size so the swap happens exactly when
// the drag visually crosses the collapsed size.
// Use `raw_outer_size` (pre-clamp) so a tight `exact_size`
// panel can still detect inward overshoot.
let collapse_threshold =
self.collapse_threshold.unwrap_or(outer_size_range.min);
if raw_outer_size < collapse_threshold && raw_outer_size < prev_outer_size {
*is_expanded = false;
}
// Drag-to-expand: pointer pulled outward past `max_size` → open.
// Triggers when this panel is acting as the collapsed view of
// `show_animated_between_inside`, with `resize_id_source` set
// to the expanded panel's id. `raw_outer_size` is required
// because `outer_size` is clamped to `max` and would never
// exceed it (so `exact_size` panels couldn't otherwise expand).
if outer_size_range.max < raw_outer_size {
*is_expanded = true;
}
}
}
}
@@ -562,9 +708,10 @@ impl Panel {
parent_ui.set_cursor_icon(self.cursor_icon(outer_size));
}
if self.slide_fraction == 1.0 {
// Only persist the panel's rect when it's fully expanded —
// skip while sliding so the stored rect always reflects the real layout.
if !resize_drag_in_progress {
// Skipping during a drag
// means the stored size reflects the panel's pre-drag size — so a
// drag-to-close followed by a drag-to-reopen restores the original size.
PanelState {
outer_rect: shifted_outer_rect,
}
@@ -603,18 +750,67 @@ impl Panel {
.unwrap_or_else(|| Frame::side_top_panel(ui.style()))
}
/// Panel is fully closed. If the user is still dragging the resize handle
/// from the frame the panel closed on, keep its widget id registered so the
/// drag survives, and reopen if they drag back past the minimum size.
fn keep_drag_alive_for_reopen(&self, ui: &Ui, is_expanded: &mut bool) {
let resize_id = self.id.with("__resize");
let Some(resize_response) = ui.read_response(resize_id) else {
return;
};
if !resize_response.dragged() {
return;
}
let Some(pointer) = resize_response.interact_pointer_pos() else {
return;
};
// Re-register the resize widget at the (now collapsed) fixed edge so its
// id stays alive in egui's interaction state.
let available_rect = ui.available_rect_before_wrap();
let fixed_edge_pos = self.side.fixed_pos(available_rect);
let cross_range = available_rect.range_along(self.side.cross_axis());
let resize_rect = if self.side.axis() == 0 {
Rect::from_x_y_ranges(Rangef::point(fixed_edge_pos), cross_range)
} else {
Rect::from_x_y_ranges(cross_range, Rangef::point(fixed_edge_pos))
};
let grab = ui.style().interaction.resize_grab_radius_side;
let resize_rect = resize_rect.expand2(grab * self.side.axis_unit());
ui.interact(resize_rect, resize_id, Sense::drag());
// Keep the resize cursor while the user is still holding the drag.
// Otherwise the cursor would snap back to the default the moment the
// panel closed, even though the gesture is still ongoing.
ui.set_cursor_icon(self.cursor_icon(0.0));
// Signed distance from the fixed edge to the pointer along the panel's
// axis. Only counts as "pulled outward" while positive — going past the
// fixed edge gives a negative value, NOT a mirrored positive one (no
// `.abs()`), so dragging past the screen edge can't spuriously reopen.
let dragged_size = -self.side.sign() * (pointer[self.side.axis()] - fixed_edge_pos);
if self.outer_size_range.min < dragged_size {
*is_expanded = true;
}
}
/// Get the current _outer_ width or height of the panel (from previous frame),
/// including the [`Frame`] margin & border, or fall back to some default.
///
/// Always clamped to [`Self::outer_size_range`] so callers get the size the
/// panel would actually render at — never a stale persisted size from a
/// previous build with a different range.
fn outer_size(&self, ui: &Ui) -> f32 {
let axis = self.side.axis();
if let Some(state) = PanelState::load(ui, self.id) {
let raw = if let Some(state) = PanelState::load(ui, self.id) {
state.outer_rect.size_along(axis)
} else if let Some(default_outer_size) = self.default_outer_size {
default_outer_size
} else {
let frame = self.resolve_frame(ui);
ui.style().spacing.interact_size[axis] + frame.total_margin().sum()[axis]
}
};
clamp_to_range(raw, self.outer_size_range)
}
/// Clamp `outer_size` to the allowed range / available space, then compute the panel rect.
@@ -637,7 +833,9 @@ impl Panel {
};
let amount = ui.style().interaction.resize_grab_radius_side * self.side.axis_unit();
let resize_id = self.id.with("__resize");
// Use `resize_id_source` so collapsed/expanded panels in
// `show_animated_between_inside` share one resize widget.
let resize_id = self.resize_id_source.unwrap_or(self.id).with("__resize");
let resize_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amount);
let resize_response = ui.interact(resize_rect, resize_id, Sense::drag());
@@ -645,6 +843,18 @@ impl Panel {
}
fn cursor_icon(&self, outer_size: f32) -> CursorIcon {
// When this panel is the collapsed view of `show_animated_between_inside`
// (`resize_id_source` is set), dragging past `max_size` triggers
// drag-to-expand — so the user can always grow further. Treat the cap
// as `INFINITY` for cursor purposes, otherwise we'd advertise
// "can only shrink" while sitting on a drag-to-expand affordance.
let can_drag_to_expand = self.resize_id_source.is_some();
let max_for_cursor = if can_drag_to_expand {
f32::INFINITY
} else {
self.outer_size_range.max
};
if outer_size <= self.outer_size_range.min {
// Can only grow (toward the resizable side):
match self.side {
@@ -653,7 +863,7 @@ impl Panel {
PanelSide::Top => CursorIcon::ResizeSouth,
PanelSide::Bottom => CursorIcon::ResizeNorth,
}
} else if outer_size < self.outer_size_range.max {
} else if outer_size < max_for_cursor {
if self.side.axis() == 0 {
CursorIcon::ResizeHorizontal
} else {
@@ -676,6 +886,23 @@ impl Panel {
self.slide_fraction = slide_fraction;
self
}
/// Register the resize-handle widget under this `Id` instead of `self.id`.
///
/// Used by [`Self::show_animated_between_inside`] to share one widget across
/// the collapsed and expanded panels.
#[inline]
fn with_resize_id_source(mut self, id: Id) -> Self {
self.resize_id_source = Some(id);
self
}
/// Override the drag-to-collapse threshold (defaults to `min_size`).
#[inline]
fn with_collapse_threshold(mut self, threshold: f32) -> Self {
self.collapse_threshold = Some(threshold);
self
}
}
// ----------------------------------------------------------------------------

View File

@@ -343,13 +343,14 @@ impl WrapApp {
fn backend_panel(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> Command {
// The backend-panel can be toggled on/off.
// We show a little animation when the user switches it.
let is_open = self.state.backend_panel.open || ui.memory(|mem| mem.everything_is_visible());
let mut is_open =
self.state.backend_panel.open || ui.memory(|mem| mem.everything_is_visible());
let mut cmd = Command::Nothing;
egui::Panel::left("backend_panel")
.resizable(false)
.show_animated_inside(ui, is_open, |ui| {
.show_animated_inside(ui, &mut is_open, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("💻 Backend");
@@ -359,6 +360,9 @@ impl WrapApp {
self.backend_panel_contents(ui, frame, &mut cmd);
});
// Allow drag-to-close to close the backend panel:
self.state.backend_panel.open = is_open;
cmd
}

View File

@@ -49,7 +49,7 @@ impl crate::View for Panels {
egui::Panel::top("top_panel")
.resizable(true)
.min_size(32.0)
.show_animated_inside(ui, *top, |ui| {
.show_animated_inside(ui, top, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.heading("Expandable Upper Panel");
@@ -62,7 +62,7 @@ impl crate::View for Panels {
.resizable(true)
.default_size(150.0)
.size_range(80.0..=200.0)
.show_animated_inside(ui, *left, |ui| {
.show_animated_inside(ui, left, |ui| {
ui.vertical_centered(|ui| {
ui.heading("Left Panel");
});
@@ -75,7 +75,7 @@ impl crate::View for Panels {
.resizable(true)
.default_size(150.0)
.size_range(80.0..=200.0)
.show_animated_inside(ui, *right, |ui| {
.show_animated_inside(ui, right, |ui| {
ui.vertical_centered(|ui| {
ui.heading("Right Panel");
});
@@ -84,24 +84,31 @@ impl crate::View for Panels {
});
});
// Bottom panel: drag the top edge down past the expanded panel's min size
// to collapse; drag it back up past the collapsed panel's max size to
// re-expand. Both panels are `.resizable(true)` so each one's edge accepts
// the gesture; the collapsed panel uses `exact_size` so even a tiny
// outward drag is enough to trigger the swap.
egui::Panel::show_animated_between_inside(
ui,
*bottom,
egui::Panel::bottom("bottom_panel_collapsed"),
egui::Panel::bottom("bottom_panel_expanded"),
bottom,
egui::Panel::bottom("bottom_panel_collapsed")
.resizable(true)
.exact_size(16.0),
egui::Panel::bottom("bottom_panel_expanded")
.resizable(true)
.max_size(128.0),
|ui, expanded| {
if expanded {
ui.vertical_centered(|ui| {
if ui.button("Collapse bottom panel").clicked() {
*bottom = false;
}
ui.heading("Bottom panel");
});
egui::ScrollArea::vertical().show(ui, |ui| {
lorem_ipsum(ui);
});
ui.label(egui::RichText::new(crate::LOREM_IPSUM_LONG).small().weak());
} else {
ui.vertical_centered(|ui| {
if ui.button("Expand bottom panel").clicked() {
*bottom = true;
}
ui.label("Bottom panel (collapsed)");
});
}
},

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b339d04f331a5d5f7ea17a1c68f61626e08ad5199aec9181080c711a60f07d53
size 344102
oid sha256:b2d186d7404839e1b8d33ea186751035362a085e5f60a774a5f1bd66dd3e1442
size 347049

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eba6e690937fbbd22c8edfce14078f50998e968324d8073d5db5829493d957e0
size 5292

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525
size 65797

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525
size 65797

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcf9871c19199c94cfbc83818b4f70fe9fab5c396a3fb32cb79b713e9145bcae
size 3001

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf09470a6628e62421bfa754ce238dd13820ba0bf8146ae835e539874d39a150
size 4882

View File

@@ -0,0 +1,157 @@
//! Snapshot tests for `Panel`'s drag-to-close and drag-to-expand gestures.
//!
//! Covers:
//! * [`Panel::show_animated_inside`] — drag-to-close on a `Left` panel.
//! * [`Panel::show_animated_between_inside`] — drag-to-close on the expanded panel
//! followed by drag-to-expand on the collapsed panel, both via the shared
//! resize handle.
use egui::{Panel, Pos2, Vec2};
use egui_kittest::{Harness, SnapshotResults};
/// Pure-data state for the kittest UI closure.
#[derive(Default)]
struct State {
is_expanded: bool,
}
#[test]
fn drag_to_close_animated_inside() {
let mut results = SnapshotResults::new();
let mut harness = Harness::builder()
.with_size(Vec2::new(400.0, 200.0))
.build_ui_state(
|ui, state: &mut State| {
Panel::left("test_left_panel")
.resizable(true)
.default_size(120.0)
.min_size(60.0)
.show_animated_inside(ui, &mut state.is_expanded, |ui| {
ui.label("Left panel content");
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.label("Central");
});
},
State { is_expanded: true },
);
harness.run();
assert!(harness.state().is_expanded, "should start expanded");
results.add(harness.try_snapshot("panel_drag/inside_initial"));
// Query the actual resize edge from PanelState (avoids assumptions about
// Frame margins and the harness's ui padding).
let panel_state = egui::PanelState::load(&harness.ctx, egui::Id::new("test_left_panel"))
.expect("PanelState should be persisted after the first frame");
let resize_x = panel_state.outer_rect.right();
let resize_y = panel_state.outer_rect.center().y;
let drag_start = Pos2::new(resize_x, resize_y);
let drag_end = Pos2::new(resize_x - 200.0, resize_y);
harness.drag_at(drag_start);
harness.run();
harness.hover_at(drag_end);
harness.run();
harness.drop_at(drag_end);
harness.run();
assert!(
!harness.state().is_expanded,
"drag past min_size should have closed the panel"
);
results.add(harness.try_snapshot("panel_drag/inside_closed"));
}
#[test]
fn drag_to_close_and_reopen_animated_between() {
let mut results = SnapshotResults::new();
let panel_size = 400.0_f32;
let expanded_size = 120.0_f32;
let collapsed_size = 28.0_f32;
let mut harness = Harness::builder()
.with_size(Vec2::new(panel_size, 300.0))
.build_ui_state(
|ui, state: &mut State| {
let collapsed = Panel::bottom("between_collapsed")
.resizable(true)
.exact_size(collapsed_size);
let expanded = Panel::bottom("between_expanded")
.resizable(true)
.default_size(expanded_size);
Panel::show_animated_between_inside(
ui,
&mut state.is_expanded,
collapsed,
expanded,
|ui, expanded| {
if expanded {
ui.heading("Expanded panel");
ui.separator();
for i in 0..6 {
ui.label(format!(
"Row {i}: filler content so the \
expanded panel is clearly taller than the \
collapsed one in the snapshot."
));
}
} else {
ui.label("Collapsed");
}
},
);
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.label("Central");
});
},
State { is_expanded: true },
);
harness.run();
assert!(harness.state().is_expanded, "should start expanded");
results.add(harness.try_snapshot("panel_drag/between_initial_expanded"));
// Drag-to-close: grab the top edge of the expanded bottom panel and drag
// it down past the panel's minimum height to collapse.
let expanded_state = egui::PanelState::load(&harness.ctx, egui::Id::new("between_expanded"))
.expect("expanded PanelState should be persisted");
let expanded_resize_y = expanded_state.outer_rect.top();
let drag_x = expanded_state.outer_rect.center().x;
let bottom_y = expanded_state.outer_rect.bottom();
harness.drag_at(Pos2::new(drag_x, expanded_resize_y));
harness.run();
harness.hover_at(Pos2::new(drag_x, bottom_y - 1.0));
harness.run();
harness.drop_at(Pos2::new(drag_x, bottom_y - 1.0));
harness.run();
assert!(
!harness.state().is_expanded,
"drag past min should have closed the expanded panel"
);
results.add(harness.try_snapshot("panel_drag/between_collapsed"));
// Drag-to-expand: grab the top edge of the (now visible) collapsed panel
// and drag it upward past the collapsed panel's exact_size cap.
let collapsed_state = egui::PanelState::load(&harness.ctx, egui::Id::new("between_collapsed"))
.expect("collapsed PanelState should be persisted");
let collapsed_resize_y = collapsed_state.outer_rect.top();
harness.drag_at(Pos2::new(drag_x, collapsed_resize_y));
harness.run();
harness.hover_at(Pos2::new(drag_x, collapsed_resize_y - 200.0));
harness.run();
harness.drop_at(Pos2::new(drag_x, collapsed_resize_y - 200.0));
harness.run();
assert!(
harness.state().is_expanded,
"drag past collapsed exact_size should have reopened the panel"
);
results.add(harness.try_snapshot("panel_drag/between_reopened"));
}