From de077b92d3c4354a919a5212b4fc4986fb0de6fb Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 1 Sep 2026 11:39:13 +0200 Subject: [PATCH] Rename classes, more button options via `StyleProvider` (#8464) Moves global classnames to e.g. `egui::class::ROOT`. Widget specific classes live on the widgets struct. Adds some more classes for our buttons styling options (`CLASS_SMALL` etc), so that custom `StyleProvider`s can choose to implement them differently or ignore them altogether. Also removes some styleproviders that were partly or completely unused (e.g. basestyle / labelstyle). We can add them back once we actually implement styling for those. --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/egui/src/atomics/atom_kind.rs | 1 - crates/egui/src/class/class_name.rs | 147 +++++++++++++++++ crates/egui/src/class/classes.rs | 104 ++++++++++++ crates/egui/src/class/has_classes.rs | 140 ++++++++++++++++ crates/egui/src/class/mod.rs | 10 ++ crates/egui/src/containers/frame.rs | 30 ++++ crates/egui/src/context.rs | 13 +- crates/egui/src/lib.rs | 1 + crates/egui/src/theme/default_style.rs | 149 +++++++----------- crates/egui/src/theme/mod.rs | 3 +- crates/egui/src/theme/themes.rs | 53 +------ crates/egui/src/ui.rs | 4 +- crates/egui/src/ui_builder.rs | 2 +- crates/egui/src/ui_stack.rs | 7 +- crates/egui/src/widget_style/classes.rs | 109 ------------- crates/egui/src/widget_style/mod.rs | 73 +++++---- crates/egui/src/widgets/button.rs | 110 +++++++------ crates/egui/src/widgets/checkbox.rs | 6 +- crates/egui/src/widgets/separator.rs | 6 +- .../tests/snapshots/imageviewer.png | 4 +- examples/styling_engine/src/main.rs | 16 +- tests/egui_tests/tests/test_atoms.rs | 2 +- 22 files changed, 631 insertions(+), 359 deletions(-) create mode 100644 crates/egui/src/class/class_name.rs create mode 100644 crates/egui/src/class/classes.rs create mode 100644 crates/egui/src/class/has_classes.rs create mode 100644 crates/egui/src/class/mod.rs delete mode 100644 crates/egui/src/widget_style/classes.rs 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/class/class_name.rs b/crates/egui/src/class/class_name.rs new file mode 100644 index 000000000..5095c6507 --- /dev/null +++ b/crates/egui/src/class/class_name.rs @@ -0,0 +1,147 @@ +use core::borrow::Borrow; +use std::borrow::Cow; +use std::fmt; +use std::sync::Arc; + +/// A class, used to customize widget styling and behavior. +#[derive(Clone)] +pub struct ClassName(ClassNameInner); + +#[derive(Clone)] +enum ClassNameInner { + Static(&'static str), + Owned(Arc), +} + +impl ClassName { + /// A class from a string known at compile time. This never allocates. + #[inline] + pub const fn from_static(class: &'static str) -> Self { + Self(ClassNameInner::Static(class)) + } + + /// A class from anything that converts into one. + #[inline] + pub fn new(class: impl Into) -> Self { + class.into() + } + + /// The class as a string. + #[inline] + pub fn as_str(&self) -> &str { + match &self.0 { + ClassNameInner::Static(class) => class, + ClassNameInner::Owned(class) => class, + } + } +} + +impl From<&'static str> for ClassName { + #[inline] + fn from(class: &'static str) -> Self { + Self::from_static(class) + } +} + +impl From for ClassName { + #[inline] + fn from(class: String) -> Self { + Self(ClassNameInner::Owned(class.into())) + } +} + +impl From<&String> for ClassName { + #[inline] + fn from(class: &String) -> Self { + Self(ClassNameInner::Owned(class.as_str().into())) + } +} + +impl From> for ClassName { + #[inline] + fn from(class: Arc) -> Self { + Self(ClassNameInner::Owned(class)) + } +} + +impl From> for ClassName { + #[inline] + fn from(class: Cow<'static, str>) -> Self { + match class { + Cow::Borrowed(class) => Self::from_static(class), + Cow::Owned(class) => class.into(), + } + } +} + +impl From<&Self> for ClassName { + #[inline] + fn from(class: &Self) -> Self { + class.clone() + } +} + +impl Borrow for ClassName { + #[inline] + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl AsRef for ClassName { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl core::ops::Deref for ClassName { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + self.as_str() + } +} + +impl PartialEq for ClassName { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for ClassName {} + +impl PartialEq for ClassName { + #[inline] + fn eq(&self, other: &str) -> bool { + self.as_str() == other + } +} + +impl PartialEq<&str> for ClassName { + #[inline] + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +impl core::hash::Hash for ClassName { + #[inline] + fn hash(&self, state: &mut H) { + self.as_str().hash(state); + } +} + +impl fmt::Debug for ClassName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl fmt::Display for ClassName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/egui/src/class/classes.rs b/crates/egui/src/class/classes.rs new file mode 100644 index 000000000..849fc8dc8 --- /dev/null +++ b/crates/egui/src/class/classes.rs @@ -0,0 +1,104 @@ +use crate::class::{ClassName, HasClasses}; +use smallvec::SmallVec; +use std::fmt; + +/// [`Classes`] is a collection of [`ClassName`]s that can be added to widgets or containers. +/// +/// This can be used by styling engine to compute a different style +/// based on the set of classes present on the widget/Ui. +/// You could also use it to e.g. change widget behavior based on the context of some container. +/// +/// Class order is preserved and may be used by a style provider for precedence (last class should win). +/// +/// Use [`HasClasses`] to add/modify classes. +#[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. + /// + /// A class is never added twice. This never removes a class: use [`Self::set`] for that. + #[inline] + pub(crate) fn add_if(&mut self, class: impl Into, condition: bool) { + if condition { + self.set(class, true); + } + } + + /// Add the class if `present`, remove it otherwise. + #[inline] + pub(crate) 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); + } + } + + /// Extend the classes and deduplicate them. + /// + /// A class that is already present is moved to the end, since order can matter. + #[inline] + pub(crate) fn extend(&mut self, classes: impl IntoIterator>) { + for class in classes { + self.set(class, true); + } + } + + /// Iterate over the classes, in order. + #[inline] + pub fn iter(&self) -> core::slice::Iter<'_, ClassName> { + self.classes.iter() + } + + /// Return the classes as a slice + #[inline] + pub fn as_slice(&self) -> &[ClassName] { + self.classes.as_slice() + } +} + +impl IntoIterator for Classes { + type Item = ClassName; + type IntoIter = smallvec::IntoIter<[ClassName; 5]>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.classes.into_iter() + } +} + +impl<'a> IntoIterator for &'a Classes { + type Item = &'a ClassName; + type IntoIter = core::slice::Iter<'a, ClassName>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.classes.iter() + } +} + +impl HasClasses for Classes { + fn classes(&self) -> &Classes { + self + } + + fn classes_mut(&mut self) -> &mut Classes { + self + } +} + +impl core::fmt::Display for Classes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, class) in self.classes.iter().enumerate() { + if i > 0 { + f.write_str(" ")?; + } + f.write_str(class.as_str())?; + } + Ok(()) + } +} diff --git a/crates/egui/src/class/has_classes.rs b/crates/egui/src/class/has_classes.rs new file mode 100644 index 000000000..43b13636f --- /dev/null +++ b/crates/egui/src/class/has_classes.rs @@ -0,0 +1,140 @@ +use crate::class::ClassName; + +/// Any widgets supporting [`crate::class::Classes`] must implement this trait. +pub trait HasClasses { + fn classes(&self) -> &crate::class::Classes; + + fn classes_mut(&mut self) -> &mut crate::class::Classes; + + /// True if the class is present. + #[inline] + fn has_class(&self, class: &str) -> bool { + self.classes() + .iter() + .any(|existing| existing.as_str() == class) + } + + /// Add the given class in-place. + #[inline] + fn add_class(&mut self, class: impl Into) -> &mut Self + where + Self: Sized, + { + self.classes_mut().add_if(class.into(), true); + 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 + } + + /// Add the given class by consuming `self`. + #[inline] + fn with_class(mut self, class: impl Into) -> Self + where + Self: Sized, + { + self.classes_mut().add_if(class.into(), true); + self + } + + /// Append all the given classes at the end, deduplicating them. + #[inline] + fn add_classes(&mut self, classes: crate::class::Classes) -> &mut Self { + self.classes_mut().extend(classes); + self + } + + /// Append all the given classes at the end, deduplicating them. Consuming `self`. + #[inline] + fn with_classes(mut self, classes: crate::class::Classes) -> Self + where + Self: Sized, + { + self.classes_mut().extend(classes); + self + } + + /// 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_class`] for that. + #[inline] + fn add_class_if(&mut self, class: impl Into, condition: bool) -> &mut Self + where + Self: Sized, + { + self.classes_mut().add_if(class.into(), condition); + self + } + + /// Add the given class by consuming `self` if the condition is true. + /// + /// A class is never added twice. This never removes a class: use [`Self::set_class`] for that. + #[inline] + fn with_class_if(mut self, class: impl Into, condition: bool) -> Self + where + Self: Sized, + { + self.classes_mut().add_if(class.into(), condition); + self + } + + /// Iterate over the classes, in the order they were added. + #[inline] + fn iter_classes(&self) -> core::slice::Iter<'_, ClassName> { + self.classes().iter() + } + + /// Return the classes as a slice. + #[inline] + fn classes_as_slice(&self) -> &[ClassName] { + self.classes().as_slice() + } +} + +#[cfg(test)] +mod tests { + use crate::class::{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/class/mod.rs b/crates/egui/src/class/mod.rs new file mode 100644 index 000000000..912ac4d9e --- /dev/null +++ b/crates/egui/src/class/mod.rs @@ -0,0 +1,10 @@ +mod class_name; +mod classes; +mod has_classes; + +pub use class_name::ClassName; +pub use classes::Classes; +pub use has_classes::HasClasses; + +/// Present on every top-level [`crate::Ui`]. +pub const ROOT: ClassName = ClassName::from_static("egui::root"); diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index ebef299df..1b28719ec 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -298,6 +298,25 @@ impl Frame { self } + /// Handle `stroke` and `expansion` without affecting layout. + /// + /// This handles `expansion` by subtracting it from the outer margin and adding it to the + /// inner margin. It also corrects for `stroke`, by subtracting the stroke width from `inner_margin`. + /// + /// Use this when stroke or expansion might change on hover, and you don't want it to cause + /// layout shifts. + #[inline] + pub fn apply_stroke_and_expansion_without_layout_shift( + mut self, + stroke: Stroke, + expansion: f32, + ) -> Self { + self.outer_margin = self.outer_margin - Margin::from(expansion); + self.inner_margin = self.inner_margin + Margin::from(expansion - stroke.width); + self.stroke = stroke; + self + } + /// Optional drop-shadow behind the frame. #[inline] pub fn shadow(mut self, shadow: Shadow) -> Self { @@ -316,6 +335,17 @@ impl Frame { self.shadow.color = self.shadow.color.gamma_multiply(opacity); self } + + /// Make this frame invisible by setting background and stroke to transparent. + /// + /// Will not affect layout or contents. + #[inline] + pub fn invisible(mut self) -> Self { + self.fill = Color32::TRANSPARENT; + self.stroke.color = Color32::TRANSPARENT; + self.shadow = Shadow::NONE; + self + } } /// ## Inspectors diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index a48b10570..ac307b0b5 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -753,6 +753,9 @@ impl Default for Context { ctx.add_plugin(crate::text_selection::LabelSelectionState::default()); ctx.add_plugin(crate::DragAndDrop::default()); + // Register the default theme for all built-in widgets: + theme::DefaultStyle::register(&ctx); + ctx } } @@ -2104,7 +2107,10 @@ impl Context { /// If a theme is already registered for this widget, this is a no-op (useful for `eframe::run_simple_native`). /// /// If you want to add the theme anyway, use [`Self::replace_widget_theme`] instead. - #[cfg(feature = "experimental")] + /// + /// The types you need to call this (e.g. `StyleProvider`) are only public + /// with the `experimental_theme` feature. + #[cfg_attr(not(feature = "experimental"), doc(hidden))] pub fn add_widget_theme( &self, theme: impl theme::StyleProvider + Send + Sync + 'static, @@ -2116,7 +2122,10 @@ impl Context { /// /// Overwrite any theme already registered for the specified widget [`WidgetStyle`]. /// This allow to live edit a theme. - #[cfg(feature = "experimental")] + /// + /// The types you need to call this (e.g. `StyleProvider`) are only public + /// with the `experimental_theme` feature. + #[cfg_attr(not(feature = "experimental"), doc(hidden))] pub fn replace_widget_theme( &self, theme: impl theme::StyleProvider + Send + Sync + 'static, diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 2d4099d92..435bf6646 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -437,6 +437,7 @@ pub mod widgets; #[cfg(feature = "callstack")] #[cfg(debug_assertions)] mod callstack; +pub mod class; pub use accesskit; diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index a23ef8fcd..a6a89a230 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,12 +1,12 @@ use emath::Vec2; -use epaint::{Shadow, Stroke, text::TextWrapMode}; +use epaint::Margin; use crate::{ - Frame, TextStyle, + Button, Context, Frame, TextStyle, + class::HasClasses as _, theme::StyleProvider, widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, SELECTED_CLASS, - SeparatorStyle, StyleArgs, TextVisuals, WidgetState, + ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextVisuals, WidgetState, }, }; @@ -15,97 +15,88 @@ 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, - }, - } +impl DefaultStyle { + /// Register `Self` as the [`StyleProvider`] of every built-in widget style. + /// + /// [`Context::default`] does this. Any theme you register yourself + /// replaces the default one for that widget style. + pub fn register(ctx: &Context) { + ctx.add_widget_theme::(Self); + ctx.add_widget_theme::(Self); + ctx.add_widget_theme::(Self); } } 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(&Button::CLASS_SELECTED) { 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; } + let mut inner_margin: Margin = spacing.button_padding.into(); + + // A small button as high as regular text + if classes.has_class(&Button::CLASS_SMALL) { + inner_margin.top = 0; + inner_margin.bottom = 0; + } + + let painted_frame = Frame { + fill: widget_visuals.weak_bg_fill, + corner_radius: widget_visuals.corner_radius, + inner_margin, + ..Default::default() + } + // Ensure changing expansion and stroke don't affect layout: + .apply_stroke_and_expansion_without_layout_shift( + widget_visuals.bg_stroke, + widget_visuals.expansion, + ); + + let has_frame = classes.has_class(&Button::CLASS_FRAME) + || (!classes.has_class(&Button::CLASS_NO_FRAME) && style.visuals.button_frame); + + let frame = if !has_frame { + // No frame at all: the button takes up no more room than its contents. + Frame::new() + } else if classes.has_class(&Button::CLASS_HIDE_FRAME_WHEN_INACTIVE) + && *state == WidgetState::Inactive + { + // Hide the frame, but keep its spacing + painted_frame.invisible() + } else { + painted_frame + }; + ButtonStyle { - frame: 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(), - ..Default::default() + min_size: if classes.has_class(&Button::CLASS_SMALL) { + Vec2::ZERO + } else { + Vec2::new(0.0, spacing.interact_size.y) }, - text_style: ws.text, + frame, + text_style: TextVisuals::from_widget_visuals(style, TextStyle::Body, &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 +108,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: TextVisuals::from_widget_visuals(style, TextStyle::Body, &widget_visuals), + check_stroke: widget_visuals.fg_stroke, } } } diff --git a/crates/egui/src/theme/mod.rs b/crates/egui/src/theme/mod.rs index 088d7f778..45f6cc9d2 100644 --- a/crates/egui/src/theme/mod.rs +++ b/crates/egui/src/theme/mod.rs @@ -12,7 +12,8 @@ pub use self::{default_style::DefaultStyle, style_provider::StyleProvider, theme use crate::{ Ui, - widget_style::{Classes, StyleArgs, WidgetState, WidgetStyle}, + class::Classes, + widget_style::{StyleArgs, WidgetState, WidgetStyle}, }; impl Ui { diff --git a/crates/egui/src/theme/themes.rs b/crates/egui/src/theme/themes.rs index 4294f9460..d816f69b3 100644 --- a/crates/egui/src/theme/themes.rs +++ b/crates/egui/src/theme/themes.rs @@ -2,64 +2,25 @@ use std::sync::Arc; use epaint::mutex::Mutex; -use crate::{ - Id, - theme::{StyleProvider, default_style::DefaultStyle}, - util::IdTypeMap, - widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, SeparatorStyle, WidgetStyle, - }, -}; +use crate::{Id, theme::StyleProvider, util::IdTypeMap, widget_style::WidgetStyle}; /// The registry of [`StyleProvider`]s, one per [`WidgetStyle`] type. /// /// Each widget asks this registry for the provider of its style type -/// (e.g. [`ButtonStyle`]), and that provider computes the final style from the +/// (e.g. [`crate::widget_style::ButtonStyle`]), and that provider computes the final style from the /// widget's classes and state. /// -/// A default provider is registered for every built-in style; register your -/// own with [`Context::add_widget_theme`](crate::Context::add_widget_theme) or -/// [`Context::replace_widget_theme`](crate::Context::replace_widget_theme). +/// A default provider is registered for every built-in style. Register your +/// own with [`crate::Context::add_widget_theme`] or [`crate::Context::replace_widget_theme`]. +/// +/// The [`crate::theme::DefaultStyle`] is registered in [`crate::Context::default`]. +#[derive(Default)] pub struct Themes { themes: IdTypeMap, } type ThemeWrap = Arc + Send + Sync>>>; -impl Default for Themes { - /// Register the default egui theme - 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))), - ); - - themes.insert_temp::>( - Id::NULL, - 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))), - ); - - Self { themes } - } -} - impl Themes { /// Register a [`StyleProvider`] for the specified widget [`WidgetStyle`] `S` /// diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 9021d2d03..cb38cb8f3 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -5,8 +5,8 @@ use core::{any::Any, ops::Deref}; use std::sync::Arc; use crate::containers::menu; -use crate::widget_style::{HasClasses as _, ROOT_CLASS}; use crate::{IdSource, containers::*, ecolor::*, layout::*, placer::Placer, widgets::*, *}; +use crate::{class, class::HasClasses as _}; use emath::GuiRounding as _; // ---------------------------------------------------------------------------- @@ -135,7 +135,7 @@ impl Ui { let disabled = disabled || invisible; let style = style.unwrap_or_else(|| ctx.global_style()); let sense = sense.unwrap_or_else(Sense::hover); - let classes = classes.with_class(ROOT_CLASS); + let classes = classes.with_class(class::ROOT); let placer = Placer::new(max_rect, layout); let ui_stack = UiStack { diff --git a/crates/egui/src/ui_builder.rs b/crates/egui/src/ui_builder.rs index 67ed692a2..c99dbf61a 100644 --- a/crates/egui/src/ui_builder.rs +++ b/crates/egui/src/ui_builder.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::Ui; use crate::{ AsIdSalt, ClosableTag, Id, IdSalt, LayerId, Layout, Rect, Sense, Style, UiStackInfo, - widget_style::{Classes, HasClasses}, + class::{Classes, HasClasses}, }; /// The properties specified when creating a top-level or child [`Ui`]. diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index f6b4865be..508b9e53e 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -1,7 +1,7 @@ use core::{any::Any, iter::FusedIterator}; use std::sync::Arc; -use crate::widget_style::Classes; +use crate::class::{Classes, HasClasses as _}; use epaint::Color32; use crate::{Direction, Frame, Id, Rect}; @@ -290,6 +290,11 @@ impl UiStack { pub fn contained_in(&self, kind: UiKind) -> bool { self.iter().any(|frame| frame.kind() == Some(kind)) } + + /// Does this node, or any [`crate::Ui`] up the stack, carry this class? + pub fn has_class(&self, class: &str) -> bool { + self.iter().any(|node| node.classes.has_class(class)) + } } // ---------------------------------------------------------------------------- diff --git a/crates/egui/src/widget_style/classes.rs b/crates/egui/src/widget_style/classes.rs deleted file mode 100644 index 95ef43aae..000000000 --- a/crates/egui/src/widget_style/classes.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::{borrow::Cow, fmt}; - -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"; - -/// The selected class is a special class present on selected [`crate::Button`]. -pub const SELECTED_CLASS: &str = "selected"; - -/// A class is a static string identifier. -pub type ClassName = Cow<'static, str>; - -/// Classes are string identifier that can be set on widget/Ui. -/// -/// This can be used by styling engine to compute a different style -/// based on the set of classes present on the widget/Ui. -#[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 - #[inline] - fn add_if(&mut self, class: impl Into, condition: bool) { - if condition { - self.classes.push(class.into()); - } - } -} - -impl HasClasses for Classes { - fn classes(&self) -> &Classes { - self - } - - fn classes_mut(&mut self) -> &mut Classes { - self - } -} - -impl core::fmt::Display for Classes { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.classes.iter().for_each(|class| { - let _ = f.write_str(class.as_str()); - }); - f.write_str("") - } -} - -/// Any widgets supporting [`Classes`] must implement this trait -pub trait HasClasses { - fn classes(&self) -> &Classes; - - fn classes_mut(&mut self) -> &mut Classes; - - /// Add the given class by consuming `self` - #[inline] - fn with_class(mut self, class: impl Into) -> Self - where - Self: Sized, - { - self.classes_mut().add_if(class.into(), true); - self - } - - /// Add the given class by consuming `self` if the condition is true - #[inline] - fn with_class_if(mut self, class: impl Into, condition: bool) -> Self - where - Self: Sized, - { - self.classes_mut().add_if(class.into(), condition); - self - } - - /// Add the given class in-place - #[inline] - fn add_class(&mut self, class: impl Into) -> &mut Self - where - Self: Sized, - { - self.classes_mut().add_if(class.into(), true); - self - } - - /// Add the given class in-place if the condition is true - #[inline] - fn add_class_if(&mut self, class: impl Into, condition: bool) -> &mut Self - where - Self: Sized, - { - self.classes_mut().add_if(class.into(), condition); - self - } - - /// True if the class is present - fn has(&self, class: impl Into) -> bool { - self.classes().classes.contains(&class.into()) - } - - /// The list of class - fn as_slice(&self) -> &[ClassName] { - &self.classes().classes - } -} diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index 1a2c19959..46ee4e132 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -2,16 +2,12 @@ // so without it a lot of it looks unused: #![cfg_attr(not(feature = "experimental"), allow(dead_code, unused_imports))] -mod classes; - -pub use self::classes::{ClassName, Classes, HasClasses, ROOT_CLASS, SELECTED_CLASS}; - use core::fmt::Debug; - -use epaint::{Color32, FontId, Stroke, text::TextWrapMode}; +use epaint::{Color32, FontId, Stroke, Vec2}; use crate::{ - Context, Frame, Response, Style, UiStack, + Context, FontSelection, Frame, Response, Style, UiStack, + class::{Classes, HasClasses as _}, style::{WidgetVisuals, Widgets}, }; @@ -26,27 +22,38 @@ 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, +impl TextVisuals { + /// Text in `color`, using the given font. + /// + /// `style.override_font_id` wins over `font`, if it is set. + pub fn new(style: &Style, font: impl Into, color: Color32) -> Self { + Self { + color, + font_id: style + .override_font_id + .clone() + .unwrap_or_else(|| font.into().resolve(style)), + } + } - pub text: TextVisuals, - - pub stroke: Stroke, + /// The text of a widget, colored by the [`WidgetVisuals`] of its current state. + pub fn from_widget_visuals( + style: &Style, + font: impl Into, + widget_visuals: &WidgetVisuals, + ) -> Self { + Self::new(style, font, widget_visuals.text_color()) + } } -impl WidgetStyle for BaseStyle {} - /// Dedicated button style #[derive(Debug, Clone)] pub struct ButtonStyle { + /// The minimum size of the button before any per-button override. + pub min_size: Vec2, + pub frame: Frame, pub text_style: TextVisuals, } @@ -77,21 +84,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 { @@ -147,3 +139,14 @@ pub struct StyleArgs<'a> { pub style: &'a Style, pub ctx: &'a Context, } + +impl StyleArgs<'_> { + /// Does the widget or any of its parents contain this class? + /// + /// See also: + /// - [`Classes::has_class`] + /// - [`UiStack::has_class`] + pub fn has_class(&self, class: &str) -> bool { + self.classes.has_class(class) || self.stack.has_class(class) + } +} diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index d7f6c8a0c..a385e5b18 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,10 +1,9 @@ -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, + class::{ClassName, Classes, HasClasses}, + widget_style::ButtonStyle, }; /// Clickable button with text. @@ -30,9 +29,6 @@ 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, @@ -42,6 +38,22 @@ pub struct Button<'a> { } impl<'a> Button<'a> { + /// Present on a selected button. + pub const CLASS_SELECTED: ClassName = ClassName::from_static("egui::selected"); + + /// Present on a small button. + pub const CLASS_SMALL: ClassName = ClassName::from_static("egui::small"); + + /// Present on a button that should have no frame at all. + pub const CLASS_NO_FRAME: ClassName = ClassName::from_static("egui::no_frame"); + + /// Present on a button that should have a frame, even when the global default is frameless. + pub const CLASS_FRAME: ClassName = ClassName::from_static("egui::frame"); + + /// Present on a button that should have no frame while it is inactive. + pub const CLASS_HIDE_FRAME_WHEN_INACTIVE: ClassName = + ClassName::from_static("egui::button::hide_frame_when_inactive"); + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { Self { layout: AtomLayout::new(atoms.into_atoms()) @@ -49,9 +61,6 @@ 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 +81,8 @@ impl<'a> Button<'a> { /// # }); /// ``` /// + /// When selected, [`Self::CLASS_SELECTED`] is added. + /// /// See also: /// - [`Ui::selectable_value`] /// - [`Ui::selectable_label`] @@ -142,7 +153,7 @@ impl<'a> Button<'a> { #[inline] pub fn fill(mut self, fill: impl Into) -> Self { self.fill = Some(fill.into()); - self + self.frame(true) } /// Override button stroke. Note that this will override any on-hover effects. @@ -150,33 +161,44 @@ impl<'a> Button<'a> { #[inline] pub fn stroke(mut self, stroke: impl Into) -> Self { self.stroke = Some(stroke.into()); - self.frame = Some(true); - self + self.frame(true) } /// Make this a small button, suitable for embedding into text. + /// + /// This adds the built-in [`Self::CLASS_SMALL`], which with the default style removes the top and + /// bottom margin. #[inline] - pub fn small(mut self) -> Self { - self.small = true; - self + pub fn small(self) -> Self { + self.with_class(Self::CLASS_SMALL) } /// Turn off the frame + /// + /// This adds either the built-in [`Self::CLASS_FRAME`] or [`Self::CLASS_NO_FRAME`] class. + /// With the default style, the latter 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(Self::CLASS_FRAME, frame); + self.set_class(Self::CLASS_NO_FRAME, !frame); self } /// If `false`, the button will not have a frame when inactive. /// + /// This adds the built-in [`Self::CLASS_HIDE_FRAME_WHEN_INACTIVE`], 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(Self::CLASS_HIDE_FRAME_WHEN_INACTIVE, !frame_when_inactive); self } @@ -266,9 +288,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, [`Self::CLASS_SELECTED`] is added. You should prefer calling this though over + /// just adding [`Self::CLASS_SELECTED`] manually, since this also exposes accessibility information. #[inline] pub fn selected(mut self, selected: bool) -> Self { self.selected = Some(selected); + self.set_class(Self::CLASS_SELECTED, selected); self } @@ -292,22 +318,14 @@ impl<'a> Button<'a> { mut layout, fill, stroke, - small, - frame, - frame_when_inactive, mut min_size, corner_radius, selected, image_tint_follows_text_color, limit_image_size, - mut classes, + classes, } = self; - // Min size height always equal or greater than interact size if not small - if !small { - min_size.y = min_size.y.at_least(ui.spacing().interact_size.y); - } - if limit_image_size { layout.map_atoms(|atom| { if matches!(&atom.kind, AtomKind::Image(_)) { @@ -320,29 +338,16 @@ 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(); + let ButtonStyle { + mut frame, + text_style, + min_size: style_min_size, + } = ui.widget_style(id, &classes); - 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; - } + min_size = min_size.at_least(style_min_size); // Override global style by local style - let mut frame = frame; if let Some(fill) = fill { frame = frame.fill(fill); } @@ -353,21 +358,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/crates/egui/src/widgets/checkbox.rs b/crates/egui/src/widgets/checkbox.rs index 9310c64d5..f1c90fa4c 100644 --- a/crates/egui/src/widgets/checkbox.rs +++ b/crates/egui/src/widgets/checkbox.rs @@ -2,8 +2,10 @@ use emath::Rect; use crate::{ Atom, AtomLayout, Atoms, Id, IntoAtoms, NumExt as _, Response, Sense, Shape, Ui, Vec2, Widget, - WidgetInfo, WidgetType, epaint, pos2, - widget_style::{CheckboxStyle, Classes, HasClasses}, + WidgetInfo, WidgetType, + class::{Classes, HasClasses}, + epaint, pos2, + widget_style::CheckboxStyle, }; // TODO(emilk): allow checkbox without a text label diff --git a/crates/egui/src/widgets/separator.rs b/crates/egui/src/widgets/separator.rs index e188d4127..daef53157 100644 --- a/crates/egui/src/widgets/separator.rs +++ b/crates/egui/src/widgets/separator.rs @@ -1,6 +1,8 @@ use crate::{ - Response, Sense, Ui, Vec2, Widget, vec2, - widget_style::{Classes, HasClasses, SeparatorStyle}, + Response, Sense, Ui, Vec2, Widget, + class::{Classes, HasClasses}, + vec2, + widget_style::SeparatorStyle, }; /// A visual separator. A horizontal or vertical line (depending on [`crate::Layout`]). diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index c08ab1aac..29d317b3f 100644 --- a/crates/egui_demo_app/tests/snapshots/imageviewer.png +++ b/crates/egui_demo_app/tests/snapshots/imageviewer.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24fc466f6470761a5064a590929fa3a0d95741ed691fc257415eee349bc528e9 -size 101394 +oid sha256:6264f32d3323d0d499ba0da9d379e562c06a7f50930dbec424039549ce7f50f9 +size 101414 diff --git a/examples/styling_engine/src/main.rs b/examples/styling_engine/src/main.rs index 54763696a..6442b50fb 100644 --- a/examples/styling_engine/src/main.rs +++ b/examples/styling_engine/src/main.rs @@ -4,9 +4,10 @@ //! 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, + class::HasClasses as _, theme::StyleProvider, - widget_style::{BaseStyle, ButtonStyle, HasClasses as _, StyleArgs, WidgetState}, + widget_style::{ButtonStyle, StyleArgs, TextVisuals, WidgetState}, }; /// Buttons with this class are styled as a destructive action. @@ -42,14 +43,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 @@ -63,11 +61,13 @@ impl StyleProvider for MyTheme { }; ButtonStyle { + min_size: egui::vec2(0.0, style.spacing.interact_size.y), frame: Frame::new() .fill(fill) .corner_radius(self.corner_radius) .inner_margin(8), - text_style: base.text, + // Resolve the font from the style, so we follow the user's font sizes: + text_style: TextVisuals::new(style, TextStyle::Button, Color32::WHITE), } } } diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index b6cd6cda8..981133a9a 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -133,7 +133,7 @@ fn test_atom_layout_nesting_and_direction() { let button_frame = ui .get_widget_style::(&StyleArgs { - classes: &egui::widget_style::Classes::default(), + classes: &egui::class::Classes::default(), state: egui::widget_style::WidgetState::Inactive, ctx: ui, stack: ui.stack(),