diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index d7cb2642a..0ccd77822 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -15,7 +15,7 @@ //! //! Add your [`crate::Window`]:s after any top-level panels. -use emath::GuiRounding as _; +use emath::{GuiRounding as _, Pos2}; use crate::{ lerp, vec2, Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, NumExt, @@ -273,6 +273,629 @@ impl PanelSizer { // ---------------------------------------------------------------------------- +/// A panel that covers an entire side +/// ([`Left`](VerticalSide::Left), [`Right`](VerticalSide::Right), +/// [`Top`](HorizontalSide::Top) or [`Bottom`](HorizontalSide::Bottom)) +/// of a [`Ui`] or screen. +/// +/// The order in which you add panels matter! +/// The first panel you add will always be the outermost, and the last you add will always be the innermost. +/// +/// ⚠ Always add any [`CentralPanel`] last. +/// +/// See the [module level docs](crate::containers::panel) for more details. +/// +/// TODO(shark98): Fix the example test code. +/// ``` +/// # egui::__run_test_ctx(|ctx| { +/// egui::Panel::left("my_left_panel").show(ctx, |ui| { +/// ui.label("Hello World!"); +/// }); +/// # }); +/// ``` +#[must_use = "You should call .show()"] +pub struct Panel { + side: Side, + id: Id, + frame: Option, + resizable: bool, + show_separator_line: bool, + + /// The size is defined as being either the width for a Vertical Panel + /// or the height for a Horizontal Panel. + default_size: Option, + + /// The size is defined as being either the width for a Vertical Panel + /// or the height for a Horizontal Panel. + size_range: Rangef, +} + +impl Panel { + /// Create a left panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_left_panel")`. + pub fn left(id: impl Into) -> Self { + Self::new(Side::Vertical(VerticalSide::Left), id) + } + + /// Create a right panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_right_panel")`. + pub fn right(id: impl Into) -> Self { + Self::new(Side::Vertical(VerticalSide::Right), id) + } + + /// Create a top panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_top_panel")`. + pub fn top(id: impl Into) -> Self { + Self::new(Side::Horizontal(HorizontalSide::Top), id) + } + + /// Create a bottom panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_bottom_panel")`. + pub fn bottom(id: impl Into) -> Self { + Self::new(Side::Horizontal(HorizontalSide::Bottom), id) + } + + /// Create a panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_panel")`. + pub fn new(side: Side, id: impl Into) -> Self { + let default_size: Option = match side { + Side::Vertical(_) => Some(200.0), + Side::Horizontal(_) => None, + }; + + let size_range: Rangef = match side { + Side::Vertical(_) => Rangef::new(96.0, f32::INFINITY), + Side::Horizontal(_) => Rangef::new(20.0, f32::INFINITY), + }; + + Self { + side, + id: id.into(), + frame: None, + resizable: true, + show_separator_line: true, + default_size, + size_range, + } + } + + /// Can panel be resized by dragging the edge of it? + /// + /// Default is `true`. + /// + /// If you want your panel to be resizable you also need a widget in it that + /// takes up more space as you resize it, such as: + /// * Wrapping text ([`Ui::horizontal_wrapped`]). + /// * A [`crate::ScrollArea`]. + /// * A [`crate::Separator`]. + /// * A [`crate::TextEdit`]. + /// * … + #[inline] + pub fn resizable(mut self, resizable: bool) -> Self { + self.resizable = resizable; + self + } + + /// Show a separator line, even when not interacting with it? + /// + /// Default: `true`. + #[inline] + pub fn show_separator_line(mut self, show_separator_line: bool) -> Self { + self.show_separator_line = show_separator_line; + self + } + + /// The initial wrapping width of the [`Panel`], including margins. + #[inline] + pub fn default_size(mut self, default_size: f32) -> Self { + self.default_size = Some(default_size); + self.size_range = Rangef::new( + self.size_range.min.at_most(default_size), + self.size_range.max.at_least(default_size), + ); + self + } + + /// Minimum size of the panel, including margins. + #[inline] + pub fn min_size(mut self, min_size: f32) -> Self { + self.size_range = Rangef::new(min_size, self.size_range.max.at_least(min_size)); + self + } + + /// Maximum size of the panel, including margins. + #[inline] + pub fn max_size(mut self, max_size: f32) -> Self { + self.size_range = Rangef::new(self.size_range.min.at_most(max_size), max_size); + self + } + + /// The allowable size range for the panel, including margins. + #[inline] + pub fn size_range(mut self, size_range: impl Into) -> Self { + let size_range = size_range.into(); + self.default_size = self + .default_size + .map(|default_size| clamp_to_range(default_size, size_range)); + self.size_range = size_range; + self + } + + /// Enforce this exact size, including margins. + #[inline] + pub fn exact_size(mut self, size: f32) -> Self { + self.default_size = Some(size); + self.size_range = Rangef::point(size); + self + } + + /// Change the background color, margins, etc. + #[inline] + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = Some(frame); + self + } +} + +// Public showing methods +impl Panel { + /// Show the panel inside a [`Ui`]. + pub fn show_inside( + self, + ui: &mut Ui, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + self.show_inside_dyn(ui, Box::new(add_contents)) + } + + /// Show the panel at the top level. + pub fn show( + self, + ctx: &Context, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + self.show_dyn(ctx, Box::new(add_contents)) + } + + /// Show the panel if `is_expanded` is `true`, + /// otherwise don't show it, but with a nice animation between collapsed and expanded. + pub fn show_animated( + self, + ctx: &Context, + is_expanded: bool, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ctx(), self.id.with("animation"), is_expanded); + + let animated_panel = self.get_animated_panel(ctx(), is_expanded); + + if animated_panel.is_none() { + None + } else if how_expanded < 1.0 { + // Show a fake panel in this in-between animation state: + animated_panel.unwrap().show(ctx, |_ui| {}); + None + } else { + // Show the real panel: + Some(animated_panel.unwrap().show(ctx, add_contents)) + } + } + + /// Show the panel if `is_expanded` is `true`, + /// otherwise don't show it, but with a nice animation between collapsed and expanded. + pub fn show_animated_inside( + self, + ui: &mut Ui, + is_expanded: bool, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); + + // Get either the fake or the real panel to animate + let animated_panel = self.get_animated_panel(ui.ctx(), is_expanded); + + if animated_panel.is_none() { + None + } else if how_expanded < 1.0 { + // Show a fake panel in this in-between animation state: + animated_panel.unwrap().show_inside(ui, |_ui| {}); + None + } else { + // Show the real panel: + Some(animated_panel.unwrap().show_inside(ui, add_contents)) + } + } + + /// Show either a collapsed or a expanded panel, with a nice animation between. + pub fn show_animated_between( + ctx: &Context, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + add_contents: impl FnOnce(&mut Ui, f32) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); + + // Get either the fake or the real panel to animate + let animated_between_panel = + Self::get_animated_between_panel(ctx(), is_expanded, collapsed_panel, expanded_panel); + + if 0.0 == how_expanded { + Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + } else if how_expanded < 1.0 { + // Show animation: + animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded)); + None + } else { + Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + } + } + + /// Show either a collapsed or a expanded panel, with a nice animation between. + pub fn show_animated_between_inside( + ui: &mut Ui, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + add_contents: impl FnOnce(&mut Ui, f32) -> R, + ) -> InnerResponse { + let how_expanded = + animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); + + let animated_between_panel = Self::get_animated_between_panel( + ui.ctx(), + is_expanded, + collapsed_panel, + expanded_panel, + ); + + if 0.0 == how_expanded { + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } else if how_expanded < 1.0 { + // Show animation: + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } else { + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } + } +} + +// Private methods to support the various show methods +impl Panel { + /// Show the panel inside a [`Ui`]. + fn show_inside_dyn<'c, R>( + self, + ui: &mut Ui, + add_contents: Box R + 'c>, + ) -> InnerResponse { + let side = self.side; + let id = self.id; + let resizable = self.resizable; + let show_separator_line = self.show_separator_line; + let size_range = self.size_range; + + // Define the sizing of the panel. + let mut panel_sizer = PanelSizer::new(&self, ui); + + // Check for duplicate id + ui.ctx() + .check_for_id_clash(id, panel_sizer.panel_rect, "Panel"); + + if self.resizable { + // Prepare the resizable panel to avoid frame latency in the resize + self.prepare_resizable_panel(&mut panel_sizer, ui); + } + + // NOTE(shark98): This must be **after** the resizable preparation, as the size + // may change and round_ui() uses the size. + panel_sizer.panel_rect = panel_sizer.panel_rect.round_ui(); + + let get_ui_kind = || match side { + Side::Vertical(v_side) => match v_side { + VerticalSide::Left => UiKind::LeftPanel, + VerticalSide::Right => UiKind::RightPanel, + }, + Side::Horizontal(h_side) => match h_side { + HorizontalSide::Top => UiKind::TopPanel, + HorizontalSide::Bottom => UiKind::BottomPanel, + }, + }; + + let mut panel_ui = ui.new_child( + UiBuilder::new() + .id_salt(id) + .ui_stack_info(UiStackInfo::new(get_ui_kind())) + .max_rect(panel_sizer.panel_rect) + .layout(Layout::top_down(Align::Min)), + ); + panel_ui.expand_to_include_rect(panel_sizer.panel_rect); + panel_ui.set_clip_rect(panel_sizer.panel_rect); // If we overflow, don't do so visibly (#4475) + + let inner_response = panel_sizer.frame.show(&mut panel_ui, |ui| { + match side { + Side::Vertical(_) => { + ui.set_min_height(ui.max_rect().height()); // Make sure the frame fills the full height + ui.set_min_width( + (size_range.min - panel_sizer.frame.inner_margin.sum().x).at_least(0.0), + ); + } + Side::Horizontal(_) => { + ui.set_min_width(ui.max_rect().width()); // Make the frame fill full width + ui.set_min_height( + (size_range.min - panel_sizer.frame.inner_margin.sum().y).at_least(0.0), + ); + } + } + + add_contents(ui) + }); + + let rect = inner_response.response.rect; + + { + let mut cursor = ui.cursor(); + match side { + Side::Vertical(v_side) => match v_side { + VerticalSide::Left => cursor.min.x = rect.max.x, + VerticalSide::Right => cursor.max.x = rect.min.x, + }, + Side::Horizontal(h_side) => match h_side { + HorizontalSide::Top => cursor.min.y = rect.max.y, + HorizontalSide::Bottom => cursor.max.y = rect.min.y, + }, + }; + ui.set_cursor(cursor); + } + + ui.expand_to_include_rect(rect); + + let mut resize_hover = false; + let mut is_resizing = false; + if resizable { + // Now we do the actual resize interaction, on top of all the contents, + // otherwise its input could be eaten by the contents, e.g. a + // `ScrollArea` on either side of the panel boundary. + (resize_hover, is_resizing) = self.resize_panel(&mut panel_sizer, ui); + } + + if resize_hover || is_resizing { + ui.ctx().set_cursor_icon(self.get_cursor_icon(&panel_sizer)); + } + + PanelState { rect }.store(ui.ctx(), id); + + { + let stroke = if is_resizing { + ui.style().visuals.widgets.active.fg_stroke // highly visible + } else if resize_hover { + ui.style().visuals.widgets.hovered.fg_stroke // highly visible + } else if show_separator_line { + // TODO(emilk): distinguish resizable from non-resizable + ui.style().visuals.widgets.noninteractive.bg_stroke // dim + } else { + Stroke::NONE + }; + // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done + let resize_axe = side.opposite().side_axe(rect); + let resize_axe = resize_axe + 0.5 * side.sign() * stroke.width; + match side { + Side::Vertical(_) => { + ui.painter() + .vline(resize_axe, panel_sizer.panel_rect.y_range(), stroke); + } + Side::Horizontal(_) => { + ui.painter() + .hline(panel_sizer.panel_rect.x_range(), resize_axe, stroke); + } + } + } + + inner_response + } + + /// Show the panel at the top level. + fn show_dyn<'c, R>( + self, + ctx: &Context, + add_contents: Box R + 'c>, + ) -> InnerResponse { + let side = self.side; + let available_rect = ctx.available_rect(); + let mut panel_ui = Ui::new( + ctx.clone(), + self.id, + UiBuilder::new() + .layer_id(LayerId::background()) + .max_rect(available_rect), + ); + panel_ui.set_clip_rect(ctx.screen_rect()); + + let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents); + let rect = inner_response.response.rect; + + match side { + Side::Vertical(v_side) => match v_side { + VerticalSide::Left => ctx.pass_state_mut(|state| { + state.allocate_left_panel(Rect::from_min_max(available_rect.min, rect.max)); + }), + VerticalSide::Right => ctx.pass_state_mut(|state| { + state.allocate_right_panel(Rect::from_min_max(rect.min, available_rect.max)); + }), + }, + Side::Horizontal(h_side) => match h_side { + HorizontalSide::Top => { + ctx.pass_state_mut(|state| { + state.allocate_top_panel(Rect::from_min_max(available_rect.min, rect.max)); + }); + } + HorizontalSide::Bottom => { + ctx.pass_state_mut(|state| { + state.allocate_bottom_panel(Rect::from_min_max( + rect.min, + available_rect.max, + )); + }); + } + }, + } + inner_response + } + + fn prepare_resizable_panel(&self, panel_sizer: &mut PanelSizer, ui: &mut Ui) { + let resize_id = self.id.with("__resize"); + let resize_response = ui.ctx().read_response(resize_id); + + if resize_response.is_some() { + let resize_response = resize_response.unwrap(); + + // NOTE(sharky98): The original code was initializing to + // false first, but it doesn't seem necessary. + let is_resizing = resize_response.dragged(); + let pointer = resize_response.interact_pointer_pos(); + panel_sizer.prepare_resizing_response(is_resizing, pointer); + } + } + + fn resize_panel(&self, panel_sizer: &mut PanelSizer, ui: &mut Ui) -> (bool, bool) { + let (resize_x, resize_y, amnt): (impl Into, impl Into, Vec2) = + match self.side { + Side::Vertical(_) => { + let resize_x = self.side.opposite().side_axe(panel_sizer.panel_rect); + let resize_y = panel_sizer.panel_rect.y_range(); + ( + resize_x..=resize_x, + resize_y, + vec2(ui.style().interaction.resize_grab_radius_side, 0.0), + ) + } + Side::Horizontal(_) => { + let resize_x = panel_sizer.panel_rect.x_range(); + let resize_y = self.side.opposite().side_axe(panel_sizer.panel_rect); + ( + resize_x, + resize_y..=resize_y, + vec2(0.0, ui.style().interaction.resize_grab_radius_side), + ) + } + }; + + let resize_id = self.id.with("__resize"); + let resize_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amnt); + let resize_response = ui.interact(resize_rect, resize_id, Sense::drag()); + + (resize_response.hovered(), resize_response.dragged()) + } + + fn get_cursor_icon(&self, panel_sizer: &PanelSizer) -> CursorIcon { + if panel_sizer.size <= self.size_range.min { + match self.side { + Side::Vertical(v_side) => match v_side { + VerticalSide::Left => CursorIcon::ResizeEast, + VerticalSide::Right => CursorIcon::ResizeWest, + }, + Side::Horizontal(h_side) => match h_side { + HorizontalSide::Top => CursorIcon::ResizeSouth, + HorizontalSide::Bottom => CursorIcon::ResizeNorth, + }, + } + } else if panel_sizer.size < self.size_range.max { + match self.side { + Side::Vertical(_) => CursorIcon::ResizeHorizontal, + Side::Horizontal(_) => CursorIcon::ResizeVertical, + } + } else { + match self.side { + Side::Vertical(v_side) => match v_side { + VerticalSide::Left => CursorIcon::ResizeWest, + VerticalSide::Right => CursorIcon::ResizeEast, + }, + Side::Horizontal(h_side) => match h_side { + HorizontalSide::Top => CursorIcon::ResizeNorth, + HorizontalSide::Bottom => CursorIcon::ResizeSouth, + }, + } + } + } + + /// Get the real or fake panel to animate if `is_expanded` is `true`. + fn get_animated_panel(self, ctx: &Context, is_expanded: bool) -> Option { + let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); + + if 0.0 == how_expanded { + None + } else if how_expanded < 1.0 { + // Show a fake panel in this in-between animation state: + // TODO(emilk): move the panel out-of-screen instead of changing its width. + // Then we can actually paint it as it animates. + let expanded_size = Self::get_animated_size(ctx, &self); + let fake_size = how_expanded * expanded_size; + Some( + Self { + id: self.id.with("animating_panel"), + ..self + } + .resizable(false) + .exact_size(fake_size), + ) + } else { + // Show the real panel: + Some(self) + } + } + + /// Get either the collapsed or expended panel to animate. + fn get_animated_between_panel( + ctx: &Context, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + ) -> Self { + let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); + + if 0.0 == how_expanded { + collapsed_panel + } else if how_expanded < 1.0 { + let collapsed_size = Self::get_animated_size(ctx, &collapsed_panel); + let expanded_size = Self::get_animated_size(ctx, &expanded_panel); + + let fake_size = lerp(collapsed_size..=expanded_size, how_expanded); + + Self { + id: expanded_panel.id.with("animating_panel"), + ..expanded_panel + } + .resizable(false) + .exact_size(fake_size) + } else { + expanded_panel + } + } + + fn get_animated_size(ctx: &Context, panel: &Panel) -> f32 { + let get_rect_state_size = |state: PanelState| match panel.side { + Side::Vertical(_) => state.rect.width(), + Side::Horizontal(_) => state.rect.height(), + }; + + let get_spacing_size = || match panel.side { + Side::Vertical(_) => ctx.style().spacing.interact_size.x, + Side::Horizontal(_) => ctx.style().spacing.interact_size.y, + }; + + PanelState::load(ctx, panel.id) + .map(get_rect_state_size) + .or(panel.default_size) + .unwrap_or(get_spacing_size()) + } +} + +// ---------------------------------------------------------------------------- + /// A panel that covers the entire left or right side of a [`Ui`] or screen. /// /// The order in which you add panels matter!