Skip to main content

egui_material3/
fab.rs

1use crate::get_global_color;
2use crate::icon::MaterialIcon;
3use crate::material_symbol::material_symbol_text;
4use egui::{self, Color32, Pos2, Rect, Response, Sense, Ui, Vec2, Widget};
5
6/// Material Design FAB (Floating Action Button) variants
7#[derive(Clone, Copy, PartialEq)]
8pub enum FabVariant {
9    /// Surface FAB - uses surface colors for neutral actions
10    Surface,
11    /// Primary FAB - uses primary colors for main actions (most common)
12    Primary,
13    /// Secondary FAB - uses secondary colors for secondary actions
14    Secondary,
15    /// Tertiary FAB - uses tertiary colors for alternative actions
16    Tertiary,
17    /// Branded FAB - uses custom brand colors
18    Branded,
19}
20
21/// Material Design FAB sizes following Material Design 3 specifications
22#[derive(Clone, Copy, PartialEq, Debug)]
23pub enum FabSize {
24    /// Small FAB - 40x40dp, used in compact layouts
25    Small,
26    /// Regular FAB - 56x56dp, the standard size
27    Regular,
28    /// Large FAB - 96x96dp, used for prominent actions
29    Large,
30    /// Extended FAB - Variable width with text, at least 80dp wide
31    Extended,
32}
33
34/// Material Design Floating Action Button (FAB) component
35///
36/// FABs help users take primary actions within an app. They appear in front of all screen content,
37/// typically as a circular button with an icon in the center.
38///
39/// ## Usage Examples
40/// ```rust
41/// # egui::__run_test_ui(|ui| {
42/// // Primary FAB with add icon
43/// ui.add(MaterialFab::primary()
44///     .icon("add")
45///     .action(|| println!("Add clicked")));
46///
47/// // Extended FAB with text
48/// ui.add(MaterialFab::primary()
49///     .size(FabSize::Extended)
50///     .icon("edit")
51///     .text("Compose")
52///     .action(|| println!("Compose clicked")));
53///
54/// // Large FAB for prominent action
55/// ui.add(MaterialFab::primary()
56///     .size(FabSize::Large)
57///     .icon("camera")
58///     .action(|| println!("Camera clicked")));
59/// # });
60/// ```
61///
62/// ## Material Design Spec
63/// - Elevation: 6dp (raised above content)
64/// - Corner radius: 50% (fully rounded)
65/// - Sizes: Small (40dp), Regular (56dp), Large (96dp), Extended (≥80dp)
66/// - Icon size: 24dp for regular, 32dp for large
67/// - Placement: 16dp from screen edge, above navigation bars
68pub struct MaterialFab<'a> {
69    /// Color variant of the FAB
70    variant: FabVariant,
71    /// Size of the FAB
72    size: FabSize,
73    /// Material Design icon name
74    icon: Option<String>,
75    /// Text content (for extended FABs)
76    text: Option<String>,
77    /// Custom SVG icon data
78    svg_icon: Option<SvgIcon>,
79    /// Raw SVG data string
80    svg_data: Option<String>,
81    /// Whether the FAB is interactive
82    enabled: bool,
83    /// Action callback when FAB is pressed
84    action: Option<Box<dyn Fn() + 'a>>,
85}
86
87/// SVG icon data for custom FAB icons
88#[derive(Clone)]
89pub struct SvgIcon {
90    /// Vector of SVG paths that make up the icon
91    pub paths: Vec<SvgPath>,
92    /// Viewbox dimensions of the SVG
93    pub viewbox_size: Vec2,
94}
95
96/// Individual SVG path with styling
97#[derive(Clone)]
98pub struct SvgPath {
99    /// SVG path data string
100    pub path: String,
101    /// Fill color for the path
102    pub fill: Color32,
103}
104
105impl<'a> MaterialFab<'a> {
106    /// Create a new FAB with the specified variant
107    ///
108    /// ## Parameters
109    /// - `variant`: The color variant to use for the FAB
110    pub fn new(variant: FabVariant) -> Self {
111        Self {
112            variant,
113            size: FabSize::Regular,
114            icon: None,
115            text: None,
116            svg_icon: None,
117            svg_data: None,
118            enabled: true,
119            action: None,
120        }
121    }
122
123    /// Create a surface FAB
124    pub fn surface() -> Self {
125        Self::new(FabVariant::Surface)
126    }
127
128    /// Create a primary FAB
129    pub fn primary() -> Self {
130        Self::new(FabVariant::Primary)
131    }
132
133    /// Create a secondary FAB
134    pub fn secondary() -> Self {
135        Self::new(FabVariant::Secondary)
136    }
137
138    /// Create a tertiary FAB
139    pub fn tertiary() -> Self {
140        Self::new(FabVariant::Tertiary)
141    }
142
143    /// Create a branded FAB
144    pub fn branded() -> Self {
145        Self::new(FabVariant::Branded)
146    }
147
148    /// Set the size of the FAB
149    pub fn size(mut self, size: FabSize) -> Self {
150        self.size = size;
151        self
152    }
153
154    /// Set the icon of the FAB
155    pub fn icon(mut self, icon: impl Into<String>) -> Self {
156        self.icon = Some(icon.into());
157        self
158    }
159
160    /// Set the text of the FAB (for extended FABs)
161    pub fn text(mut self, text: impl Into<String>) -> Self {
162        self.text = Some(text.into());
163        self.size = FabSize::Extended;
164        self
165    }
166
167    /// Enable or disable the FAB
168    pub fn enabled(mut self, enabled: bool) -> Self {
169        self.enabled = enabled;
170        self
171    }
172
173    /// Set the lowered state of the FAB (elevation effect)
174    pub fn lowered(self, _lowered: bool) -> Self {
175        // Placeholder for lowered state (elevation effect)
176        // In a real implementation, this would affect the visual elevation
177        self
178    }
179
180    /// Set a custom SVG icon for the FAB
181    pub fn svg_icon(mut self, svg_icon: SvgIcon) -> Self {
182        self.svg_icon = Some(svg_icon);
183        self
184    }
185
186    /// Set raw SVG data for the FAB icon
187    pub fn svg_data(mut self, svg_data: impl Into<String>) -> Self {
188        self.svg_data = Some(svg_data.into());
189        self
190    }
191
192    /// Set the action to perform when the FAB is clicked
193    pub fn on_click<F>(mut self, f: F) -> Self
194    where
195        F: Fn() + 'a,
196    {
197        self.action = Some(Box::new(f));
198        self
199    }
200}
201
202impl<'a> Widget for MaterialFab<'a> {
203    fn ui(self, ui: &mut Ui) -> Response {
204        let size = match self.size {
205            FabSize::Small => Vec2::splat(40.0),
206            FabSize::Regular => Vec2::splat(56.0),
207            FabSize::Large => Vec2::splat(96.0),
208            FabSize::Extended => {
209                let left_margin = 16.0;
210                let right_margin = 24.0;
211                let icon_width = if self.icon.is_some() || self.svg_icon.is_some() || self.svg_data.is_some() {
212                    24.0 + 12.0
213                } else {
214                    0.0
215                };
216
217                let text_width = if let Some(ref text) = self.text {
218                    let font_id = egui::FontId::proportional(14.0);
219                    ui.painter().layout_no_wrap(text.clone(), font_id, Color32::WHITE)
220                        .size()
221                        .x
222                } else {
223                    0.0
224                };
225
226                let total_width = left_margin + icon_width + text_width + right_margin;
227                Vec2::new(total_width.max(80.0), 56.0) // Minimum width of 80px
228            }
229        };
230
231        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
232
233        // Extract all needed data before partial move
234        let action = self.action;
235        let enabled = self.enabled;
236        let variant = self.variant;
237        let size_enum = self.size;
238        let icon = self.icon;
239        let text = self.text;
240        let svg_icon = self.svg_icon;
241        let svg_data = self.svg_data;
242
243        let clicked = response.clicked() && enabled;
244
245        if clicked {
246            if let Some(action) = action {
247                action();
248            }
249        }
250
251        // Material Design colors
252        let primary_color = get_global_color("primary");
253        let secondary_color = get_global_color("secondary");
254        let tertiary_color = get_global_color("tertiary");
255        let surface = get_global_color("surface");
256        let on_primary = get_global_color("onPrimary");
257        let on_surface = get_global_color("onSurface");
258
259        let (bg_color, icon_color) = if !enabled {
260            (
261                get_global_color("surfaceContainer"),
262                get_global_color("outline"),
263            )
264        } else {
265            match variant {
266                FabVariant::Surface => {
267                    if response.is_pointer_button_down_on() {
268                        (get_global_color("surfaceContainerHighest"), on_surface)
269                    } else if response.hovered() {
270                        (get_global_color("surfaceContainerHigh"), on_surface)
271                    } else {
272                        (surface, on_surface)
273                    }
274                }
275                FabVariant::Primary => {
276                    if response.hovered() || response.is_pointer_button_down_on() {
277                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
278                        (
279                            Color32::from_rgba_premultiplied(
280                                primary_color.r().saturating_add(lighten_amount),
281                                primary_color.g().saturating_add(lighten_amount),
282                                primary_color.b().saturating_add(lighten_amount),
283                                255,
284                            ),
285                            on_primary,
286                        )
287                    } else {
288                        (primary_color, on_primary)
289                    }
290                }
291                FabVariant::Secondary => {
292                    if response.hovered() || response.is_pointer_button_down_on() {
293                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
294                        (
295                            Color32::from_rgba_premultiplied(
296                                secondary_color.r().saturating_add(lighten_amount),
297                                secondary_color.g().saturating_add(lighten_amount),
298                                secondary_color.b().saturating_add(lighten_amount),
299                                255,
300                            ),
301                            on_primary,
302                        )
303                    } else {
304                        (secondary_color, on_primary)
305                    }
306                }
307                FabVariant::Tertiary => {
308                    if response.hovered() || response.is_pointer_button_down_on() {
309                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
310                        (
311                            Color32::from_rgba_premultiplied(
312                                tertiary_color.r().saturating_add(lighten_amount),
313                                tertiary_color.g().saturating_add(lighten_amount),
314                                tertiary_color.b().saturating_add(lighten_amount),
315                                255,
316                            ),
317                            on_primary,
318                        )
319                    } else {
320                        (tertiary_color, on_primary)
321                    }
322                }
323                FabVariant::Branded => {
324                    // Google brand colors
325                    let google_brand = Color32::from_rgb(66, 133, 244);
326                    if response.hovered() || response.is_pointer_button_down_on() {
327                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
328                        (
329                            Color32::from_rgba_premultiplied(
330                                google_brand.r().saturating_add(lighten_amount),
331                                google_brand.g().saturating_add(lighten_amount),
332                                google_brand.b().saturating_add(lighten_amount),
333                                255,
334                            ),
335                            on_primary,
336                        )
337                    } else {
338                        (google_brand, on_primary)
339                    }
340                }
341            }
342        };
343
344        // Calculate corner radius for FAB
345        let corner_radius = match size_enum {
346            FabSize::Small => 12.0,
347            FabSize::Large => 16.0,
348            _ => 14.0,
349        };
350
351        // Draw FAB background with less rounded corners
352        ui.painter().rect_filled(rect, corner_radius, bg_color);
353
354        // Draw content
355        match size_enum {
356            FabSize::Extended => {
357                // Draw icon and text with proper spacing
358                let left_margin = 16.0;
359                let _right_margin = 24.0;
360                let icon_text_gap = 12.0;
361                let mut content_x = rect.min.x + left_margin;
362
363                if let Some(ref svg_str) = svg_data {
364                    // Render SVG data
365                    if let Ok(texture) = render_svg_to_texture(ui.ctx(), svg_str, 24) {
366                        let icon_rect = Rect::from_center_size(
367                            Pos2::new(content_x + 12.0, rect.center().y),
368                            Vec2::splat(24.0),
369                        );
370                        ui.painter().image(
371                            texture.id(),
372                            icon_rect,
373                            Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
374                            Color32::WHITE,
375                        );
376                    }
377                    content_x += 24.0 + icon_text_gap;
378                } else if let Some(ref icon_name) = icon {
379                    let icon_rect = Rect::from_min_size(
380                        Pos2::new(content_x, rect.center().y - 12.0),
381                        Vec2::splat(24.0),
382                    );
383
384                    // Draw material icon
385                    let icon_char = material_symbol_text(icon_name);
386                    let icon = MaterialIcon::new(icon_char).size(24.0).color(icon_color);
387                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
388                        ui.add(icon);
389                    });
390
391                    content_x += 24.0 + icon_text_gap;
392                } else if let Some(ref _svg_icon) = svg_icon {
393                    // Render simplified Google logo for branded FAB
394                    draw_google_logo(ui, Pos2::new(content_x + 12.0, rect.center().y), 24.0);
395                    content_x += 24.0 + icon_text_gap;
396                }
397
398                if let Some(ref text) = text {
399                    let text_pos = Pos2::new(content_x, rect.center().y);
400                    ui.painter().text(
401                        text_pos,
402                        egui::Align2::LEFT_CENTER,
403                        text,
404                        egui::FontId::proportional(14.0),
405                        icon_color,
406                    );
407                }
408            }
409            _ => {
410                // Draw centered icon
411                if let Some(ref svg_str) = svg_data {
412                    let icon_size = match size_enum {
413                        FabSize::Small => 18,
414                        FabSize::Large => 36,
415                        _ => 24,
416                    };
417
418                    // Render SVG data
419                    if let Ok(texture) = render_svg_to_texture(ui.ctx(), svg_str, icon_size) {
420                        let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size as f32));
421                        ui.painter().image(
422                            texture.id(),
423                            icon_rect,
424                            Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
425                            Color32::WHITE,
426                        );
427                    }
428                } else if let Some(ref _svg_icon) = svg_icon {
429                    let icon_size = match size_enum {
430                        FabSize::Small => 18.0,
431                        FabSize::Large => 36.0,
432                        _ => 24.0,
433                    };
434
435                    // Render simplified Google logo for branded FAB
436                    draw_google_logo(ui, rect.center(), icon_size);
437                } else if let Some(ref icon_name) = icon {
438                    let icon_size = match size_enum {
439                        FabSize::Small => 18.0,
440                        FabSize::Large => 36.0,
441                        _ => 24.0,
442                    };
443
444                    let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
445                    let icon_char = material_symbol_text(icon_name);
446                    let icon = MaterialIcon::new(icon_char)
447                        .size(icon_size)
448                        .color(icon_color);
449                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
450                        ui.add(icon);
451                    });
452                } else {
453                    // Default add icon
454                    let icon_size = match size_enum {
455                        FabSize::Small => 18.0,
456                        FabSize::Large => 36.0,
457                        _ => 24.0,
458                    };
459
460                    let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
461                    let icon_char = material_symbol_text("add");
462                    let icon = MaterialIcon::new(icon_char).size(icon_size).color(icon_color);
463                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
464                        ui.add(icon);
465                    });
466                }
467            }
468        }
469
470        response
471    }
472}
473
474// Helper function to draw Google logo
475fn draw_google_logo(ui: &mut Ui, center: Pos2, size: f32) {
476    let half_size = size / 2.0;
477    let quarter_size = size / 4.0;
478
479    // Google 4-color logo - simplified version
480    // Green (top right)
481    ui.painter().rect_filled(
482        Rect::from_min_size(
483            Pos2::new(center.x, center.y - half_size),
484            Vec2::new(half_size, quarter_size),
485        ),
486        0.0,
487        Color32::from_rgb(52, 168, 83), // Green #34A853
488    );
489
490    // Blue (right)
491    ui.painter().rect_filled(
492        Rect::from_min_size(
493            Pos2::new(center.x, center.y - quarter_size),
494            Vec2::new(half_size, half_size),
495        ),
496        0.0,
497        Color32::from_rgb(66, 133, 244), // Blue #4285F4
498    );
499
500    // Yellow (bottom left)
501    ui.painter().rect_filled(
502        Rect::from_min_size(
503            Pos2::new(center.x - half_size, center.y + quarter_size),
504            Vec2::new(half_size, quarter_size),
505        ),
506        0.0,
507        Color32::from_rgb(251, 188, 5), // Yellow #FBBC05
508    );
509
510    // Red (left)
511    ui.painter().rect_filled(
512        Rect::from_min_size(
513            Pos2::new(center.x - half_size, center.y - half_size),
514            Vec2::new(quarter_size, size),
515        ),
516        0.0,
517        Color32::from_rgb(234, 67, 53), // Red #EA4335
518    );
519}
520
521// Helper function to render SVG data to texture
522fn render_svg_to_texture(
523    ctx: &egui::Context,
524    svg_data: &str,
525    size: u32,
526) -> Result<egui::TextureHandle, String> {
527    use resvg::{usvg, tiny_skia};
528
529    let tree = usvg::Tree::from_str(svg_data, &usvg::Options::default())
530        .map_err(|e| e.to_string())?;
531    let mut pixmap =
532        tiny_skia::Pixmap::new(size, size).ok_or_else(|| "pixmap alloc failed".to_string())?;
533
534    let ts = tree.size();
535    let scale = (size as f32 / ts.width()).min(size as f32 / ts.height());
536    resvg::render(
537        &tree,
538        tiny_skia::Transform::from_scale(scale, scale),
539        &mut pixmap.as_mut(),
540    );
541
542    let color_image = egui::ColorImage::from_rgba_unmultiplied(
543        [size as usize, size as usize],
544        pixmap.data(),
545    );
546
547    // Create a unique key for this texture
548    use std::collections::hash_map::DefaultHasher;
549    use std::hash::{Hash, Hasher};
550    let mut hasher = DefaultHasher::new();
551    svg_data.hash(&mut hasher);
552    size.hash(&mut hasher);
553    let key = format!("fab_svg_{:x}", hasher.finish());
554
555    Ok(ctx.load_texture(key, color_image, egui::TextureOptions::LINEAR))
556}
557
558pub fn fab_surface() -> MaterialFab<'static> {
559    MaterialFab::surface()
560}
561
562pub fn fab_primary() -> MaterialFab<'static> {
563    MaterialFab::primary()
564}
565
566pub fn fab_secondary() -> MaterialFab<'static> {
567    MaterialFab::secondary()
568}
569
570pub fn fab_tertiary() -> MaterialFab<'static> {
571    MaterialFab::tertiary()
572}
573
574pub fn fab_branded() -> MaterialFab<'static> {
575    MaterialFab::branded()
576}
577
578/// Create Google branded icon (4-color logo)
579pub fn google_branded_icon() -> SvgIcon {
580    SvgIcon {
581        paths: vec![
582            SvgPath {
583                path: "M16 16v14h4V20z".to_string(),
584                fill: Color32::from_rgb(52, 168, 83), // Green #34A853
585            },
586            SvgPath {
587                path: "M30 16H20l-4 4h14z".to_string(),
588                fill: Color32::from_rgb(66, 133, 244), // Blue #4285F4
589            },
590            SvgPath {
591                path: "M6 16v4h10l4-4z".to_string(),
592                fill: Color32::from_rgb(251, 188, 5), // Yellow #FBBC05
593            },
594            SvgPath {
595                path: "M20 16V6h-4v14z".to_string(),
596                fill: Color32::from_rgb(234, 67, 53), // Red #EA4335
597            },
598        ],
599        viewbox_size: Vec2::new(36.0, 36.0),
600    }
601}