diff --git a/crates/egui/src/containers/menu.rs b/crates/egui/src/containers/menu.rs index d4c95b298..8a586cf22 100644 --- a/crates/egui/src/containers/menu.rs +++ b/crates/egui/src/containers/menu.rs @@ -9,9 +9,13 @@ //! See [`MenuBar`] for an example. use crate::style::StyleModifier; +use crate::widget_style::{ + Classes, HasClasses as _, MENU_CLASS, PopupStyle, StyleArgs, WidgetState, +}; use crate::{ - Button, Color32, Context, Frame, Id, InnerResponse, IntoAtoms, Layout, PointerButton, Popup, - PopupCloseBehavior, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget as _, + Button, Color32, Context, Id, InnerResponse, IntoAtoms, Layout, PointerButton, Popup, + PopupCloseBehavior, PopupKind, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, + Widget as _, }; use emath::{Align, RectAlign, Vec2, vec2}; use epaint::Stroke; @@ -429,10 +433,22 @@ impl SubMenu { button_response: &Response, content: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let frame = Frame::menu(ui.style()); - let id = Self::id_from_widget_id(button_response.id); + // A submenu is a menu, so the theme frames it like one. `Popup` computes the same style + // itself; we need it here too, because its margin decides where the submenu sits and how + // much of the parent menu counts as hovered. A popup frame does not depend on any widget + // state, so we ask for it directly rather than through `Ui::widget_style`, which would + // want a response that does not exist while the submenu is closed. + let classes = Classes::default().with_class(MENU_CLASS); + let PopupStyle { frame, .. } = ui.ctx().get_widget_style(&StyleArgs { + classes: &classes, + state: WidgetState::default(), + stack: ui.stack(), + style: ui.style(), + ctx: ui.ctx(), + }); + // Get the state from the parent menu let (open_item, menu_id, parent_config) = MenuState::from_ui(ui, |state, stack| { (state.open_item, stack.id, MenuConfig::from_stack(stack)) @@ -501,12 +517,12 @@ impl SubMenu { let popup_response = Popup::from_response(&response) .id(id) + .kind(PopupKind::Menu) .open(is_open) .align(RectAlign::RIGHT_START) .layout(Layout::top_down_justified(Align::Min)) .gap(gap) .style(menu_config.style.clone()) - .frame(frame) // The close behavior is handled by the menu (see below) .close_behavior(PopupCloseBehavior::IgnoreClicks) .info( diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index e20285913..486ee67c3 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -637,6 +637,7 @@ impl<'a> Popup<'a> { // The theme decides how the popup is framed and how tightly its items sit together. let popup_style: PopupStyle = ui.widget_style(id, &classes); ui.spacing_mut().item_spacing = popup_style.item_spacing; + ui.spacing_mut().scroll.overflow_margin = popup_style.scroll_overflow_margin; let frame = frame.unwrap_or(popup_style.frame); frame.show(ui, content).inner diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 54d6550ca..1edebb4a1 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -36,6 +36,11 @@ pub struct State { /// The content were to large to fit large frame. content_is_too_large: Vec2b, + /// The largest offset the content allowed last frame. + /// + /// Zero or less when the content fits, so there is nothing to scroll. + max_offset: Vec2, + /// Did the user interact (hover or drag) the scroll bars last frame? scroll_bar_interaction: Vec2b, @@ -62,6 +67,7 @@ impl Default for State { offset_target: Default::default(), show_scroll: Vec2b::FALSE, content_is_too_large: Vec2b::FALSE, + max_offset: Vec2::ZERO, scroll_bar_interaction: Vec2b::FALSE, vel: Vec2::ZERO, scroll_start_offset_from_top_left: [None; 2], @@ -353,6 +359,7 @@ pub struct ScrollArea { wheel_scroll_multiplier: Vec2, content_margin: Option, + overflow_margin: Option, /// If true for vertical or horizontal the scroll wheel will stick to the /// end position until user manually changes position. It will become true @@ -407,6 +414,7 @@ impl ScrollArea { scroll_source: ScrollSource::default(), wheel_scroll_multiplier: Vec2::splat(1.0), content_margin: None, + overflow_margin: None, stick_to_end: Vec2b::FALSE, animated: true, } @@ -633,6 +641,19 @@ impl ScrollArea { self } + /// How far the contents may paint outside the viewport. + /// + /// Some widgets paint outside their own box — a menu item whose fill bleeds past it, say — and + /// the scroll area would otherwise cut that off. Only the ends of the scroll range are + /// widened, where there is nothing that could scroll into view through the gap. + /// + /// Default: [`crate::style::ScrollStyle::overflow_margin`]. + #[inline] + pub fn overflow_margin(mut self, margin: impl Into) -> Self { + self.overflow_margin = Some(margin.into()); + self + } + /// The scroll handle will stick to the rightmost position even while the content size /// changes dynamically. This can be useful to simulate text scrollers coming in from right /// hand side. The scroll handle remains stuck until user manually changes position. Once "unstuck" @@ -724,6 +745,7 @@ impl ScrollArea { scroll_source, wheel_scroll_multiplier, content_margin: _, // Used elsewhere + overflow_margin, stick_to_end, animated, } = self; @@ -811,6 +833,11 @@ impl ScrollArea { { // Clip the content, but only when we really need to: let mut content_clip_rect = ui.clip_rect(); + let overflow_margin = + overflow_margin.unwrap_or_else(|| ui.spacing().scroll.overflow_margin); + let overflow_min = Vec2::new(overflow_margin.leftf(), overflow_margin.topf()); + let overflow_max = Vec2::new(overflow_margin.rightf(), overflow_margin.bottomf()); + for d in 0..2 { if direction_enabled[d] { content_clip_rect.min[d] = inner_rect.min[d]; @@ -819,8 +846,18 @@ impl ScrollArea { // Nice handling of forced resizing beyond the possible: content_clip_rect.max[d] = ui.clip_rect().max[d] - current_bar_use[d]; } + + // Let the contents paint a little outside the viewport, but only where nothing + // could scroll into view through the gap: at the start of the scroll range there + // is nothing above the first item, and at its end nothing below the last one. + if !direction_enabled[d] || state.offset[d] <= 0.0 { + content_clip_rect.min[d] -= overflow_min[d]; + } + if !direction_enabled[d] || state.max_offset[d] <= state.offset[d] { + content_clip_rect.max[d] += overflow_max[d]; + } } - // Make sure we didn't accidentally expand the clip rect + // Make sure we didn't grow past what our parent allows content_clip_rect = content_clip_rect.intersect(ui.clip_rect()); content_ui.set_clip_rect(content_clip_rect); } @@ -1551,6 +1588,7 @@ impl Prepared { state.show_scroll = show_scroll_this_frame; state.content_is_too_large = content_is_too_large; + state.max_offset = max_offset; state.interact_rect = Some(inner_rect); state.store(ui.ctx(), id); diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index ae467656c..0e1ceae09 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -508,6 +508,13 @@ pub struct ScrollStyle { /// depending on the value of [`Self::floating`]. pub content_margin: Margin, + /// How far the contents of a [`crate::ScrollArea`] may paint outside its viewport. + /// + /// Some widgets paint outside their own box — a menu item whose fill bleeds past it, say — and + /// a [`crate::ScrollArea`] would otherwise cut that off. Only the ends of the scroll range are + /// widened, where there is nothing that could scroll into view through the gap. + pub overflow_margin: Margin, + /// The width of the scroll bars at it largest. pub bar_width: f32, @@ -594,6 +601,7 @@ impl ScrollStyle { Self { floating: false, content_margin: Margin::ZERO, + overflow_margin: Margin::ZERO, bar_width: 6.0, handle_min_length: 12.0, bar_inner_margin: 4.0, @@ -679,6 +687,7 @@ impl ScrollStyle { floating, content_margin, + overflow_margin, bar_width, handle_min_length, @@ -710,6 +719,11 @@ impl ScrollStyle { content_margin.ui(ui); }); + ui.horizontal(|ui| { + ui.label("Overflow margin:"); + overflow_margin.ui(ui); + }); + ui.horizontal(|ui| { ui.add(DragValue::new(bar_width).range(0.0..=32.0)); ui.label("Full bar width"); diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index 7aa882098..c303dbefe 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -106,6 +106,7 @@ impl StyleProvider for DefaultStyle { PopupStyle { frame: Frame::popup(style), item_spacing: style.spacing.item_spacing, + scroll_overflow_margin: style.spacing.scroll.overflow_margin, } } } diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index d25e424fc..7ecb8bbc4 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -14,7 +14,7 @@ use emath::Vec2; use epaint::{Color32, FontId, Stroke, text::TextWrapMode}; use crate::{ - Context, Frame, Response, Style, TextStyle, UiStack, + Context, Frame, Margin, Response, Style, TextStyle, UiStack, style::{WidgetVisuals, Widgets}, }; @@ -109,6 +109,13 @@ pub struct PopupStyle { /// A menu wants its items flush against each other, while a tooltip wants them spaced out /// like any other content. pub item_spacing: Vec2, + + /// How far the contents of a [`crate::ScrollArea`] inside the popup may paint outside it. + /// + /// A [`crate::ScrollArea`] clips to its viewport, which would cut off anything a widget paints + /// outside its own box — an item whose fill bleeds past it, say. This is how much room such a + /// widget gets, applied via [`crate::style::ScrollStyle::overflow_margin`]. + pub scroll_overflow_margin: Margin, } impl WidgetStyle for PopupStyle {} diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index c646214af..1aae9b140 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -366,6 +366,12 @@ impl<'a> Button<'a> { frame = frame.inner_margin(button_padding); + if !has_frame_margin { + // A frameless button asks for no padding, so any outer margin the theme used to + // compensate for that padding has to go as well. + frame = frame.outer_margin(Margin::ZERO); + } + // Apply the style font and color as fallback layout = layout .fallback_font(text_style.font_id.clone()) @@ -375,7 +381,13 @@ impl<'a> Button<'a> { layout = if has_frame_margin && (state != WidgetState::Inactive || frame_when_inactive) { layout.frame(frame) } else { - layout.frame(Frame::new().inner_margin(frame.inner_margin)) + // The frame is not painted, but it still takes up the same space: keep both margins, + // or the contents would move as soon as the button is hovered or selected. + layout.frame( + Frame::new() + .inner_margin(frame.inner_margin) + .outer_margin(frame.outer_margin), + ) }; let mut prepared = layout.min_size(min_size).allocate(ui);