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}