mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Add drag-to-open for collapsible panels (#8363)
A fully collapsed `show_collapsible` panel now leaves a thin grab handle at its fixed edge, invisible until hovered. Dragging it out past `min_size` (or double-clicking it) reopens the panel. Opt out with `panel.drag_to_open(false)`. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,14 +18,24 @@
|
|||||||
use emath::GuiRounding as _;
|
use emath::GuiRounding as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Align, Context, CursorIcon, Frame, Id, InnerResponse, Layout, Margin, NumExt as _, Rangef,
|
Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, Margin, NumExt as _,
|
||||||
Rect, Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp,
|
Order, Rangef, Rect, Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
|
fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
|
||||||
ctx.animate_bool_responsive(id, is_expanded)
|
ctx.animate_bool_responsive(id, is_expanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Id`] of a panel's resize-handle widget.
|
||||||
|
///
|
||||||
|
/// A panel registers its handle under this same id whether it is open,
|
||||||
|
/// mid-slide, or fully collapsed — that is what lets one uninterrupted drag
|
||||||
|
/// collapse the panel and pull it back open. [`Panel::show_switched`] points
|
||||||
|
/// both of its panels at one shared handle the same way.
|
||||||
|
fn resize_widget_id(id_source: Id) -> Id {
|
||||||
|
id_source.with("__resize")
|
||||||
|
}
|
||||||
|
|
||||||
/// State regarding panels.
|
/// State regarding panels.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
@@ -198,6 +208,7 @@ pub struct Panel {
|
|||||||
id: Id,
|
id: Id,
|
||||||
frame: Option<Frame>,
|
frame: Option<Frame>,
|
||||||
resizable: bool,
|
resizable: bool,
|
||||||
|
drag_to_open: bool,
|
||||||
show_separator_line: bool,
|
show_separator_line: bool,
|
||||||
|
|
||||||
/// _Outer_ size (including [`Frame`] margin & border):
|
/// _Outer_ size (including [`Frame`] margin & border):
|
||||||
@@ -283,6 +294,7 @@ impl Panel {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
frame: None,
|
frame: None,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
|
drag_to_open: true,
|
||||||
show_separator_line: true,
|
show_separator_line: true,
|
||||||
default_outer_size,
|
default_outer_size,
|
||||||
outer_size_range,
|
outer_size_range,
|
||||||
@@ -312,6 +324,24 @@ impl Panel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can a fully collapsed panel be dragged back open?
|
||||||
|
///
|
||||||
|
/// Default: `true`.
|
||||||
|
///
|
||||||
|
/// When enabled, a panel that [`Self::show_collapsible`] has collapsed all
|
||||||
|
/// the way still leaves a thin grab handle at its fixed edge. The handle is
|
||||||
|
/// invisible until hovered, at which point it lights up like a normal resize
|
||||||
|
/// handle. Dragging it outward past [`Self::min_size`] — or double-clicking
|
||||||
|
/// it — reopens the panel.
|
||||||
|
///
|
||||||
|
/// This is the counterpart to drag-to-collapse, and like it requires
|
||||||
|
/// [`Self::resizable`] to be `true`.
|
||||||
|
#[inline]
|
||||||
|
pub fn drag_to_open(mut self, drag_to_open: bool) -> Self {
|
||||||
|
self.drag_to_open = drag_to_open;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Show a separator line, even when not interacting with it?
|
/// Show a separator line, even when not interacting with it?
|
||||||
///
|
///
|
||||||
/// The separator line sits on the panel's inner edge, i.e. the edge facing the rest of the ui.
|
/// The separator line sits on the panel's inner edge, i.e. the edge facing the rest of the ui.
|
||||||
@@ -415,6 +445,9 @@ impl Panel {
|
|||||||
/// to `true` if the user drags the handle outward while the panel is closed.
|
/// to `true` if the user drags the handle outward while the panel is closed.
|
||||||
/// When [`Self::resizable`] is `true`, double-clicking the resize edge also
|
/// When [`Self::resizable`] is `true`, double-clicking the resize edge also
|
||||||
/// flips `*is_expanded`.
|
/// flips `*is_expanded`.
|
||||||
|
///
|
||||||
|
/// A fully collapsed panel keeps a thin grab handle at its fixed edge, so the
|
||||||
|
/// user can drag it back open. See [`Self::drag_to_open`] to opt out.
|
||||||
pub fn show_collapsible<R>(
|
pub fn show_collapsible<R>(
|
||||||
self,
|
self,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
@@ -424,10 +457,11 @@ impl Panel {
|
|||||||
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
|
// Panel is fully closed, but we still leave a grab handle at its fixed
|
||||||
// from a previous frame, keep its widget id alive so they can drag the
|
// edge so the user can drag it back open.
|
||||||
// panel back out without releasing.
|
if self.resizable && self.drag_to_open {
|
||||||
self.keep_drag_alive_for_reopen(ui, is_expanded);
|
self.collapsed_resize_handle(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);
|
||||||
@@ -436,7 +470,7 @@ impl Panel {
|
|||||||
|
|
||||||
// Don't lose the drag during the slide-back-open animation:
|
// Don't lose the drag during the slide-back-open animation:
|
||||||
let drag_in_progress = ui
|
let drag_in_progress = ui
|
||||||
.read_response(self.id.with("__resize"))
|
.read_response(self.resize_id())
|
||||||
.is_some_and(|r| r.dragged());
|
.is_some_and(|r| r.dragged());
|
||||||
|
|
||||||
let panel = if how_expanded < 1.0 {
|
let panel = if how_expanded < 1.0 {
|
||||||
@@ -549,20 +583,11 @@ impl Panel {
|
|||||||
|
|
||||||
// Is the resize handle currently being dragged?
|
// Is the resize handle currently being dragged?
|
||||||
let drag_in_progress = ui
|
let drag_in_progress = ui
|
||||||
.read_response(resize_id_source.with("__resize"))
|
.read_response(resize_widget_id(resize_id_source))
|
||||||
.is_some_and(|r| r.dragged());
|
.is_some_and(|r| r.dragged());
|
||||||
|
|
||||||
let animation_id = expanded_panel.id.with("animation");
|
let animation_id = expanded_panel.id.with("animation");
|
||||||
// While the user is dragging, snap the animation to the target so the
|
let how_expanded = animate_expansion(ui, animation_id, *is_expanded);
|
||||||
// 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,
|
||||||
@@ -585,7 +610,19 @@ impl Panel {
|
|||||||
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 expanded_size = expanded_panel.outer_size(ui);
|
let expanded_size = if drag_in_progress {
|
||||||
|
// During a drag the pointer sets the size, clamped to `min_size`
|
||||||
|
// — so that, not the (stale) persisted size, is where the slide
|
||||||
|
// meets the collapsed panel, whether opening or closing. Get it
|
||||||
|
// wrong and the panel jumps the gap between the two sizes in one
|
||||||
|
// frame.
|
||||||
|
expanded_panel
|
||||||
|
.outer_size_range
|
||||||
|
.min
|
||||||
|
.at_least(collapse_threshold)
|
||||||
|
} else {
|
||||||
|
expanded_panel.outer_size(ui)
|
||||||
|
};
|
||||||
let visible_size = lerp(collapse_threshold..=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
|
||||||
@@ -702,7 +739,7 @@ impl Panel {
|
|||||||
// released size gets persisted into [`PanelState`] — without this the
|
// released size gets persisted into [`PanelState`] — without this the
|
||||||
// store-skipped-during-drag rule would leave the stored size at the
|
// store-skipped-during-drag rule would leave the stored size at the
|
||||||
// pre-drag value.
|
// pre-drag value.
|
||||||
let resize_id = self.resize_id_source.unwrap_or(id).with("__resize");
|
let resize_id = self.resize_id();
|
||||||
let resize_response = parent_ui.read_response(resize_id);
|
let resize_response = parent_ui.read_response(resize_id);
|
||||||
|
|
||||||
// Double-click on the resize edge toggles `*is_expanded` for the
|
// Double-click on the resize edge toggles `*is_expanded` for the
|
||||||
@@ -860,19 +897,25 @@ impl Panel {
|
|||||||
.store(parent_ui, id);
|
.store(parent_ui, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the separator once the panel is mostly slid off — at that point
|
// The highlight follows the pointer all the way down to zero size, where
|
||||||
// the line would just be a stray dash hovering near the parent edge.
|
// `collapsed_resize_handle` picks it straight up again — so the user never
|
||||||
if 0.01 < self.slide_fraction {
|
// loses sight of the edge they are dragging. The dim idle separator does
|
||||||
let stroke = if is_resizing {
|
// get hidden once the panel is mostly slid off, since there it would just
|
||||||
parent_ui.style().visuals.widgets.active.fg_stroke // highly visible
|
// be a stray dash hovering near the parent edge.
|
||||||
} else if resize_hover {
|
let stroke = if is_resizing {
|
||||||
parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible
|
parent_ui.style().visuals.widgets.active.fg_stroke // highly visible
|
||||||
} else if show_separator_line {
|
} else if resize_hover {
|
||||||
// TODO(emilk): distinguish resizable from non-resizable
|
parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible
|
||||||
parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim
|
} else if show_separator_line && 0.01 < self.slide_fraction {
|
||||||
} else {
|
// TODO(emilk): distinguish resizable from non-resizable
|
||||||
Stroke::NONE
|
parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim
|
||||||
};
|
} else {
|
||||||
|
Stroke::NONE
|
||||||
|
};
|
||||||
|
if 0.0 < stroke.width {
|
||||||
|
// Nudged inward, to keep the line inside the panel's own (shifted)
|
||||||
|
// rect: `parent_ui`'s painter sits below the panels that come after
|
||||||
|
// this one, so anything drawn past the fixed edge is covered by them.
|
||||||
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
|
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
|
||||||
|
|
||||||
// The line goes just _outside_ the frame's outline, in the room `resolve_frame`
|
// The line goes just _outside_ the frame's outline, in the room `resolve_frame`
|
||||||
@@ -893,6 +936,13 @@ impl Panel {
|
|||||||
inner_response
|
inner_response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Id`] of this panel's resize-handle widget.
|
||||||
|
///
|
||||||
|
/// See [`resize_widget_id`] for why open and collapsed panels must share it.
|
||||||
|
fn resize_id(&self) -> Id {
|
||||||
|
resize_widget_id(self.resize_id_source.unwrap_or(self.id))
|
||||||
|
}
|
||||||
|
|
||||||
/// The configured [`Frame`], or the default side/top panel frame for this [`Ui`].
|
/// The configured [`Frame`], or the default side/top panel frame for this [`Ui`].
|
||||||
fn resolve_frame(&self, ui: &Ui) -> Frame {
|
fn resolve_frame(&self, ui: &Ui) -> Frame {
|
||||||
let mut frame = self
|
let mut frame = self
|
||||||
@@ -918,48 +968,91 @@ impl Panel {
|
|||||||
frame
|
frame
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panel is fully closed. If the user is still dragging the resize handle
|
/// The grab handle of a fully collapsed panel: a thin strip along the panel's
|
||||||
/// from the frame the panel closed on, keep its widget id registered so the
|
/// fixed edge, invisible until hovered.
|
||||||
/// 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) {
|
/// Dragging it outward past the minimum size — or double-clicking it —
|
||||||
let resize_id = self.id.with("__resize");
|
/// reopens the panel. Registering it under the same id as the expanded
|
||||||
let Some(resize_response) = ui.read_response(resize_id) else {
|
/// panel's resize handle also keeps an in-progress drag-to-collapse gesture
|
||||||
return;
|
/// alive, so the user can drag the panel straight back out without releasing.
|
||||||
};
|
fn collapsed_resize_handle(&self, ui: &Ui, is_expanded: &mut bool) {
|
||||||
if !resize_response.dragged() {
|
let side = self.side;
|
||||||
return;
|
let axis = side.axis();
|
||||||
}
|
|
||||||
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 available_rect = ui.available_rect_before_wrap();
|
||||||
let fixed_edge_pos = self.side.fixed_pos(available_rect);
|
let fixed_edge_pos = side.fixed_pos(available_rect);
|
||||||
let cross_range = available_rect.range_along(self.side.cross_axis());
|
let cross_range = available_rect.range_along(side.cross_axis());
|
||||||
let resize_rect = if self.side.axis() == 0 {
|
|
||||||
|
// The strip lies just _inside_ the fixed edge, so it never reaches
|
||||||
|
// outside the area the panel is allowed to occupy.
|
||||||
|
let mut resize_rect = if axis == 0 {
|
||||||
Rect::from_x_y_ranges(Rangef::point(fixed_edge_pos), cross_range)
|
Rect::from_x_y_ranges(Rangef::point(fixed_edge_pos), cross_range)
|
||||||
} else {
|
} else {
|
||||||
Rect::from_x_y_ranges(cross_range, Rangef::point(fixed_edge_pos))
|
Rect::from_x_y_ranges(cross_range, Rangef::point(fixed_edge_pos))
|
||||||
};
|
};
|
||||||
let grab = ui.style().interaction.resize_grab_radius_side;
|
side.set_rect_size(
|
||||||
let resize_rect = resize_rect.expand2(grab * self.side.axis_unit());
|
&mut resize_rect,
|
||||||
ui.interact(resize_rect, resize_id, Sense::drag());
|
ui.style().interaction.resize_grab_radius_side,
|
||||||
|
);
|
||||||
|
|
||||||
// Keep the resize cursor while the user is still holding the drag.
|
let resize_id = self.resize_id();
|
||||||
// Otherwise the cursor would snap back to the default the moment the
|
let response = ui.interact(resize_rect, resize_id, Sense::click_and_drag());
|
||||||
// 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
|
if response.double_clicked() {
|
||||||
// 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;
|
*is_expanded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.hovered() || response.dragged() {
|
||||||
|
// Advertise that the panel can be pulled out. Also keeps the resize
|
||||||
|
// cursor for a drag that started before the panel closed, instead of
|
||||||
|
// snapping back to the default mid-gesture.
|
||||||
|
ui.set_cursor_icon(self.cursor_icon(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.dragged()
|
||||||
|
&& let Some(pointer) = response.interact_pointer_pos()
|
||||||
|
{
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// We require the full minimum size, so the panel never jumps ahead of
|
||||||
|
// the pointer: it opens exactly when the drag reaches the size it will
|
||||||
|
// open at, and follows the pointer from there.
|
||||||
|
let dragged_size = -side.sign() * (pointer[axis] - fixed_edge_pos);
|
||||||
|
if self.outer_size_range.min < dragged_size {
|
||||||
|
*is_expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible until hovered, so the handle doesn't read as a stray line at
|
||||||
|
// the edge of the screen.
|
||||||
|
let stroke = if response.dragged() {
|
||||||
|
ui.style().visuals.widgets.active.fg_stroke
|
||||||
|
} else if response.hovered() {
|
||||||
|
ui.style().visuals.widgets.hovered.fg_stroke
|
||||||
|
} else {
|
||||||
|
Stroke::NONE
|
||||||
|
};
|
||||||
|
if 0.0 < stroke.width {
|
||||||
|
// The collapsed panel occupies no space of its own, so the line has to
|
||||||
|
// go _inside_ the area the following panels use — which means painting
|
||||||
|
// in a layer above them, or they would cover it.
|
||||||
|
// TODO(emilk): use the panel's own layer once https://github.com/emilk/egui/issues/1516 is done
|
||||||
|
let painter = ui
|
||||||
|
.ctx()
|
||||||
|
.layer_painter(LayerId::new(Order::Middle, resize_id))
|
||||||
|
.with_clip_rect(resize_rect);
|
||||||
|
|
||||||
|
// Nudge the line inward so it isn't half-clipped by the edge.
|
||||||
|
let line_pos = fixed_edge_pos - 0.5 * side.sign() * stroke.width;
|
||||||
|
if axis == 0 {
|
||||||
|
painter.vline(line_pos, cross_range, stroke);
|
||||||
|
} else {
|
||||||
|
painter.hline(cross_range, line_pos, stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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),
|
||||||
@@ -994,7 +1087,7 @@ impl Panel {
|
|||||||
|
|
||||||
// Use `resize_id_source` so collapsed/expanded panels in
|
// Use `resize_id_source` so collapsed/expanded panels in
|
||||||
// `show_switched` share one resize widget.
|
// `show_switched` share one resize widget.
|
||||||
let resize_id = self.resize_id_source.unwrap_or(self.id).with("__resize");
|
let resize_id = self.resize_id();
|
||||||
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);
|
||||||
ui.interact(resize_rect, resize_id, Sense::click_and_drag())
|
ui.interact(resize_rect, resize_id, Sense::click_and_drag())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,7 +361,8 @@ impl WrapApp {
|
|||||||
let mut cmd = Command::Nothing;
|
let mut cmd = Command::Nothing;
|
||||||
|
|
||||||
egui::Panel::left("backend_panel")
|
egui::Panel::left("backend_panel")
|
||||||
.resizable(false)
|
.resizable(true)
|
||||||
|
.size_range(280..=400)
|
||||||
.show_collapsible(ui, &mut is_open, |ui| {
|
.show_collapsible(ui, &mut is_open, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
|
|||||||
@@ -94,10 +94,10 @@ impl crate::View for Panels {
|
|||||||
bottom,
|
bottom,
|
||||||
egui::Panel::bottom("bottom_panel_collapsed")
|
egui::Panel::bottom("bottom_panel_collapsed")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_size(20.0),
|
.exact_size(20.0),
|
||||||
egui::Panel::bottom("bottom_panel_expanded")
|
egui::Panel::bottom("bottom_panel_expanded")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.max_size(128.0),
|
.size_range(64.0..=128.0),
|
||||||
|ui, expanded| {
|
|ui, expanded| {
|
||||||
if expanded {
|
if expanded {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
|
|||||||
@@ -170,6 +170,14 @@ impl From<&RangeInclusive<f32>> for Rangef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Makes specifying size ranges slightly more convenient (no need for the extra `.0` suffixes)
|
||||||
|
impl From<RangeInclusive<i32>> for Rangef {
|
||||||
|
#[inline]
|
||||||
|
fn from(range: RangeInclusive<i32>) -> Self {
|
||||||
|
Self::new(*range.start() as _, *range.end() as _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<RangeFrom<f32>> for Rangef {
|
impl From<RangeFrom<f32>> for Rangef {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn from(range: RangeFrom<f32>) -> Self {
|
fn from(range: RangeFrom<f32>) -> Self {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:71c6c7299aad708fd16b945bcc9c82f529a276486eb1b2042f2ebaa932935a54
|
||||||
|
size 3516
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bc0393c0b85df389314d15161b14803fde138eb66ef2b055335aef275ac03d1c
|
||||||
|
size 3538
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2bbf9c826403a00937f9e37cccc76b16dc67db8aef3536bf076922fdd2d564d5
|
||||||
|
size 4881
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Covers:
|
//! Covers:
|
||||||
//! * [`Panel::show_collapsible`] — drag-to-close on a `Left` panel.
|
//! * [`Panel::show_collapsible`] — drag-to-close on a `Left` panel.
|
||||||
|
//! * [`Panel::show_collapsible`] — drag-to-open via the grab handle a fully
|
||||||
|
//! collapsed panel leaves behind, plus [`Panel::drag_to_open`] opting out of it.
|
||||||
//! * [`Panel::show_switched`] — drag-to-close on the expanded panel
|
//! * [`Panel::show_switched`] — drag-to-close on the expanded panel
|
||||||
//! followed by drag-to-expand on the collapsed panel, both via the shared
|
//! followed by drag-to-expand on the collapsed panel, both via the shared
|
||||||
//! resize handle.
|
//! resize handle.
|
||||||
@@ -13,6 +15,15 @@ use egui_kittest::{Harness, SnapshotResults};
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct State {
|
struct State {
|
||||||
is_expanded: bool,
|
is_expanded: bool,
|
||||||
|
|
||||||
|
/// The panel's live _outer_ width, recorded each pass.
|
||||||
|
///
|
||||||
|
/// `None` while the panel is fully collapsed.
|
||||||
|
///
|
||||||
|
/// We can't read this back from [`egui::PanelState`], because a panel
|
||||||
|
/// deliberately doesn't persist its size while its resize handle is being
|
||||||
|
/// dragged — which is exactly when these tests need to observe it.
|
||||||
|
panel_width: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -34,7 +45,10 @@ fn drag_to_close_animated_inside() {
|
|||||||
ui.label("Central");
|
ui.label("Central");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
State { is_expanded: true },
|
State {
|
||||||
|
is_expanded: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
harness.run();
|
harness.run();
|
||||||
@@ -65,6 +79,156 @@ fn drag_to_close_animated_inside() {
|
|||||||
results.add(harness.try_snapshot("panel_drag/inside_closed"));
|
results.add(harness.try_snapshot("panel_drag/inside_closed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size range of the collapsible left panel used by the drag-to-open tests.
|
||||||
|
const MIN_SIZE: f32 = 60.0;
|
||||||
|
const DEFAULT_SIZE: f32 = 80.0;
|
||||||
|
|
||||||
|
/// A harness with a single collapsible, resizable left panel.
|
||||||
|
///
|
||||||
|
/// `drag_to_open` is passed straight through to [`Panel::drag_to_open`].
|
||||||
|
fn collapsible_left_panel_harness(drag_to_open: bool) -> Harness<'static, State> {
|
||||||
|
Harness::builder()
|
||||||
|
.with_size(Vec2::new(400.0, 200.0))
|
||||||
|
.build_ui_state(
|
||||||
|
move |ui, state: &mut State| {
|
||||||
|
let response = Panel::left("test_left_panel")
|
||||||
|
.resizable(true)
|
||||||
|
.drag_to_open(drag_to_open)
|
||||||
|
.default_size(DEFAULT_SIZE)
|
||||||
|
.min_size(MIN_SIZE)
|
||||||
|
.show_collapsible(ui, &mut state.is_expanded, |ui| {
|
||||||
|
ui.label("Left panel content");
|
||||||
|
// Without this the frame shrinks to fit the label, and the
|
||||||
|
// panel's rect would report the content width instead of
|
||||||
|
// the width the panel was resized to.
|
||||||
|
ui.take_available_space();
|
||||||
|
});
|
||||||
|
state.panel_width = response.map(|response| response.response.rect.width());
|
||||||
|
egui::CentralPanel::default().show(ui, |ui| {
|
||||||
|
ui.label("Central");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
is_expanded: true,
|
||||||
|
panel_width: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel's live _outer_ width, as of the last completed pass.
|
||||||
|
fn panel_width(harness: &Harness<'_, State>) -> f32 {
|
||||||
|
harness
|
||||||
|
.state()
|
||||||
|
.panel_width
|
||||||
|
.expect("the panel should be showing")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse the panel by dragging its resize edge past `min_size`, and return the
|
||||||
|
/// panel's fixed (left) edge — where the grab handle it leaves behind sits.
|
||||||
|
fn collapse_by_drag(harness: &mut Harness<'_, State>) -> Pos2 {
|
||||||
|
harness.run();
|
||||||
|
assert!(harness.state().is_expanded, "should start expanded");
|
||||||
|
|
||||||
|
// 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 fixed_edge = Pos2::new(
|
||||||
|
panel_state.outer_rect.left(),
|
||||||
|
panel_state.outer_rect.center().y,
|
||||||
|
);
|
||||||
|
|
||||||
|
let drag_start = Pos2::new(panel_state.outer_rect.right(), fixed_edge.y);
|
||||||
|
let drag_end = Pos2::new(drag_start.x - 200.0, fixed_edge.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"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move the pointer away so the handle isn't left hovered.
|
||||||
|
harness.hover_at(Pos2::new(300.0, fixed_edge.y));
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
fixed_edge
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drag_to_open_collapsed_panel() {
|
||||||
|
let mut results = SnapshotResults::new();
|
||||||
|
|
||||||
|
let mut harness = collapsible_left_panel_harness(true);
|
||||||
|
let fixed_edge = collapse_by_drag(&mut harness);
|
||||||
|
// Grab just inside the fixed edge, where the handle is.
|
||||||
|
let handle_pos = fixed_edge + Vec2::new(1.0, 0.0);
|
||||||
|
|
||||||
|
// The handle is invisible until hovered:
|
||||||
|
results.add(harness.try_snapshot("panel_drag/collapsed_handle_idle"));
|
||||||
|
|
||||||
|
harness.hover_at(handle_pos);
|
||||||
|
harness.run();
|
||||||
|
results.add(harness.try_snapshot("panel_drag/collapsed_handle_hovered"));
|
||||||
|
|
||||||
|
// Dragging out but not as far as `min_size` must not reopen the panel.
|
||||||
|
harness.drag_at(handle_pos);
|
||||||
|
harness.run();
|
||||||
|
let short_of_min = Pos2::new(fixed_edge.x + MIN_SIZE - 10.0, fixed_edge.y);
|
||||||
|
harness.hover_at(short_of_min);
|
||||||
|
harness.run();
|
||||||
|
assert!(
|
||||||
|
!harness.state().is_expanded,
|
||||||
|
"dragging out less than min_size should not reopen the panel"
|
||||||
|
);
|
||||||
|
|
||||||
|
// …but continuing past `min_size` should, without releasing the drag. The
|
||||||
|
// panel opens at the size the pointer is already at, so it never jumps ahead.
|
||||||
|
let past_min = Pos2::new(fixed_edge.x + MIN_SIZE + 20.0, fixed_edge.y);
|
||||||
|
harness.hover_at(past_min);
|
||||||
|
harness.run();
|
||||||
|
assert!(
|
||||||
|
harness.state().is_expanded,
|
||||||
|
"dragging out past min_size should have reopened the panel"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
panel_width(&harness),
|
||||||
|
past_min.x - fixed_edge.x,
|
||||||
|
"the reopened panel's edge should sit under the pointer"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.drop_at(past_min);
|
||||||
|
harness.run();
|
||||||
|
assert!(
|
||||||
|
harness.state().is_expanded,
|
||||||
|
"the panel should stay open after the drag is released"
|
||||||
|
);
|
||||||
|
results.add(harness.try_snapshot("panel_drag/collapsed_handle_reopened"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drag_to_open_can_be_opted_out_of() {
|
||||||
|
let mut harness = collapsible_left_panel_harness(false);
|
||||||
|
let handle_pos = collapse_by_drag(&mut harness) + Vec2::new(1.0, 0.0);
|
||||||
|
|
||||||
|
harness.drag_at(handle_pos);
|
||||||
|
harness.run();
|
||||||
|
harness.hover_at(Pos2::new(handle_pos.x + 150.0, handle_pos.y));
|
||||||
|
harness.run();
|
||||||
|
harness.drop_at(Pos2::new(handle_pos.x + 150.0, handle_pos.y));
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!harness.state().is_expanded,
|
||||||
|
"with `drag_to_open(false)` there should be no grab handle to reopen the panel with"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drag_to_close_and_reopen_animated_between() {
|
fn drag_to_close_and_reopen_animated_between() {
|
||||||
let mut results = SnapshotResults::new();
|
let mut results = SnapshotResults::new();
|
||||||
@@ -108,7 +272,10 @@ fn drag_to_close_and_reopen_animated_between() {
|
|||||||
ui.label("Central");
|
ui.label("Central");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
State { is_expanded: true },
|
State {
|
||||||
|
is_expanded: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
harness.run();
|
harness.run();
|
||||||
@@ -155,3 +322,174 @@ fn drag_to_close_and_reopen_animated_between() {
|
|||||||
);
|
);
|
||||||
results.add(harness.try_snapshot("panel_drag/between_reopened"));
|
results.add(harness.try_snapshot("panel_drag/between_reopened"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// State for the animated-close test: records the panel's live top edge.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SwitchedState {
|
||||||
|
is_expanded: bool,
|
||||||
|
|
||||||
|
/// Bottom of whatever space is left after the panel — i.e. the top edge of
|
||||||
|
/// the panel that is currently showing.
|
||||||
|
///
|
||||||
|
/// Read from the ui rather than [`egui::PanelState`], which a panel doesn't
|
||||||
|
/// persist while its resize handle is held.
|
||||||
|
panel_top: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sizes a `show_switched` bottom panel moves between in these tests.
|
||||||
|
///
|
||||||
|
/// The expanded minimum sits well above the collapsed size, so the gap between
|
||||||
|
/// the two shows up in the panel's edge.
|
||||||
|
const SWITCHED_COLLAPSED_SIZE: f32 = 20.0;
|
||||||
|
const SWITCHED_EXPANDED_MIN: f32 = 80.0;
|
||||||
|
|
||||||
|
fn switched_bottom_panel_harness(start_expanded: bool) -> Harness<'static, SwitchedState> {
|
||||||
|
let mut harness = Harness::builder()
|
||||||
|
.with_size(Vec2::new(400.0, 300.0))
|
||||||
|
.with_step_dt(1.0 / 60.0)
|
||||||
|
.build_ui_state(
|
||||||
|
move |ui, state: &mut SwitchedState| {
|
||||||
|
Panel::show_switched(
|
||||||
|
ui,
|
||||||
|
&mut state.is_expanded,
|
||||||
|
Panel::bottom("switched_collapsed")
|
||||||
|
.resizable(true)
|
||||||
|
.exact_size(SWITCHED_COLLAPSED_SIZE),
|
||||||
|
Panel::bottom("switched_expanded")
|
||||||
|
.resizable(true)
|
||||||
|
.default_size(160.0)
|
||||||
|
.min_size(SWITCHED_EXPANDED_MIN)
|
||||||
|
.max_size(250.0),
|
||||||
|
|ui, _expanded| ui.take_available_space(),
|
||||||
|
);
|
||||||
|
state.panel_top = ui.available_rect_before_wrap().bottom();
|
||||||
|
egui::CentralPanel::default().show(ui, |_ui| {});
|
||||||
|
},
|
||||||
|
SwitchedState {
|
||||||
|
is_expanded: start_expanded,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// kittest disables animations by default, and these tests are about one.
|
||||||
|
harness
|
||||||
|
.ctx
|
||||||
|
.all_styles_mut(|style| style.animation_time = 0.25);
|
||||||
|
for _ in 0..4 {
|
||||||
|
harness.step();
|
||||||
|
}
|
||||||
|
harness
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that the panel edge crossed `gap` gradually, rather than in one frame.
|
||||||
|
fn assert_crossed_gradually(tops: &[f32], gap: std::ops::Range<f32>) {
|
||||||
|
let frames_in_gap = tops.iter().filter(|top| gap.contains(top)).count();
|
||||||
|
assert!(
|
||||||
|
3 <= frames_in_gap,
|
||||||
|
"expected the panel to be animated across the gap between the collapsed \
|
||||||
|
size and the expanded min_size, but only {frames_in_gap} frame(s) landed \
|
||||||
|
inside {gap:?}: {tops:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dragging the expanded panel shut animates it the rest of the way, rather than
|
||||||
|
/// snapping, even while the drag is still held.
|
||||||
|
///
|
||||||
|
/// The expanded panel can't shrink past its own `min_size`, so a drag that goes
|
||||||
|
/// below that leaves a gap between where the panel is stuck and the collapsed
|
||||||
|
/// panel's size. That gap has to be animated, or the panel jumps.
|
||||||
|
#[test]
|
||||||
|
fn drag_to_close_switched_animates_while_held() {
|
||||||
|
let collapsed_size = SWITCHED_COLLAPSED_SIZE;
|
||||||
|
let expanded_min = SWITCHED_EXPANDED_MIN;
|
||||||
|
|
||||||
|
let mut harness = switched_bottom_panel_harness(true);
|
||||||
|
|
||||||
|
let expanded = egui::PanelState::load(&harness.ctx, egui::Id::new("switched_expanded"))
|
||||||
|
.expect("PanelState should be persisted after the first frame");
|
||||||
|
let (x, bottom) = (expanded.outer_rect.center().x, expanded.outer_rect.bottom());
|
||||||
|
let collapsed_top = bottom - collapsed_size;
|
||||||
|
|
||||||
|
// Drag the top edge down well past the collapsed size, and keep holding.
|
||||||
|
harness.drag_at(Pos2::new(x, expanded.outer_rect.top()));
|
||||||
|
harness.step();
|
||||||
|
harness.hover_at(Pos2::new(x, bottom - 10.0));
|
||||||
|
harness.step();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!harness.state().is_expanded,
|
||||||
|
"dragging past the collapsed size should have collapsed the panel"
|
||||||
|
);
|
||||||
|
let top_at_collapse = harness.state().panel_top;
|
||||||
|
assert_eq!(
|
||||||
|
top_at_collapse,
|
||||||
|
bottom - expanded_min,
|
||||||
|
"the expanded panel should be stuck at its min_size when the collapse fires"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Follow the close, still holding the drag.
|
||||||
|
let mut tops = vec![top_at_collapse];
|
||||||
|
for _ in 0..40 {
|
||||||
|
harness.step();
|
||||||
|
tops.push(harness.state().panel_top);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tops.windows(2).all(|w| w[0] <= w[1]),
|
||||||
|
"the panel should only ever move towards being shut, never jump back open: {tops:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(tops.last().copied().unwrap_or_default() - collapsed_top).abs() < 1.0,
|
||||||
|
"the close should end at the collapsed panel's size, got {:?}",
|
||||||
|
tops.last()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The gap between min_size and the collapsed size must be crossed over
|
||||||
|
// several frames, not in one jump.
|
||||||
|
assert_crossed_gradually(&tops, (top_at_collapse + 1.0)..(collapsed_top - 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mirror image: dragging the collapsed panel open animates across the same
|
||||||
|
/// gap, instead of snapping straight out to the expanded panel's `min_size`.
|
||||||
|
#[test]
|
||||||
|
fn drag_to_open_switched_animates_while_held() {
|
||||||
|
let mut harness = switched_bottom_panel_harness(false);
|
||||||
|
|
||||||
|
let collapsed = egui::PanelState::load(&harness.ctx, egui::Id::new("switched_collapsed"))
|
||||||
|
.expect("PanelState should be persisted after the first frame");
|
||||||
|
let (x, collapsed_top, bottom) = (
|
||||||
|
collapsed.outer_rect.center().x,
|
||||||
|
collapsed.outer_rect.top(),
|
||||||
|
collapsed.outer_rect.bottom(),
|
||||||
|
);
|
||||||
|
let expanded_min_top = bottom - SWITCHED_EXPANDED_MIN;
|
||||||
|
|
||||||
|
// Nudge the collapsed panel's top edge out past its `exact_size` cap, and keep
|
||||||
|
// holding. The pointer stays far short of the expanded panel's `min_size`.
|
||||||
|
harness.drag_at(Pos2::new(x, collapsed_top));
|
||||||
|
harness.step();
|
||||||
|
harness.hover_at(Pos2::new(x, collapsed_top - 10.0));
|
||||||
|
harness.step();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
harness.state().is_expanded,
|
||||||
|
"a small outward drag past the collapsed panel's cap should expand it"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tops = vec![harness.state().panel_top];
|
||||||
|
for _ in 0..40 {
|
||||||
|
harness.step();
|
||||||
|
tops.push(harness.state().panel_top);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tops.windows(2).all(|w| w[1] <= w[0]),
|
||||||
|
"the panel should only ever grow, never jump back shut: {tops:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(tops.last().copied().unwrap_or_default() - expanded_min_top).abs() < 1.0,
|
||||||
|
"the panel should settle at the expanded min_size (top {expanded_min_top}), \
|
||||||
|
since the pointer never got further out than that, got {:?}",
|
||||||
|
tops.last()
|
||||||
|
);
|
||||||
|
assert_crossed_gradually(&tops, (expanded_min_top + 1.0)..(collapsed_top - 1.0));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user