Skip to main content

egui_material3/
iconbutton.rs

1use crate::get_global_color;
2use eframe::egui::{
3    Align2, Color32, ColorImage, FontId, Rect, Response, Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2,
4    Widget,
5};
6use std::path::Path;
7use std::fs;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::sync::Mutex;
11use resvg::usvg::{Options, Tree};
12use resvg::tiny_skia::{Pixmap, Transform};
13use resvg::render;
14
15lazy_static::lazy_static! {
16    /// Cache to store pre-rendered SVG textures (ColorImage)
17    static ref SVG_IMAGE_CACHE: Mutex<HashMap<String, Arc<ColorImage>>> = Mutex::new(HashMap::new());
18}
19
20/// Visual variants for the icon button component.
21#[derive(Clone, Copy, PartialEq)]
22pub enum IconButtonVariant {
23    /// Standard icon button (minimal visual emphasis)
24    Standard,
25    /// Filled icon button (high emphasis with filled background)
26    Filled,
27    /// Filled tonal icon button (medium emphasis with tonal background)
28    FilledTonal,
29    /// Outlined icon button (medium emphasis with border)
30    Outlined,
31}
32
33/// Material Design icon button component.
34///
35/// Icon buttons help users take supplementary actions with a single tap.
36/// They're used when a compact button is required.
37///
38/// # Example
39/// ```rust
40/// # egui::__run_test_ui(|ui| {
41/// // Standard icon button
42/// if ui.add(MaterialIconButton::standard("favorite")).clicked() {
43///     println!("Favorite clicked!");
44/// }
45///
46/// // Filled icon button with toggle state
47/// let mut liked = false;
48/// ui.add(MaterialIconButton::filled("favorite")
49///     .toggle(&mut liked)
50///     .size(48.0));
51/// # });
52/// ```
53#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
54pub struct MaterialIconButton<'a> {
55    /// Icon identifier (e.g., "favorite", "settings", "delete")
56    icon: String,
57    /// Visual variant of the button
58    variant: IconButtonVariant,
59    /// Optional toggle state for the button
60    selected: Option<&'a mut bool>,
61    /// Whether the button is enabled for interaction
62    enabled: bool,
63    /// Size of the button (width and height)
64    size: f32,
65    /// Whether to use rectangular container (true) or circular (false)
66    container: bool,
67    /// Optional SVG file path to render as the icon
68    svg_path: Option<String>,
69    /// Optional SVG content string to render as the icon
70    svg_data: Option<String>,
71    /// Optional override for the icon color
72    icon_color_override: Option<Color32>,
73    /// Optional callback to execute when clicked
74    action: Option<Box<dyn Fn() + 'a>>,
75}
76
77impl<'a> MaterialIconButton<'a> {
78    /// Create a new icon button with the specified variant.
79    ///
80    /// # Arguments
81    /// * `icon` - Icon identifier (e.g., "home", "settings", "delete")
82    /// * `variant` - Visual variant of the button
83    ///
84    /// # Example
85    /// ```rust
86    /// # egui::__run_test_ui(|ui| {
87    /// let button = MaterialIconButton::new("settings", IconButtonVariant::Outlined);
88    /// # });
89    /// ```
90    pub fn new(icon: impl Into<String>, variant: IconButtonVariant) -> Self {
91        Self {
92            icon: icon.into(),
93            variant,
94            selected: None,
95            enabled: true,
96            size: 40.0,
97            container: false, // circular by default
98            svg_path: None,
99            svg_data: None,
100            icon_color_override: None,
101            action: None,
102        }
103    }
104
105    /// Create a standard icon button (minimal visual emphasis).
106    ///
107    /// # Arguments
108    /// * `icon` - Icon identifier
109    ///
110    /// # Example
111    /// ```rust
112    /// # egui::__run_test_ui(|ui| {
113    /// ui.add(MaterialIconButton::standard("menu"));
114    /// # });
115    /// ```
116    pub fn standard(icon: impl Into<String>) -> Self {
117        Self::new(icon, IconButtonVariant::Standard)
118    }
119
120    /// Create a filled icon button (high emphasis with filled background).
121    ///
122    /// # Arguments
123    /// * `icon` - Icon identifier
124    ///
125    /// # Example
126    /// ```rust
127    /// # egui::__run_test_ui(|ui| {
128    /// ui.add(MaterialIconButton::filled("add"));
129    /// # });
130    /// ```
131    pub fn filled(icon: impl Into<String>) -> Self {
132        Self::new(icon, IconButtonVariant::Filled)
133    }
134
135    /// Create a filled tonal icon button (medium emphasis with tonal background).
136    ///
137    /// # Arguments
138    /// * `icon` - Icon identifier
139    ///
140    /// # Example
141    /// ```rust
142    /// # egui::__run_test_ui(|ui| {
143    /// ui.add(MaterialIconButton::filled_tonal("edit"));
144    /// # });
145    /// ```
146    pub fn filled_tonal(icon: impl Into<String>) -> Self {
147        Self::new(icon, IconButtonVariant::FilledTonal)
148    }
149
150    /// Create an outlined icon button (medium emphasis with border).
151    ///
152    /// # Arguments
153    /// * `icon` - Icon identifier
154    ///
155    /// # Example
156    /// ```rust
157    /// # egui::__run_test_ui(|ui| {
158    /// ui.add(MaterialIconButton::outlined("delete"));
159    /// # });
160    /// ```
161    pub fn outlined(icon: impl Into<String>) -> Self {
162        Self::new(icon, IconButtonVariant::Outlined)
163    }
164
165    /// Create a toggleable icon button.
166    ///
167    /// The button's appearance will change based on the `selected` state.
168    ///
169    /// # Arguments
170    /// * `icon` - Icon identifier
171    /// * `selected` - Mutable reference to the toggle state
172    ///
173    /// # Example
174    /// ```rust
175    /// # egui::__run_test_ui(|ui| {
176    /// let mut is_favorite = false;
177    /// ui.add(MaterialIconButton::toggle("favorite", &mut is_favorite));
178    /// # });
179    /// ```
180    pub fn toggle(icon: impl Into<String>, selected: &'a mut bool) -> Self {
181        let mut button = Self::standard(icon);
182        button.selected = Some(selected);
183        button
184    }
185
186    /// Set the size of the icon button.
187    ///
188    /// # Arguments
189    /// * `size` - Desired size (width and height) of the button
190    ///
191    /// # Example
192    /// ```rust
193    /// # egui::__run_test_ui(|ui| {
194    /// ui.add(MaterialIconButton::standard("settings").size(48.0));
195    /// # });
196    /// ```
197    pub fn size(mut self, size: f32) -> Self {
198        self.size = size;
199        self
200    }
201
202    /// Enable or disable the icon button.
203    ///
204    /// # Arguments
205    /// * `enabled` - `true` to enable the button, `false` to disable
206    ///
207    /// # Example
208    /// ```rust
209    /// # egui::__run_test_ui(|ui| {
210    /// ui.add(MaterialIconButton::standard("download").enabled(false));
211    /// # });
212    /// ```
213    pub fn enabled(mut self, enabled: bool) -> Self {
214        self.enabled = enabled;
215        self
216    }
217
218    /// Set the container style of the icon button.
219    ///
220    /// # Arguments
221    /// * `container` - `true` for rectangular container, `false` for circular
222    ///
223    /// # Example
224    /// ```rust
225    /// # egui::__run_test_ui(|ui| {
226    /// ui.add(MaterialIconButton::standard("share").container(true));
227    /// # });
228    /// ```
229    pub fn container(mut self, container: bool) -> Self {
230        self.container = container;
231        self
232    }
233
234    /// Use an SVG file as the icon. The path will be loaded and rasterized.
235    pub fn svg(mut self, path: impl Into<String>) -> Self {
236        self.svg_path = Some(path.into());
237        self
238    }
239
240    /// Use inline SVG content as the icon. The content will be rasterized directly.
241    pub fn svg_data(mut self, svg_content: impl Into<String>) -> Self {
242        self.svg_data = Some(svg_content.into());
243        self
244    }
245
246    /// Override the icon color.
247    pub fn icon_color(mut self, color: Color32) -> Self {
248        self.icon_color_override = Some(color);
249        self
250    }
251
252    /// Set the click action for the icon button.
253    ///
254    /// # Arguments
255    /// * `f` - Function to execute when the button is clicked
256    ///
257    /// # Example
258    /// ```rust
259    /// # egui::__run_test_ui(|ui| {
260    /// ui.add(MaterialIconButton::standard("info").on_click(|| {
261    ///     println!("Info button clicked!");
262    /// }));
263    /// # });
264    /// ```
265    pub fn on_click<F>(mut self, f: F) -> Self
266    where
267        F: Fn() + 'a,
268    {
269        self.action = Some(Box::new(f));
270        self
271    }
272}
273
274impl<'a> Widget for MaterialIconButton<'a> {
275    fn ui(self, ui: &mut Ui) -> Response {
276        let desired_size = Vec2::splat(self.size);
277        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
278
279        let is_selected = self.selected.as_ref().map_or(false, |s| **s);
280
281        if response.clicked() && self.enabled {
282            if let Some(selected) = self.selected {
283                *selected = !*selected;
284                response.mark_changed();
285            }
286            if let Some(action) = self.action {
287                action();
288            }
289        }
290
291        // Material Design colors
292        let primary_color = get_global_color("primary");
293        let secondary_container = get_global_color("secondaryContainer");
294        let on_secondary_container = get_global_color("onSecondaryContainer");
295        let _surface = get_global_color("surface");
296        let on_surface = get_global_color("onSurface");
297        let on_surface_variant = get_global_color("onSurfaceVariant");
298        let outline = get_global_color("outline");
299
300        let (bg_color, icon_color, border_color) = if !self.enabled {
301            (
302                get_global_color("surfaceContainer"),
303                get_global_color("outline"),
304                Color32::TRANSPARENT,
305            )
306        } else {
307            match self.variant {
308                IconButtonVariant::Standard => {
309                    if is_selected {
310                        (Color32::TRANSPARENT, primary_color, Color32::TRANSPARENT)
311                    } else if response.hovered() {
312                        (
313                            Color32::from_rgba_premultiplied(
314                                on_surface.r(),
315                                on_surface.g(),
316                                on_surface.b(),
317                                20,
318                            ),
319                            on_surface,
320                            Color32::TRANSPARENT,
321                        )
322                    } else {
323                        (
324                            Color32::TRANSPARENT,
325                            on_surface_variant,
326                            Color32::TRANSPARENT,
327                        )
328                    }
329                }
330                IconButtonVariant::Filled => {
331                    if is_selected {
332                        (
333                            primary_color,
334                            get_global_color("onPrimary"),
335                            Color32::TRANSPARENT,
336                        )
337                    } else if response.hovered() || response.is_pointer_button_down_on() {
338                        // Lighten background by blending with white
339                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
340                        (
341                            Color32::from_rgba_premultiplied(
342                                primary_color.r().saturating_add(lighten_amount),
343                                primary_color.g().saturating_add(lighten_amount),
344                                primary_color.b().saturating_add(lighten_amount),
345                                255,
346                            ),
347                            get_global_color("onPrimary"),
348                            Color32::TRANSPARENT,
349                        )
350                    } else {
351                        (primary_color, get_global_color("onPrimary"), Color32::TRANSPARENT)
352                    }
353                }
354                IconButtonVariant::FilledTonal => {
355                    if is_selected {
356                        (
357                            secondary_container,
358                            on_secondary_container,
359                            Color32::TRANSPARENT,
360                        )
361                    } else if response.hovered() {
362                        (
363                            Color32::from_rgba_premultiplied(
364                                secondary_container.r().saturating_sub(10),
365                                secondary_container.g().saturating_sub(10),
366                                secondary_container.b().saturating_sub(10),
367                                255,
368                            ),
369                            on_secondary_container,
370                            Color32::TRANSPARENT,
371                        )
372                    } else {
373                        (
374                            secondary_container,
375                            on_secondary_container,
376                            Color32::TRANSPARENT,
377                        )
378                    }
379                }
380                IconButtonVariant::Outlined => {
381                    if is_selected {
382                        (
383                            Color32::from_rgba_premultiplied(
384                                primary_color.r(),
385                                primary_color.g(),
386                                primary_color.b(),
387                                24,
388                            ),
389                            primary_color,
390                            primary_color,
391                        )
392                    } else if response.hovered() {
393                        (
394                            Color32::from_rgba_premultiplied(
395                                on_surface.r(),
396                                on_surface.g(),
397                                on_surface.b(),
398                                20,
399                            ),
400                            on_surface_variant,
401                            outline,
402                        )
403                    } else {
404                        (Color32::TRANSPARENT, on_surface_variant, outline)
405                    }
406                }
407            }
408        };
409
410        // Calculate corner radius based on container style
411        let corner_radius = if self.container {
412            // Rectangular container: smaller radius for more rectangular shape
413            rect.height() * 0.2 // About 8px for 40px button
414        } else {
415            // Circular container: full radius
416            rect.height() / 2.0
417        };
418
419        // Draw background
420        if bg_color != Color32::TRANSPARENT {
421            ui.painter().rect_filled(rect, corner_radius, bg_color);
422        }
423
424        // Draw border for outlined variant
425        if border_color != Color32::TRANSPARENT {
426            ui.painter().rect_stroke(
427                rect,
428                corner_radius,
429                Stroke::new(1.0, border_color),
430                egui::epaint::StrokeKind::Outside,
431            );
432        }
433
434        // Draw icon: SVG (if provided) or emoji/text fallback
435        let icon_size = self.size * 0.6;
436        let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
437
438        // Helper function to render SVG from bytes with caching
439        let render_svg = |ui: &mut Ui, bytes: &[u8], cache_key: &str, icon_rect: Rect, icon_size: f32| {
440            let size_px = (icon_size.max(1.0).ceil() as u32).max(1);
441            let texture_id = format!("svg_icon:{}:{}", cache_key, size_px);
442            
443            // Try to get cached ColorImage, or create it if not exists
444            let color_image = {
445                let mut cache = SVG_IMAGE_CACHE.lock().unwrap();
446                
447                if let Some(cached_image) = cache.get(&texture_id) {
448                    // Image already rendered, use cached version
449                    Some(cached_image.clone())
450                } else {
451                    // Need to parse and render SVG (expensive operation - only happens once!)
452                    let mut opt = Options::default();
453                    opt.fontdb_mut().load_system_fonts();
454                    
455                    if let Ok(tree) = Tree::from_data(bytes, &opt) {
456                        if let Some(mut pixmap) = Pixmap::new(size_px, size_px) {
457                            let tree_size = tree.size();
458                            let scale_x = size_px as f32 / tree_size.width();
459                            let scale_y = size_px as f32 / tree_size.height();
460                            let scale = scale_x.min(scale_y);
461                            let transform = Transform::from_scale(scale, scale);
462                            render(&tree, transform, &mut pixmap.as_mut());
463                            let data = pixmap.data();
464                            
465                            // Convert premultiplied bytes to plain RGBA
466                            let mut rgba: Vec<u8> = Vec::with_capacity((size_px * size_px * 4) as usize);
467                            rgba.extend_from_slice(data);
468                            
469                            let img = Arc::new(ColorImage::from_rgba_unmultiplied(
470                                [size_px as usize, size_px as usize],
471                                &rgba
472                            ));
473                            
474                            // Store in cache for future use
475                            cache.insert(texture_id.clone(), img.clone());
476                            Some(img)
477                        } else {
478                            None
479                        }
480                    } else {
481                        None
482                    }
483                }
484            };
485            
486            // Display the image if we have it
487            if let Some(img) = color_image {
488                let tex: TextureHandle = ui.ctx().load_texture(
489                    texture_id,
490                    (*img).clone(),
491                    TextureOptions::LINEAR,
492                );
493                
494                ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
495                    ui.image(&tex);
496                });
497            }
498        };
499
500        if let Some(svg_content) = &self.svg_data {
501            // Render inline SVG content
502            // Create a hash-like cache key from first and last bytes
503            let bytes = svg_content.as_bytes();
504            let len = bytes.len();
505            let cache_key = if len > 16 {
506                format!("inline_{}_{}_{}_{}", 
507                    bytes[0], bytes[1], bytes[len-2], bytes[len-1])
508            } else {
509                format!("inline_{}", len)
510            };
511            render_svg(ui, bytes, &cache_key, icon_rect, icon_size);
512        } else if let Some(path) = &self.svg_path {
513            // Try to load and rasterize SVG from file
514            if Path::new(path).exists() {
515                if let Ok(bytes) = fs::read(path) {
516                    render_svg(ui, &bytes, path, icon_rect, icon_size);
517                }
518            }
519        } else {
520            // Fallback: draw provided icon string (emoji constants from `noto_emoji` or raw text)
521            let text = &self.icon;
522            let font = FontId::proportional(icon_size);
523            let final_icon_color = self.icon_color_override.unwrap_or(icon_color);
524            ui.painter().text(icon_rect.center(), Align2::CENTER_CENTER, text, font, final_icon_color);
525        }
526
527        // Add ripple effect on hover (skip for Filled variant as it already has state changes)
528        if response.hovered() && self.enabled && self.variant != IconButtonVariant::Filled {
529            let ripple_color = Color32::from_rgba_premultiplied(
530                icon_color.r(),
531                icon_color.g(),
532                icon_color.b(),
533                30,
534            );
535            ui.painter().rect_filled(rect, corner_radius, ripple_color);
536        }
537
538        response
539    }
540}
541
542/// Convenience function to create a standard icon button.
543///
544/// # Arguments
545/// * `icon` - Icon identifier
546///
547/// # Example
548/// ```rust
549/// # egui::__run_test_ui(|ui| {
550/// ui.add(icon_button_standard("menu"));
551/// # });
552/// ```
553pub fn icon_button_standard(icon: impl Into<String>) -> MaterialIconButton<'static> {
554    MaterialIconButton::standard(icon)
555}
556
557/// Convenience function to create a filled icon button.
558///
559/// # Arguments
560/// * `icon` - Icon identifier
561///
562/// # Example
563/// ```rust
564/// # egui::__run_test_ui(|ui| {
565/// ui.add(icon_button_filled("add"));
566/// # });
567/// ```
568pub fn icon_button_filled(icon: impl Into<String>) -> MaterialIconButton<'static> {
569    MaterialIconButton::filled(icon)
570}
571
572/// Convenience function to create a filled tonal icon button.
573///
574/// # Arguments
575/// * `icon` - Icon identifier
576///
577/// # Example
578/// ```rust
579/// # egui::__run_test_ui(|ui| {
580/// ui.add(icon_button_filled_tonal("edit"));
581/// # });
582/// ```
583pub fn icon_button_filled_tonal(icon: impl Into<String>) -> MaterialIconButton<'static> {
584    MaterialIconButton::filled_tonal(icon)
585}
586
587/// Convenience function to create an outlined icon button.
588///
589/// # Arguments
590/// * `icon` - Icon identifier
591///
592/// # Example
593/// ```rust
594/// # egui::__run_test_ui(|ui| {
595/// ui.add(icon_button_outlined("delete"));
596/// # });
597/// ```
598pub fn icon_button_outlined(icon: impl Into<String>) -> MaterialIconButton<'static> {
599    MaterialIconButton::outlined(icon)
600}
601
602/// Convenience function to create a toggleable icon button.
603///
604/// # Arguments
605/// * `icon` - Icon identifier
606/// * `selected` - Mutable reference to the toggle state
607///
608/// # Example
609/// ```rust
610/// # egui::__run_test_ui(|ui| {
611/// let mut is_liked = false;
612/// ui.add(icon_button_toggle("favorite", &mut is_liked));
613/// # });
614/// ```
615pub fn icon_button_toggle(icon: impl Into<String>, selected: &mut bool) -> MaterialIconButton<'_> {
616    MaterialIconButton::toggle(icon, selected)
617}