From b742e230a0114b3d2a6835c30457ecacfa62eff0 Mon Sep 17 00:00:00 2001 From: adrien <221212@umons.ac.be> Date: Sun, 21 Jun 2026 20:36:18 +0200 Subject: [PATCH] clean history --- Cargo.lock | 45 +++++- crates/egui/src/context.rs | 42 +++++- crates/egui/src/lib.rs | 1 + crates/egui/src/theme_plugin.rs | 113 +++++++++++++++ crates/egui/src/widget_style.rs | 101 ++++++++++++- crates/egui/src/widgets/button.rs | 3 +- examples/styling_engine/Cargo.toml | 20 +++ examples/styling_engine/README.md | 7 + examples/styling_engine/src/custom_engine.rs | 140 +++++++++++++++++++ examples/styling_engine/src/main.rs | 106 ++++++++++++++ examples/styling_engine/src/style.ess | 8 ++ 11 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 crates/egui/src/theme_plugin.rs create mode 100644 examples/styling_engine/Cargo.toml create mode 100644 examples/styling_engine/README.md create mode 100644 examples/styling_engine/src/custom_engine.rs create mode 100644 examples/styling_engine/src/main.rs create mode 100644 examples/styling_engine/src/style.ess diff --git a/Cargo.lock b/Cargo.lock index 2085fa955..2b9c2aa1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2751,6 +2751,38 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lz4_flex" version = "0.13.1" @@ -3966,9 +3998,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4530,6 +4562,15 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "styling_engine" +version = "0.1.0" +dependencies = [ + "eframe", + "env_logger", + "logos", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 1ad4a8fb1..cbd0381de 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -35,9 +35,10 @@ use crate::{ output::FullOutput, pass_state::PassState, plugin::{self, TypedPluginHandle}, - resize, response, scroll_area, + resize, response, scroll_area, theme_plugin, util::IdTypeMap, viewport::ViewportClass, + widget_style::{Classes, StyleStruct, WidgetState}, }; use crate::IdMap; @@ -405,6 +406,8 @@ struct ContextImpl { is_accesskit_enabled: bool, loaders: Arc, + + themes: theme_plugin::Themes, } impl ContextImpl { @@ -2027,6 +2030,43 @@ impl Context { } } +impl Context { + /// Register a [`ThemeStyle`](crate::theme_plugin::ThemeStyle) for the specified widget [`StyleStruct`](StyleStruct) `S` + /// + /// A theme can only be added once for a specified widget. + /// This way it's convenient to add themes in `eframe::run_simple_native`. + /// If you want to add the theme anyway, use [`Self::replace_theme`] instead. + pub fn add_theme( + &self, + theme: impl theme_plugin::ThemeStyle + Send + Sync + 'static, + ) { + self.write(|ctx| ctx.themes.register::(theme, false)); + } + + /// Register a [`ThemeStyle`](crate::theme_plugin::ThemeStyle) for the specified widget [`StyleStruct`](StyleStruct) `S` + /// + /// Overwrite any theme already registered for the specified widget [`StyleStruct`](StyleStruct). + /// If you want to avoid overwriting existing theme, use [`Self::add_theme`] instead. + pub fn replace_theme( + &self, + theme: impl theme_plugin::ThemeStyle + Send + Sync + 'static, + ) { + self.write(|ctx| ctx.themes.register::(theme, true)); + } + + /// Compute the [`StyleStruct`] using the registered theme if available. + /// + /// Return `None` if no theme was registered for the [`StyleStruct`] + pub fn get_style( + &self, + classes: &Classes, + state: WidgetState, + base: &Style, + ) -> Option { + self.write(move |ctx| ctx.themes.get::(classes, state, base)) + } +} + impl Context { /// Tell `egui` which fonts to use. /// diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 3dc85a28d..f55fb30e9 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -417,6 +417,7 @@ pub mod response; mod sense; pub mod style; pub mod text_selection; +pub mod theme_plugin; mod ui; mod ui_builder; mod ui_stack; diff --git a/crates/egui/src/theme_plugin.rs b/crates/egui/src/theme_plugin.rs new file mode 100644 index 000000000..7d56ed63b --- /dev/null +++ b/crates/egui/src/theme_plugin.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use epaint::mutex::Mutex; + +use crate::{ + Id, Style, Ui, + util::IdTypeMap, + widget_style::{Classes, StyleStruct, WidgetState}, +}; + +/// A cache that can be implemented to reduce computation time of a `ThemeStyle` +#[derive(Debug, Default, Clone)] +pub struct ThemeCache { + cache: IdTypeMap, +} + +impl ThemeCache { + /// Access the cache for the requested [`StyleStruct`] based on the [`Classes`] and + /// the [`WidgetState`] + /// + /// If no entry match the parameter then compute the fallback style and + /// save the output for later. + pub fn get( + &mut self, + classes: &Classes, + state: WidgetState, + fallback: impl FnOnce() -> S, + ) -> S { + let style_id = Id::new(classes).with(state); + if let Some(style) = self.cache.get_temp::(style_id) { + style + } else { + let style = fallback(); + self.cache.insert_temp(style_id, style.clone()); + style + } + } +} + +/// A Theme plugin that implement a style computation for a defined `StyleStruct` +pub trait ThemeStyle { + /// The style according to the classes and state of the widget + fn style(&mut self, classes: &Classes, state: WidgetState, base: &Style) -> S; +} + +impl Ui { + /// Access the installed theme plugin if there is one and fetch the requested widget style if it exist. + /// Fallback to the default style if not found. + /// + /// Requested widget style must implement [`StyleStruct`]. + pub fn widget_style( + &self, + id: crate::Id, + classes: &Classes, + ) -> S { + // If the requested `StyleStruct` is cached, return it without computing. + // Otherwise proceed to compute the style from the widget information. + + // Fetch the current state of the widget + let state = self + .ctx() + .read_response(id) + .map(|r| r.widget_state()) + .unwrap_or_default(); + + if let Some(style) = self.get_style::(classes, state, self.style()) { + style + } else { + S::default_style(classes, state, self.style()) + } + } +} + +#[derive(Default)] +pub(crate) struct Themes { + themes: IdTypeMap, +} + +impl Themes { + /// Register a theme and the style associated + pub(crate) fn register( + &mut self, + theme: impl ThemeStyle + Send + Sync + 'static, + force: bool, + ) { + if !force + && self + .themes + .get_temp:: + Send + Sync>>>>(Id::NULL) + .is_some() + { + return; + } + self.themes + .insert_temp:: + Send + Sync>>>>( + Id::NULL, + Arc::new(Mutex::new(Box::new(theme))), + ); + } + + /// Fetch the style of the current theme + pub(crate) fn get( + &self, + classes: &Classes, + state: WidgetState, + base: &Style, + ) -> Option { + let v = self + .themes + .get_temp:: + Send + Sync>>>>(Id::NULL); + v.map(|engine| engine.lock().style(classes, state, base)) + } +} diff --git a/crates/egui/src/widget_style.rs b/crates/egui/src/widget_style.rs index f3c5e5bd0..1953034e6 100644 --- a/crates/egui/src/widget_style.rs +++ b/crates/egui/src/widget_style.rs @@ -1,4 +1,7 @@ -use std::{borrow::Cow, fmt}; +use std::{ + borrow::Cow, + fmt::{self, Debug}, +}; use emath::Vec2; use epaint::{Color32, FontId, Shadow, Stroke, text::TextWrapMode}; @@ -9,7 +12,15 @@ use crate::{ style::{WidgetVisuals, Widgets}, }; +/// Each dedicated style must implement this trait to be used in the theme plugin system +pub trait StyleStruct: Debug + Clone + Send + Sync + std::any::Any + 'static { + fn default_style(classes: &Classes, state: WidgetState, base: &Style) -> Self + where + Self: Sized; +} + /// General text style +#[derive(Debug, Clone)] pub struct TextVisuals { /// Font used pub font_id: FontId, @@ -23,6 +34,7 @@ pub struct TextVisuals { } /// General widget style +#[derive(Debug, Clone)] pub struct WidgetStyle { pub frame: Frame, @@ -31,13 +43,69 @@ pub struct WidgetStyle { pub stroke: Stroke, } +impl StyleStruct for WidgetStyle { + fn default_style(_classes: &Classes, state: WidgetState, base: &Style) -> Self { + let visuals = base.visuals.widgets.state(state); + let font_id = base.override_font_id.clone(); + Self { + frame: Frame { + fill: visuals.bg_fill, + stroke: visuals.bg_stroke, + corner_radius: visuals.corner_radius, + inner_margin: base.spacing.button_padding.into(), + ..Default::default() + }, + stroke: visuals.fg_stroke, + text: TextVisuals { + color: base + .visuals + .override_text_color + .unwrap_or_else(|| visuals.text_color()), + font_id: font_id.unwrap_or_else(|| TextStyle::Body.resolve(base)), + strikethrough: Stroke::NONE, + underline: Stroke::NONE, + }, + } + } +} + /// Dedicated button style +#[derive(Debug, Clone)] pub struct ButtonStyle { pub frame: Frame, pub text_style: TextVisuals, } +impl StyleStruct for ButtonStyle { + fn default_style(classes: &Classes, state: WidgetState, base: &Style) -> Self { + let mut visuals = *base.visuals.widgets.state(state); + let mut ws = WidgetStyle::default_style(classes, state, base); + + if classes.has(SELECTED_CLASS) { + visuals.weak_bg_fill = base.visuals.selection.bg_fill; + visuals.bg_fill = base.visuals.selection.bg_fill; + visuals.fg_stroke = base.visuals.selection.stroke; + ws.text.color = base.visuals.selection.stroke.color; + } + + Self { + 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: (base.spacing.button_padding + Vec2::splat(visuals.expansion) + - Vec2::splat(visuals.bg_stroke.width)) + .into(), + ..Default::default() + }, + text_style: ws.text, + } + } +} + /// Dedicated checkbox style +#[derive(Debug, Clone)] pub struct CheckboxStyle { /// Frame around pub frame: Frame, @@ -58,7 +126,28 @@ pub struct CheckboxStyle { pub check_stroke: Stroke, } +impl StyleStruct for CheckboxStyle { + fn default_style(classes: &Classes, state: WidgetState, base: &Style) -> Self { + let visuals = base.visuals.widgets.state(state); + let ws = WidgetStyle::default_style(classes, state, base); + Self { + frame: Frame::new(), + checkbox_size: base.spacing.icon_width, + check_size: base.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, + } + } +} + /// Dedicated label style +#[derive(Debug, Clone)] pub struct LabelStyle { /// Frame around pub frame: Frame, @@ -71,6 +160,7 @@ pub struct LabelStyle { } /// Dedicated separator style +#[derive(Debug, Clone)] pub struct SeparatorStyle { /// How much space is allocated in the layout direction pub spacing: f32, @@ -80,7 +170,7 @@ pub struct SeparatorStyle { } /// The different state of a widget can be -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum WidgetState { Noninteractive, #[default] @@ -231,7 +321,7 @@ pub type ClassName = Cow<'static, str>; /// /// This can be used by styling engine to compute a different style /// based on the set of classes present on the widget/Ui. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, Hash)] pub struct Classes { classes: SmallVec<[ClassName; 5]>, } @@ -315,4 +405,9 @@ pub trait HasClasses { fn has(&self, class: impl Into) -> bool { self.classes().classes.contains(&class.into()) } + + /// The list of class + fn list(&self) -> Vec { + self.classes().classes.to_vec() + } } diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index a1d2f84ed..011488399 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -328,7 +328,8 @@ 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.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/examples/styling_engine/Cargo.toml b/examples/styling_engine/Cargo.toml new file mode 100644 index 000000000..8f39b3f6a --- /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 +] } +env_logger = { workspace = true, features = ["auto-color", "humantime"] } +logos = "0.16.1" diff --git a/examples/styling_engine/README.md b/examples/styling_engine/README.md new file mode 100644 index 000000000..f2cc8721c --- /dev/null +++ b/examples/styling_engine/README.md @@ -0,0 +1,7 @@ +Example showing how the style engine work. + +```sh +cargo run -p styling_engine +``` + + diff --git a/examples/styling_engine/src/custom_engine.rs b/examples/styling_engine/src/custom_engine.rs new file mode 100644 index 000000000..9f5a7facb --- /dev/null +++ b/examples/styling_engine/src/custom_engine.rs @@ -0,0 +1,140 @@ +use std::collections::HashMap; + +use eframe::egui::{ + Color32, Style, + theme_plugin::{ThemeCache, ThemeStyle}, + widget_style::{ + ButtonStyle, Classes, HasClasses as _, StyleStruct as _, WidgetState, WidgetStyle, + }, +}; +use logos::Logos; + +#[derive(Debug, Default, Clone)] +pub struct ESSEngine { + info: HashMap>, + cache: ThemeCache, +} + +impl ESSEngine { + pub fn try_parse(ess: &str) -> Result { + let mut engine = Self::default(); + let mut lexer = Token::lexer(ess); + let mut hash = HashMap::new(); + while let Some(token) = lexer.next() { + if token == Ok(Token::Class) { + let selector = lexer.slice()[1..].to_owned(); + if lexer + .next() + .is_some_and(|token| token.is_ok_and(|token| token != Token::Open)) + { + return Err("No opening bracket found !".to_owned()); + } + + let mut declarations = vec![]; + + loop { + match lexer.next() { + Some(Ok(Token::Property)) => { + let property = lexer.slice().to_owned(); + + if lexer + .next() + .is_some_and(|token| token.is_ok_and(|token| token != Token::Is)) + { + return Err("No separator between property and value !".to_owned()); + } + + let value = match lexer.next() { + Some(Ok(Token::Number)) => Value::Number( + lexer + .slice() + .to_owned() + .parse::() + .expect("Should be a positive integer"), + ), + Some(Ok(Token::Color)) => Value::Color( + Color32::from_hex(lexer.slice()) + .expect("Should be a valid hex"), + ), + Some(Ok(v)) => return Err(format!("Invalid value : {v:?}")), + _ => return Err("Error".to_owned()), + }; + + declarations.push((property, value)); + } + Some(Ok(Token::Close)) => break, + Some(Ok(v)) => { + return Err(format!("Missing close bracket, found : {v:?}")); + } + v => return Err(format!("Error : {v:?}")), + } + } + + hash.insert(selector, declarations); + } + } + engine.info = hash; + Ok(engine) + } +} + +impl ThemeStyle for ESSEngine { + fn style(&mut self, classes: &Classes, state: WidgetState, base: &Style) -> WidgetStyle { + self.cache + .get(classes, state, || base.widget_style(classes, state)) + } +} + +impl ThemeStyle for ESSEngine { + fn style(&mut self, classes: &Classes, state: WidgetState, base: &Style) -> ButtonStyle { + self.cache.get(classes, state, || { + let mut default = ButtonStyle::default_style(classes, state, base); + for classe in classes.list() { + if let Some(properties) = self.info.get(&classe.to_string()) { + for (property, value) in properties { + match property.as_str() { + "fill" => { + if let Value::Color(color) = value { + default.frame.fill = *color; + } + } + "border" => { + if let Value::Number(size) = value { + default.frame.stroke.width = *size as f32; + } + } + _ => {} + } + } + } + } + default + }) + } +} + +#[derive(Debug, Logos, PartialEq)] +enum Token { + #[token("{")] + Open, + #[token("}")] + Close, + #[token(":")] + Is, + #[regex(r"\.[a-zA-Z]+")] + Class, + #[regex(r"[a-zA-Z]+")] + Property, + #[regex(r"[0-9]+")] + Number, + #[regex(r"#(?:[0-9a-fA-F]{3}){1,2}")] + Color, + #[regex(r"[ \t\n\f;]+", logos::skip)] + Whitespace, +} + +#[derive(Debug, Clone)] +enum Value { + Number(usize), + Color(Color32), +} diff --git a/examples/styling_engine/src/main.rs b/examples/styling_engine/src/main.rs new file mode 100644 index 000000000..322d1af86 --- /dev/null +++ b/examples/styling_engine/src/main.rs @@ -0,0 +1,106 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +#![expect(rustdoc::missing_crate_level_docs)] // it's an example + +use eframe::egui::widget_style::WidgetStyle; +use eframe::egui::{ + self, Button, Frame, Margin, Panel, UiBuilder, + widget_style::{ButtonStyle, HasClasses as _}, +}; + +use crate::custom_engine::ESSEngine; + +mod custom_engine; + +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, 400.0]), + ..Default::default() + }; + + let mut style_code = " +.red { + fill: #f00; +} + +.blue { + fill: #00f; + border: 10; +} +" + .to_owned(); + + let mut toggled = false; + + eframe::run_ui_native("My egui App", options, move |ui, _frame| { + // Register the theme plugin and which style they implement + if let Ok(engine) = ESSEngine::try_parse(&style_code) { + ui.add_theme::(engine.clone()); + ui.add_theme::(engine); + } + + ui.scope_builder(UiBuilder::new().with_class("body"), |ui| { + ui.label("body"); + ui.label("central panel"); + + Panel::left("style_code").show(ui, |ui| { + ui.scope_builder(UiBuilder::new().with_class("panel_left"), |ui| { + ui.label( + "Live editor\n(type color hex to change the color of the dynamic button)", + ); + + if ui.text_edit_multiline(&mut style_code).changed() + && let Ok(engine) = ESSEngine::try_parse(&style_code) + { + ui.replace_theme::(engine.clone()); + ui.replace_theme::(engine); + } + + // Should find a way to detect if a change is made on a plugin + // Maybe by saving the plugin in the system instead and invalidating the cache when it's pulled as mut ? + }); + }); + + ui.scope_builder(UiBuilder::new().with_class("grid"), |ui| { + Frame::new().inner_margin(Margin::same(10)).show(ui, |ui| { + ui.scope_builder(UiBuilder::new().with_class("frame1"), |ui| { + let mut parent = Some(ui.stack()); + let mut text = vec![]; + let mut i: i32 = 0; + while let Some(p) = parent { + text.push(format!( + "{}{}class : '{}', kind : {:?}", + " ".repeat((2 * 0_i32.max(i - 1) + 1.min(i)) as usize), + if i > 0 { "\\- " } else { "" }, + p.classes, + p.kind() + )); + i += 1; + parent = p.parent.as_ref(); + } + ui.label(format!( + "Current hierarchy (child to root):\n{}", + text.join("\n") + )); + }) + }) + }); + + ui.add(Button::new("Normal")); + ui.add(Button::new("red").with_class("red")); + ui.add(Button::new("blue").with_class("blue")); + ui.add(Button::new("dynamic in engine A").with_class("dynamic")); + if ui + .add( + Button::new("red/blue") + .with_class_if("red", toggled) + .with_class_if("blue", !toggled), + ) + .clicked() + { + toggled = !toggled; + } + }); + }) +} diff --git a/examples/styling_engine/src/style.ess b/examples/styling_engine/src/style.ess new file mode 100644 index 000000000..24358af1e --- /dev/null +++ b/examples/styling_engine/src/style.ess @@ -0,0 +1,8 @@ +.red { + fill: #f00; +} + +.blue { + fill: #00f; + border: 4; +}