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. // The theme decides how the popup is framed and how tightly its items sit together.
let popup_style: PopupStyle = ui.widget_style(id, &classes); let popup_style: PopupStyle = ui.widget_style(id, &classes);
ui.spacing_mut().item_spacing = popup_style.item_spacing; 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); let frame = frame.unwrap_or(popup_style.frame);
frame.show(ui, content).inner 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, AsIdSalt, Context, CursorIcon, Id, IdSalt, NumExt as _, Pos2, Rangef, Rect, Response, Sense,
Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, WidgetInfo, emath, epaint, lerp, pass_state, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, WidgetInfo, emath, epaint, lerp, pass_state,
pos2, remap, remap_clamp, pos2, remap, remap_clamp,
widget_style::{Classes, HasClasses, ScrollAreaStyle, StyleArgs, WidgetState},
}; };
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -361,7 +362,9 @@ pub struct ScrollArea {
content_margin: Option<Margin>, content_margin: Option<Margin>,
overflow_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 /// If true for vertical or horizontal the scroll wheel will stick to the
/// end position until user manually changes position. It will become true /// end position until user manually changes position. It will become true
@@ -417,7 +420,8 @@ impl ScrollArea {
wheel_scroll_multiplier: Vec2::splat(1.0), wheel_scroll_multiplier: Vec2::splat(1.0),
content_margin: None, content_margin: None,
overflow_margin: None, overflow_margin: None,
extend_into_parent_margin: false, extend_into_parent_margin: None,
classes: Classes::default(),
stick_to_end: Vec2b::FALSE, stick_to_end: Vec2b::FALSE,
animated: true, animated: true,
} }
@@ -637,7 +641,7 @@ impl ScrollArea {
/// The scroll bars will be either on top of this margin, or outside of it, /// The scroll bars will be either on top of this margin, or outside of it,
/// depending on the value of [`crate::style::ScrollStyle::floating`]. /// depending on the value of [`crate::style::ScrollStyle::floating`].
/// ///
/// Default: [`crate::style::ScrollStyle::content_margin`]. /// Default: [`crate::widget_style::ScrollAreaStyle::scroll`].
#[inline] #[inline]
pub fn content_margin(mut self, margin: impl Into<Margin>) -> Self { pub fn content_margin(mut self, margin: impl Into<Margin>) -> Self {
self.content_margin = Some(margin.into()); 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 /// 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. /// 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] #[inline]
pub fn overflow_margin(mut self, margin: impl Into<Margin>) -> Self { pub fn overflow_margin(mut self, margin: impl Into<Margin>) -> Self {
self.overflow_margin = Some(margin.into()); 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 /// _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). /// first, e.g. with [`Layout::bottom_up`](crate::Layout::bottom_up).
/// ///
/// Default: `false`. /// Default: [`crate::widget_style::ScrollAreaStyle::extend_into_parent_margin`].
#[inline] #[inline]
pub fn extend_into_parent_margin(mut self, extend: bool) -> Self { 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 self
} }
@@ -739,6 +746,9 @@ struct Prepared {
/// See [`ScrollArea::extend_into_parent_margin`]. /// See [`ScrollArea::extend_into_parent_margin`].
claimed_margin: Margin, claimed_margin: Margin,
/// The resolved style, with the builder overrides already folded in.
scroll_style: crate::style::ScrollStyle,
content_ui: Ui, content_ui: Ui,
/// Relative coordinates: the offset and size of the view of the inner UI. /// Relative coordinates: the offset and size of the view of the inner UI.
@@ -759,6 +769,16 @@ struct Prepared {
animated: bool, animated: bool,
} }
impl HasClasses for ScrollArea {
fn classes(&self) -> &Classes {
&self.classes
}
fn classes_mut(&mut self) -> &mut Classes {
&mut self.classes
}
}
impl ScrollArea { impl ScrollArea {
fn begin(self, ui: &mut Ui) -> Prepared { fn begin(self, ui: &mut Ui) -> Prepared {
let Self { let Self {
@@ -775,9 +795,10 @@ impl ScrollArea {
on_drag_cursor, on_drag_cursor,
scroll_source, scroll_source,
wheel_scroll_multiplier, wheel_scroll_multiplier,
content_margin: _, // Used elsewhere content_margin,
overflow_margin, overflow_margin,
extend_into_parent_margin, extend_into_parent_margin,
classes,
stick_to_end, stick_to_end,
animated, animated,
} = self; } = self;
@@ -807,7 +828,27 @@ impl ScrollArea {
ctx.animate_bool_responsive(id.with("v"), show_bars[1]), 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 { let current_bar_use = if scroll_style.floating {
show_bars.to_vec2().yx() * scroll_style.allocated_width() show_bars.to_vec2().yx() * scroll_style.allocated_width()
} else { } else {
@@ -874,8 +915,7 @@ impl ScrollArea {
{ {
// Clip the content, but only when we really need to: // Clip the content, but only when we really need to:
let mut content_clip_rect = ui.clip_rect(); let mut content_clip_rect = ui.clip_rect();
let overflow_margin = let overflow_margin = scroll_style.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_min = Vec2::new(overflow_margin.leftf(), overflow_margin.topf());
let overflow_max = Vec2::new(overflow_margin.rightf(), overflow_margin.bottomf()); let overflow_max = Vec2::new(overflow_margin.rightf(), overflow_margin.bottomf());
@@ -1021,6 +1061,7 @@ impl ScrollArea {
scroll_bar_rect, scroll_bar_rect,
inner_rect, inner_rect,
claimed_margin, claimed_margin,
scroll_style,
content_ui, content_ui,
viewport, viewport,
scroll_source, scroll_source,
@@ -1109,16 +1150,13 @@ impl ScrollArea {
ui: &mut Ui, ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui, Rect) -> R + 'c>, add_contents: Box<dyn FnOnce(&mut Ui, Rect) -> R + 'c>,
) -> ScrollAreaOutput<R> { ) -> ScrollAreaOutput<R> {
let content_margin = self.content_margin;
let mut prepared = self.begin(ui); let mut prepared = self.begin(ui);
let id = prepared.id; let id = prepared.id;
let inner_rect = prepared.inner_rect; let inner_rect = prepared.inner_rect;
// The margin we took over from the parent frame is applied to the contents instead, // The margin we took over from the parent frame is applied to the contents instead,
// so they keep the same padding: // so they keep the same padding:
let margin = content_margin.unwrap_or_else(|| ui.spacing().scroll.content_margin) let margin = prepared.scroll_style.content_margin + prepared.claimed_margin;
+ prepared.claimed_margin;
let inner = crate::Frame::NONE let inner = crate::Frame::NONE
.inner_margin(margin) .inner_margin(margin)
@@ -1152,6 +1190,7 @@ impl Prepared {
scroll_bar_visibility, scroll_bar_visibility,
scroll_bar_rect, scroll_bar_rect,
claimed_margin, claimed_margin,
scroll_style,
content_ui, content_ui,
viewport: _, viewport: _,
scroll_source, 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 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 outer_rect
} else { } else {
inner_rect inner_rect
@@ -1350,13 +1389,18 @@ impl Prepared {
show_bars_factor.y = ui.ctx().animate_bool_responsive(id.with("v"), true); 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 // 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. // 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)); 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: // Paint the bars:
let scroll_bar_rect = scroll_bar_rect.unwrap_or(inner_rect); 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. /// indicate that more content is available beyond the visible region.
fn paint_fade_areas_impl( fn paint_fade_areas_impl(
ui: &Ui, ui: &Ui,
fade: crate::style::ScrollFadeStyle,
inner_rect: Rect, inner_rect: Rect,
corner_inset: Margin, corner_inset: Margin,
content_size: Vec2, content_size: Vec2,
@@ -1656,7 +1701,7 @@ fn paint_fade_areas_impl(
let crate::style::ScrollFadeStyle { let crate::style::ScrollFadeStyle {
strength, strength,
size: fade_size, size: fade_size,
} = ui.spacing().scroll.fade; } = fade;
if strength <= 0.0 { if strength <= 0.0 {
return; return;

View File

@@ -6,8 +6,8 @@ use crate::{
theme::StyleProvider, theme::StyleProvider,
widget_style::{ widget_style::{
BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, LayoutStyle, BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, LayoutStyle,
PopupStyle, READ_ONLY_CLASS, SELECTED_CLASS, SeparatorStyle, StyleArgs, TextEditStyle, PopupStyle, READ_ONLY_CLASS, SELECTED_CLASS, ScrollAreaStyle, SeparatorStyle, StyleArgs,
TextVisuals, WidgetState, TextEditStyle, TextVisuals, WidgetState,
}, },
}; };
@@ -106,7 +106,15 @@ impl StyleProvider<PopupStyle> for DefaultStyle {
PopupStyle { PopupStyle {
frame: Frame::popup(style), frame: Frame::popup(style),
item_spacing: style.spacing.item_spacing, 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}, theme::{StyleProvider, default_style::DefaultStyle},
util::IdTypeMap, util::IdTypeMap,
widget_style::{ widget_style::{
BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, PopupStyle, SeparatorStyle, BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, PopupStyle, ScrollAreaStyle,
TextEditStyle, WidgetStyle, SeparatorStyle, TextEditStyle, WidgetStyle,
}, },
}; };
@@ -62,6 +62,11 @@ impl Default for Themes {
Arc::new(Mutex::new(Box::new(DefaultStyle))), 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>>( themes.insert_temp::<ThemeWrap<LabelStyle>>(
Id::NULL, Id::NULL,
Arc::new(Mutex::new(Box::new(DefaultStyle))), Arc::new(Mutex::new(Box::new(DefaultStyle))),

View File

@@ -14,7 +14,7 @@ use emath::Vec2;
use epaint::{Color32, FontId, Stroke, text::TextWrapMode}; use epaint::{Color32, FontId, Stroke, text::TextWrapMode};
use crate::{ use crate::{
Context, Frame, Margin, Response, Style, TextStyle, UiStack, Context, Frame, Response, Style, TextStyle, UiStack,
style::{WidgetVisuals, Widgets}, 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 /// A menu wants its items flush against each other, while a tooltip wants them spaced out
/// like any other content. /// like any other content.
pub item_spacing: Vec2, 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 {} 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 /// Dedicated text edit style
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TextEditStyle { pub struct TextEditStyle {