diff --git a/crates/egui/src/containers/collapsing_header.rs b/crates/egui/src/containers/collapsing_header.rs index 3afb77682..de08f35b1 100644 --- a/crates/egui/src/containers/collapsing_header.rs +++ b/crates/egui/src/containers/collapsing_header.rs @@ -1,5 +1,7 @@ use std::hash::Hash; +use crate::style::WidgetVisuals; +use crate::style_trait::{Classes, WidgetName}; use crate::{ Context, Id, InnerResponse, NumExt as _, Rect, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetInfo, WidgetText, WidgetType, @@ -580,7 +582,11 @@ impl CollapsingHeader { let openness = state.openness(ui.ctx()); if ui.is_rect_visible(rect) { - let visuals = ui.style().interact_selectable(&header_response, selected); + let visuals: WidgetVisuals = ui.widget_style( + WidgetName::Custom("CollapsingHeader".into()), + &header_response, + &Classes::default().with_if("selected", selected), + ); if ui.visuals().collapsing_header_frame || show_background { ui.painter().add(epaint::RectShape::new( diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index c2908df19..5b41d9fc3 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -446,6 +446,7 @@ mod atomics; #[cfg(feature = "callstack")] #[cfg(debug_assertions)] mod callstack; +pub mod style_trait; #[cfg(feature = "accesskit")] pub use accesskit; diff --git a/crates/egui/src/style_trait.rs b/crates/egui/src/style_trait.rs new file mode 100644 index 000000000..996325968 --- /dev/null +++ b/crates/egui/src/style_trait.rs @@ -0,0 +1,175 @@ +use crate::style::WidgetVisuals; +use crate::{Context, Frame, Id, Response, TextStyle, Ui}; +use emath::TSTransform; +use epaint::text::TextFormat; +use epaint::{CornerRadius, Stroke}; +use std::borrow::Cow; +use std::fmt::Debug; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub enum WidgetName { + Button, + Checkbox, + Slider, + TextInput, + Custom(Cow<'static, str>), +} + +#[derive(Clone)] +pub struct WidgetContext<'c> { + pub ui: &'c Ui, + pub response: &'c Response, + pub classes: &'c Classes, + pub name: WidgetName, +} + +impl Debug for WidgetContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WidgetContext") + .field("name", &self.name) + .field("classes", &self.classes.classes) + .finish() + } +} + +const CLASS_SELECTED: &str = "selected"; + +pub trait StyleEngine: Send + Sync { + fn get(&self, ctx: &WidgetContext) -> T; +} + +#[derive(Clone)] +pub struct DefaultWidgetStyle; + +impl StyleEngine for DefaultWidgetStyle { + fn get(&self, ctx: &WidgetContext) -> WidgetVisuals { + ctx.ui + .style() + .interact_selectable(ctx.response, ctx.classes.has(CLASS_SELECTED)) + } +} + +fn widget_style_id() -> Id { + Id::new("WidgetStyle") +} + +impl Ui { + pub fn widget_style( + &self, + name: WidgetName, + response: &Response, + classes: &Classes, + ) -> T { + let style = self.data_mut(|d| { + let style: StyleEngineContainer = + d.get_temp(widget_style_id()).expect("Widget style not set"); + style + }); + let ctx = WidgetContext { + ui: self, + response: &response, + classes, + name, + }; + + style.0.get(&ctx) + } +} + +impl Context { + pub fn set_style_engine(&self, style: impl StyleEngine + 'static) { + self.data_mut(|d| { + d.insert_temp(widget_style_id(), StyleEngineContainer(Arc::new(style))); + }); + } +} + +struct StyleEngineContainer(Arc>); + +impl Clone for StyleEngineContainer { + fn clone(&self) -> Self { + StyleEngineContainer(self.0.clone()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Classes { + pub classes: smallvec::SmallVec<[&'static str; 1]>, +} + +impl Classes { + pub fn with_if(mut self, class: &'static str, condition: bool) -> Self { + if condition { + self.classes.push(class); + } + self + } + + pub fn add_if(&mut self, class: &'static str, condition: bool) { + if condition { + self.classes.push(class); + } + } + + pub fn has(&self, class: &str) -> bool { + self.classes.contains(&class) + } +} + +pub trait HasClasses { + fn classes(&self) -> &Classes; + + fn classes_mut(&mut self) -> &mut Classes; + + fn add_class(&mut self, class: &'static str) -> &Self { + self.classes_mut().add_if(class, true); + self + } + + fn with_class(mut self, class: &'static str) -> Self + where + Self: Sized, + { + self.classes_mut().add_if(class, true); + self + } + + fn with_class_if(mut self, class: &'static str, condition: bool) -> Self + where + Self: Sized, + { + self.classes_mut().add_if(class, condition); + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct WidgetStyle { + /// Background color, stroke, margin, and shadow. + pub frame: Frame, + + /// What font to use and at what size. + pub text: TextFormat, + + /// Color and width of e.g. checkbox checkmark. + /// Also text color. + /// + /// Note that this is different from the frame border color. + pub stroke: Stroke, + + pub transform: TSTransform, +} + +impl From for WidgetVisuals { + fn from(value: WidgetStyle) -> Self { + Self { + bg_stroke: value.frame.stroke, + bg_fill: value.frame.fill, + fg_stroke: value.stroke, + corner_radius: value.frame.corner_radius, + weak_bg_fill: value.frame.fill, + expansion: value.frame.inner_margin.sum().length(), + } + } +} diff --git a/crates/egui/src/util/id_type_map.rs b/crates/egui/src/util/id_type_map.rs index bd4d14efd..157796c1e 100644 --- a/crates/egui/src/util/id_type_map.rs +++ b/crates/egui/src/util/id_type_map.rs @@ -192,6 +192,13 @@ impl Element { } } + pub(crate) fn into_temp(self) -> Option { + match self { + Self::Value { value, .. } => value.downcast().ok().map(|b| *b), + Self::Serialized(_) => None, + } + } + #[inline] pub(crate) fn get_temp_mut_or_insert_with( &mut self, @@ -485,10 +492,10 @@ impl IdTypeMap { /// Remove and fetch the state of this type and id. #[inline] - pub fn remove_temp(&mut self, id: Id) -> Option { + pub fn remove_temp(&mut self, id: Id) -> Option { let hash = hash(TypeId::of::(), id); let mut element = self.map.remove(&hash)?; - Some(std::mem::take(element.get_mut_temp()?)) + element.into_temp() } /// Note all state of the given type. diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index d836c0701..e0462d6ad 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,8 +1,12 @@ +use crate::style::WidgetVisuals; +use crate::style_trait::{Classes, HasClasses, WidgetName, WidgetStyle}; use crate::{ Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, CornerRadius, Frame, - Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextWrapMode, Ui, Vec2, Widget, - WidgetInfo, WidgetText, WidgetType, + Image, IntoAtoms, NumExt as _, Response, RichText, Sense, SizedAtomKind, Stroke, TextWrapMode, + Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType, }; +use std::mem; +use std::sync::Arc; /// Clickable button with text. /// @@ -35,6 +39,17 @@ pub struct Button<'a> { selected: bool, image_tint_follows_text_color: bool, limit_image_size: bool, + classes: Classes, +} + +impl HasClasses for Button<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } } impl<'a> Button<'a> { @@ -51,6 +66,7 @@ impl<'a> Button<'a> { selected: false, image_tint_follows_text_color: false, limit_image_size: false, + classes: Classes::default(), } } @@ -261,6 +277,7 @@ impl<'a> Button<'a> { selected, image_tint_follows_text_color, limit_image_size, + classes, } = self; if !small { @@ -290,44 +307,57 @@ impl<'a> Button<'a> { button_padding.y = 0.0; } - let mut prepared = layout - .frame(Frame::new().inner_margin(button_padding)) - .min_size(min_size) - .allocate(ui); + let response = ui.ctx().read_response(ui.next_auto_id()); + + let style: WidgetStyle = response + .map(|r| ui.widget_style(WidgetName::Button, &r, &classes)) + .unwrap_or_default(); + + layout.map_texts(|t| match t { + WidgetText::RichText(mut text) => { + let mut text_mut = Arc::make_mut(&mut text); + *text_mut = mem::take(text_mut).font(style.text.font_id.clone()); + WidgetText::RichText(text) + } + WidgetText::Text(text) => { + let mut rich_text = RichText::new(text.clone()).font(style.text.font_id.clone()); + WidgetText::RichText(Arc::new(rich_text)) + } + w => w, + }); + + let mut prepared = layout.frame(style.frame).min_size(min_size).allocate(ui); let response = if ui.is_rect_visible(prepared.response.rect) { - let visuals = ui.style().interact_selectable(&prepared.response, selected); + // let visuals = ui.style().interact_selectable(&prepared.response, selected); + let visuals: WidgetStyle = ui.widget_style( + WidgetName::Button, + &prepared.response, + &classes.with_if("selected", selected), + ); + ui.with_visual_transform(visuals.transform, |ui| { + let visible_frame = if frame_when_inactive { + has_frame_margin + } else { + has_frame_margin + && (prepared.response.hovered() + || prepared.response.is_pointer_button_down_on() + || prepared.response.has_focus()) + }; - let visible_frame = if frame_when_inactive { - has_frame_margin - } else { - has_frame_margin - && (prepared.response.hovered() - || prepared.response.is_pointer_button_down_on() - || prepared.response.has_focus()) - }; + if image_tint_follows_text_color { + prepared.map_images(|image| image.tint(visuals.text.color)); + } - if image_tint_follows_text_color { - prepared.map_images(|image| image.tint(visuals.text_color())); - } + prepared.fallback_text_color = visuals.text.color; - prepared.fallback_text_color = visuals.text_color(); + if visible_frame { + prepared.frame = visuals.frame; + }; - if visible_frame { - let stroke = stroke.unwrap_or(visuals.bg_stroke); - let fill = fill.unwrap_or(visuals.weak_bg_fill); - prepared.frame = prepared - .frame - .inner_margin( - button_padding + Vec2::splat(visuals.expansion) - Vec2::splat(stroke.width), - ) - .outer_margin(-Vec2::splat(visuals.expansion)) - .fill(fill) - .stroke(stroke) - .corner_radius(corner_radius.unwrap_or(visuals.corner_radius)); - }; - - prepared.paint(ui) + prepared.paint(ui) + }) + .inner } else { AtomLayoutResponse::empty(prepared.response) }; diff --git a/examples/hello_world_simple/src/main.rs b/examples/hello_world_simple/src/main.rs index 4fe49a89d..bb55bab28 100644 --- a/examples/hello_world_simple/src/main.rs +++ b/examples/hello_world_simple/src/main.rs @@ -2,6 +2,15 @@ #![allow(rustdoc::missing_crate_level_docs)] // it's an example use eframe::egui; +use eframe::egui::style::WidgetVisuals; +use eframe::egui::style_trait::{ + Classes, DefaultWidgetStyle, HasClasses, StyleEngine, WidgetContext, WidgetName, WidgetStyle, +}; +use eframe::egui::{ + Button, Color32, FontFamily, FontId, Frame, Margin, Response, Stroke, TextFormat, +}; +use eframe::emath::TSTransform; +use std::fmt::Display; fn main() -> eframe::Result { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). @@ -16,6 +25,10 @@ fn main() -> eframe::Result { let mut age = 42; eframe::run_simple_native("My egui App", options, move |ctx, _frame| { + ctx.set_style_engine(MyCustomWidgetStyle { + default: DefaultWidgetStyle, + }); + egui::CentralPanel::default().show(ctx, |ui| { ui.heading("My egui Application"); ui.horizontal(|ui| { @@ -28,6 +41,227 @@ fn main() -> eframe::Result { age += 1; } ui.label(format!("Hello '{name}', age {age}")); + + ui.horizontal(|ui| { + ui.add(Button::new("Primary Button").primary()); + ui.add(Button::new("Secondary Button").secondary()); + ui.add(Button::new("Normal Button")); + }); + + ui.horizontal(|ui| { + ui.add(Button::new("Large Primary").primary().lg()); + ui.add(Button::new("Large Secondary").secondary().lg()); + ui.add(Button::new("Small Normal").sm()); + }); }); }) } + +#[derive(Clone)] +struct MyCustomWidgetStyle { + // Can fallback to DefaultWidgetStyle + default: DefaultWidgetStyle, +} + +enum Variant { + Primary, + Secondary, + Normal, +} + +impl Display for Variant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Variant::Primary => write!(f, "primary"), + Variant::Secondary => write!(f, "secondary"), + Variant::Normal => write!(f, "normal"), + } + } +} + +impl Variant { + fn from_classes(classes: &Classes) -> Self { + if classes.has("primary") { + Variant::Primary + } else if classes.has("secondary") { + Variant::Secondary + } else { + Variant::Normal + } + } + + fn color(&self) -> Color32 { + match self { + Variant::Primary => Color32::LIGHT_RED, + Variant::Secondary => Color32::LIGHT_BLUE, + Variant::Normal => Color32::LIGHT_GRAY, + } + } + + fn contrast_color(&self) -> Color32 { + match self { + Variant::Primary => Color32::WHITE, + Variant::Secondary => Color32::BLACK, + Variant::Normal => Color32::BLACK, + } + } +} + +enum Size { + Sm, + Md, + Lg, +} + +impl Display for Size { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Size::Sm => write!(f, "sm"), + Size::Md => write!(f, "md"), + Size::Lg => write!(f, "lg"), + } + } +} + +impl Size { + fn from_classes(classes: &Classes) -> Self { + if classes.has("sm") { + Size::Sm + } else if classes.has("md") { + Size::Md + } else if classes.has("lg") { + Size::Lg + } else { + Size::Md // Default size + } + } + + fn inner_margin(&self) -> Margin { + match self { + Size::Sm => Margin::symmetric(2, 2), + Size::Md => Margin::symmetric(4, 4), + Size::Lg => Margin::symmetric(6, 6), + } + } + + fn font_size(&self) -> f32 { + match self { + Size::Sm => 12.0, + Size::Md => 14.0, + Size::Lg => 16.0, + } + } + + fn frame_rounding(&self) -> f32 { + match self { + Size::Sm => 2.0, + Size::Md => 4.0, + Size::Lg => 6.0, + } + } +} + +impl StyleEngine for MyCustomWidgetStyle { + fn get(&self, ctx: &WidgetContext) -> WidgetStyle { + // let mut visuals = self.default.get(ctx); + + let variant = Variant::from_classes(&ctx.classes); + + let size = Size::from_classes(&ctx.classes); + + let mut style = WidgetStyle { + frame: Frame::new() + .fill(variant.color()) + .inner_margin(size.inner_margin()) + .corner_radius(size.frame_rounding()), + text: TextFormat::simple( + FontId::new(size.font_size(), FontFamily::Proportional), + variant.contrast_color(), + ), + stroke: Stroke::default(), + transform: TSTransform::default(), + }; + + let state = if ctx.response.is_pointer_button_down_on() { + -1.0 + } else if ctx.response.hovered() { + 1.0 + } else { + 0.0 + }; + + let state_animated = + ctx.ui + .ctx() + .animate_value_with_time(ctx.response.id.with("style_anim"), state, 0.05); + + let lerp_to_darken = Color32::BLACK; + let lerp_to_lighten = Color32::WHITE; + + if state_animated < 0.0 { + style.frame.fill = variant + .color() + .lerp_to_gamma(lerp_to_darken, 0.03 * -state_animated); + } else if state_animated > 0.0 { + style.frame.fill = variant + .color() + .lerp_to_gamma(lerp_to_lighten, 0.1 * state_animated); + } + + match ctx.name { + WidgetName::Button => { + let scale = 1.0 + state_animated * 0.02; + if scale != 1.0 { + // style.frame.inner_margin += 4; + let center = ctx.response.rect.center().to_vec2(); + style.transform = TSTransform::from_translation(center) + * TSTransform::from_scaling(scale) + * TSTransform::from_translation(-center); + } + } + WidgetName::Checkbox => {} + WidgetName::Slider => {} + WidgetName::TextInput => {} + WidgetName::Custom(_) => {} + } + + style + } +} + +// trait ClassExt { +// fn primary(self) -> Self; +// } +// +// impl ClassExt for T where T: HasClasses { +// fn primary(self) -> Self { +// self.with_class("primary") +// } +// } + +macro_rules! classes { + ($trait_name:ident: ($($name:ident, )+)) => { + // pub enum $trait_name { + // $( + // $name, + // )* + // } + + pub trait $trait_name { + $( + fn $name(self) -> Self; + )* + } + + impl $trait_name for T + where + T: HasClasses, + { + $(fn $name(mut self) -> Self { + self.with_class(stringify!($name)) + })? + } + }; +} + +classes!(CustomStyle: (primary, secondary, normal, sm, md, lg, ));