Skip to main content

egui_charts/ui_kit/list_item/
button_content.rs

1//! ButtonContent - Clickable button content for list items
2
3use crate::localization as l10n;
4use egui::{Color32, Response, Role, Ui, Vec2, WidgetInfo};
5
6use crate::icons::Icon;
7use crate::theme::ThemeExt;
8use crate::tokens::DESIGN_TOKENS;
9use crate::ui_kit::buttons::ButtonVariant;
10
11use super::content::{ContentContext, ListItemContent};
12
13/// Button style variants
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ButtonStyle {
16    /// Standard button appearance
17    #[default]
18    Default,
19    /// Primary/accent color button
20    Primary,
21    /// Danger/destructive action button (red)
22    Danger,
23    /// Subtle/ghost button (minimal background)
24    Subtle,
25}
26
27/// Clickable button content for list items
28///
29/// Use this when you need an actionable button row in a list,
30/// such as "Add new item", "Clear all", or action buttons.
31///
32/// # Example
33///
34/// ```
35/// use egui_charts::icons::icons;
36/// use egui_charts::ui_kit::list_item::{ButtonContent, ListItem};
37///
38/// # egui::__run_test_ui(|ui| {
39/// let response = ListItem::new()
40///     .show(ui, ButtonContent::new(&icons::PLUS, "Add Item"));
41///
42/// if response.clicked() {
43///     // add the item
44/// }
45/// # });
46/// ```
47pub struct ButtonContent<'a> {
48    icon: &'a Icon,
49    label: Option<String>,
50    style: ButtonStyle,
51    icon_size: f32,
52    disabled: bool,
53}
54
55impl<'a> ButtonContent<'a> {
56    /// Create new button content with an icon
57    pub fn new(icon: &'a Icon, label: impl Into<String>) -> Self {
58        Self {
59            icon,
60            label: Some(label.into()),
61            style: ButtonStyle::Default,
62            icon_size: DESIGN_TOKENS.sizing.icon_sm,
63            disabled: false,
64        }
65    }
66
67    /// Create button content with only an icon (no label)
68    pub fn icon_only(icon: &'a Icon) -> Self {
69        Self {
70            icon,
71            label: None,
72            style: ButtonStyle::Default,
73            icon_size: DESIGN_TOKENS.sizing.icon_sm,
74            disabled: false,
75        }
76    }
77
78    /// Set the button style
79    #[must_use]
80    pub fn style(mut self, style: ButtonStyle) -> Self {
81        self.style = style;
82        self
83    }
84
85    /// Make this a primary/accent button
86    #[must_use]
87    pub fn primary(mut self) -> Self {
88        self.style = ButtonStyle::Primary;
89        self
90    }
91
92    /// Make this a danger/destructive button
93    #[must_use]
94    pub fn danger(mut self) -> Self {
95        self.style = ButtonStyle::Danger;
96        self
97    }
98
99    /// Make this a subtle/ghost button
100    #[must_use]
101    pub fn subtle(mut self) -> Self {
102        self.style = ButtonStyle::Subtle;
103        self
104    }
105
106    /// Set the icon size
107    #[must_use]
108    pub fn icon_size(mut self, size: f32) -> Self {
109        self.icon_size = size;
110        self
111    }
112
113    /// Disable the button
114    #[must_use]
115    pub fn disabled(mut self, disabled: bool) -> Self {
116        self.disabled = disabled;
117        self
118    }
119
120    /// Get colors based on style and state
121    fn get_colors(&self, ui: &Ui, hovered: bool, active: bool) -> (Color32, Color32, Color32) {
122        let visuals = &ui.style().visuals;
123
124        if self.disabled || !ui.is_enabled() {
125            let text = ui.active_theme().ui.text_disabled;
126            return (Color32::TRANSPARENT, text, text);
127        }
128
129        match self.style {
130            ButtonStyle::Default => {
131                if active {
132                    (
133                        visuals.widgets.active.bg_fill,
134                        visuals.widgets.active.fg_stroke.color,
135                        visuals.widgets.active.fg_stroke.color,
136                    )
137                } else if hovered {
138                    (
139                        visuals.widgets.hovered.bg_fill,
140                        visuals.widgets.hovered.fg_stroke.color,
141                        visuals.widgets.hovered.fg_stroke.color,
142                    )
143                } else {
144                    (
145                        Color32::TRANSPARENT,
146                        visuals.widgets.inactive.fg_stroke.color,
147                        visuals.widgets.inactive.fg_stroke.color,
148                    )
149                }
150            }
151            ButtonStyle::Primary | ButtonStyle::Danger => {
152                let variant = if self.style == ButtonStyle::Primary {
153                    ButtonVariant::Primary
154                } else {
155                    ButtonVariant::Danger
156                };
157                if active || hovered {
158                    let text = variant.fg_color(ui);
159                    (variant.bg_color(ui, hovered, active), text, text)
160                } else {
161                    let text = if self.style == ButtonStyle::Danger {
162                        ui.active_theme().ui.error
163                    } else {
164                        visuals.text_color()
165                    };
166                    (Color32::TRANSPARENT, text, text)
167                }
168            }
169            ButtonStyle::Subtle => {
170                if active {
171                    (
172                        visuals.widgets.active.bg_fill.gamma_multiply(0.5),
173                        visuals.widgets.active.fg_stroke.color,
174                        visuals.widgets.active.fg_stroke.color,
175                    )
176                } else if hovered {
177                    (
178                        visuals.widgets.hovered.bg_fill.gamma_multiply(0.5),
179                        visuals.widgets.hovered.fg_stroke.color,
180                        visuals.widgets.hovered.fg_stroke.color,
181                    )
182                } else {
183                    (
184                        Color32::TRANSPARENT,
185                        visuals.widgets.noninteractive.fg_stroke.color,
186                        visuals.widgets.noninteractive.fg_stroke.color,
187                    )
188                }
189            }
190        }
191    }
192}
193
194impl<'a> ListItemContent for ButtonContent<'a> {
195    fn ui(self, ui: &mut Ui, context: &ContentContext<'_>) -> Response {
196        let response = context.response;
197        let translated_label = l10n::text(ui.ctx(), self.label.as_deref().unwrap_or_default());
198        response.widget_info(|| {
199            WidgetInfo::labeled(
200                Role::Button,
201                !self.disabled && ui.is_enabled(),
202                &translated_label,
203            )
204        });
205
206        if ui.is_rect_visible(response.rect) {
207            let (bg_color, icon_color, text_color) =
208                self.get_colors(ui, response.hovered(), response.is_pointer_button_down_on());
209
210            // The fill covers the whole row, like the list item's own hover fill.
211            if bg_color != Color32::TRANSPARENT {
212                ui.painter()
213                    .rect_filled(response.rect, ui.active_theme().radius.sm, bg_color);
214            }
215
216            let content_rect = context.rect;
217
218            // Draw icon
219            let icon_rect = egui::Rect::from_min_size(
220                content_rect.min + Vec2::new(0.0, (content_rect.height() - self.icon_size) / 2.0),
221                Vec2::splat(self.icon_size),
222            );
223            self.icon
224                .as_image_tinted(Vec2::splat(self.icon_size), icon_color)
225                .paint_at(ui, icon_rect);
226
227            // Draw label if present
228            if let Some(label) = &self.label {
229                let text_pos = egui::pos2(
230                    content_rect.min.x + self.icon_size + ui.active_theme().spacing.sm,
231                    content_rect.center().y,
232                );
233
234                ui.painter().text(
235                    text_pos,
236                    egui::Align2::LEFT_CENTER,
237                    l10n::text(ui.ctx(), label),
238                    egui::TextStyle::Body.resolve(ui.style()),
239                    text_color,
240                );
241            }
242        }
243
244        response.clone()
245    }
246
247    fn interactive(&self) -> bool {
248        !self.disabled
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::icons::icons;
256    use crate::theme::{apply_to_egui, contrast, presets};
257
258    #[test]
259    fn action_rows_are_transparent_at_rest_and_use_readable_state_colours() {
260        for (name, theme) in presets() {
261            let ctx = egui::Context::default();
262            apply_to_egui(&ctx, &theme);
263            ctx.run_ui(Default::default(), |ui| {
264                for style in [
265                    ButtonStyle::Default,
266                    ButtonStyle::Primary,
267                    ButtonStyle::Danger,
268                    ButtonStyle::Subtle,
269                ] {
270                    let row = ButtonContent::new(&icons::PLUS, "Action").style(style);
271                    let (fill, icon, text) = row.get_colors(ui, false, false);
272                    assert_eq!(fill, Color32::TRANSPARENT, "{name}: {style:?}");
273                    assert_eq!(icon, text);
274                    assert!(
275                        contrast(text, theme.ui.panel_bg) >= 4.5,
276                        "{name}: {style:?}"
277                    );
278                    let disabled = row.disabled(true).get_colors(ui, true, true);
279                    assert_eq!(
280                        disabled,
281                        (
282                            Color32::TRANSPARENT,
283                            theme.ui.text_disabled,
284                            theme.ui.text_disabled
285                        )
286                    );
287                }
288                for (row, hover, active, text) in [
289                    (
290                        ButtonContent::new(&icons::PLUS, "Add").primary(),
291                        theme.ui.accent_hover,
292                        theme.ui.accent_active,
293                        theme.ui.accent_text,
294                    ),
295                    (
296                        ButtonContent::new(&icons::CLOSE, "Delete").danger(),
297                        theme.ui.danger_bg_hover,
298                        theme.ui.danger_bg_active,
299                        theme.ui.danger_text,
300                    ),
301                ] {
302                    assert_eq!(row.get_colors(ui, true, false), (hover, text, text));
303                    assert_eq!(row.get_colors(ui, true, true), (active, text, text));
304                }
305            })
306            .drop_without_applying_deltas();
307        }
308    }
309}