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 eframe::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    /// Whether the FAB is interactive
80    enabled: bool,
81    /// Action callback when FAB is pressed
82    action: Option<Box<dyn Fn() + 'a>>,
83}
84
85/// SVG icon data for custom FAB icons
86#[derive(Clone)]
87pub struct SvgIcon {
88    /// Vector of SVG paths that make up the icon
89    pub paths: Vec<SvgPath>,
90    /// Viewbox dimensions of the SVG
91    pub viewbox_size: Vec2,
92}
93
94/// Individual SVG path with styling
95#[derive(Clone)]
96pub struct SvgPath {
97    /// SVG path data string
98    pub path: String,
99    /// Fill color for the path
100    pub fill: Color32,
101}
102
103impl<'a> MaterialFab<'a> {
104    /// Create a new FAB with the specified variant
105    ///
106    /// ## Parameters
107    /// - `variant`: The color variant to use for the FAB
108    pub fn new(variant: FabVariant) -> Self {
109        Self {
110            variant,
111            size: FabSize::Regular,
112            icon: None,
113            text: None,
114            svg_icon: None,
115            enabled: true,
116            action: None,
117        }
118    }
119
120    /// Create a surface FAB
121    pub fn surface() -> Self {
122        Self::new(FabVariant::Surface)
123    }
124
125    /// Create a primary FAB
126    pub fn primary() -> Self {
127        Self::new(FabVariant::Primary)
128    }
129
130    /// Create a secondary FAB
131    pub fn secondary() -> Self {
132        Self::new(FabVariant::Secondary)
133    }
134
135    /// Create a tertiary FAB
136    pub fn tertiary() -> Self {
137        Self::new(FabVariant::Tertiary)
138    }
139
140    /// Create a branded FAB
141    pub fn branded() -> Self {
142        Self::new(FabVariant::Branded)
143    }
144
145    /// Set the size of the FAB
146    pub fn size(mut self, size: FabSize) -> Self {
147        self.size = size;
148        self
149    }
150
151    /// Set the icon of the FAB
152    pub fn icon(mut self, icon: impl Into<String>) -> Self {
153        self.icon = Some(icon.into());
154        self
155    }
156
157    /// Set the text of the FAB (for extended FABs)
158    pub fn text(mut self, text: impl Into<String>) -> Self {
159        self.text = Some(text.into());
160        self.size = FabSize::Extended;
161        self
162    }
163
164    /// Enable or disable the FAB
165    pub fn enabled(mut self, enabled: bool) -> Self {
166        self.enabled = enabled;
167        self
168    }
169
170    /// Set the lowered state of the FAB (elevation effect)
171    pub fn lowered(self, _lowered: bool) -> Self {
172        // Placeholder for lowered state (elevation effect)
173        // In a real implementation, this would affect the visual elevation
174        self
175    }
176
177    /// Set a custom SVG icon for the FAB
178    pub fn svg_icon(mut self, svg_icon: SvgIcon) -> Self {
179        self.svg_icon = Some(svg_icon);
180        self
181    }
182
183    /// Set the action to perform when the FAB is clicked
184    pub fn on_click<F>(mut self, f: F) -> Self
185    where
186        F: Fn() + 'a,
187    {
188        self.action = Some(Box::new(f));
189        self
190    }
191}
192
193impl<'a> Widget for MaterialFab<'a> {
194    fn ui(self, ui: &mut Ui) -> Response {
195        let size = match self.size {
196            FabSize::Small => Vec2::splat(40.0),
197            FabSize::Regular => Vec2::splat(56.0),
198            FabSize::Large => Vec2::splat(96.0),
199            FabSize::Extended => {
200                let left_margin = 16.0;
201                let right_margin = 24.0;
202                let icon_width = if self.icon.is_some() || self.svg_icon.is_some() {
203                    24.0 + 12.0
204                } else {
205                    0.0
206                };
207
208                let text_width = if let Some(ref text) = self.text {
209                    ui.fonts(|fonts| {
210                        let font_id = egui::FontId::proportional(14.0);
211                        fonts
212                            .layout_no_wrap(text.clone(), font_id, Color32::WHITE)
213                            .size()
214                            .x
215                    })
216                } else {
217                    0.0
218                };
219
220                let total_width = left_margin + icon_width + text_width + right_margin;
221                Vec2::new(total_width.max(80.0), 56.0) // Minimum width of 80px
222            }
223        };
224
225        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
226
227        // Extract all needed data before partial move
228        let action = self.action;
229        let enabled = self.enabled;
230        let variant = self.variant;
231        let size_enum = self.size;
232        let icon = self.icon;
233        let text = self.text;
234        let svg_icon = self.svg_icon;
235
236        let clicked = response.clicked() && enabled;
237
238        if clicked {
239            if let Some(action) = action {
240                action();
241            }
242        }
243
244        // Material Design colors
245        let primary_color = get_global_color("primary");
246        let secondary_color = get_global_color("secondary");
247        let tertiary_color = get_global_color("tertiary");
248        let surface = get_global_color("surface");
249        let on_primary = get_global_color("onPrimary");
250        let on_surface = get_global_color("onSurface");
251
252        let (bg_color, icon_color) = if !enabled {
253            (
254                get_global_color("surfaceContainer"),
255                get_global_color("outline"),
256            )
257        } else {
258            match variant {
259                FabVariant::Surface => {
260                    if response.is_pointer_button_down_on() {
261                        (get_global_color("surfaceContainerHighest"), on_surface)
262                    } else if response.hovered() {
263                        (get_global_color("surfaceContainerHigh"), on_surface)
264                    } else {
265                        (surface, on_surface)
266                    }
267                }
268                FabVariant::Primary => {
269                    if response.hovered() || response.is_pointer_button_down_on() {
270                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
271                        (
272                            Color32::from_rgba_premultiplied(
273                                primary_color.r().saturating_add(lighten_amount),
274                                primary_color.g().saturating_add(lighten_amount),
275                                primary_color.b().saturating_add(lighten_amount),
276                                255,
277                            ),
278                            on_primary,
279                        )
280                    } else {
281                        (primary_color, on_primary)
282                    }
283                }
284                FabVariant::Secondary => {
285                    if response.hovered() || response.is_pointer_button_down_on() {
286                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
287                        (
288                            Color32::from_rgba_premultiplied(
289                                secondary_color.r().saturating_add(lighten_amount),
290                                secondary_color.g().saturating_add(lighten_amount),
291                                secondary_color.b().saturating_add(lighten_amount),
292                                255,
293                            ),
294                            on_primary,
295                        )
296                    } else {
297                        (secondary_color, on_primary)
298                    }
299                }
300                FabVariant::Tertiary => {
301                    if response.hovered() || response.is_pointer_button_down_on() {
302                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
303                        (
304                            Color32::from_rgba_premultiplied(
305                                tertiary_color.r().saturating_add(lighten_amount),
306                                tertiary_color.g().saturating_add(lighten_amount),
307                                tertiary_color.b().saturating_add(lighten_amount),
308                                255,
309                            ),
310                            on_primary,
311                        )
312                    } else {
313                        (tertiary_color, on_primary)
314                    }
315                }
316                FabVariant::Branded => {
317                    // Google brand colors
318                    let google_brand = Color32::from_rgb(66, 133, 244);
319                    if response.hovered() || response.is_pointer_button_down_on() {
320                        let lighten_amount = if response.is_pointer_button_down_on() { 40 } else { 20 };
321                        (
322                            Color32::from_rgba_premultiplied(
323                                google_brand.r().saturating_add(lighten_amount),
324                                google_brand.g().saturating_add(lighten_amount),
325                                google_brand.b().saturating_add(lighten_amount),
326                                255,
327                            ),
328                            on_primary,
329                        )
330                    } else {
331                        (google_brand, on_primary)
332                    }
333                }
334            }
335        };
336
337        // Calculate corner radius for FAB
338        let corner_radius = match size_enum {
339            FabSize::Small => 12.0,
340            FabSize::Large => 16.0,
341            _ => 14.0,
342        };
343
344        // Draw FAB background with less rounded corners
345        ui.painter().rect_filled(rect, corner_radius, bg_color);
346
347        // Draw content
348        match size_enum {
349            FabSize::Extended => {
350                // Draw icon and text with proper spacing
351                let left_margin = 16.0;
352                let _right_margin = 24.0;
353                let icon_text_gap = 12.0;
354                let mut content_x = rect.min.x + left_margin;
355
356                if let Some(ref icon_name) = icon {
357                    let icon_rect = Rect::from_min_size(
358                        Pos2::new(content_x, rect.center().y - 12.0),
359                        Vec2::splat(24.0),
360                    );
361
362                    // Draw material icon
363                    let icon_char = material_symbol_text(icon_name);
364                    let icon = MaterialIcon::new(icon_char).size(24.0).color(icon_color);
365                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
366                        ui.add(icon);
367                    });
368
369                    content_x += 24.0 + icon_text_gap;
370                } else if let Some(ref _svg_icon) = svg_icon {
371                    // Render simplified Google logo for branded FAB
372                    draw_google_logo(ui, Pos2::new(content_x + 12.0, rect.center().y), 24.0);
373                    content_x += 24.0 + icon_text_gap;
374                }
375
376                if let Some(ref text) = text {
377                    let text_pos = Pos2::new(content_x, rect.center().y);
378                    ui.painter().text(
379                        text_pos,
380                        egui::Align2::LEFT_CENTER,
381                        text,
382                        egui::FontId::proportional(14.0),
383                        icon_color,
384                    );
385                }
386            }
387            _ => {
388                // Draw centered icon
389                if let Some(ref _svg_icon) = svg_icon {
390                    let icon_size = match size_enum {
391                        FabSize::Small => 18.0,
392                        FabSize::Large => 36.0,
393                        _ => 24.0,
394                    };
395
396                    // Render simplified Google logo for branded FAB
397                    draw_google_logo(ui, rect.center(), icon_size);
398                } else if let Some(ref icon_name) = icon {
399                    let icon_size = match size_enum {
400                        FabSize::Small => 18.0,
401                        FabSize::Large => 36.0,
402                        _ => 24.0,
403                    };
404
405                    let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
406                    let icon_char = material_symbol_text(icon_name);
407                    let icon = MaterialIcon::new(icon_char)
408                        .size(icon_size)
409                        .color(icon_color);
410                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
411                        ui.add(icon);
412                    });
413                } else {
414                    // Default add icon
415                    let icon_size = match size_enum {
416                        FabSize::Small => 18.0,
417                        FabSize::Large => 36.0,
418                        _ => 24.0,
419                    };
420
421                    let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
422                    let icon_char = material_symbol_text("add");
423                    let icon = MaterialIcon::new(icon_char).size(icon_size).color(icon_color);
424                    ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| {
425                        ui.add(icon);
426                    });
427                }
428            }
429        }
430
431        response
432    }
433}
434
435// Helper function to draw Google logo
436fn draw_google_logo(ui: &mut Ui, center: Pos2, size: f32) {
437    let half_size = size / 2.0;
438    let quarter_size = size / 4.0;
439
440    // Google 4-color logo - simplified version
441    // Green (top right)
442    ui.painter().rect_filled(
443        Rect::from_min_size(
444            Pos2::new(center.x, center.y - half_size),
445            Vec2::new(half_size, quarter_size),
446        ),
447        0.0,
448        Color32::from_rgb(52, 168, 83), // Green #34A853
449    );
450
451    // Blue (right)
452    ui.painter().rect_filled(
453        Rect::from_min_size(
454            Pos2::new(center.x, center.y - quarter_size),
455            Vec2::new(half_size, half_size),
456        ),
457        0.0,
458        Color32::from_rgb(66, 133, 244), // Blue #4285F4
459    );
460
461    // Yellow (bottom left)
462    ui.painter().rect_filled(
463        Rect::from_min_size(
464            Pos2::new(center.x - half_size, center.y + quarter_size),
465            Vec2::new(half_size, quarter_size),
466        ),
467        0.0,
468        Color32::from_rgb(251, 188, 5), // Yellow #FBBC05
469    );
470
471    // Red (left)
472    ui.painter().rect_filled(
473        Rect::from_min_size(
474            Pos2::new(center.x - half_size, center.y - half_size),
475            Vec2::new(quarter_size, size),
476        ),
477        0.0,
478        Color32::from_rgb(234, 67, 53), // Red #EA4335
479    );
480}
481
482pub fn fab_surface() -> MaterialFab<'static> {
483    MaterialFab::surface()
484}
485
486pub fn fab_primary() -> MaterialFab<'static> {
487    MaterialFab::primary()
488}
489
490pub fn fab_secondary() -> MaterialFab<'static> {
491    MaterialFab::secondary()
492}
493
494pub fn fab_tertiary() -> MaterialFab<'static> {
495    MaterialFab::tertiary()
496}
497
498pub fn fab_branded() -> MaterialFab<'static> {
499    MaterialFab::branded()
500}
501
502/// Create Google branded icon (4-color logo)
503pub fn google_branded_icon() -> SvgIcon {
504    SvgIcon {
505        paths: vec![
506            SvgPath {
507                path: "M16 16v14h4V20z".to_string(),
508                fill: Color32::from_rgb(52, 168, 83), // Green #34A853
509            },
510            SvgPath {
511                path: "M30 16H20l-4 4h14z".to_string(),
512                fill: Color32::from_rgb(66, 133, 244), // Blue #4285F4
513            },
514            SvgPath {
515                path: "M6 16v4h10l4-4z".to_string(),
516                fill: Color32::from_rgb(251, 188, 5), // Yellow #FBBC05
517            },
518            SvgPath {
519                path: "M20 16V6h-4v14z".to_string(),
520                fill: Color32::from_rgb(234, 67, 53), // Red #EA4335
521            },
522        ],
523        viewbox_size: Vec2::new(36.0, 36.0),
524    }
525}