Skip to main content

elegance/
button.rs

1//! Buttons in the elegance style.
2//!
3//! A [`Button`] is a chunky, rounded rectangle with a coloured fill, bold
4//! text, and smooth hover/press transitions. Six accent colours are
5//! available: Blue, Green, Red, Purple, Amber, and Sky. For secondary
6//! actions, [`Button::outline`] gives a transparent, bordered treatment.
7
8use egui::{
9    vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetInfo, WidgetText,
10    WidgetType,
11};
12
13use crate::theme::{mix, Accent, Theme};
14
15/// Size presets for buttons.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum ButtonSize {
18    /// Compact — tight padding, the small typography size.
19    Small,
20    /// The default button size.
21    Medium,
22    /// Chunky — extra padding and a slightly larger font.
23    Large,
24}
25
26impl ButtonSize {
27    /// Resolve the `(pad_x, pad_y)` padding for a given size against the
28    /// active theme. Used by [`Button`] and [`SegmentedButton`](crate::SegmentedButton)
29    /// so both widgets produce identical control heights at a given size.
30    pub fn padding(self, theme: &Theme) -> Vec2 {
31        match self {
32            ButtonSize::Small => vec2(theme.control_padding_x * 0.6, theme.control_padding_y * 0.6),
33            ButtonSize::Medium => vec2(theme.control_padding_x, theme.control_padding_y),
34            ButtonSize::Large => vec2(
35                theme.control_padding_x * 1.25,
36                theme.control_padding_y * 1.2,
37            ),
38        }
39    }
40
41    /// Resolve the label font size for a given size against the active theme.
42    pub fn font_size(self, theme: &Theme) -> f32 {
43        match self {
44            ButtonSize::Small => theme.typography.small,
45            ButtonSize::Medium => theme.typography.button,
46            ButtonSize::Large => theme.typography.body + 1.0,
47        }
48    }
49}
50
51/// A coloured, rounded button.
52///
53/// ```no_run
54/// # use elegance::{Button, Accent};
55/// # egui::__run_test_ui(|ui| {
56/// if ui.add(Button::new("Save").accent(Accent::Green)).clicked() {
57///     // ...
58/// }
59/// # });
60/// ```
61#[must_use = "Call `ui.add(...)` to render the button."]
62pub struct Button {
63    text: WidgetText,
64    accent: Accent,
65    size: ButtonSize,
66    outline: bool,
67    min_width: Option<f32>,
68    full_width: bool,
69    enabled: bool,
70}
71
72impl std::fmt::Debug for Button {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("Button")
75            .field("accent", &self.accent)
76            .field("size", &self.size)
77            .field("outline", &self.outline)
78            .field("min_width", &self.min_width)
79            .field("full_width", &self.full_width)
80            .field("enabled", &self.enabled)
81            .finish()
82    }
83}
84
85impl Button {
86    /// Create a new button. Defaults to the Blue accent and medium size.
87    pub fn new(text: impl Into<WidgetText>) -> Self {
88        Self {
89            text: text.into(),
90            accent: Accent::Blue,
91            size: ButtonSize::Medium,
92            outline: false,
93            min_width: None,
94            full_width: false,
95            enabled: true,
96        }
97    }
98
99    /// Pick the button accent colour. Ignored when the button is set to
100    /// [`Button::outline`], which has no fill colour of its own.
101    #[inline]
102    pub fn accent(mut self, accent: Accent) -> Self {
103        self.accent = accent;
104        self
105    }
106
107    /// Render the button as a transparent, bordered "ghost" treatment for
108    /// secondary actions.
109    #[inline]
110    pub fn outline(mut self) -> Self {
111        self.outline = true;
112        self
113    }
114
115    /// Pick a size preset.
116    #[inline]
117    pub fn size(mut self, size: ButtonSize) -> Self {
118        self.size = size;
119        self
120    }
121
122    /// Set a minimum width (in points). Useful to line up button groups.
123    #[inline]
124    pub fn min_width(mut self, w: f32) -> Self {
125        self.min_width = Some(w);
126        self
127    }
128
129    /// Stretch to fill the available horizontal space.
130    #[inline]
131    pub fn full_width(mut self) -> Self {
132        self.full_width = true;
133        self
134    }
135
136    /// Disable the button.
137    #[inline]
138    pub fn enabled(mut self, enabled: bool) -> Self {
139        self.enabled = enabled;
140        self
141    }
142
143    fn padding(&self, theme: &Theme) -> Vec2 {
144        self.size.padding(theme)
145    }
146
147    fn font_size(&self, theme: &Theme) -> f32 {
148        self.size.font_size(theme)
149    }
150}
151
152impl Widget for Button {
153    fn ui(self, ui: &mut Ui) -> Response {
154        let theme = Theme::current(ui.ctx());
155        let padding = self.padding(&theme);
156        let font_size = self.font_size(&theme);
157
158        let wrap_width = (ui.available_width() - 2.0 * padding.x).max(0.0);
159        let galley =
160            crate::theme::placeholder_galley(ui, self.text.text(), font_size, false, wrap_width);
161
162        let mut desired = galley.size() + 2.0 * padding;
163        desired.y = desired.y.max(font_size + 2.0 * padding.y);
164        if let Some(min_w) = self.min_width {
165            desired.x = desired.x.max(min_w);
166        }
167        if self.full_width {
168            desired.x = ui.available_width().max(desired.x);
169        }
170
171        let sense = if self.enabled {
172            Sense::click()
173        } else {
174            Sense::hover()
175        };
176        let (rect, response) = ui.allocate_exact_size(desired, sense);
177
178        let visible = ui.is_rect_visible(rect);
179        if visible {
180            // Work out fill and text colour for the current state.
181            let (fill, stroke, text_color) =
182                resolve_colors(&theme, self.accent, self.outline, self.enabled, &response);
183
184            let radius = CornerRadius::same(theme.control_radius as u8);
185            ui.painter()
186                .rect(rect, radius, fill, stroke, egui::StrokeKind::Inside);
187
188            let text_pos = rect.center();
189            ui.painter()
190                .galley(galley_top_left(rect, galley.size()), galley, text_color);
191            let _ = text_pos;
192        }
193
194        response.widget_info(|| {
195            WidgetInfo::labeled(WidgetType::Button, self.enabled, self.text.text())
196        });
197        response
198    }
199}
200
201fn galley_top_left(rect: egui::Rect, galley_size: Vec2) -> egui::Pos2 {
202    let center = rect.center();
203    center - galley_size * 0.5
204}
205
206fn resolve_colors(
207    theme: &Theme,
208    accent: Accent,
209    outline: bool,
210    enabled: bool,
211    response: &Response,
212) -> (Color32, Stroke, Color32) {
213    let p = &theme.palette;
214    if !enabled {
215        if outline {
216            return (
217                Color32::TRANSPARENT,
218                Stroke::new(1.0, p.border),
219                mix(p.text_muted, p.card, 0.4),
220            );
221        }
222        return (
223            mix(p.accent_fill(accent), p.card, 0.55),
224            Stroke::NONE,
225            mix(p.text, p.card, 0.4),
226        );
227    }
228    let is_down = response.is_pointer_button_down_on();
229    let is_hovered = response.hovered();
230
231    if outline {
232        let text = if is_hovered { p.text } else { p.text_muted };
233        let stroke_color = if is_hovered { p.text_muted } else { p.border };
234        let fill = if is_down {
235            with_alpha(p.text_muted, 30)
236        } else if is_hovered {
237            with_alpha(p.text_muted, 20)
238        } else {
239            Color32::TRANSPARENT
240        };
241        return (fill, Stroke::new(1.0, stroke_color), text);
242    }
243
244    let resting = p.accent_fill(accent);
245    let hover = p.accent_hover(accent);
246    let fill = if is_down {
247        // Slightly darker than the resting hover colour for a satisfying click.
248        mix(hover, Color32::BLACK, 0.08)
249    } else if is_hovered {
250        hover
251    } else {
252        resting
253    };
254    let stroke = if response.has_focus() {
255        Stroke::new(2.0, with_alpha(p.sky, 180))
256    } else {
257        Stroke::NONE
258    };
259    (fill, stroke, Color32::WHITE)
260}
261
262fn with_alpha(c: Color32, alpha: u8) -> Color32 {
263    crate::theme::with_alpha(c, alpha)
264}