Skip to main content

egui_components/
button.rs

1//! `Button` widget.
2//!
3//! Idiomatic egui widget: build with the chainable setters, then call
4//! `.ui(ui)` (or pass to `ui.add(...)`). Returns [`egui::Response`] so callers
5//! can `.clicked()` etc. exactly as with any built-in widget.
6
7use crate::common::{Size, Variant};
8use egui::{Color32, FontId, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetText};
9use egui_components_theme::{mix, Theme, ThemeColor};
10
11pub struct Button {
12    label: WidgetText,
13    variant: Variant,
14    size: Size,
15    disabled: bool,
16    full_width: bool,
17    min_width: Option<f32>,
18}
19
20impl Button {
21    pub fn new(label: impl Into<WidgetText>) -> Self {
22        Self {
23            label: label.into(),
24            variant: Variant::Primary,
25            size: Size::Medium,
26            disabled: false,
27            full_width: false,
28            min_width: None,
29        }
30    }
31
32    pub fn primary(label: impl Into<WidgetText>) -> Self {
33        Self::new(label).variant(Variant::Primary)
34    }
35    pub fn secondary(label: impl Into<WidgetText>) -> Self {
36        Self::new(label).variant(Variant::Secondary)
37    }
38    pub fn ghost(label: impl Into<WidgetText>) -> Self {
39        Self::new(label).variant(Variant::Ghost)
40    }
41    pub fn outline(label: impl Into<WidgetText>) -> Self {
42        Self::new(label).variant(Variant::Outline)
43    }
44    pub fn danger(label: impl Into<WidgetText>) -> Self {
45        Self::new(label).variant(Variant::Danger)
46    }
47    pub fn link(label: impl Into<WidgetText>) -> Self {
48        Self::new(label).variant(Variant::Link)
49    }
50
51    pub fn variant(mut self, v: Variant) -> Self {
52        self.variant = v;
53        self
54    }
55    pub fn size(mut self, s: Size) -> Self {
56        self.size = s;
57        self
58    }
59    pub fn small(self) -> Self {
60        self.size(Size::Small)
61    }
62    pub fn large(self) -> Self {
63        self.size(Size::Large)
64    }
65    pub fn disabled(mut self, d: bool) -> Self {
66        self.disabled = d;
67        self
68    }
69    pub fn full_width(mut self) -> Self {
70        self.full_width = true;
71        self
72    }
73    pub fn min_width(mut self, w: f32) -> Self {
74        self.min_width = Some(w);
75        self
76    }
77}
78
79impl Widget for Button {
80    fn ui(self, ui: &mut Ui) -> Response {
81        let theme = Theme::get(ui.ctx());
82        let m = theme.metrics;
83        let height = self.size.button_height(&m);
84        let pad_x = self.size.button_padding_x(&m);
85        let font = FontId::proportional(self.size.font_size(&m));
86
87        let galley = self.label.clone().into_galley(
88            ui,
89            Some(egui::TextWrapMode::Extend),
90            f32::INFINITY,
91            font,
92        );
93        let text_w = galley.size().x;
94
95        let desired_w = if self.full_width {
96            ui.available_width()
97        } else {
98            (text_w + pad_x * 2.0).max(self.min_width.unwrap_or(0.0))
99        };
100        let desired_size = Vec2::new(desired_w, height);
101
102        let sense = if self.disabled { Sense::hover() } else { Sense::click() };
103        let (rect, response) = ui.allocate_exact_size(desired_size, sense);
104
105        if ui.is_rect_visible(rect) {
106            paint_button(ui, rect, &response, &theme, self.variant, self.disabled, &galley);
107        }
108
109        response
110    }
111}
112
113fn paint_button(
114    ui: &mut Ui,
115    rect: Rect,
116    response: &Response,
117    theme: &Theme,
118    variant: Variant,
119    disabled: bool,
120    galley: &std::sync::Arc<egui::Galley>,
121) {
122    let c = &theme.colors;
123    let radius = theme.corner();
124
125    let (bg, fg, border) = variant_colors(c, variant);
126
127    let state_bg = if disabled {
128        mix(bg, Color32::TRANSPARENT, 0.5)
129    } else if response.is_pointer_button_down_on() {
130        match variant {
131            Variant::Primary => c.primary_active_background,
132            Variant::Secondary => c.secondary_active_background,
133            Variant::Danger => darken(c.danger_background, 0.15),
134            Variant::Success => darken(c.success_background, 0.15),
135            Variant::Warning => darken(c.warning_background, 0.15),
136            Variant::Info => darken(c.info_background, 0.15),
137            Variant::Ghost | Variant::Outline => mix(c.accent_background, c.foreground, 0.05),
138            Variant::Link => bg,
139        }
140    } else if response.hovered() {
141        match variant {
142            Variant::Primary => c.primary_hover_background,
143            Variant::Secondary => c.secondary_hover_background,
144            Variant::Danger => lighten(c.danger_background, 0.08),
145            Variant::Success => lighten(c.success_background, 0.08),
146            Variant::Warning => lighten(c.warning_background, 0.08),
147            Variant::Info => lighten(c.info_background, 0.08),
148            Variant::Ghost | Variant::Outline => c.accent_background,
149            Variant::Link => bg,
150        }
151    } else {
152        bg
153    };
154
155    let painter = ui.painter();
156
157    if !matches!(variant, Variant::Link | Variant::Ghost) || response.hovered() || response.is_pointer_button_down_on() {
158        painter.rect_filled(rect, radius, state_bg);
159    }
160
161    if let Some(stroke) = border {
162        painter.rect_stroke(rect, radius, stroke, egui::StrokeKind::Inside);
163    }
164
165    // Focus ring
166    if response.has_focus() {
167        let ring_rect = rect.expand(2.0);
168        painter.rect_stroke(
169            ring_rect,
170            theme.corner(),
171            theme.focus_ring(),
172            egui::StrokeKind::Outside,
173        );
174    }
175
176    // Text
177    let text_color = if disabled { mix(fg, c.muted_foreground, 0.5) } else { fg };
178    let text_pos = rect.center();
179    painter.galley_with_override_text_color(
180        text_pos - galley.size() * 0.5,
181        galley.clone(),
182        text_color,
183    );
184
185    // Link underline on hover
186    if matches!(variant, Variant::Link) && response.hovered() {
187        let underline_y = text_pos.y + galley.size().y * 0.5 - 1.0;
188        painter.line_segment(
189            [
190                egui::pos2(rect.center().x - galley.size().x * 0.5, underline_y),
191                egui::pos2(rect.center().x + galley.size().x * 0.5, underline_y),
192            ],
193            Stroke::new(1.0, text_color),
194        );
195    }
196
197    // Hover cursor
198    if !disabled && response.hovered() {
199        ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
200    }
201}
202
203fn variant_colors(c: &ThemeColor, variant: Variant) -> (Color32, Color32, Option<Stroke>) {
204    match variant {
205        Variant::Primary => (c.primary_background, c.primary_foreground, None),
206        Variant::Secondary => (c.secondary_background, c.secondary_foreground, None),
207        Variant::Ghost => (Color32::TRANSPARENT, c.foreground, None),
208        Variant::Outline => (
209            Color32::TRANSPARENT,
210            c.foreground,
211            Some(Stroke::new(1.0, c.border)),
212        ),
213        Variant::Link => (Color32::TRANSPARENT, c.link_foreground, None),
214        Variant::Danger => (c.danger_background, c.danger_foreground, None),
215        Variant::Success => (c.success_background, c.success_foreground, None),
216        Variant::Warning => (c.warning_background, c.warning_foreground, None),
217        Variant::Info => (c.info_background, c.info_foreground, None),
218    }
219}
220
221fn darken(c: Color32, t: f32) -> Color32 {
222    mix(c, Color32::BLACK, t)
223}
224fn lighten(c: Color32, t: f32) -> Color32 {
225    mix(c, Color32::WHITE, t)
226}