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