diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index e4954b6cd..cc732007a 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -18,14 +18,24 @@ use emath::GuiRounding as _; use crate::{ - Align, Context, CursorIcon, Frame, Id, InnerResponse, Layout, Margin, NumExt as _, Rangef, - Rect, Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, + Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, Margin, NumExt as _, + Order, Rangef, Rect, Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, }; fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { 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. #[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -198,6 +208,7 @@ pub struct Panel { id: Id, frame: Option, resizable: bool, + drag_to_open: bool, show_separator_line: bool, /// _Outer_ size (including [`Frame`] margin & border): @@ -283,6 +294,7 @@ impl Panel { id: id.into(), frame: None, resizable: true, + drag_to_open: true, show_separator_line: true, default_outer_size, outer_size_range, @@ -312,6 +324,24 @@ impl Panel { 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? /// /// 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. /// When [`Self::resizable`] is `true`, double-clicking the resize edge also /// 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( self, ui: &mut Ui, @@ -424,10 +457,11 @@ impl Panel { 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); + // Panel is fully closed, but we still leave a grab handle at its fixed + // edge so the user can drag it back open. + if self.resizable && self.drag_to_open { + 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: ui.skip_ahead_auto_ids(1); @@ -436,7 +470,7 @@ impl Panel { // Don't lose the drag during the slide-back-open animation: let drag_in_progress = ui - .read_response(self.id.with("__resize")) + .read_response(self.resize_id()) .is_some_and(|r| r.dragged()); let panel = if how_expanded < 1.0 { @@ -549,20 +583,11 @@ impl Panel { // Is the resize handle currently being dragged? 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()); 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) - }; + let how_expanded = 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, @@ -585,7 +610,19 @@ impl Panel { 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 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 slide_fraction = if 0.0 < expanded_size { visible_size / expanded_size @@ -702,7 +739,7 @@ impl Panel { // 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"); + let resize_id = self.resize_id(); let resize_response = parent_ui.read_response(resize_id); // Double-click on the resize edge toggles `*is_expanded` for the @@ -860,19 +897,25 @@ impl Panel { .store(parent_ui, id); } - // Hide the separator once the panel is mostly slid off — at that point - // the line would just be a stray dash hovering near the parent edge. - if 0.01 < self.slide_fraction { - let stroke = if is_resizing { - parent_ui.style().visuals.widgets.active.fg_stroke // highly visible - } else if resize_hover { - parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible - } else if show_separator_line { - // TODO(emilk): distinguish resizable from non-resizable - parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim - } else { - Stroke::NONE - }; + // The highlight follows the pointer all the way down to zero size, where + // `collapsed_resize_handle` picks it straight up again — so the user never + // loses sight of the edge they are dragging. The dim idle separator does + // get hidden once the panel is mostly slid off, since there it would just + // be a stray dash hovering near the parent edge. + let stroke = if is_resizing { + parent_ui.style().visuals.widgets.active.fg_stroke // highly visible + } else if resize_hover { + parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible + } else if show_separator_line && 0.01 < self.slide_fraction { + // TODO(emilk): distinguish resizable from non-resizable + 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 // The line goes just _outside_ the frame's outline, in the room `resolve_frame` @@ -893,6 +936,13 @@ impl Panel { 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`]. fn resolve_frame(&self, ui: &Ui) -> Frame { let mut frame = self @@ -918,48 +968,91 @@ impl Panel { frame } - /// 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; - }; + /// The grab handle of a fully collapsed panel: a thin strip along the panel's + /// fixed edge, invisible until hovered. + /// + /// Dragging it outward past the minimum size — or double-clicking it — + /// reopens the panel. Registering it under the same id as the expanded + /// panel's resize handle also keeps an in-progress drag-to-collapse gesture + /// alive, so the user can drag the panel straight back out without releasing. + fn collapsed_resize_handle(&self, ui: &Ui, is_expanded: &mut bool) { + let side = self.side; + let axis = side.axis(); - // 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 { + let fixed_edge_pos = side.fixed_pos(available_rect); + let cross_range = available_rect.range_along(side.cross_axis()); + + // 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) } 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()); + side.set_rect_size( + &mut resize_rect, + ui.style().interaction.resize_grab_radius_side, + ); - // 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)); + let resize_id = self.resize_id(); + let response = ui.interact(resize_rect, resize_id, Sense::click_and_drag()); - // 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 { + if response.double_clicked() { *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), @@ -994,7 +1087,7 @@ impl Panel { // Use `resize_id_source` so collapsed/expanded panels in // `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); ui.interact(resize_rect, resize_id, Sense::click_and_drag()) } diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 540f05ca8..629ad6a97 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -361,7 +361,8 @@ impl WrapApp { let mut cmd = Command::Nothing; egui::Panel::left("backend_panel") - .resizable(false) + .resizable(true) + .size_range(280..=400) .show_collapsible(ui, &mut is_open, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index 8f2e811fa..00d19efdd 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -94,10 +94,10 @@ impl crate::View for Panels { bottom, egui::Panel::bottom("bottom_panel_collapsed") .resizable(true) - .default_size(20.0), + .exact_size(20.0), egui::Panel::bottom("bottom_panel_expanded") .resizable(true) - .max_size(128.0), + .size_range(64.0..=128.0), |ui, expanded| { if expanded { ui.vertical_centered(|ui| { diff --git a/crates/emath/src/range.rs b/crates/emath/src/range.rs index ffd34dc20..26d5f6f2c 100644 --- a/crates/emath/src/range.rs +++ b/crates/emath/src/range.rs @@ -170,6 +170,14 @@ impl From<&RangeInclusive> for Rangef { } } +/// Makes specifying size ranges slightly more convenient (no need for the extra `.0` suffixes) +impl From> for Rangef { + #[inline] + fn from(range: RangeInclusive) -> Self { + Self::new(*range.start() as _, *range.end() as _) + } +} + impl From> for Rangef { #[inline] fn from(range: RangeFrom) -> Self { diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png new file mode 100644 index 000000000..27e751fa8 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71c6c7299aad708fd16b945bcc9c82f529a276486eb1b2042f2ebaa932935a54 +size 3516 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png new file mode 100644 index 000000000..fced7caae --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc0393c0b85df389314d15161b14803fde138eb66ef2b055335aef275ac03d1c +size 3538 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png new file mode 100644 index 000000000..ba81d4095 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bbf9c826403a00937f9e37cccc76b16dc67db8aef3536bf076922fdd2d564d5 +size 4881 diff --git a/tests/egui_tests/tests/test_panel_drag.rs b/tests/egui_tests/tests/test_panel_drag.rs index 7068ba49e..0909753cc 100644 --- a/tests/egui_tests/tests/test_panel_drag.rs +++ b/tests/egui_tests/tests/test_panel_drag.rs @@ -2,6 +2,8 @@ //! //! Covers: //! * [`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 //! followed by drag-to-expand on the collapsed panel, both via the shared //! resize handle. @@ -13,6 +15,15 @@ use egui_kittest::{Harness, SnapshotResults}; #[derive(Default)] struct State { 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, } #[test] @@ -34,7 +45,10 @@ fn drag_to_close_animated_inside() { ui.label("Central"); }); }, - State { is_expanded: true }, + State { + is_expanded: true, + ..Default::default() + }, ); harness.run(); @@ -65,6 +79,156 @@ fn drag_to_close_animated_inside() { 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] fn drag_to_close_and_reopen_animated_between() { let mut results = SnapshotResults::new(); @@ -108,7 +272,10 @@ fn drag_to_close_and_reopen_animated_between() { ui.label("Central"); }); }, - State { is_expanded: true }, + State { + is_expanded: true, + ..Default::default() + }, ); harness.run(); @@ -155,3 +322,174 @@ fn drag_to_close_and_reopen_animated_between() { ); 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) { + 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)); +}