diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 0f1e8cd95..37e68c541 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -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, + + /// 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, } 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 { - 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( self, ui: &mut Ui, - is_expanded: bool, + is_expanded: &mut bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - 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( 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 { - 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 R + 'c>, ) -> InnerResponse { 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 + } } // ---------------------------------------------------------------------------- diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 64ecbce56..61e35e205 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -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 } diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index 84aefcd3e..1bda3b269 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -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)"); }); } }, diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png index 6a474260b..1ce17e54f 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b339d04f331a5d5f7ea17a1c68f61626e08ad5199aec9181080c711a60f07d53 -size 344102 +oid sha256:b2d186d7404839e1b8d33ea186751035362a085e5f60a774a5f1bd66dd3e1442 +size 347049 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png new file mode 100644 index 000000000..cb3b393e6 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba6e690937fbbd22c8edfce14078f50998e968324d8073d5db5829493d957e0 +size 5292 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png new file mode 100644 index 000000000..06679d16d --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png new file mode 100644 index 000000000..06679d16d --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png b/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png new file mode 100644 index 000000000..0e2c2cfbd --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcf9871c19199c94cfbc83818b4f70fe9fab5c396a3fb32cb79b713e9145bcae +size 3001 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png new file mode 100644 index 000000000..8a9963284 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf09470a6628e62421bfa754ce238dd13820ba0bf8146ae835e539874d39a150 +size 4882 diff --git a/tests/egui_tests/tests/test_panel_drag.rs b/tests/egui_tests/tests/test_panel_drag.rs new file mode 100644 index 000000000..bd3783257 --- /dev/null +++ b/tests/egui_tests/tests/test_panel_drag.rs @@ -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")); +}