diff --git a/Cargo.lock b/Cargo.lock index 71ed40f62..88207d5a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4479,6 +4479,14 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "styling_engine" +version = "0.1.0" +dependencies = [ + "eframe", + "env_logger", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 4b30ad18d..a9475365e 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -53,6 +53,12 @@ android-native-activity = ["egui-winit/android-native-activity"] ## If you plan on specifying your own fonts you may disable this feature. default_fonts = ["egui/default_fonts"] +## Enable experimental egui features that might have massive breaking changes or be removed entirely in future updates. +## Enabling this won't break semver, it's just future compatibility risk. +## +## Currently, this enables the theme plugin. +experimental = ["egui/experimental"] + ## Enable [`glow`](https://github.com/grovesNL/glow) for painting, via [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow). ## ## There is generally no need to enable both the `wgpu` and `glow` features, diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index 4aee66384..0bbd8494e 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -45,6 +45,12 @@ color-hex = ["epaint/color-hex"] ## If you plan on specifying your own fonts you may disable this feature. default_fonts = ["epaint/default_fonts"] +## Enable experimental egui features that might have massive breaking changes or be removed entirely in future updates. +## Enabling this won't break semver, it's just future compatibility risk. +## +## Currently, this enables the theme plugin. +experimental = [] + ## [`mint`](https://docs.rs/mint) enables interoperability with other math libraries such as [`glam`](https://docs.rs/glam) and [`nalgebra`](https://docs.rs/nalgebra). mint = ["epaint/mint"] diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 25b384c95..48c63f436 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -36,9 +36,10 @@ use crate::{ output::{FullOutput, LogicOutput}, pass_state::PassState, plugin::{self, TypedPluginHandle}, - resize, response, scroll_area, + resize, response, scroll_area, theme, util::IdTypeMap, viewport::ViewportClass, + widget_style::{StyleArgs, WidgetStyle}, }; use crate::IdMap; @@ -412,6 +413,8 @@ struct ContextImpl { is_accesskit_enabled: bool, loaders: Arc, + + themes: theme::Themes, } impl ContextImpl { @@ -2093,6 +2096,48 @@ impl Context { } } +/// Experimental theming, gated behind the `experimental_theme` feature. +impl Context { + /// Register a [`StyleProvider`](crate::theme::StyleProvider) for the specified widget type. + /// + /// A theme can only be added once for a specified widget. + /// 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")] + pub fn add_widget_theme( + &self, + theme: impl theme::StyleProvider + Send + Sync + 'static, + ) { + self.write(|ctx| ctx.themes.register::(theme, false)); + } + + /// Register a [`StyleProvider`](crate::theme::StyleProvider) for the specified widget. + /// + /// Overwrite any theme already registered for the specified widget [`WidgetStyle`]. + /// This allow to live edit a theme. + #[cfg(feature = "experimental")] + pub fn replace_widget_theme( + &self, + theme: impl theme::StyleProvider + Send + Sync + 'static, + ) { + self.write(|ctx| ctx.themes.register::(theme, true)); + } + + /// Compute the `WidgetStyle` using the registered theme. + /// + /// The types you need to call this (e.g. `StyleArgs`) are only public + /// with the `experimental_theme` feature. + #[cfg_attr(not(feature = "experimental"), doc(hidden))] + pub fn get_widget_style( + &self, + modifiers: &StyleArgs<'_>, + ) -> S { + let theme = self.read(move |ctx| ctx.themes.get::()); + theme.lock().style(modifiers) + } +} + impl Context { /// Tell `egui` which fonts to use. /// diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 27ab7035c..2d4099d92 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -417,13 +417,20 @@ pub mod response; mod sense; pub mod style; pub mod text_selection; +#[cfg(feature = "experimental")] +pub mod theme; +#[cfg(not(feature = "experimental"))] +mod theme; mod ui; mod ui_builder; mod ui_stack; pub mod util; pub mod viewport; mod widget_rect; +#[cfg(feature = "experimental")] pub mod widget_style; +#[cfg(not(feature = "experimental"))] +mod widget_style; pub mod widget_text; pub mod widgets; diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs new file mode 100644 index 000000000..a23ef8fcd --- /dev/null +++ b/crates/egui/src/theme/default_style.rs @@ -0,0 +1,156 @@ +use emath::Vec2; +use epaint::{Shadow, Stroke, text::TextWrapMode}; + +use crate::{ + Frame, TextStyle, + theme::StyleProvider, + widget_style::{ + BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, SELECTED_CLASS, + SeparatorStyle, StyleArgs, TextVisuals, WidgetState, + }, +}; + +/// The default [`StyleProvider`], implementing the default egui look based on +/// [`crate::style::WidgetVisuals`]. +#[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 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 ws: BaseStyle = ctx.get_widget_style(modifiers); + + if classes.has(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 { + 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() + }, + text_style: ws.text, + } + } +} + +impl StyleProvider for DefaultStyle { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle { + let StyleArgs { + ctx, 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); + + CheckboxStyle { + frame: Frame::new(), + checkbox_size: spacing.icon_width, + check_size: spacing.icon_width_inner, + checkbox_frame: Frame { + fill: widget_visuals.bg_fill, + corner_radius: widget_visuals.corner_radius, + 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, + } + } +} + +impl StyleProvider for DefaultStyle { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> SeparatorStyle { + let StyleArgs { style, .. } = modifiers; + + SeparatorStyle { + spacing: 6.0, + // A separator is never interactive, so its stroke doesn't depend on the widget state: + stroke: style.visuals.widgets.noninteractive.bg_stroke, + } + } +} diff --git a/crates/egui/src/theme/mod.rs b/crates/egui/src/theme/mod.rs new file mode 100644 index 000000000..088d7f778 --- /dev/null +++ b/crates/egui/src/theme/mod.rs @@ -0,0 +1,48 @@ +//! Theming: pluggable [`StyleProvider`]s that compute the style of each widget. + +// This module is only public with the `experimental_theme` feature, +// so without it a lot of it looks unused: +#![cfg_attr(not(feature = "experimental"), allow(dead_code, unused_imports))] + +mod default_style; +mod style_provider; +mod themes; + +pub use self::{default_style::DefaultStyle, style_provider::StyleProvider, themes::Themes}; + +use crate::{ + Ui, + widget_style::{Classes, StyleArgs, WidgetState, WidgetStyle}, +}; + +impl Ui { + /// The style of the widget with the given [`crate::Id`] and `Classes`, + /// as computed by the registered theme. + /// + /// The types you need to call this are only public with the + /// `experimental_theme` feature. + #[cfg_attr(not(feature = "experimental"), doc(hidden))] + pub fn widget_style( + &self, + id: crate::Id, + classes: &Classes, + ) -> S { + // Fetch the state of the widget, as it was in the previous pass + let state = if let Some(response) = self.read_response(id) { + response.widget_state() + } else { + // We don't know the state of the widget yet, so we would style it wrong. + // It will be styled correctly on next frame. + self.ctx().request_repaint(); + WidgetState::default() + }; + + self.get_widget_style::(&StyleArgs { + classes, + state, + style: self.style(), + stack: self.stack(), + ctx: self, + }) + } +} diff --git a/crates/egui/src/theme/style_provider.rs b/crates/egui/src/theme/style_provider.rs new file mode 100644 index 000000000..5f9205a85 --- /dev/null +++ b/crates/egui/src/theme/style_provider.rs @@ -0,0 +1,17 @@ +use core::any::TypeId; + +use crate::widget_style::StyleArgs; + +/// A Theme plugin that implement a style computation for a defined `WidgetStyle` +pub trait StyleProvider { + /// The style according to the classes and state of the widget + fn style(&mut self, modifiers: &StyleArgs<'_>) -> S; + + /// Used to tell different themes apart + fn type_id(&self) -> TypeId + where + Self: 'static, + { + TypeId::of::() + } +} diff --git a/crates/egui/src/theme/themes.rs b/crates/egui/src/theme/themes.rs new file mode 100644 index 000000000..4294f9460 --- /dev/null +++ b/crates/egui/src/theme/themes.rs @@ -0,0 +1,96 @@ +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, + }, +}; + +/// 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 +/// 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). +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` + /// + /// Existing themes are overwritten if `force` is `true` or the new theme differs. + pub(crate) fn register( + &mut self, + theme: impl StyleProvider + Send + Sync + 'static, + force: bool, + ) { + if !force + && self + .themes + .get_temp::>(Id::NULL) + .is_some_and(|t| t.lock().type_id() == theme.type_id()) + { + return; + } + + self.themes + .insert_temp::>(Id::NULL, Arc::new(Mutex::new(Box::new(theme)))); + } + + /// Fetch the style of the current theme + pub fn get(&self) -> ThemeWrap { + let v = self.themes.get_temp::>(Id::NULL); + + v.unwrap_or_else(|| { + panic!( + "A style should be set for {:?}", + core::any::type_name::() + ) + }) + } +} diff --git a/crates/egui/src/widget_style.rs b/crates/egui/src/widget_style.rs deleted file mode 100644 index f6239cae9..000000000 --- a/crates/egui/src/widget_style.rs +++ /dev/null @@ -1,318 +0,0 @@ -use std::{borrow::Cow, fmt}; - -use emath::Vec2; -use epaint::{Color32, FontId, Shadow, Stroke, text::TextWrapMode}; -use smallvec::SmallVec; - -use crate::{ - Frame, Response, Style, TextBuffer as _, TextStyle, - style::{WidgetVisuals, Widgets}, -}; - -/// General text style -pub struct TextVisuals { - /// Font used - pub font_id: FontId, - - /// Font color - pub color: Color32, - - /// Text decoration - pub underline: Stroke, - pub strikethrough: Stroke, -} - -/// General widget style -pub struct WidgetStyle { - pub frame: Frame, - - pub text: TextVisuals, - - 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, - - /// Text next to it - pub text_style: TextVisuals, - - /// Checkbox size - pub checkbox_size: f32, - - /// Checkmark size - pub check_size: f32, - - /// Frame of the checkbox itself - pub checkbox_frame: Frame, - - /// Checkmark stroke - pub check_stroke: Stroke, -} - -/// Dedicated label style -pub struct LabelStyle { - /// Frame around - pub frame: Frame, - - /// Text style - pub text: TextVisuals, - - /// Wrap mode used - pub wrap_mode: TextWrapMode, -} - -/// Dedicated separator style -pub struct SeparatorStyle { - /// How much space is allocated in the layout direction - pub spacing: f32, - - /// How to paint it - pub stroke: Stroke, -} - -/// The different state of a widget can be -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] -pub enum WidgetState { - Noninteractive, - #[default] - Inactive, - Hovered, - Active, -} - -impl Widgets { - /// The widget visuals according to the state - pub fn state(&self, state: WidgetState) -> &WidgetVisuals { - match state { - WidgetState::Noninteractive => &self.noninteractive, - WidgetState::Inactive => &self.inactive, - WidgetState::Hovered => &self.hovered, - WidgetState::Active => &self.active, - } - } -} - -impl Response { - pub fn widget_state(&self) -> WidgetState { - if !self.sense.interactive() { - WidgetState::Noninteractive - } else if self.is_pointer_button_down_on() || self.has_focus() || self.clicked() { - WidgetState::Active - } else if self.hovered() || self.highlighted() { - WidgetState::Hovered - } else { - WidgetState::Inactive - } - } -} - -impl Style { - /// 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 { - frame: Frame { - fill: visuals.bg_fill, - stroke: visuals.bg_stroke, - corner_radius: visuals.corner_radius, - inner_margin: self.spacing.button_padding.into(), - ..Default::default() - }, - stroke: visuals.fg_stroke, - text: TextVisuals { - color: self - .visuals - .override_text_color - .unwrap_or_else(|| visuals.text_color()), - font_id: font_id.unwrap_or_else(|| TextStyle::Body.resolve(self)), - strikethrough: Stroke::NONE, - underline: Stroke::NONE, - }, - } - } - - /// 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(classes, state); - - 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; - ws.text.color = self.visuals.selection.stroke.color; - } - - ButtonStyle { - frame: Frame { - fill: visuals.weak_bg_fill, - stroke: visuals.bg_stroke, - corner_radius: visuals.corner_radius, - outer_margin: (-Vec2::splat(visuals.expansion)).into(), - inner_margin: (self.spacing.button_padding + Vec2::splat(visuals.expansion) - - Vec2::splat(visuals.bg_stroke.width)) - .into(), - ..Default::default() - }, - text_style: ws.text, - } - } - - /// 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(classes, state); - CheckboxStyle { - frame: Frame::new(), - checkbox_size: self.spacing.icon_width, - check_size: self.spacing.icon_width_inner, - checkbox_frame: Frame { - fill: visuals.bg_fill, - corner_radius: visuals.corner_radius, - stroke: visuals.bg_stroke, - ..Default::default() - }, - text_style: ws.text, - check_stroke: ws.stroke, - } - } - - /// 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, - 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, - } - } - - /// 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, - stroke: visuals.bg_stroke, - } - } -} - -/// 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 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()) - } -} diff --git a/crates/egui/src/widget_style/classes.rs b/crates/egui/src/widget_style/classes.rs new file mode 100644 index 000000000..95ef43aae --- /dev/null +++ b/crates/egui/src/widget_style/classes.rs @@ -0,0 +1,109 @@ +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 new file mode 100644 index 000000000..1a2c19959 --- /dev/null +++ b/crates/egui/src/widget_style/mod.rs @@ -0,0 +1,149 @@ +// This module is only public with the `experimental_theme` feature, +// 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 crate::{ + Context, Frame, Response, Style, UiStack, + style::{WidgetVisuals, Widgets}, +}; + +/// Each dedicated style must implement this trait to be used in the theme plugin system +pub trait WidgetStyle: Debug + Clone + Send + Sync + core::any::Any + 'static {} + +/// General text style +#[derive(Debug, Clone)] +pub struct TextVisuals { + /// Font used + pub font_id: FontId, + + /// 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 { + pub frame: Frame, + pub text_style: TextVisuals, +} + +impl WidgetStyle for ButtonStyle {} + +/// Dedicated checkbox style +#[derive(Debug, Clone)] +pub struct CheckboxStyle { + /// Frame around + pub frame: Frame, + + /// Text next to it + pub text_style: TextVisuals, + + /// Checkbox size + pub checkbox_size: f32, + + /// Checkmark size + pub check_size: f32, + + /// Frame of the checkbox itself + pub checkbox_frame: Frame, + + /// Checkmark stroke + pub check_stroke: Stroke, +} + +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 { + /// How much space is allocated in the layout direction + pub spacing: f32, + + /// How to paint it + pub stroke: Stroke, +} + +impl WidgetStyle for SeparatorStyle {} + +/// The different state of a widget can be +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WidgetState { + Noninteractive, + #[default] + Inactive, + Hovered, + Active, +} + +impl Widgets { + /// The widget visuals according to the state + pub fn state(&self, state: WidgetState) -> &WidgetVisuals { + match state { + WidgetState::Noninteractive => &self.noninteractive, + WidgetState::Inactive => &self.inactive, + WidgetState::Hovered => &self.hovered, + WidgetState::Active => &self.active, + } + } +} + +impl Response { + pub fn widget_state(&self) -> WidgetState { + if !self.sense.interactive() { + WidgetState::Noninteractive + } else if self.is_pointer_button_down_on() || self.has_focus() || self.clicked() { + WidgetState::Active + } else if self.hovered() || self.highlighted() { + WidgetState::Hovered + } else { + WidgetState::Inactive + } + } +} + +pub struct StyleArgs<'a> { + pub classes: &'a Classes, + pub state: WidgetState, + pub stack: &'a UiStack, + pub style: &'a Style, + pub ctx: &'a Context, +} diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index a1d2f84ed..d7f6c8a0c 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -328,7 +328,7 @@ impl<'a> Button<'a> { classes.add_class_if(SELECTED_CLASS, selected.unwrap_or(false)); - let ButtonStyle { frame, text_style } = ui.style().button_style(&classes, state); + let ButtonStyle { frame, text_style } = ui.widget_style(id, &classes); let mut button_padding = if has_frame_margin { frame.inner_margin diff --git a/crates/egui/src/widgets/checkbox.rs b/crates/egui/src/widgets/checkbox.rs index 0aaedec76..9310c64d5 100644 --- a/crates/egui/src/widgets/checkbox.rs +++ b/crates/egui/src/widgets/checkbox.rs @@ -70,9 +70,6 @@ impl Widget for Checkbox<'_> { // Get the widget style by reading the response from the previous pass 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 CheckboxStyle { check_size, checkbox_frame, @@ -80,7 +77,7 @@ impl Widget for Checkbox<'_> { frame, check_stroke, text_style, - } = ui.style().checkbox_style(&classes, state); + } = ui.widget_style(id, &classes); let mut min_size = Vec2::splat(ui.spacing().interact_size.y); min_size.y = min_size.y.at_least(checkbox_size); diff --git a/crates/egui/src/widgets/separator.rs b/crates/egui/src/widgets/separator.rs index 48d02fe8e..e188d4127 100644 --- a/crates/egui/src/widgets/separator.rs +++ b/crates/egui/src/widgets/separator.rs @@ -101,12 +101,10 @@ impl Widget for Separator { // Get the widget style by reading the response from the previous pass 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 SeparatorStyle { spacing: spacing_style, stroke, - } = ui.style().separator_style(&classes, state); + } = ui.widget_style(id, &classes); // override the spacing if not set let spacing = spacing.unwrap_or(spacing_style); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png index ec34487f1..cd27c9038 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05527c073b1ee2f6a15052a9097d1ad515331ff32d572cf510086f9ebb7a7bbc -size 27253 +oid sha256:ad431823e85de056c95e6e97d1a9f5905880a1ecc34f24da5bc0b611e42fe99b +size 27260 diff --git a/examples/styling_engine/Cargo.toml b/examples/styling_engine/Cargo.toml new file mode 100644 index 000000000..038b59fd6 --- /dev/null +++ b/examples/styling_engine/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "styling_engine" +version = "0.1.0" +authors = ["Emil Ernerfeldt "] +license = "MIT OR Apache-2.0" +edition = "2024" +rust-version = "1.92" +publish = false + +[lints] +workspace = true + + +[dependencies] +eframe = { workspace = true, features = [ + "default", + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO + "experimental", # this example is all about the experimental theming API +] } +env_logger = { workspace = true, features = ["auto-color", "humantime"] } diff --git a/examples/styling_engine/README.md b/examples/styling_engine/README.md new file mode 100644 index 000000000..43f54f5bb --- /dev/null +++ b/examples/styling_engine/README.md @@ -0,0 +1,10 @@ +Example showing how the style engine work. + +A custom `StyleProvider` styles every button, using _classes_ to tell them apart. +The theme can be edited live in the left panel. + +```sh +cargo run -p styling_engine +``` + + diff --git a/examples/styling_engine/src/main.rs b/examples/styling_engine/src/main.rs new file mode 100644 index 000000000..54763696a --- /dev/null +++ b/examples/styling_engine/src/main.rs @@ -0,0 +1,162 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +//! A small styling engine: a custom [`StyleProvider`] that styles every [`egui::Button`] +//! based on the _classes_ set on it, and that can be edited live. + +use eframe::egui::{ + self, CentralPanel, Color32, Frame, Panel, + theme::StyleProvider, + widget_style::{BaseStyle, ButtonStyle, HasClasses as _, StyleArgs, WidgetState}, +}; + +/// Buttons with this class are styled as a destructive action. +const DANGER: &str = "danger"; + +/// Our styling engine: it decides what every button looks like. +#[derive(Clone, Copy, PartialEq)] +struct MyTheme { + normal: Color32, + danger: Color32, + corner_radius: u8, +} + +impl MyTheme { + fn preset(preset: Preset) -> Self { + match preset { + Preset::Ocean => Self { + normal: Color32::from_rgb(0x1E, 0x5A, 0x8A), + danger: Color32::from_rgb(0x9B, 0x2C, 0x2C), + corner_radius: 4, + }, + Preset::Candy => Self { + normal: Color32::from_rgb(0xB8, 0x3B, 0x9E), + danger: Color32::from_rgb(0xD9, 0x6A, 0x1F), + corner_radius: 16, + }, + } + } +} + +impl StyleProvider for MyTheme { + fn style(&mut self, args: &StyleArgs<'_>) -> ButtonStyle { + let StyleArgs { + classes, + state, + ctx, + .. + } = 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) { + self.danger + } else { + self.normal + }; + + // React to what the user is doing with the button: + let fill = match state { + WidgetState::Hovered => fill.gamma_multiply(1.4), + WidgetState::Active => fill.gamma_multiply(0.7), + _ => fill, + }; + + ButtonStyle { + frame: Frame::new() + .fill(fill) + .corner_radius(self.corner_radius) + .inner_margin(8), + text_style: base.text, + } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Preset { + Ocean, + Candy, +} + +fn main() -> eframe::Result { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([600.0, 340.0]), + ..Default::default() + }; + + let mut preset = Preset::Ocean; + let mut theme = MyTheme::preset(preset); + let mut last_click = "nothing"; + + eframe::run_ui_native("Styling engine", options, move |ui, _frame| { + // Register our theme for all buttons. This is a no-op after the first frame. + ui.add_widget_theme::(theme); + + Panel::left("controls").default_size(260.0).show(ui, |ui| { + // The custom theme inherits the text color from egui's light/dark theme: + egui::global_theme_preference_buttons(ui); + + ui.separator(); + + ui.heading("Button theme"); + + let mut changed = false; + + egui::ComboBox::from_label("Preset") + .selected_text(match preset { + Preset::Ocean => "Ocean", + Preset::Candy => "Candy", + }) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value(&mut preset, Preset::Ocean, "Ocean") + .changed(); + changed |= ui + .selectable_value(&mut preset, Preset::Candy, "Candy") + .changed(); + }); + if changed { + theme = MyTheme::preset(preset); + } + + ui.horizontal(|ui| { + changed |= ui.color_edit_button_srgba(&mut theme.normal).changed(); + ui.label("Normal"); + }); + ui.horizontal(|ui| { + changed |= ui.color_edit_button_srgba(&mut theme.danger).changed(); + ui.label("Danger"); + }); + changed |= ui + .add(egui::Slider::new(&mut theme.corner_radius, 0..=24).text("Corner radius")) + .changed(); + + if changed { + // Overwrite the registered theme with the edited one: + ui.replace_widget_theme::(theme); + } + }); + + CentralPanel::default().show(ui, |ui| { + ui.heading("Buttons"); + ui.label("All buttons below are styled by the custom theme."); + ui.add_space(8.0); + + if ui.button("Save").clicked() { + last_click = "Save"; + } + ui.add_space(4.0); + if ui + .add(egui::Button::new("Delete everything").with_class(DANGER)) + .clicked() + { + last_click = "Delete everything"; + } + + ui.add_space(8.0); + ui.label(format!("Last clicked: {last_click}")); + }); + }) +} diff --git a/tests/egui_tests/Cargo.toml b/tests/egui_tests/Cargo.toml index 89d6a9fda..7e894a2c0 100644 --- a/tests/egui_tests/Cargo.toml +++ b/tests/egui_tests/Cargo.toml @@ -10,7 +10,7 @@ version.workspace = true ignored = ["image"] # We need the png feature [dev-dependencies] -egui = { workspace = true, default-features = true } +egui = { workspace = true, default-features = true, features = ["experimental"] } egui_kittest = { workspace = true, features = ["snapshot", "wgpu"] } egui_extras = { workspace = true, features = ["image"] } image = { workspace = true, features = ["png"] } diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index 6368de3a8..b6cd6cda8 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -1,5 +1,7 @@ use egui::{ - Align, Atom, AtomExt as _, AtomLayout, Button, Direction, Frame, Layout, TextWrapMode, Ui, Vec2, + Align, Atom, AtomExt as _, AtomLayout, Button, Direction, Frame, Layout, TextWrapMode, Ui, + Vec2, + widget_style::{ButtonStyle, StyleArgs}, }; use egui_kittest::{HarnessBuilder, SnapshotResult, SnapshotResults}; @@ -129,11 +131,14 @@ fn test_atom_layout_nesting_and_direction() { let style = ui.style(); let canvas_frame = Frame::canvas(style); - let button_frame = style - .button_style( - &egui::widget_style::Classes::default(), - egui::widget_style::WidgetState::Inactive, - ) + let button_frame = ui + .get_widget_style::(&StyleArgs { + classes: &egui::widget_style::Classes::default(), + state: egui::widget_style::WidgetState::Inactive, + ctx: ui, + stack: ui.stack(), + style, + }) .frame; let row = |direction: Direction| {