1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-01 14:20:04 -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 17984adeb6
commit 23efedd3e3
6 changed files with 101 additions and 37 deletions

View File

@@ -6,8 +6,9 @@ use emath::GuiRounding as _;
use crate::{ use crate::{
Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt as _, Order, Pos2, Rect, Response, Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt as _, Order, Pos2, Rect, Response,
Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState, emath, pos2, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
widget_style::{Classes, HasClasses}, class::{Classes, HasClasses},
emath, pos2,
}; };
/// State of an [`Area`] that is persisted between frames. /// State of an [`Area`] that is persisted between frames.

View File

@@ -8,8 +8,9 @@
//! //!
//! See [`MenuBar`] for an example. //! See [`MenuBar`] for an example.
use crate::class::{Classes, HasClasses as _};
use crate::style::StyleModifier; use crate::style::StyleModifier;
use crate::widget_style::{Classes, HasClasses as _, PopupStyle, StyleArgs, WidgetState}; use crate::widget_style::{PopupStyle, StyleArgs, WidgetState};
use crate::{ use crate::{
Button, Color32, Context, Id, InnerResponse, IntoAtoms, Layout, PointerButton, Popup, Button, Color32, Context, Id, InnerResponse, IntoAtoms, Layout, PointerButton, Popup,
PopupCloseBehavior, PopupKind, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, PopupCloseBehavior, PopupKind, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo,

View File

@@ -5,9 +5,10 @@ use emath::{Align, Pos2, Rect, RectAlign, Vec2, vec2};
use crate::{ use crate::{
Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response, Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response,
Sense, Ui, UiKind, UiStackInfo, Sense, Ui, UiKind, UiStackInfo,
class::{ClassName, Classes, HasClasses},
containers::menu::{MenuConfig, MenuState, menu_style}, containers::menu::{MenuConfig, MenuState, menu_style},
style::StyleModifier, style::StyleModifier,
widget_style::{ClassName, Classes, HasClasses, PopupStyle}, widget_style::PopupStyle,
}; };
/// What should we anchor the popup to? /// What should we anchor the popup to?
@@ -643,7 +644,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

@@ -9,8 +9,10 @@ use epaint::{Color32, Direction, Margin, Shape};
use crate::{ 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,
pos2, remap, remap_clamp, class::{Classes, HasClasses},
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp,
widget_style::{ScrollAreaStyle, StyleArgs, WidgetState},
}; };
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -361,7 +363,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 +421,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 +642,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 +655,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 +681,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 +747,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 +770,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 +796,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 +829,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 +916,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 +1062,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 +1151,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 +1191,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 +1317,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 +1390,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 +1693,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 +1702,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::{
class::HasClasses as _, class::HasClasses as _,
theme::StyleProvider, theme::StyleProvider,
widget_style::{ widget_style::{
AtomLayoutStyle, ButtonStyle, CheckboxStyle, PopupStyle, SeparatorStyle, StyleArgs, AtomLayoutStyle, ButtonStyle, CheckboxStyle, PopupStyle, ScrollAreaStyle, SeparatorStyle,
TextEditStyle, TextVisuals, WidgetState, StyleArgs, TextEditStyle, TextVisuals, WidgetState,
}, },
}; };
@@ -27,6 +27,7 @@ impl DefaultStyle {
ctx.add_widget_theme::<CheckboxStyle>(Self); ctx.add_widget_theme::<CheckboxStyle>(Self);
ctx.add_widget_theme::<TextEditStyle>(Self); ctx.add_widget_theme::<TextEditStyle>(Self);
ctx.add_widget_theme::<PopupStyle>(Self); ctx.add_widget_theme::<PopupStyle>(Self);
ctx.add_widget_theme::<ScrollAreaStyle>(Self);
} }
} }
@@ -114,7 +115,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

@@ -8,7 +8,7 @@ use emath::{Align2, Vec2};
use epaint::{Color32, FontId, Stroke}; use epaint::{Color32, FontId, Stroke};
use crate::{ use crate::{
AtomLayout, Context, FontSelection, Frame, Margin, Response, Style, UiStack, AtomLayout, Context, FontSelection, Frame, Response, Style, UiStack,
class::{Classes, HasClasses as _}, class::{Classes, HasClasses as _},
style::{WidgetVisuals, Widgets}, style::{WidgetVisuals, Widgets},
}; };
@@ -138,17 +138,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 {