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

Add a ScrollAreaStyle, so the theme decides how a scroll area scrolls

`ScrollArea` read `ui.spacing().scroll` directly, so the only way to change
how one scrolls was to mutate the ambient style around it — which is what
`Popup` did for `overflow_margin`. It now resolves a `ScrollAreaStyle` once
per pass, and the builder methods override that.

This lets a theme turn on full bleed for the scroll areas it wants it for,
so `PopupStyle::scroll_overflow_margin` and the ambient mutation both go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-08-25 09:21:25 +02:00
parent 39796a9b78
commit 1b3717f44c
5 changed files with 97 additions and 33 deletions

View File

@@ -637,7 +637,6 @@ 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

@@ -11,6 +11,7 @@ use crate::{
AsIdSalt, Context, CursorIcon, Id, IdSalt, NumExt as _, Pos2, Rangef, Rect, Response, Sense,
Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, WidgetInfo, emath, epaint, lerp, pass_state,
pos2, remap, remap_clamp,
widget_style::{Classes, HasClasses, ScrollAreaStyle, StyleArgs, WidgetState},
};
#[derive(Clone, Copy, Debug)]
@@ -361,7 +362,9 @@ pub struct ScrollArea {
content_margin: Option<Margin>,
overflow_margin: Option<Margin>,
extend_into_parent_margin: bool,
extend_into_parent_margin: Option<bool>,
classes: Classes,
/// If true for vertical or horizontal the scroll wheel will stick to the
/// end position until user manually changes position. It will become true
@@ -417,7 +420,8 @@ impl ScrollArea {
wheel_scroll_multiplier: Vec2::splat(1.0),
content_margin: None,
overflow_margin: None,
extend_into_parent_margin: false,
extend_into_parent_margin: None,
classes: Classes::default(),
stick_to_end: Vec2b::FALSE,
animated: true,
}
@@ -637,7 +641,7 @@ impl ScrollArea {
/// The scroll bars will be either on top of this margin, or outside of it,
/// depending on the value of [`crate::style::ScrollStyle::floating`].
///
/// Default: [`crate::style::ScrollStyle::content_margin`].
/// Default: [`crate::widget_style::ScrollAreaStyle::scroll`].
#[inline]
pub fn content_margin(mut self, margin: impl Into<Margin>) -> Self {
self.content_margin = Some(margin.into());
@@ -650,7 +654,10 @@ impl ScrollArea {
/// 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`].
/// This covers the sides that [`Self::extend_into_parent_margin`] cannot: a side with a widget
/// between the scroll area and the frame edge stays where it is, and still clips.
///
/// Default: [`crate::widget_style::ScrollAreaStyle::scroll`].
#[inline]
pub fn overflow_margin(mut self, margin: impl Into<Margin>) -> Self {
self.overflow_margin = Some(margin.into());
@@ -673,10 +680,10 @@ impl ScrollArea {
/// _after_ the scroll area would be overlapped by it, so reserve the space for it
/// first, e.g. with [`Layout::bottom_up`](crate::Layout::bottom_up).
///
/// Default: `false`.
/// Default: [`crate::widget_style::ScrollAreaStyle::extend_into_parent_margin`].
#[inline]
pub fn extend_into_parent_margin(mut self, extend: bool) -> Self {
self.extend_into_parent_margin = extend;
self.extend_into_parent_margin = Some(extend);
self
}
@@ -739,6 +746,9 @@ struct Prepared {
/// See [`ScrollArea::extend_into_parent_margin`].
claimed_margin: Margin,
/// The resolved style, with the builder overrides already folded in.
scroll_style: crate::style::ScrollStyle,
content_ui: Ui,
/// Relative coordinates: the offset and size of the view of the inner UI.
@@ -759,6 +769,16 @@ struct Prepared {
animated: bool,
}
impl HasClasses for ScrollArea {
fn classes(&self) -> &Classes {
&self.classes
}
fn classes_mut(&mut self) -> &mut Classes {
&mut self.classes
}
}
impl ScrollArea {
fn begin(self, ui: &mut Ui) -> Prepared {
let Self {
@@ -775,9 +795,10 @@ impl ScrollArea {
on_drag_cursor,
scroll_source,
wheel_scroll_multiplier,
content_margin: _, // Used elsewhere
content_margin,
overflow_margin,
extend_into_parent_margin,
classes,
stick_to_end,
animated,
} = self;
@@ -807,7 +828,27 @@ impl ScrollArea {
ctx.animate_bool_responsive(id.with("v"), show_bars[1]),
);
let scroll_style = ui.spacing().scroll;
// The theme decides how the scroll area looks; the builder methods override it.
let scroll_area_style: ScrollAreaStyle = ui.ctx().get_widget_style(&StyleArgs {
classes: &classes,
state: WidgetState::Inactive,
style: ui.style(),
stack: ui.stack(),
ctx: ui.ctx(),
});
let ScrollAreaStyle {
mut scroll,
extend_into_parent_margin: extend_by_default,
} = scroll_area_style;
if let Some(content_margin) = content_margin {
scroll.content_margin = content_margin;
}
if let Some(overflow_margin) = overflow_margin {
scroll.overflow_margin = overflow_margin;
}
let extend_into_parent_margin = extend_into_parent_margin.unwrap_or(extend_by_default);
let scroll_style = scroll;
let current_bar_use = if scroll_style.floating {
show_bars.to_vec2().yx() * scroll_style.allocated_width()
} else {
@@ -874,8 +915,7 @@ 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_margin = scroll_style.overflow_margin;
let overflow_min = Vec2::new(overflow_margin.leftf(), overflow_margin.topf());
let overflow_max = Vec2::new(overflow_margin.rightf(), overflow_margin.bottomf());
@@ -1021,6 +1061,7 @@ impl ScrollArea {
scroll_bar_rect,
inner_rect,
claimed_margin,
scroll_style,
content_ui,
viewport,
scroll_source,
@@ -1109,16 +1150,13 @@ impl ScrollArea {
ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui, Rect) -> R + 'c>,
) -> ScrollAreaOutput<R> {
let content_margin = self.content_margin;
let mut prepared = self.begin(ui);
let id = prepared.id;
let inner_rect = prepared.inner_rect;
// The margin we took over from the parent frame is applied to the contents instead,
// so they keep the same padding:
let margin = content_margin.unwrap_or_else(|| ui.spacing().scroll.content_margin)
+ prepared.claimed_margin;
let margin = prepared.scroll_style.content_margin + prepared.claimed_margin;
let inner = crate::Frame::NONE
.inner_margin(margin)
@@ -1152,6 +1190,7 @@ impl Prepared {
scroll_bar_visibility,
scroll_bar_rect,
claimed_margin,
scroll_style,
content_ui,
viewport: _,
scroll_source,
@@ -1277,7 +1316,7 @@ impl Prepared {
let outer_rect = Rect::from_min_size(inner_rect.min, inner_rect.size() + current_bar_use);
let limit_rect = if ui.spacing().scroll.floating {
let limit_rect = if scroll_style.floating {
outer_rect
} else {
inner_rect
@@ -1350,13 +1389,18 @@ impl Prepared {
show_bars_factor.y = ui.ctx().animate_bool_responsive(id.with("v"), true);
}
let scroll_style = ui.spacing().scroll;
// We paint into the margin of the parent frame, but we allocate only the rect we would
// have used without it. The frame then adds that margin back around what we painted.
ui.advance_cursor_after_rect(shrink_at_least_to_nothing(outer_rect, claimed_margin));
paint_fade_areas_impl(ui, inner_rect, claimed_margin, content_size, state.offset);
paint_fade_areas_impl(
ui,
scroll_style.fade,
inner_rect,
claimed_margin,
content_size,
state.offset,
);
// Paint the bars:
let scroll_bar_rect = scroll_bar_rect.unwrap_or(inner_rect);
@@ -1648,6 +1692,7 @@ impl Prepared {
/// indicate that more content is available beyond the visible region.
fn paint_fade_areas_impl(
ui: &Ui,
fade: crate::style::ScrollFadeStyle,
inner_rect: Rect,
corner_inset: Margin,
content_size: Vec2,
@@ -1656,7 +1701,7 @@ fn paint_fade_areas_impl(
let crate::style::ScrollFadeStyle {
strength,
size: fade_size,
} = ui.spacing().scroll.fade;
} = fade;
if strength <= 0.0 {
return;

View File

@@ -6,8 +6,8 @@ use crate::{
theme::StyleProvider,
widget_style::{
BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, LayoutStyle,
PopupStyle, READ_ONLY_CLASS, SELECTED_CLASS, SeparatorStyle, StyleArgs, TextEditStyle,
TextVisuals, WidgetState,
PopupStyle, READ_ONLY_CLASS, SELECTED_CLASS, ScrollAreaStyle, SeparatorStyle, StyleArgs,
TextEditStyle, TextVisuals, WidgetState,
},
};
@@ -106,7 +106,15 @@ impl StyleProvider<PopupStyle> for DefaultStyle {
PopupStyle {
frame: Frame::popup(style),
item_spacing: style.spacing.item_spacing,
scroll_overflow_margin: style.spacing.scroll.overflow_margin,
}
}
}
impl StyleProvider<ScrollAreaStyle> for DefaultStyle {
fn style(&mut self, modifiers: &StyleArgs<'_>) -> ScrollAreaStyle {
ScrollAreaStyle {
scroll: modifiers.style.spacing.scroll,
extend_into_parent_margin: false,
}
}
}

View File

@@ -7,8 +7,8 @@ use crate::{
theme::{StyleProvider, default_style::DefaultStyle},
util::IdTypeMap,
widget_style::{
BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, PopupStyle, SeparatorStyle,
TextEditStyle, WidgetStyle,
BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, PopupStyle, ScrollAreaStyle,
SeparatorStyle, TextEditStyle, WidgetStyle,
},
};
@@ -62,6 +62,11 @@ impl Default for Themes {
Arc::new(Mutex::new(Box::new(DefaultStyle))),
);
themes.insert_temp::<ThemeWrap<ScrollAreaStyle>>(
Id::NULL,
Arc::new(Mutex::new(Box::new(DefaultStyle))),
);
themes.insert_temp::<ThemeWrap<LabelStyle>>(
Id::NULL,
Arc::new(Mutex::new(Box::new(DefaultStyle))),

View File

@@ -14,7 +14,7 @@ use emath::Vec2;
use epaint::{Color32, FontId, Stroke, text::TextWrapMode};
use crate::{
Context, Frame, Margin, Response, Style, TextStyle, UiStack,
Context, Frame, Response, Style, TextStyle, UiStack,
style::{WidgetVisuals, Widgets},
};
@@ -109,17 +109,24 @@ 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 {}
/// Dedicated style for a [`crate::ScrollArea`]
#[derive(Debug, Clone)]
pub struct ScrollAreaStyle {
/// How the scroll bars look, and how much room the contents get.
pub scroll: crate::style::ScrollStyle,
/// Grow into the inner margin of the closest parent [`Frame`].
///
/// See [`crate::ScrollArea::extend_into_parent_margin`], which overrides this.
pub extend_into_parent_margin: bool,
}
impl WidgetStyle for ScrollAreaStyle {}
/// Dedicated text edit style
#[derive(Debug, Clone)]
pub struct TextEditStyle {