Skip to main content

egui_charts/ui_kit/buttons/
variant.rs

1//! ButtonVariant - Button emphasis levels
2//!
3//! Provides semantic button variants that communicate different levels
4//! of importance and action types.
5
6use egui::{Color32, Ui};
7
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10
11/// Button variant for different emphasis levels
12///
13/// Semantic variants communicate importance and action type visually.
14#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
15pub enum ButtonVariant {
16    /// High emphasis - inverse colors (Save, Submit, Confirm)
17    ///
18    /// Use for primary actions that are the main call-to-action on a screen.
19    Primary,
20
21    /// Medium emphasis - subtle background (Cancel, Reset)
22    ///
23    /// Use for secondary actions that support the primary action.
24    Secondary,
25
26    /// Low emphasis - transparent background (default toolbar buttons)
27    ///
28    /// Use for tertiary actions or when you need buttons to blend in.
29    #[default]
30    Ghost,
31
32    /// Border stroke - alternative to Secondary
33    ///
34    /// Use when you need a button that's more prominent than Ghost
35    /// but less prominent than Secondary.
36    Outlined,
37
38    /// Destructive action - red colors (Delete, Remove)
39    ///
40    /// Use for actions that are destructive or irreversible.
41    Danger,
42}
43
44impl ButtonVariant {
45    /// Get the background color for this variant in the given state.
46    ///
47    /// Primary and Danger use the theme's opaque state shades. Ghost and Outlined
48    /// share egui's hover and pressed fills; Secondary retains its filled state ladder.
49    /// In a disabled `ui`, Primary and Danger paint as Secondary so fading keeps
50    /// their text readable.
51    pub fn bg_color(&self, ui: &Ui, hovered: bool, pressed: bool) -> Color32 {
52        let state = |rest, hover, press| {
53            if pressed {
54                press
55            } else if hovered {
56                hover
57            } else {
58                rest
59            }
60        };
61        match self {
62            Self::Primary if ui.is_enabled() => {
63                let colors = ui.active_theme().ui;
64                return state(colors.accent, colors.accent_hover, colors.accent_active);
65            }
66            Self::Danger if ui.is_enabled() => {
67                let colors = ui.active_theme().ui;
68                return state(
69                    colors.danger_bg,
70                    colors.danger_bg_hover,
71                    colors.danger_bg_active,
72                );
73            }
74            Self::Ghost | Self::Outlined => {
75                return state(
76                    Color32::TRANSPARENT,
77                    ui.visuals().widgets.hovered.bg_fill,
78                    ui.visuals().widgets.active.bg_fill,
79                );
80            }
81            Self::Secondary | Self::Primary | Self::Danger => {}
82        }
83        let steps = 1 + u8::from(hovered || pressed) + u8::from(pressed);
84        Color32::TRANSPARENT.lerp_to_gamma(ui.visuals().strong_text_color(), 0.1 * f32::from(steps))
85    }
86
87    /// Get the foreground (text/icon) color for this variant
88    pub fn fg_color(&self, ui: &Ui) -> Color32 {
89        match self {
90            Self::Primary if ui.is_enabled() => ui.active_theme().ui.accent_text,
91            Self::Danger if ui.is_enabled() => ui.active_theme().ui.danger_text,
92            _ => ui.visuals().widgets.inactive.fg_stroke.color,
93        }
94    }
95
96    /// Get the border color for this variant: Outlined, and a disabled Primary, which keeps an
97    /// accent outline so a disabled selected button still reads as selected
98    pub fn border_color(&self, ui: &Ui) -> Option<Color32> {
99        match self {
100            Self::Outlined => Some(ui.visuals().widgets.inactive.fg_stroke.color),
101            Self::Primary if !ui.is_enabled() => Some(ui.active_theme().ui.accent),
102            _ => None,
103        }
104    }
105
106    /// Apply this variant's styling to `ui`'s widget visuals
107    ///
108    /// Call it inside `ui.scope` so the style is restored after rendering the button.
109    pub fn apply_to_style(&self, ui: &mut Ui, hovered: bool, active: bool) {
110        let bg = self.bg_color(ui, hovered, active);
111        let fg = if self.is_inverse() {
112            self.fg_color(ui)
113        } else if active {
114            ui.visuals().widgets.active.fg_stroke.color
115        } else if hovered {
116            ui.visuals().widgets.hovered.fg_stroke.color
117        } else {
118            self.fg_color(ui)
119        };
120        let border = self.border_color(ui);
121
122        let widgets = &mut ui.visuals_mut().widgets;
123        for w in [
124            &mut widgets.inactive,
125            &mut widgets.hovered,
126            &mut widgets.active,
127        ] {
128            w.bg_fill = bg;
129            w.weak_bg_fill = bg;
130            w.fg_stroke.color = fg;
131            if let Some(border) = border {
132                w.bg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, border);
133            }
134        }
135    }
136
137    /// Check if this variant uses inverse colors (light text on dark bg)
138    pub fn is_inverse(&self) -> bool {
139        matches!(self, Self::Primary | Self::Danger)
140    }
141
142    /// Check if this variant has a visible background
143    pub fn has_background(&self) -> bool {
144        !matches!(self, Self::Ghost | Self::Outlined)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::theme::{Theme, apply_to_egui, contrast, presets};
152
153    #[test]
154    fn neutral_buttons_share_widget_fills_and_resting_foreground() {
155        for (_, theme) in presets() {
156            let ctx = egui::Context::default();
157            apply_to_egui(&ctx, &theme);
158            ctx.run_ui(Default::default(), |ui| {
159                // A host may distinguish resting controls from strong body text.
160                ui.visuals_mut().widgets.inactive.fg_stroke.color =
161                    Color32::from_rgb(150, 160, 170);
162                for variant in [ButtonVariant::Ghost, ButtonVariant::Outlined] {
163                    assert_eq!(
164                        variant.fg_color(ui),
165                        ui.visuals().widgets.inactive.fg_stroke.color
166                    );
167                    assert_eq!(variant.bg_color(ui, false, false), Color32::TRANSPARENT);
168                    for (hovered, pressed, expected) in [
169                        (true, false, ui.visuals().widgets.hovered.bg_fill),
170                        (false, true, ui.visuals().widgets.active.bg_fill),
171                        (true, true, ui.visuals().widgets.active.bg_fill),
172                    ] {
173                        assert_eq!(variant.bg_color(ui, hovered, pressed), expected);
174                    }
175                }
176            })
177            .drop_without_applying_deltas();
178        }
179    }
180
181    #[test]
182    fn primary_and_danger_text_reads_on_the_fill_in_every_state_and_preset() {
183        let mut failures = Vec::new();
184        // The presets all have `danger_text == accent_text`; an orange accent makes them differ.
185        let orange = Theme::light().with_accent(Color32::from_rgb(255, 120, 0));
186        for (name, theme) in presets()
187            .into_iter()
188            .chain([("light, orange accent", orange)])
189        {
190            let ctx = egui::Context::default();
191            apply_to_egui(&ctx, &theme);
192            ctx.run_ui(Default::default(), |ui| {
193                egui::CentralPanel::default().show(ui, |ui| {
194                    // Disabled buttons are exempt from 4.5:1 but must stay as legible as other
195                    // disabled buttons; egui fades everything a disabled `Ui` paints.
196                    for (enabled, min) in [(true, 4.5), (false, 3.0)] {
197                        ui.add_enabled_ui(enabled, |ui| {
198                            let fade = ui.painter().opacity();
199                            let surface = ui.visuals().panel_fill;
200                            for variant in [ButtonVariant::Primary, ButtonVariant::Danger] {
201                                let text = variant.fg_color(ui).gamma_multiply(fade);
202                                for (state, hovered, pressed) in [
203                                    ("rest", false, false),
204                                    ("hovered", true, false),
205                                    ("pressed", true, true),
206                                ] {
207                                    let fill = variant.bg_color(ui, hovered, pressed);
208                                    let ratio =
209                                        contrast(text, surface.blend(fill.gamma_multiply(fade)));
210                                    if ratio < min {
211                                        failures.push(format!(
212                                            "{name}: {variant:?} {state} (enabled: {enabled}) is \
213                                             {ratio:.2}:1, below {min}:1"
214                                        ));
215                                    }
216                                }
217                            }
218                        });
219                    }
220                });
221            })
222            .drop_without_applying_deltas();
223        }
224        assert!(failures.is_empty(), "{}", failures.join("\n"));
225    }
226}