From 1b3717f44c63893da8cf036eb52730caabf0ff17 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 25 Aug 2026 09:21:25 +0200 Subject: [PATCH] Add a ScrollAreaStyle, so the theme decides how a scroll area scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- crates/egui/src/containers/popup.rs | 1 - crates/egui/src/containers/scroll_area.rs | 83 +++++++++++++++++------ crates/egui/src/theme/default_style.rs | 14 +++- crates/egui/src/theme/themes.rs | 9 ++- crates/egui/src/widget_style/mod.rs | 23 ++++--- 5 files changed, 97 insertions(+), 33 deletions(-) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 486ee67c3..e20285913 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -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 diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 8560e9d01..da3e99c4a 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -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, overflow_margin: Option, - extend_into_parent_margin: bool, + extend_into_parent_margin: Option, + + 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) -> 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) -> 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 R + 'c>, ) -> ScrollAreaOutput { - 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; diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index c303dbefe..6cf69792f 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -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 for DefaultStyle { PopupStyle { frame: Frame::popup(style), item_spacing: style.spacing.item_spacing, - scroll_overflow_margin: style.spacing.scroll.overflow_margin, + } + } +} + +impl StyleProvider for DefaultStyle { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> ScrollAreaStyle { + ScrollAreaStyle { + scroll: modifiers.style.spacing.scroll, + extend_into_parent_margin: false, } } } diff --git a/crates/egui/src/theme/themes.rs b/crates/egui/src/theme/themes.rs index be84d0fc9..b9e0180a7 100644 --- a/crates/egui/src/theme/themes.rs +++ b/crates/egui/src/theme/themes.rs @@ -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::>( + Id::NULL, + Arc::new(Mutex::new(Box::new(DefaultStyle))), + ); + themes.insert_temp::>( Id::NULL, Arc::new(Mutex::new(Box::new(DefaultStyle))), diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index 7ecb8bbc4..020368f00 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, 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 {