Skip to main content

egui_charts/ui_kit/buttons/
button.rs

1//! Button - Variant-aware button widget
2//!
3//! A button that supports semantic variants (Primary, Secondary, Ghost, etc.)
4//! providing clear visual hierarchy for different action types.
5
6use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use egui::{Color32, Rect, Response, Sense, TextStyle, Ui, Vec2, Widget, pos2};
9
10use super::{ButtonSize, ButtonVariant, finish};
11use crate::icons::Icon;
12use crate::tokens::DESIGN_TOKENS;
13
14/// A button widget with support for semantic variants
15///
16/// Button provides distinct visual variants that communicate different
17/// levels of importance and action types.
18///
19/// # Example
20///
21/// ```
22/// use egui_charts::icons::icons;
23/// use egui_charts::ui_kit::buttons::Button;
24///
25/// # egui::__run_test_ui(|ui| {
26/// // Primary button (high emphasis)
27/// if Button::new("Save Changes").primary().show(ui).clicked() {
28///     // save
29/// }
30///
31/// // Ghost button with icon (low emphasis)
32/// Button::icon_and_text(&icons::SETTINGS, "Settings")
33///     .ghost()
34///     .show(ui);
35///
36/// // Danger button (destructive action)
37/// Button::new("Delete")
38///     .danger()
39///     .show(ui);
40/// # });
41/// ```
42pub struct Button<'a> {
43    text: Option<String>,
44    icon: Option<&'a Icon>,
45    variant: ButtonVariant,
46    size: ButtonSize,
47    tooltip: Option<String>,
48    enabled: bool,
49    selected: Option<bool>,
50    icon_scale: Option<f32>,
51    text_color: Option<Color32>,
52}
53
54impl<'a> Button<'a> {
55    /// Create a new button with text
56    pub fn new(text: impl Into<String>) -> Self {
57        Self {
58            text: Some(text.into()),
59            icon: None,
60            variant: ButtonVariant::Ghost,
61            size: ButtonSize::MD,
62            tooltip: None,
63            enabled: true,
64            selected: None,
65            icon_scale: None,
66            text_color: None,
67        }
68    }
69
70    /// Create a new button with only an icon
71    pub fn icon(icon: &'a Icon) -> Self {
72        Self {
73            text: None,
74            icon: Some(icon),
75            variant: ButtonVariant::Ghost,
76            size: ButtonSize::MD,
77            tooltip: None,
78            enabled: true,
79            selected: None,
80            icon_scale: None,
81            text_color: None,
82        }
83    }
84
85    /// Create a new button with both icon and text
86    pub fn icon_and_text(icon: &'a Icon, text: impl Into<String>) -> Self {
87        Self {
88            text: Some(text.into()),
89            icon: Some(icon),
90            variant: ButtonVariant::Ghost,
91            size: ButtonSize::MD,
92            tooltip: None,
93            enabled: true,
94            selected: None,
95            icon_scale: None,
96            text_color: None,
97        }
98    }
99
100    /// Set the button variant
101    #[must_use]
102    pub fn variant(mut self, variant: ButtonVariant) -> Self {
103        self.variant = variant;
104        self
105    }
106
107    /// Make this a primary button (high emphasis)
108    #[must_use]
109    pub fn primary(mut self) -> Self {
110        self.variant = ButtonVariant::Primary;
111        self
112    }
113
114    /// Make this a secondary button (medium emphasis)
115    #[must_use]
116    pub fn secondary(mut self) -> Self {
117        self.variant = ButtonVariant::Secondary;
118        self
119    }
120
121    /// Make this a ghost button (low emphasis, transparent)
122    #[must_use]
123    pub fn ghost(mut self) -> Self {
124        self.variant = ButtonVariant::Ghost;
125        self
126    }
127
128    /// Make this an outlined button (border stroke)
129    #[must_use]
130    pub fn outlined(mut self) -> Self {
131        self.variant = ButtonVariant::Outlined;
132        self
133    }
134
135    /// Make this a danger button (destructive action)
136    #[must_use]
137    pub fn danger(mut self) -> Self {
138        self.variant = ButtonVariant::Danger;
139        self
140    }
141
142    /// Override only the label colour, retaining the variant's fill and icon colour.
143    #[must_use]
144    pub fn text_color(mut self, color: Color32) -> Self {
145        self.text_color = Some(color);
146        self
147    }
148
149    /// Set the button size
150    #[must_use]
151    pub fn size(mut self, size: ButtonSize) -> Self {
152        self.size = size;
153        self
154    }
155
156    /// Make this a small button
157    #[must_use]
158    pub fn small(mut self) -> Self {
159        self.size = ButtonSize::SM;
160        self
161    }
162
163    /// Make this a large button
164    #[must_use]
165    pub fn large(mut self) -> Self {
166        self.size = ButtonSize::LG;
167        self
168    }
169
170    /// Set the tooltip text
171    #[must_use]
172    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
173        self.tooltip = Some(text.into());
174        self
175    }
176
177    /// Set whether the button is enabled
178    #[must_use]
179    pub fn enabled(mut self, enabled: bool) -> Self {
180        self.enabled = enabled;
181        self
182    }
183
184    /// Set whether the button appears selected/active
185    #[must_use]
186    pub fn selected(mut self, selected: bool) -> Self {
187        self.selected = Some(selected);
188        self
189    }
190
191    /// Set the icon size as a fraction of the button height (0.1-1.0).
192    ///
193    /// By default the icon is 16, 20 or 24 px for small, medium and larger buttons.
194    #[must_use]
195    pub fn icon_scale(mut self, scale: f32) -> Self {
196        self.icon_scale = Some(scale.clamp(0.1, 1.0));
197        self
198    }
199
200    /// Show the button and return the response
201    pub fn show(self, ui: &mut Ui) -> Response {
202        ui.add(self)
203    }
204}
205
206impl<'a> Widget for Button<'a> {
207    fn ui(self, ui: &mut Ui) -> Response {
208        if !self.enabled {
209            // `enabled(false)` looks exactly like a button inside `add_enabled_ui(false)`.
210            return ui.add_enabled(
211                false,
212                Self {
213                    enabled: true,
214                    ..self
215                },
216            );
217        }
218
219        let theme = ui.active_theme();
220        let height = self.size.pixels();
221        let icon_size = self.size.icon_pixels(self.icon_scale);
222        let galley = self.text.as_ref().map(|text| {
223            let font = TextStyle::Button.resolve(ui.style());
224            ui.painter().layout_no_wrap(
225                l10n::text(ui.ctx(), text.clone()),
226                font,
227                Color32::PLACEHOLDER,
228            )
229        });
230
231        // Content is laid out text, so the padding is the same for every label.
232        let (padding, gap) = match (self.icon.is_some(), galley.is_some()) {
233            (true, true) => (theme.spacing.xl, theme.spacing.sm),
234            (false, true) => (theme.spacing.xl, 0.0),
235            _ => (0.0, 0.0),
236        };
237        let content_width = self.icon.map_or(0.0, |_| icon_size)
238            + gap
239            + galley.as_ref().map_or(0.0, |galley| galley.size().x);
240        let width = (content_width + 2.0 * padding).max(height);
241
242        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click());
243        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.sm);
244
245        if ui.is_rect_visible(rect) {
246            let hovered = response.hovered();
247            let pressed = response.is_pointer_button_down_on();
248            let variant = if self.selected == Some(true) {
249                ButtonVariant::Primary
250            } else {
251                self.variant
252            };
253            let fg = if variant.is_inverse() {
254                variant.fg_color(ui)
255            } else {
256                ui.style().interact(&response).fg_stroke.color
257            };
258            let rounding = theme.radius.sm;
259
260            ui.painter()
261                .rect_filled(rect, rounding, variant.bg_color(ui, hovered, pressed));
262            if let Some(border) = variant.border_color(ui) {
263                ui.painter().rect_stroke(
264                    rect,
265                    rounding,
266                    egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, border),
267                    egui::epaint::StrokeKind::Inside,
268                );
269            }
270
271            // Centred, so a label narrower than a square button still sits in the middle.
272            let mut x = rect.center().x - content_width / 2.0;
273            if let Some(icon) = self.icon {
274                let icon_rect = Rect::from_min_size(
275                    pos2(x, rect.center().y - icon_size / 2.0),
276                    Vec2::splat(icon_size),
277                );
278                icon.as_image_tinted(Vec2::splat(icon_size), fg)
279                    .paint_at(ui, icon_rect);
280                x += icon_size + gap;
281            }
282            if let Some(galley) = galley {
283                let pos = pos2(x, rect.center().y - galley.size().y / 2.0);
284                ui.painter()
285                    .galley(pos, galley, self.text_color.unwrap_or(fg));
286            }
287        }
288
289        let label = self.text.as_deref().or(self.tooltip.as_deref());
290        finish(
291            response,
292            self.selected,
293            label.unwrap_or_default(),
294            self.tooltip.as_deref(),
295        )
296    }
297}
298
299/// Extension trait for quick button creation from strings
300pub trait ButtonExt {
301    /// Create a primary Button
302    fn primary_button(self) -> Button<'static>;
303
304    /// Create a secondary Button
305    fn secondary_button(self) -> Button<'static>;
306
307    /// Create an outlined Button
308    fn outlined_button(self) -> Button<'static>;
309
310    /// Create a danger Button
311    fn danger_button(self) -> Button<'static>;
312}
313
314impl ButtonExt for &str {
315    fn primary_button(self) -> Button<'static> {
316        Button::new(self).primary()
317    }
318
319    fn secondary_button(self) -> Button<'static> {
320        Button::new(self).secondary()
321    }
322
323    fn outlined_button(self) -> Button<'static> {
324        Button::new(self).outlined()
325    }
326
327    fn danger_button(self) -> Button<'static> {
328        Button::new(self).danger()
329    }
330}
331
332impl ButtonExt for String {
333    fn primary_button(self) -> Button<'static> {
334        Button::new(self).primary()
335    }
336
337    fn secondary_button(self) -> Button<'static> {
338        Button::new(self).secondary()
339    }
340
341    fn outlined_button(self) -> Button<'static> {
342        Button::new(self).outlined()
343    }
344
345    fn danger_button(self) -> Button<'static> {
346        Button::new(self).danger()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn width_is_the_laid_out_label_plus_padding() {
356        // Multi-byte characters: the width must follow the rendered text, not the byte count.
357        let label = "Größe ändern";
358        let ctx = egui::Context::default();
359        ctx.run_ui(Default::default(), |ui| {
360            egui::CentralPanel::default().show(ui, |ui| {
361                let font = egui::TextStyle::Button.resolve(ui.style());
362                let text_width = ui
363                    .painter()
364                    .layout_no_wrap(label.to_owned(), font, egui::Color32::PLACEHOLDER)
365                    .size()
366                    .x;
367                let width = Button::new(label).show(ui).rect.width();
368                assert_eq!(width, text_width + 2.0 * ctx.active_theme().spacing.xl);
369            });
370        })
371        .drop_without_applying_deltas();
372    }
373
374    #[test]
375    fn focus_ring_follows_the_button_corners_and_stays_within_two_pixels() {
376        let mut custom = crate::theme::Theme::light();
377        custom.radius.sm = 9.0;
378        for (_, theme) in crate::theme::presets()
379            .into_iter()
380            .chain([("custom", custom)])
381        {
382            let ctx = egui::Context::default();
383            crate::theme::apply_to_egui(&ctx, &theme);
384            let mut id = egui::Id::NULL;
385            ctx.run_ui(Default::default(), |ui| {
386                id = Button::new("Focus").show(ui).id;
387            })
388            .drop_without_applying_deltas();
389            ctx.memory_mut(|m| m.request_focus(id));
390            let mut rect = egui::Rect::NOTHING;
391            let output = ctx.run_ui(Default::default(), |ui| {
392                rect = Button::new("Focus").show(ui).rect;
393            });
394            let rings: Vec<_> = output
395                .shapes
396                .iter()
397                .filter_map(|s| match &s.shape {
398                    egui::Shape::Rect(r) if r.stroke.width == DESIGN_TOKENS.stroke.thick => {
399                        Some(r.clone())
400                    }
401                    _ => None,
402                })
403                .collect();
404            output.drop_without_applying_deltas();
405            assert_eq!(rings.len(), 1);
406            assert_eq!(rings[0].rect, rect.expand(2.0));
407            assert_eq!(rings[0].stroke_kind, egui::epaint::StrokeKind::Inside);
408            assert_eq!(
409                rings[0].corner_radius,
410                egui::CornerRadius::from(ctx.active_theme().radius.sm + 2.0)
411            );
412        }
413    }
414}