From 7a18bc9cf3ff8b20344b4be0de286b21ce773dd8 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Fri, 28 Aug 2026 12:34:07 +0200 Subject: [PATCH] Move more button features to classes and rename classes --- crates/egui/src/atomics/atom_kind.rs | 1 - crates/egui/src/theme/default_style.rs | 131 ++++++++---------------- crates/egui/src/theme/themes.rs | 14 +-- crates/egui/src/widget_style/classes.rs | 89 +++++++++++++++- crates/egui/src/widget_style/mod.rs | 38 +------ crates/egui/src/widgets/button.rs | 83 +++++++-------- examples/styling_engine/src/main.rs | 17 +-- 7 files changed, 183 insertions(+), 190 deletions(-) diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index 0dfe56069..9e7aa3bb0 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -31,7 +31,6 @@ pub enum AtomKind<'a> { /// Text atom. /// /// Truncation within [`crate::AtomLayout`] works like this: - /// - /// - if `wrap_mode` is not Extend /// - if no atom is `shrink` /// - the first text atom is selected and will be marked as `shrink` diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index a23ef8fcd..d584c75d1 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,12 +1,13 @@ use emath::Vec2; -use epaint::{Shadow, Stroke, text::TextWrapMode}; +use epaint::Margin; use crate::{ - Frame, TextStyle, + Frame, Style, TextStyle, + style::WidgetVisuals, theme::StyleProvider, widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, SELECTED_CLASS, - SeparatorStyle, StyleArgs, TextVisuals, WidgetState, + ButtonStyle, CheckboxStyle, HasClasses as _, NO_FRAME_CLASS, BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS, + SELECTED_CLASS, SMALL_CLASS, SeparatorStyle, StyleArgs, TextVisuals, WidgetState, }, }; @@ -15,97 +16,75 @@ use crate::{ #[derive(Debug, Clone)] pub struct DefaultStyle; -impl StyleProvider for DefaultStyle { - fn style(&mut self, modifiers: &StyleArgs<'_>) -> BaseStyle { - let StyleArgs { style, state, .. } = modifiers; - let spacing = &style.spacing; - let widget_visuals = match state { - WidgetState::Noninteractive => style.visuals.widgets.noninteractive, - WidgetState::Inactive => style.visuals.widgets.inactive, - WidgetState::Hovered => style.visuals.widgets.hovered, - WidgetState::Active => style.visuals.widgets.active, - }; - - BaseStyle { - frame: Frame { - fill: widget_visuals.bg_fill, - stroke: widget_visuals.bg_stroke, - corner_radius: widget_visuals.corner_radius, - inner_margin: spacing.button_padding.into(), - ..Default::default() - }, - stroke: widget_visuals.fg_stroke, - text: TextVisuals { - color: widget_visuals.text_color(), - font_id: modifiers - .style - .override_font_id - .clone() - .unwrap_or_else(|| TextStyle::Body.resolve(style)), - strikethrough: Stroke::NONE, - underline: Stroke::NONE, - }, - } +/// The text of a widget, based on the [`WidgetVisuals`] of its current state. +fn text_visuals(style: &Style, widget_visuals: &WidgetVisuals) -> TextVisuals { + TextVisuals { + color: widget_visuals.text_color(), + font_id: style + .override_font_id + .clone() + .unwrap_or_else(|| TextStyle::Body.resolve(style)), } } impl StyleProvider for DefaultStyle { fn style(&mut self, modifiers: &StyleArgs<'_>) -> ButtonStyle { let StyleArgs { - ctx, classes, style, state, .. } = modifiers; let spacing = &style.spacing; - let mut widget_visuals = match state { - WidgetState::Noninteractive => style.visuals.widgets.noninteractive, - WidgetState::Inactive => style.visuals.widgets.inactive, - WidgetState::Hovered => style.visuals.widgets.hovered, - WidgetState::Active => style.visuals.widgets.active, - }; + let mut widget_visuals = *style.visuals.widgets.state(*state); - let mut ws: BaseStyle = ctx.get_widget_style(modifiers); - - if classes.has(SELECTED_CLASS) { + if classes.has_class(SELECTED_CLASS) { let visuals = &style.visuals; widget_visuals.weak_bg_fill = visuals.selection.bg_fill; widget_visuals.bg_fill = visuals.selection.bg_fill; widget_visuals.fg_stroke = visuals.selection.stroke; - ws.text.color = visuals.selection.stroke.color; } - ButtonStyle { - frame: Frame { + let mut inner_margin: Margin = (spacing.button_padding + + Vec2::splat(widget_visuals.expansion) + - Vec2::splat(widget_visuals.bg_stroke.width)) + .into(); + + // A small button is meant to be embedded into text, so it must not add any height. + if classes.has_class(SMALL_CLASS) { + inner_margin.top = 0; + inner_margin.bottom = 0; + } + + let frame = if classes.has_class(NO_FRAME_CLASS) { + // No frame at all: the button takes up no more room than its contents. + Frame::new() + } else if classes.has_class(BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS) && *state == WidgetState::Inactive { + // Invisible, but as big as it will be once the user interacts with it. + Frame::new().inner_margin(inner_margin) + } else { + Frame { fill: widget_visuals.weak_bg_fill, stroke: widget_visuals.bg_stroke, corner_radius: widget_visuals.corner_radius, outer_margin: (-Vec2::splat(widget_visuals.expansion)).into(), - inner_margin: (spacing.button_padding + Vec2::splat(widget_visuals.expansion) - - Vec2::splat(widget_visuals.bg_stroke.width)) - .into(), + inner_margin, ..Default::default() - }, - text_style: ws.text, + } + }; + + ButtonStyle { + frame, + text_style: text_visuals(style, &widget_visuals), } } } impl StyleProvider for DefaultStyle { fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle { - let StyleArgs { - ctx, style, state, .. - } = modifiers; + let StyleArgs { style, state, .. } = modifiers; let spacing = &style.spacing; - let widget_visuals = match state { - WidgetState::Noninteractive => style.visuals.widgets.noninteractive, - WidgetState::Inactive => style.visuals.widgets.inactive, - WidgetState::Hovered => style.visuals.widgets.hovered, - WidgetState::Active => style.visuals.widgets.active, - }; - - let ws: BaseStyle = ctx.get_widget_style(modifiers); + let widget_visuals = *style.visuals.widgets.state(*state); CheckboxStyle { frame: Frame::new(), @@ -117,28 +96,8 @@ impl StyleProvider for DefaultStyle { stroke: widget_visuals.bg_stroke, ..Default::default() }, - text_style: ws.text, - check_stroke: ws.stroke, - } - } -} - -impl StyleProvider for DefaultStyle { - fn style(&mut self, modifiers: &StyleArgs<'_>) -> LabelStyle { - let StyleArgs { ctx, .. } = modifiers; - let ws: BaseStyle = ctx.get_widget_style(modifiers); - - LabelStyle { - frame: Frame { - fill: ws.frame.fill, - inner_margin: 0.0.into(), - outer_margin: 0.0.into(), - stroke: Stroke::NONE, - shadow: Shadow::NONE, - corner_radius: 0.into(), - }, - text: ws.text, - wrap_mode: TextWrapMode::Wrap, + text_style: text_visuals(style, &widget_visuals), + check_stroke: widget_visuals.fg_stroke, } } } diff --git a/crates/egui/src/theme/themes.rs b/crates/egui/src/theme/themes.rs index 4294f9460..ef1429db4 100644 --- a/crates/egui/src/theme/themes.rs +++ b/crates/egui/src/theme/themes.rs @@ -6,9 +6,7 @@ use crate::{ Id, theme::{StyleProvider, default_style::DefaultStyle}, util::IdTypeMap, - widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, SeparatorStyle, WidgetStyle, - }, + widget_style::{ButtonStyle, CheckboxStyle, SeparatorStyle, WidgetStyle}, }; /// The registry of [`StyleProvider`]s, one per [`WidgetStyle`] type. @@ -31,11 +29,6 @@ impl Default for Themes { fn default() -> Self { let mut themes = IdTypeMap::default(); - themes.insert_temp::>( - Id::NULL, - Arc::new(Mutex::new(Box::new(DefaultStyle))), - ); - themes.insert_temp::>( Id::NULL, Arc::new(Mutex::new(Box::new(DefaultStyle))), @@ -51,11 +44,6 @@ impl Default for Themes { Arc::new(Mutex::new(Box::new(DefaultStyle))), ); - themes.insert_temp::>( - Id::NULL, - Arc::new(Mutex::new(Box::new(DefaultStyle))), - ); - Self { themes } } } diff --git a/crates/egui/src/widget_style/classes.rs b/crates/egui/src/widget_style/classes.rs index 95ef43aae..3830f37b3 100644 --- a/crates/egui/src/widget_style/classes.rs +++ b/crates/egui/src/widget_style/classes.rs @@ -5,10 +5,20 @@ use smallvec::SmallVec; use crate::TextBuffer as _; /// The root class is a special class present on every top-level [`crate::Ui`]. -pub const ROOT_CLASS: &str = "root"; +pub const ROOT_CLASS: &str = "egui::root"; /// The selected class is a special class present on selected [`crate::Button`]. -pub const SELECTED_CLASS: &str = "selected"; +pub const SELECTED_CLASS: &str = "egui::selected"; + +/// The small class is a special class present on small [`crate::Button`]. +pub const SMALL_CLASS: &str = "egui::small"; + +/// Present on a [`crate::Button`] that should have no frame at all. +pub const NO_FRAME_CLASS: &str = "egui::no_frame"; + +/// Present on a [`crate::Button`] that should have no frame while it is +/// [`crate::widget_style::WidgetState::Inactive`]. +pub const BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS: &str = "egui::button::no_frame_when_inactive"; /// A class is a static string identifier. pub type ClassName = Cow<'static, str>; @@ -17,17 +27,34 @@ pub type ClassName = Cow<'static, str>; /// /// This can be used by styling engine to compute a different style /// based on the set of classes present on the widget/Ui. +/// Class order is preserved and may be used by a style provider for precedence. #[derive(Debug, Default, Clone, Hash)] pub struct Classes { classes: SmallVec<[ClassName; 5]>, } impl Classes { - /// Add a class to the list if the condition is true + /// Add a class to the list if the condition is true. + /// + /// A class is never added twice. This never removes a class: use [`Self::set`] for that. #[inline] fn add_if(&mut self, class: impl Into, condition: bool) { if condition { - self.classes.push(class.into()); + let class = class.into(); + // Always retain and push again, since order of classes can matter. + self.classes.retain(|existing| existing != &class); + self.classes.push(class); + } + } + + /// Add the class if `present`, remove it otherwise. + #[inline] + fn set(&mut self, class: impl Into, present: bool) { + let class = class.into(); + // Always retain and push again, since order of classes can matter. + self.classes.retain(|existing| existing != &class); + if present { + self.classes.push(class); } } } @@ -97,8 +124,31 @@ pub trait HasClasses { self } + /// Add the given class in-place if `present`, remove it otherwise + /// + /// Use this for a setter that takes a `bool`, so that the last call wins. + #[inline] + fn set_class(&mut self, class: impl Into, present: bool) -> &mut Self + where + Self: Sized, + { + self.classes_mut().set(class.into(), present); + self + } + + /// Remove the given class in-place + #[inline] + fn remove_class(&mut self, class: impl Into) -> &mut Self + where + Self: Sized, + { + self.classes_mut().set(class.into(), false); + self + } + /// True if the class is present - fn has(&self, class: impl Into) -> bool { + #[inline] + fn has_class(&self, class: impl Into) -> bool { self.classes().classes.contains(&class.into()) } @@ -107,3 +157,32 @@ pub trait HasClasses { &self.classes().classes } } + +#[cfg(test)] +mod tests { + use super::{Classes, HasClasses as _}; + + #[test] + fn setting_a_class_moves_it_to_end() { + let mut classes = Classes::default(); + classes.add_class("first"); + classes.add_class("updated"); + classes.add_class("second"); + + classes.set_class("updated", true); + + assert_eq!(classes.as_slice(), ["first", "second", "updated"]); + } + + #[test] + fn adding_a_class_twice_moves_it_to_end() { + let mut classes = Classes::default(); + classes.add_class("first"); + classes.add_class("updated"); + classes.add_class("second"); + + classes.add_class("updated"); + + assert_eq!(classes.as_slice(), ["first", "second", "updated"]); + } +} diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index 1a2c19959..ef57eb38a 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -4,11 +4,14 @@ mod classes; -pub use self::classes::{ClassName, Classes, HasClasses, ROOT_CLASS, SELECTED_CLASS}; +pub use self::classes::{ + ClassName, Classes, HasClasses, NO_FRAME_CLASS, BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS, ROOT_CLASS, + SELECTED_CLASS, SMALL_CLASS, +}; use core::fmt::Debug; -use epaint::{Color32, FontId, Stroke, text::TextWrapMode}; +use epaint::{Color32, FontId, Stroke}; use crate::{ Context, Frame, Response, Style, UiStack, @@ -26,24 +29,8 @@ pub struct TextVisuals { /// Font color pub color: Color32, - - /// Text decoration - pub underline: Stroke, - pub strikethrough: Stroke, } -/// General widget style -#[derive(Debug, Clone)] -pub struct BaseStyle { - pub frame: Frame, - - pub text: TextVisuals, - - pub stroke: Stroke, -} - -impl WidgetStyle for BaseStyle {} - /// Dedicated button style #[derive(Debug, Clone)] pub struct ButtonStyle { @@ -77,21 +64,6 @@ pub struct CheckboxStyle { impl WidgetStyle for CheckboxStyle {} -/// Dedicated label style -#[derive(Debug, Clone)] -pub struct LabelStyle { - /// Frame around - pub frame: Frame, - - /// Text style - pub text: TextVisuals, - - /// Wrap mode used - pub wrap_mode: TextWrapMode, -} - -impl WidgetStyle for LabelStyle {} - /// Dedicated separator style #[derive(Debug, Clone)] pub struct SeparatorStyle { diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index d7f6c8a0c..502b4407d 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,10 +1,11 @@ -use epaint::Margin; - use crate::{ Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Atoms, Color32, CornerRadius, - Frame, Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, - Vec2, Widget, WidgetInfo, WidgetText, WidgetType, - widget_style::{ButtonStyle, Classes, HasClasses, SELECTED_CLASS, WidgetState}, + Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, + Widget, WidgetInfo, WidgetText, WidgetType, + widget_style::{ + ButtonStyle, Classes, HasClasses, NO_FRAME_CLASS, BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS, + SELECTED_CLASS, SMALL_CLASS, + }, }; /// Clickable button with text. @@ -30,9 +31,7 @@ pub struct Button<'a> { layout: AtomLayout<'a>, fill: Option, stroke: Option, - small: bool, frame: Option, - frame_when_inactive: bool, min_size: Vec2, corner_radius: Option, selected: Option, @@ -49,9 +48,7 @@ impl<'a> Button<'a> { .fallback_font(TextStyle::Button), fill: None, stroke: None, - small: false, frame: None, - frame_when_inactive: true, min_size: Vec2::ZERO, corner_radius: None, selected: None, @@ -72,6 +69,8 @@ impl<'a> Button<'a> { /// # }); /// ``` /// + /// When selected, [`SELECTED_CLASS`] is added. + /// /// See also: /// - [`Ui::selectable_value`] /// - [`Ui::selectable_label`] @@ -155,28 +154,41 @@ impl<'a> Button<'a> { } /// Make this a small button, suitable for embedding into text. + /// + /// This adds the built-in [`SMALL_CLASS`], which with the default style removes the top and + /// bottom margin. #[inline] pub fn small(mut self) -> Self { - self.small = true; + self.add_class(SMALL_CLASS); self } /// Turn off the frame + /// + /// If `false`, this adds the built-in [`NO_FRAME_CLASS`], which with the default style + /// removes the fill, the stroke and the margin. + /// + /// Default: `ui.visuals().button_frame`. #[inline] pub fn frame(mut self, frame: bool) -> Self { self.frame = Some(frame); + self.set_class(NO_FRAME_CLASS, !frame); self } /// If `false`, the button will not have a frame when inactive. /// + /// This adds the built-in [`BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS`], which with the + /// default style removes the fill and the stroke, but keeps the margin, so the button does + /// not change size once the user interacts with it. + /// /// Default: `true`. /// /// Note: When [`Self::frame`] (or `ui.visuals().button_frame`) is `false`, this setting /// has no effect. #[inline] pub fn frame_when_inactive(mut self, frame_when_inactive: bool) -> Self { - self.frame_when_inactive = frame_when_inactive; + self.set_class(BUTTON_NO_FRAME_WHEN_INACTIVE_CLASS, !frame_when_inactive); self } @@ -266,9 +278,13 @@ impl<'a> Button<'a> { /// current pressed/not-pressed state will be reported to assistive /// technologies (e.g. screen readers). Plain buttons that never call /// `selected` are not announced as toggles. + /// + /// When selected, [`SELECTED_CLASS`] is added. You should prefer calling this though over + /// just adding [`SELECTED_CLASS`] manually, since this also exposes accessibility information. #[inline] pub fn selected(mut self, selected: bool) -> Self { self.selected = Some(selected); + self.set_class(SELECTED_CLASS, selected); self } @@ -292,9 +308,7 @@ impl<'a> Button<'a> { mut layout, fill, stroke, - small, frame, - frame_when_inactive, mut min_size, corner_radius, selected, @@ -304,7 +318,7 @@ impl<'a> Button<'a> { } = self; // Min size height always equal or greater than interact size if not small - if !small { + if !classes.has_class(SMALL_CLASS) { min_size.y = min_size.y.at_least(ui.spacing().interact_size.y); } @@ -320,29 +334,19 @@ impl<'a> Button<'a> { let text = layout.text().map(String::from); - let has_frame_margin = frame.unwrap_or_else(|| ui.visuals().button_frame); - - let id = ui.next_auto_id(); - let response: Option = ui.ctx().read_response(id); - let state = response.map(|r| r.widget_state()).unwrap_or_default(); - - classes.add_class_if(SELECTED_CLASS, selected.unwrap_or(false)); - - let ButtonStyle { frame, text_style } = ui.widget_style(id, &classes); - - let mut button_padding = if has_frame_margin { - frame.inner_margin - } else { - Margin::ZERO - }; - - if small { - button_padding.bottom = 0; - button_padding.top = 0; + // An explicit `frame` call already updated the classes at the call site, preserving + // its order relative to user classes. Only apply the global default here. + if frame.is_none() { + classes.add_class_if(NO_FRAME_CLASS, !ui.visuals().button_frame); } + let id = ui.next_auto_id(); + let ButtonStyle { + mut frame, + text_style, + } = ui.widget_style(id, &classes); + // Override global style by local style - let mut frame = frame; if let Some(fill) = fill { frame = frame.fill(fill); } @@ -353,21 +357,12 @@ impl<'a> Button<'a> { frame = frame.stroke(stroke); } - frame = frame.inner_margin(button_padding); - // Apply the style font and color as fallback layout = layout .fallback_font(text_style.font_id.clone()) .fallback_text_color(text_style.color); - // Retrocompatibility with button settings - layout = if has_frame_margin && (state != WidgetState::Inactive || frame_when_inactive) { - layout.frame(frame) - } else { - layout.frame(Frame::new().inner_margin(frame.inner_margin)) - }; - - let mut prepared = layout.min_size(min_size).allocate(ui); + let mut prepared = layout.frame(frame).min_size(min_size).allocate(ui); // Get AtomLayoutResponse, empty if not visible let response = if ui.is_rect_visible(prepared.response.rect) { diff --git a/examples/styling_engine/src/main.rs b/examples/styling_engine/src/main.rs index 54763696a..b4df71710 100644 --- a/examples/styling_engine/src/main.rs +++ b/examples/styling_engine/src/main.rs @@ -4,9 +4,9 @@ //! based on the _classes_ set on it, and that can be edited live. use eframe::egui::{ - self, CentralPanel, Color32, Frame, Panel, + self, CentralPanel, Color32, Frame, Panel, TextStyle, theme::StyleProvider, - widget_style::{BaseStyle, ButtonStyle, HasClasses as _, StyleArgs, WidgetState}, + widget_style::{ButtonStyle, HasClasses as _, StyleArgs, TextVisuals, WidgetState}, }; /// Buttons with this class are styled as a destructive action. @@ -42,14 +42,11 @@ impl StyleProvider for MyTheme { let StyleArgs { classes, state, - ctx, + style, .. } = args; - // Start from the style egui computed for a generic widget, so we inherit e.g. the font: - let base: BaseStyle = ctx.get_widget_style(args); - - let fill = if classes.has(DANGER) { + let fill = if classes.has_class(DANGER) { self.danger } else { self.normal @@ -67,7 +64,11 @@ impl StyleProvider for MyTheme { .fill(fill) .corner_radius(self.corner_radius) .inner_margin(8), - text_style: base.text, + text_style: TextVisuals { + color: Color32::WHITE, + // Resolve the font from the style, so we follow the user's font sizes: + font_id: TextStyle::Button.resolve(style), + }, } } }