From 4a8618498e88fe596c21f7b4b5cfcb7350b4686b Mon Sep 17 00:00:00 2001 From: YouStones <100306716+AdrienZianne@users.noreply.github.com> Date: Tue, 12 May 2026 13:43:49 +0200 Subject: [PATCH] Add `Classes` to `UiBuilder` and some Widgets (#7843) * Closes part of Add a class system toward a CSS-like styling. Widget and Ui can implement the trait `HasClasses` which can be used later by theme engines to compute a style based on the set of classes the component has. --------- Co-authored-by: adrien <221212@umons.ac.be> Co-authored-by: Adrien Zianne Co-authored-by: lucasmerlin --- crates/egui/src/ui.rs | 8 ++ crates/egui/src/ui_builder.rs | 14 ++- crates/egui/src/ui_stack.rs | 2 + crates/egui/src/widget_style.rs | 139 ++++++++++++++++++++++++--- crates/egui/src/widgets/button.rs | 19 +++- crates/egui/src/widgets/checkbox.rs | 18 +++- crates/egui/src/widgets/separator.rs | 20 +++- 7 files changed, 201 insertions(+), 19 deletions(-) diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 3e713934e..9e7208fc3 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -4,6 +4,7 @@ use std::{any::Any, hash::Hash, ops::Deref, sync::Arc}; use crate::containers::menu; +use crate::widget_style::{HasClasses as _, ROOT_CLASS}; use crate::{containers::*, ecolor::*, layout::*, placer::Placer, widgets::*, *}; use emath::GuiRounding as _; // ---------------------------------------------------------------------------- @@ -117,6 +118,7 @@ impl Ui { style, sense, accessibility_parent, + classes, } = ui_builder; let layer_id = layer_id.unwrap_or_else(LayerId::background); @@ -132,6 +134,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 placer = Placer::new(max_rect, layout); let ui_stack = UiStack { @@ -141,7 +144,9 @@ impl Ui { parent: None, min_rect: placer.min_rect(), max_rect: placer.max_rect(), + classes, }; + let mut ui = Ui { id, unique_id: id, @@ -214,6 +219,7 @@ impl Ui { style, sense, accessibility_parent, + classes, } = ui_builder; let mut painter = self.painter.clone(); @@ -262,7 +268,9 @@ impl Ui { parent: Some(Arc::clone(&self.stack)), min_rect: placer.min_rect(), max_rect: placer.max_rect(), + classes, }; + let mut child_ui = Ui { id: stable_id, unique_id, diff --git a/crates/egui/src/ui_builder.rs b/crates/egui/src/ui_builder.rs index 87786a726..a8121e235 100644 --- a/crates/egui/src/ui_builder.rs +++ b/crates/egui/src/ui_builder.rs @@ -1,8 +1,9 @@ use std::{hash::Hash, sync::Arc}; -use crate::ClosableTag; #[expect(unused_imports)] // Used for doclinks use crate::Ui; +use crate::widget_style::HasClasses; +use crate::{ClosableTag, widget_style::Classes}; use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo}; /// Build a [`Ui`] as the child of another [`Ui`]. @@ -25,6 +26,7 @@ pub struct UiBuilder { pub style: Option>, pub sense: Option, pub accessibility_parent: Option, + pub classes: Classes, } impl UiBuilder { @@ -192,3 +194,13 @@ impl UiBuilder { self } } + +impl HasClasses for UiBuilder { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index d72b3ea0b..f43834dba 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::{any::Any, iter::FusedIterator}; +use crate::widget_style::Classes; use epaint::Color32; use crate::{Direction, Frame, Id, Rect}; @@ -212,6 +213,7 @@ pub struct UiStack { pub min_rect: Rect, pub max_rect: Rect, pub parent: Option>, + pub classes: Classes, } // these methods act on this specific node diff --git a/crates/egui/src/widget_style.rs b/crates/egui/src/widget_style.rs index c459cc81a..f3c5e5bd0 100644 --- a/crates/egui/src/widget_style.rs +++ b/crates/egui/src/widget_style.rs @@ -1,8 +1,11 @@ +use std::{borrow::Cow, fmt}; + use emath::Vec2; use epaint::{Color32, FontId, Shadow, Stroke, text::TextWrapMode}; +use smallvec::SmallVec; use crate::{ - Frame, Response, Style, TextStyle, + Frame, Response, Style, TextBuffer as _, TextStyle, style::{WidgetVisuals, Widgets}, }; @@ -28,11 +31,13 @@ pub struct WidgetStyle { pub stroke: Stroke, } +/// Dedicated button style pub struct ButtonStyle { pub frame: Frame, pub text_style: TextVisuals, } +/// Dedicated checkbox style pub struct CheckboxStyle { /// Frame around pub frame: Frame, @@ -53,6 +58,7 @@ pub struct CheckboxStyle { pub check_stroke: Stroke, } +/// Dedicated label style pub struct LabelStyle { /// Frame around pub frame: Frame, @@ -64,6 +70,7 @@ pub struct LabelStyle { pub wrap_mode: TextWrapMode, } +/// Dedicated separator style pub struct SeparatorStyle { /// How much space is allocated in the layout direction pub spacing: f32, @@ -72,6 +79,7 @@ pub struct SeparatorStyle { pub stroke: Stroke, } +/// The different state of a widget can be #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] pub enum WidgetState { Noninteractive, @@ -82,6 +90,7 @@ pub enum WidgetState { } impl Widgets { + /// The widget visuals according to the state pub fn state(&self, state: WidgetState) -> &WidgetVisuals { match state { WidgetState::Noninteractive => &self.noninteractive, @@ -107,7 +116,8 @@ impl Response { } impl Style { - pub fn widget_style(&self, state: WidgetState) -> WidgetStyle { + /// The general widget style. The style is computed according to the classes and state of the widget. + pub fn widget_style(&self, _classes: &Classes, state: WidgetState) -> WidgetStyle { let visuals = self.visuals.widgets.state(state); let font_id = self.override_font_id.clone(); WidgetStyle { @@ -131,11 +141,13 @@ impl Style { } } - pub fn button_style(&self, state: WidgetState, selected: bool) -> ButtonStyle { + /// The dedicated button style. The style is computed according to the classes and state of the widget. + /// It depend on the general widget style. + pub fn button_style(&self, classes: &Classes, state: WidgetState) -> ButtonStyle { let mut visuals = *self.visuals.widgets.state(state); - let mut ws = self.widget_style(state); + let mut ws = self.widget_style(classes, state); - if selected { + if classes.has(SELECTED_CLASS) { visuals.weak_bg_fill = self.visuals.selection.bg_fill; visuals.bg_fill = self.visuals.selection.bg_fill; visuals.fg_stroke = self.visuals.selection.stroke; @@ -157,9 +169,11 @@ impl Style { } } - pub fn checkbox_style(&self, state: WidgetState) -> CheckboxStyle { + /// The dedicated checkbox style. The style is computed according to the classes and state of the widget. + /// It depend on the general widget style. + pub fn checkbox_style(&self, classes: &Classes, state: WidgetState) -> CheckboxStyle { let visuals = self.visuals.widgets.state(state); - let ws = self.widget_style(state); + let ws = self.widget_style(classes, state); CheckboxStyle { frame: Frame::new(), checkbox_size: self.spacing.icon_width, @@ -168,8 +182,6 @@ impl Style { fill: visuals.bg_fill, corner_radius: visuals.corner_radius, stroke: visuals.bg_stroke, - // Use the inner_margin for the expansion - inner_margin: visuals.expansion.into(), ..Default::default() }, text_style: ws.text, @@ -177,8 +189,10 @@ impl Style { } } - pub fn label_style(&self, state: WidgetState) -> LabelStyle { - let ws = self.widget_style(state); + /// The dedicated label style. The style is computed according to the classes and state of the widget. + /// It depend on the general widget style. + pub fn label_style(&self, classes: &Classes, state: WidgetState) -> LabelStyle { + let ws = self.widget_style(classes, state); LabelStyle { frame: Frame { fill: ws.frame.fill, @@ -193,7 +207,9 @@ impl Style { } } - pub fn separator_style(&self, _state: WidgetState) -> SeparatorStyle { + /// The dedicated separator style. The style is computed according to the classes and state of the widget. + /// It depend on the general widget style. + pub fn separator_style(&self, _classes: &Classes, _state: WidgetState) -> SeparatorStyle { let visuals = self.visuals.noninteractive(); SeparatorStyle { spacing: 6.0, @@ -201,3 +217,102 @@ impl Style { } } } + +/// 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)] +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 std::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()) + } +} diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index e3936fb03..a1526c50e 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -4,7 +4,7 @@ use crate::{ Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, CornerRadius, Frame, Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType, - widget_style::{ButtonStyle, WidgetState}, + widget_style::{ButtonStyle, Classes, HasClasses, SELECTED_CLASS, WidgetState}, }; /// Clickable button with text. @@ -38,6 +38,7 @@ pub struct Button<'a> { selected: bool, image_tint_follows_text_color: bool, limit_image_size: bool, + classes: Classes, } impl<'a> Button<'a> { @@ -56,6 +57,7 @@ impl<'a> Button<'a> { selected: false, image_tint_follows_text_color: false, limit_image_size: false, + classes: Classes::default(), } } @@ -286,6 +288,7 @@ impl<'a> Button<'a> { selected, image_tint_follows_text_color, limit_image_size, + mut classes, } = self; // Min size height always equal or greater than interact size if not small @@ -311,7 +314,9 @@ impl<'a> Button<'a> { let response: Option = ui.ctx().read_response(id); let state = response.map(|r| r.widget_state()).unwrap_or_default(); - let ButtonStyle { frame, text_style } = ui.style().button_style(state, selected); + classes.add_class_if(SELECTED_CLASS, selected); + + let ButtonStyle { frame, text_style } = ui.style().button_style(&classes, state); let mut button_padding = if has_frame_margin { frame.inner_margin @@ -388,3 +393,13 @@ impl Widget for Button<'_> { self.atom_ui(ui).response } } + +impl HasClasses for Button<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} diff --git a/crates/egui/src/widgets/checkbox.rs b/crates/egui/src/widgets/checkbox.rs index 3d0dbc367..194bc0688 100644 --- a/crates/egui/src/widgets/checkbox.rs +++ b/crates/egui/src/widgets/checkbox.rs @@ -2,7 +2,8 @@ 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, + WidgetInfo, WidgetType, epaint, pos2, + widget_style::{CheckboxStyle, Classes, HasClasses}, }; // TODO(emilk): allow checkbox without a text label @@ -23,6 +24,7 @@ pub struct Checkbox<'a> { checked: &'a mut bool, atoms: Atoms<'a>, indeterminate: bool, + classes: Classes, } impl<'a> Checkbox<'a> { @@ -31,6 +33,7 @@ impl<'a> Checkbox<'a> { checked, atoms: atoms.into_atoms(), indeterminate: false, + classes: Classes::default(), } } @@ -55,6 +58,7 @@ impl Widget for Checkbox<'_> { checked, mut atoms, indeterminate, + classes, } = self; // Get the widget style by reading the response from the previous pass @@ -69,7 +73,7 @@ impl Widget for Checkbox<'_> { frame, check_stroke, text_style, - } = ui.style().checkbox_style(state); + } = ui.style().checkbox_style(&classes, state); let mut min_size = Vec2::splat(ui.spacing().interact_size.y); min_size.y = min_size.y.at_least(checkbox_size); @@ -153,3 +157,13 @@ impl Widget for Checkbox<'_> { } } } + +impl HasClasses for Checkbox<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} diff --git a/crates/egui/src/widgets/separator.rs b/crates/egui/src/widgets/separator.rs index ab211773e..48d02fe8e 100644 --- a/crates/egui/src/widgets/separator.rs +++ b/crates/egui/src/widgets/separator.rs @@ -1,4 +1,7 @@ -use crate::{Response, Sense, Ui, Vec2, Widget, vec2, widget_style::SeparatorStyle}; +use crate::{ + Response, Sense, Ui, Vec2, Widget, vec2, + widget_style::{Classes, HasClasses, SeparatorStyle}, +}; /// A visual separator. A horizontal or vertical line (depending on [`crate::Layout`]). /// @@ -16,6 +19,7 @@ pub struct Separator { spacing: Option, grow: f32, is_horizontal_line: Option, + classes: Classes, } impl Default for Separator { @@ -24,6 +28,7 @@ impl Default for Separator { spacing: None, grow: 0.0, is_horizontal_line: None, + classes: Classes::default(), } } } @@ -91,6 +96,7 @@ impl Widget for Separator { spacing, grow, is_horizontal_line, + classes, } = self; // Get the widget style by reading the response from the previous pass @@ -100,7 +106,7 @@ impl Widget for Separator { let SeparatorStyle { spacing: spacing_style, stroke, - } = ui.style().separator_style(state); + } = ui.style().separator_style(&classes, state); // override the spacing if not set let spacing = spacing.unwrap_or(spacing_style); @@ -142,3 +148,13 @@ impl Widget for Separator { response } } + +impl HasClasses for Separator { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +}