1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Let the theme style submenus, and let a button's fill bleed out of its box

Three things a menu needs and nothing else does:

* A submenu asked `Frame::menu` for its frame directly, so it ignored the
  theme. It now resolves the same `PopupStyle` a `Popup` does — it needs the
  margin itself, to place the submenu and to decide how much of the parent
  menu counts as hovered.
* `ScrollStyle::overflow_margin` lets a scroll area's contents paint a little
  outside the viewport, at the ends of the scroll range where nothing could
  scroll into view through the gap. A menu item whose fill bleeds past its
  own box needs it; without it the fill is sliced.
* A frameless button dropped its inner margin but kept the outer one, so its
  contents shifted as soon as it was hovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-08-24 13:49:50 +02:00
parent 08f3a2b03b
commit 97a170941c
7 changed files with 97 additions and 8 deletions

View File

@@ -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<InnerResponse<R>> {
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(

View File

@@ -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

View File

@@ -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<Margin>,
overflow_margin: Option<Margin>,
/// 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<Margin>) -> 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);

View File

@@ -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");

View File

@@ -106,6 +106,7 @@ impl StyleProvider<PopupStyle> for DefaultStyle {
PopupStyle {
frame: Frame::popup(style),
item_spacing: style.spacing.item_spacing,
scroll_overflow_margin: style.spacing.scroll.overflow_margin,
}
}
}

View File

@@ -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 {}

View File

@@ -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);