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:
@@ -31,6 +31,9 @@ fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
|
|||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
pub struct PanelState {
|
pub struct PanelState {
|
||||||
/// The _outer_ rect of the panel, i.e. including the [`Frame`] margin & border.
|
/// 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"))]
|
#[cfg_attr(feature = "serde", serde(alias = "rect"))]
|
||||||
pub outer_rect: 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.
|
/// 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`].
|
/// While `slide_fraction != 1.0` the panel does _not_ persist its [`PanelState`].
|
||||||
slide_fraction: f32,
|
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 {
|
impl Panel {
|
||||||
@@ -244,6 +262,8 @@ impl Panel {
|
|||||||
default_outer_size,
|
default_outer_size,
|
||||||
outer_size_range,
|
outer_size_range,
|
||||||
slide_fraction: 1.0,
|
slide_fraction: 1.0,
|
||||||
|
resize_id_source: None,
|
||||||
|
collapse_threshold: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,38 +356,56 @@ impl Panel {
|
|||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||||
) -> InnerResponse<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.
|
/// otherwise hide it, with a slide animation in between.
|
||||||
///
|
///
|
||||||
/// During the animation `add_contents` runs against the real panel, and the
|
/// During the animation `add_contents` runs against the real panel, and the
|
||||||
/// panel slides off-screen toward its fixed edge (clipped against the parent).
|
/// panel slides off-screen toward its fixed edge (clipped against the parent).
|
||||||
/// The parent only reserves the _visible_ portion, so neighboring widgets follow.
|
/// 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>(
|
pub fn show_animated_inside<R>(
|
||||||
self,
|
self,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
is_expanded: bool,
|
is_expanded: &mut bool,
|
||||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||||
) -> Option<InnerResponse<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 {
|
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:
|
// Make sure the ids of the next widgets are the same whether we show the panel or not:
|
||||||
ui.skip_ahead_auto_ids(1);
|
ui.skip_ahead_auto_ids(1);
|
||||||
return None;
|
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 {
|
let panel = if how_expanded < 1.0 {
|
||||||
// Mid-animation: slide the panel toward its fixed edge.
|
if drag_in_progress {
|
||||||
// Resizing a moving boundary is too awkward, so disable it during the slide.
|
// Mid-animation but the user is dragging — keep resize live so the
|
||||||
self.with_slide_fraction(how_expanded).resizable(false)
|
// 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 {
|
} else {
|
||||||
self
|
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.
|
/// 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
|
/// `add_contents` receives `expanded = true` whenever the expanded panel is
|
||||||
/// rendered (including mid-animation), and `false` for the collapsed view.
|
/// 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.
|
/// 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| {
|
/// # egui::__run_test_ui(|ui| {
|
||||||
/// let mut is_expanded = true;
|
/// 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")
|
/// let expanded = egui::Panel::top("top_expanded")
|
||||||
/// .resizable(true)
|
/// .resizable(true)
|
||||||
/// .default_size(120.0);
|
/// .default_size(120.0);
|
||||||
/// egui::Panel::show_animated_between_inside(
|
/// egui::Panel::show_animated_between_inside(
|
||||||
/// ui,
|
/// ui,
|
||||||
/// is_expanded,
|
/// &mut is_expanded,
|
||||||
/// collapsed,
|
/// collapsed,
|
||||||
/// expanded,
|
/// expanded,
|
||||||
/// |ui, expanded| {
|
/// |ui, expanded| {
|
||||||
@@ -407,43 +464,88 @@ impl Panel {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn show_animated_between_inside<R>(
|
pub fn show_animated_between_inside<R>(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
is_expanded: bool,
|
is_expanded: &mut bool,
|
||||||
collapsed_panel: Self,
|
collapsed_panel: Self,
|
||||||
expanded_panel: Self,
|
expanded_panel: Self,
|
||||||
add_contents: impl FnOnce(&mut Ui, bool) -> R,
|
add_contents: impl FnOnce(&mut Ui, bool) -> R,
|
||||||
) -> InnerResponse<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 expanding, the user sees the expanded content the moment animation starts.
|
||||||
// When collapsing, keep showing the expanded content until past the midpoint,
|
// When collapsing, keep showing the expanded content until past the midpoint,
|
||||||
// then swap to the collapsed content for the rest of the slide-out.
|
// then swap to the collapsed content for the rest of the slide-out.
|
||||||
let show_expanded_contents = if is_expanded {
|
let show_expanded_contents = *is_expanded || 0.5 < how_expanded;
|
||||||
true
|
|
||||||
} else {
|
|
||||||
0.5 < how_expanded
|
|
||||||
};
|
|
||||||
|
|
||||||
if how_expanded == 0.0 {
|
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 {
|
} else {
|
||||||
|
let expanded_panel = expanded_panel.with_collapse_threshold(collapse_threshold);
|
||||||
let panel = if how_expanded < 1.0 {
|
let panel = if how_expanded < 1.0 {
|
||||||
// Animate the visible size from collapsed_size to expanded_size,
|
// Animate the visible size from collapsed_size to expanded_size,
|
||||||
// so the slide picks up where the collapsed panel left off.
|
// 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 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 {
|
let slide_fraction = if 0.0 < expanded_size {
|
||||||
visible_size / expanded_size
|
visible_size / expanded_size
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
expanded_panel
|
let panel = expanded_panel.with_slide_fraction(slide_fraction);
|
||||||
.with_slide_fraction(slide_fraction)
|
// Keep the resize handle live during the slide if the drag is
|
||||||
.resizable(false)
|
// 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 {
|
} else {
|
||||||
expanded_panel
|
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
|
// Private methods to support the various show methods
|
||||||
impl Panel {
|
impl Panel {
|
||||||
/// Show the panel inside a [`Ui`].
|
/// 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>(
|
fn show_inside_dyn<'c, R>(
|
||||||
self,
|
self,
|
||||||
parent_ui: &mut Ui,
|
parent_ui: &mut Ui,
|
||||||
|
is_expanded: Option<&mut bool>,
|
||||||
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
|
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
|
||||||
) -> InnerResponse<R> {
|
) -> InnerResponse<R> {
|
||||||
let side = self.side;
|
let side = self.side;
|
||||||
@@ -470,18 +578,56 @@ impl Panel {
|
|||||||
// Check for duplicate id
|
// Check for duplicate id
|
||||||
parent_ui.check_for_id_clash(id, outer_rect, "Panel");
|
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 {
|
if resizable {
|
||||||
// Resolve the resize interaction first to avoid frame latency in the resize.
|
// 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)
|
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()
|
&& let Some(pointer) = resize_response.interact_pointer_pos()
|
||||||
{
|
{
|
||||||
|
resize_drag_in_progress = resize_response.dragged();
|
||||||
let axis = side.axis();
|
let axis = side.axis();
|
||||||
outer_size = (pointer[axis] - side.fixed_pos(outer_rect)).abs();
|
let prev_outer_size = outer_size;
|
||||||
outer_size = clamp_to_range(outer_size, outer_size_range)
|
// 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));
|
.at_most(available_rect.size_along(axis));
|
||||||
side.set_rect_size(&mut outer_rect, outer_size);
|
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));
|
parent_ui.set_cursor_icon(self.cursor_icon(outer_size));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.slide_fraction == 1.0 {
|
if !resize_drag_in_progress {
|
||||||
// Only persist the panel's rect when it's fully expanded —
|
// Skipping during a drag
|
||||||
// skip while sliding so the stored rect always reflects the real layout.
|
// 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 {
|
PanelState {
|
||||||
outer_rect: shifted_outer_rect,
|
outer_rect: shifted_outer_rect,
|
||||||
}
|
}
|
||||||
@@ -603,18 +750,67 @@ impl Panel {
|
|||||||
.unwrap_or_else(|| Frame::side_top_panel(ui.style()))
|
.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),
|
/// Get the current _outer_ width or height of the panel (from previous frame),
|
||||||
/// including the [`Frame`] margin & border, or fall back to some default.
|
/// 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 {
|
fn outer_size(&self, ui: &Ui) -> f32 {
|
||||||
let axis = self.side.axis();
|
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)
|
state.outer_rect.size_along(axis)
|
||||||
} else if let Some(default_outer_size) = self.default_outer_size {
|
} else if let Some(default_outer_size) = self.default_outer_size {
|
||||||
default_outer_size
|
default_outer_size
|
||||||
} else {
|
} else {
|
||||||
let frame = self.resolve_frame(ui);
|
let frame = self.resolve_frame(ui);
|
||||||
ui.style().spacing.interact_size[axis] + frame.total_margin().sum()[axis]
|
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.
|
/// 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 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_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amount);
|
||||||
let resize_response = ui.interact(resize_rect, resize_id, Sense::drag());
|
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 {
|
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 {
|
if outer_size <= self.outer_size_range.min {
|
||||||
// Can only grow (toward the resizable side):
|
// Can only grow (toward the resizable side):
|
||||||
match self.side {
|
match self.side {
|
||||||
@@ -653,7 +863,7 @@ impl Panel {
|
|||||||
PanelSide::Top => CursorIcon::ResizeSouth,
|
PanelSide::Top => CursorIcon::ResizeSouth,
|
||||||
PanelSide::Bottom => CursorIcon::ResizeNorth,
|
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 {
|
if self.side.axis() == 0 {
|
||||||
CursorIcon::ResizeHorizontal
|
CursorIcon::ResizeHorizontal
|
||||||
} else {
|
} else {
|
||||||
@@ -676,6 +886,23 @@ impl Panel {
|
|||||||
self.slide_fraction = slide_fraction;
|
self.slide_fraction = slide_fraction;
|
||||||
self
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -343,13 +343,14 @@ impl WrapApp {
|
|||||||
fn backend_panel(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> Command {
|
fn backend_panel(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> Command {
|
||||||
// The backend-panel can be toggled on/off.
|
// The backend-panel can be toggled on/off.
|
||||||
// We show a little animation when the user switches it.
|
// 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;
|
let mut cmd = Command::Nothing;
|
||||||
|
|
||||||
egui::Panel::left("backend_panel")
|
egui::Panel::left("backend_panel")
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.show_animated_inside(ui, is_open, |ui| {
|
.show_animated_inside(ui, &mut is_open, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.heading("💻 Backend");
|
ui.heading("💻 Backend");
|
||||||
@@ -359,6 +360,9 @@ impl WrapApp {
|
|||||||
self.backend_panel_contents(ui, frame, &mut cmd);
|
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
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl crate::View for Panels {
|
|||||||
egui::Panel::top("top_panel")
|
egui::Panel::top("top_panel")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.min_size(32.0)
|
.min_size(32.0)
|
||||||
.show_animated_inside(ui, *top, |ui| {
|
.show_animated_inside(ui, top, |ui| {
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.heading("Expandable Upper Panel");
|
ui.heading("Expandable Upper Panel");
|
||||||
@@ -62,7 +62,7 @@ impl crate::View for Panels {
|
|||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_size(150.0)
|
.default_size(150.0)
|
||||||
.size_range(80.0..=200.0)
|
.size_range(80.0..=200.0)
|
||||||
.show_animated_inside(ui, *left, |ui| {
|
.show_animated_inside(ui, left, |ui| {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.heading("Left Panel");
|
ui.heading("Left Panel");
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ impl crate::View for Panels {
|
|||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_size(150.0)
|
.default_size(150.0)
|
||||||
.size_range(80.0..=200.0)
|
.size_range(80.0..=200.0)
|
||||||
.show_animated_inside(ui, *right, |ui| {
|
.show_animated_inside(ui, right, |ui| {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.heading("Right Panel");
|
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(
|
egui::Panel::show_animated_between_inside(
|
||||||
ui,
|
ui,
|
||||||
*bottom,
|
bottom,
|
||||||
egui::Panel::bottom("bottom_panel_collapsed"),
|
egui::Panel::bottom("bottom_panel_collapsed")
|
||||||
egui::Panel::bottom("bottom_panel_expanded"),
|
.resizable(true)
|
||||||
|
.exact_size(16.0),
|
||||||
|
egui::Panel::bottom("bottom_panel_expanded")
|
||||||
|
.resizable(true)
|
||||||
|
.max_size(128.0),
|
||||||
|ui, expanded| {
|
|ui, expanded| {
|
||||||
if expanded {
|
if expanded {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
if ui.button("Collapse bottom panel").clicked() {
|
ui.heading("Bottom panel");
|
||||||
*bottom = false;
|
});
|
||||||
}
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
lorem_ipsum(ui);
|
||||||
});
|
});
|
||||||
ui.label(egui::RichText::new(crate::LOREM_IPSUM_LONG).small().weak());
|
|
||||||
} else {
|
} else {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
if ui.button("Expand bottom panel").clicked() {
|
ui.label("Bottom panel (collapsed)");
|
||||||
*bottom = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b339d04f331a5d5f7ea17a1c68f61626e08ad5199aec9181080c711a60f07d53
|
oid sha256:b2d186d7404839e1b8d33ea186751035362a085e5f60a774a5f1bd66dd3e1442
|
||||||
size 344102
|
size 347049
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:eba6e690937fbbd22c8edfce14078f50998e968324d8073d5db5829493d957e0
|
||||||
|
size 5292
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525
|
||||||
|
size 65797
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525
|
||||||
|
size 65797
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bcf9871c19199c94cfbc83818b4f70fe9fab5c396a3fb32cb79b713e9145bcae
|
||||||
|
size 3001
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cf09470a6628e62421bfa754ce238dd13820ba0bf8146ae835e539874d39a150
|
||||||
|
size 4882
|
||||||
157
tests/egui_tests/tests/test_panel_drag.rs
Normal file
157
tests/egui_tests/tests/test_panel_drag.rs
Normal 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"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user