Skip to main content

armas_basic/components/
button.rs

1//! Button component with shadcn/ui styling
2//!
3//! Provides variants following shadcn/ui conventions:
4//! - Default: Primary background, high emphasis
5//! - Secondary: Secondary background, medium emphasis
6//! - Outline: Border with transparent background
7//! - Ghost: No background, hover shows accent
8//! - Link: Text style with underline on hover
9
10use super::content::ContentContext;
11use crate::ext::ArmasContextExt;
12use crate::Theme;
13use egui::{Color32, Response, Sense, Ui, Vec2};
14
15// shadcn Button constants
16const CORNER_RADIUS: f32 = 6.0; // rounded-md
17
18/// Button style variant following shadcn/ui
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum ButtonVariant {
21    /// Default button - primary background, highest emphasis
22    #[default]
23    Default,
24    /// Secondary button - secondary background, medium emphasis
25    Secondary,
26    /// Outline button - border with transparent background
27    Outline,
28    /// Ghost button - no background, hover shows accent
29    Ghost,
30    /// Link button - text style with underline on hover
31    Link,
32}
33
34/// Button size following shadcn/ui
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub enum ButtonSize {
37    /// Extra small - h-5.5 (22px), px-1.5
38    Xs,
39    /// Small - h-8 (32px), px-3
40    Small,
41    /// Default - h-9 (36px), px-4
42    #[default]
43    Default,
44    /// Large - h-10 (40px), px-6
45    Large,
46}
47
48impl ButtonSize {
49    const fn height(self) -> f32 {
50        match self {
51            Self::Xs => 22.0,
52            Self::Small => 32.0,
53            Self::Default => 36.0,
54            Self::Large => 40.0,
55        }
56    }
57
58    const fn padding_x(self) -> f32 {
59        match self {
60            Self::Xs => 6.0,
61            Self::Small => 12.0,   // px-3
62            Self::Default => 16.0, // px-4
63            Self::Large => 24.0,   // px-6
64        }
65    }
66
67    const fn font_size(self, typo: &crate::theme::Typography) -> f32 {
68        match self {
69            Self::Xs => typo.xs,
70            _ => typo.base,
71        }
72    }
73}
74
75/// Get colors for a button variant and state.
76///
77/// Returns `(bg_color, text_color, border_color)`.
78fn get_colors(
79    theme: &Theme,
80    variant: ButtonVariant,
81    enabled: bool,
82    hovered: bool,
83) -> (Color32, Color32, Color32) {
84    if enabled {
85        match variant {
86            ButtonVariant::Default => {
87                let bg = if hovered {
88                    theme.primary().gamma_multiply(0.9)
89                } else {
90                    theme.primary()
91                };
92                (bg, theme.primary_foreground(), Color32::TRANSPARENT)
93            }
94            ButtonVariant::Secondary => {
95                let bg = if hovered {
96                    theme.secondary().gamma_multiply(0.8)
97                } else {
98                    theme.secondary()
99                };
100                (bg, theme.secondary_foreground(), Color32::TRANSPARENT)
101            }
102            ButtonVariant::Outline => {
103                let bg = if hovered {
104                    theme.accent()
105                } else {
106                    Color32::TRANSPARENT
107                };
108                let text = if hovered {
109                    theme.accent_foreground()
110                } else {
111                    theme.foreground()
112                };
113                (bg, text, theme.border())
114            }
115            ButtonVariant::Ghost => {
116                let bg = if hovered {
117                    theme.accent()
118                } else {
119                    Color32::TRANSPARENT
120                };
121                let text = if hovered {
122                    theme.accent_foreground()
123                } else {
124                    theme.foreground()
125                };
126                (bg, text, Color32::TRANSPARENT)
127            }
128            ButtonVariant::Link => (Color32::TRANSPARENT, theme.primary(), Color32::TRANSPARENT),
129        }
130    } else {
131        (
132            theme.primary().gamma_multiply(0.5),
133            theme.primary_foreground().gamma_multiply(0.5),
134            Color32::TRANSPARENT,
135        )
136    }
137}
138
139/// Draw button background and border. Returns whether the button is hovered.
140fn draw_button_frame(
141    ui: &mut Ui,
142    rect: egui::Rect,
143    theme: &Theme,
144    variant: ButtonVariant,
145    enabled: bool,
146    hovered: bool,
147) -> (Color32, Color32) {
148    let (bg_color, text_color, border_color) = get_colors(theme, variant, enabled, hovered);
149
150    if bg_color != Color32::TRANSPARENT {
151        ui.painter().rect_filled(rect, CORNER_RADIUS, bg_color);
152    }
153    if border_color != Color32::TRANSPARENT {
154        ui.painter().rect_stroke(
155            rect,
156            CORNER_RADIUS,
157            egui::Stroke::new(1.0, border_color),
158            egui::StrokeKind::Inside,
159        );
160    }
161
162    (bg_color, text_color)
163}
164
165/// Button component styled like shadcn/ui
166///
167/// # Example
168///
169/// ```rust,no_run
170/// # use egui::Ui;
171/// # fn example(ui: &mut Ui) {
172/// use armas_basic::components::{Button, ButtonVariant, ButtonSize};
173///
174/// if Button::new("Save")
175///     .variant(ButtonVariant::Default)
176///     .size(ButtonSize::Default)
177///     .show(ui)
178///     .clicked()
179/// {
180///     // handle click
181/// }
182/// # }
183/// ```
184pub struct Button {
185    text: String,
186    variant: ButtonVariant,
187    size: ButtonSize,
188    enabled: bool,
189    full_width: bool,
190    min_width: Option<f32>,
191    custom_height: Option<f32>,
192    content_width: Option<f32>,
193}
194
195impl Button {
196    /// Create a new button with text
197    pub fn new(text: impl Into<String>) -> Self {
198        Self {
199            text: text.into(),
200            variant: ButtonVariant::Default,
201            size: ButtonSize::Default,
202            enabled: true,
203            full_width: false,
204            min_width: None,
205            custom_height: None,
206            content_width: None,
207        }
208    }
209
210    /// Set the button variant
211    #[must_use]
212    pub const fn variant(mut self, variant: ButtonVariant) -> Self {
213        self.variant = variant;
214        self
215    }
216
217    /// Set the button size
218    #[must_use]
219    pub const fn size(mut self, size: ButtonSize) -> Self {
220        self.size = size;
221        self
222    }
223
224    /// Set enabled state
225    #[must_use]
226    pub const fn enabled(mut self, enabled: bool) -> Self {
227        self.enabled = enabled;
228        self
229    }
230
231    /// Make button take full width of container
232    #[must_use]
233    pub const fn full_width(mut self, full: bool) -> Self {
234        self.full_width = full;
235        self
236    }
237
238    /// Set minimum width for the button
239    #[must_use]
240    pub const fn min_width(mut self, width: f32) -> Self {
241        self.min_width = Some(width);
242        self
243    }
244
245    /// Set explicit height (overrides size-based height)
246    #[must_use]
247    pub const fn height(mut self, height: f32) -> Self {
248        self.custom_height = Some(height);
249        self
250    }
251
252    /// Set explicit content area width for custom content buttons.
253    ///
254    /// When using [`show_ui`](Self::show_ui), this controls the inner width
255    /// available for the closure. If not set, defaults to a square (height-based) layout.
256    #[must_use]
257    pub const fn content_width(mut self, width: f32) -> Self {
258        self.content_width = Some(width);
259        self
260    }
261
262    /// Show the button with a text label.
263    pub fn show(self, ui: &mut Ui) -> Response {
264        let theme = ui.ctx().armas_theme();
265        let sense = if self.enabled {
266            Sense::click()
267        } else {
268            Sense::hover()
269        };
270
271        let height = self.custom_height.unwrap_or_else(|| self.size.height());
272        let padding_x = self.size.padding_x();
273
274        // Measure text
275        let font_id = egui::FontId::proportional(self.size.font_size(&theme.typography));
276        let text_galley =
277            ui.painter()
278                .layout_no_wrap(self.text.clone(), font_id, Color32::PLACEHOLDER);
279        let galley_size = text_galley.rect.size();
280        let text_width = galley_size.x;
281
282        let total_content_width = text_width + padding_x * 2.0;
283        let button_width = if self.full_width {
284            ui.available_width()
285        } else if let Some(min_w) = self.min_width {
286            total_content_width.max(min_w)
287        } else {
288            total_content_width
289        };
290
291        let button_size = Vec2::new(button_width, height);
292        let (rect, mut response) = ui.allocate_exact_size(button_size, sense);
293
294        if self.enabled && response.hovered() {
295            response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
296        }
297
298        if ui.is_rect_visible(rect) {
299            let hovered = response.hovered() && self.enabled;
300            let (_, text_color) =
301                draw_button_frame(ui, rect, &theme, self.variant, self.enabled, hovered);
302
303            // Draw text
304            let text_pos = rect.center() - galley_size / 2.0;
305            ui.painter()
306                .galley(egui::pos2(text_pos.x, text_pos.y), text_galley, text_color);
307
308            // Draw underline for Link variant on hover
309            if self.variant == ButtonVariant::Link && hovered {
310                let underline_y = text_pos.y + galley_size.y + 1.0;
311                ui.painter().line_segment(
312                    [
313                        egui::pos2(text_pos.x, underline_y),
314                        egui::pos2(text_pos.x + galley_size.x, underline_y),
315                    ],
316                    egui::Stroke::new(1.0, text_color),
317                );
318            }
319        }
320
321        response
322    }
323
324    /// Show the button with custom content instead of a text label.
325    ///
326    /// The closure receives a `&mut Ui` (with override text color set) and a
327    /// [`ContentContext`] with the state-dependent color, font size, and active state.
328    ///
329    /// Use [`content_width`](Self::content_width) to control the inner width.
330    /// If not set, the button defaults to a square layout (width = height).
331    ///
332    /// # Example
333    ///
334    /// ```rust,no_run
335    /// # use egui::Ui;
336    /// # fn example(ui: &mut Ui) {
337    /// use armas_basic::components::{Button, ButtonVariant};
338    ///
339    /// // Icon-only button (square)
340    /// Button::new("")
341    ///     .variant(ButtonVariant::Ghost)
342    ///     .show_ui(ui, |ui, ctx| {
343    ///         ui.label("X");
344    ///     });
345    ///
346    /// // Icon + text button
347    /// Button::new("")
348    ///     .content_width(80.0)
349    ///     .show_ui(ui, |ui, ctx| {
350    ///         // render_icon(ui.painter(), my_icon, ctx.color);
351    ///         ui.label("Save");
352    ///     });
353    /// # }
354    /// ```
355    pub fn show_ui(self, ui: &mut Ui, content: impl FnOnce(&mut Ui, &ContentContext)) -> Response {
356        let theme = ui.ctx().armas_theme();
357        let sense = if self.enabled {
358            Sense::click()
359        } else {
360            Sense::hover()
361        };
362
363        let height = self.custom_height.unwrap_or_else(|| self.size.height());
364        let padding_x = self.size.padding_x();
365
366        // Width: use content_width if set, otherwise square
367        let inner_width = self.content_width.unwrap_or(height - padding_x * 2.0);
368        let button_width = if self.full_width {
369            ui.available_width()
370        } else if let Some(min_w) = self.min_width {
371            (inner_width + padding_x * 2.0).max(min_w)
372        } else {
373            inner_width + padding_x * 2.0
374        };
375
376        let button_size = Vec2::new(button_width, height);
377        let (rect, mut response) = ui.allocate_exact_size(button_size, sense);
378
379        if self.enabled && response.hovered() {
380            response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
381        }
382
383        if ui.is_rect_visible(rect) {
384            let hovered = response.hovered() && self.enabled;
385            let (_, text_color) =
386                draw_button_frame(ui, rect, &theme, self.variant, self.enabled, hovered);
387
388            // Create child UI for custom content
389            let content_rect = rect.shrink2(Vec2::new(padding_x, 0.0));
390            let mut child_ui = ui.new_child(
391                egui::UiBuilder::new()
392                    .max_rect(content_rect)
393                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
394            );
395            child_ui.style_mut().visuals.override_text_color = Some(text_color);
396
397            let ctx = ContentContext {
398                color: text_color,
399                font_size: self.size.font_size(&theme.typography),
400                is_active: false,
401            };
402            content(&mut child_ui, &ctx);
403        }
404
405        response
406    }
407}
408
409// Keep old variant name as alias for backwards compatibility during migration
410pub use ButtonVariant as Variant;
411
412// Aliases for old variant names (deprecated, will remove later)
413#[allow(non_upper_case_globals)]
414impl ButtonVariant {
415    /// Alias for Default (was Filled)
416    pub const Filled: Self = Self::Default;
417    /// Alias for Outline (was Outlined)
418    pub const Outlined: Self = Self::Outline;
419    /// Alias for Ghost (was Text)
420    pub const Text: Self = Self::Ghost;
421    /// Alias for Secondary (was `FilledTonal`)
422    pub const FilledTonal: Self = Self::Secondary;
423    /// Elevated is now Secondary
424    pub const Elevated: Self = Self::Secondary;
425}