Skip to main content

egui_material3/
card2.rs

1use crate::theme::get_global_color;
2use egui::{
3    ecolor::Color32,
4    epaint::{CornerRadius, Stroke},
5    Rect, Response, Sense, Ui, Vec2, Widget,
6};
7
8/// Material Design card component variants (enhanced version).
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum Card2Variant {
11    Elevated,
12    Filled,
13    Outlined,
14}
15
16/// Enhanced Material Design card component.
17///
18/// This is an enhanced version of the card component with additional features
19/// like media support, action areas, and improved layout options.
20///
21/// ```
22/// # egui::__run_test_ui(|ui| {
23/// // Enhanced card with media and actions
24/// ui.add(MaterialCard2::elevated()
25///     .header("Card Title", Some("Subtitle"))
26///     .media_area(|ui| {
27///         ui.label("Media content goes here");
28///     })
29///     .content(|ui| {
30///         ui.label("Main card content");
31///     })
32///     .actions(|ui| {
33///         if ui.button("Action 1").clicked() {
34///             println!("Action 1 clicked!");
35///         }
36///     }));
37/// # });
38/// ```
39#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
40pub struct MaterialCard2<'a> {
41    variant: Card2Variant,
42    header_title: Option<String>,
43    header_subtitle: Option<String>,
44    media_content: Option<Box<dyn FnOnce(&mut Ui) -> Response + 'a>>,
45    main_content: Option<Box<dyn FnOnce(&mut Ui) -> Response + 'a>>,
46    actions_content: Option<Box<dyn FnOnce(&mut Ui) -> Response + 'a>>,
47    min_size: Vec2,
48    corner_radius: CornerRadius,
49    clickable: bool,
50    media_height: f32,
51    elevation: Option<f32>,
52    surface_tint_color: Option<Color32>,
53    shadow_color: Option<Color32>,
54    margin: f32,
55    clip_behavior: bool,
56    border_on_foreground: bool,
57}
58
59impl<'a> MaterialCard2<'a> {
60    /// Create a new elevated material card.
61    pub fn elevated() -> Self {
62        Self::new_with_variant(Card2Variant::Elevated)
63    }
64
65    /// Create a new filled material card.
66    pub fn filled() -> Self {
67        Self::new_with_variant(Card2Variant::Filled)
68    }
69
70    /// Create a new outlined material card.
71    pub fn outlined() -> Self {
72        Self::new_with_variant(Card2Variant::Outlined)
73    }
74
75    fn new_with_variant(variant: Card2Variant) -> Self {
76        Self {
77            variant,
78            header_title: None,
79            header_subtitle: None,
80            media_content: None,
81            main_content: None,
82            actions_content: None,
83            min_size: Vec2::new(280.0, 200.0), // Larger default size for enhanced card
84            corner_radius: CornerRadius::from(12.0),
85            clickable: false,
86            media_height: 160.0,
87            elevation: None,
88            surface_tint_color: None,
89            shadow_color: None,
90            margin: 4.0,
91            clip_behavior: false,
92            border_on_foreground: true,
93        }
94    }
95
96    /// Set card header with title and optional subtitle.
97    pub fn header(mut self, title: impl Into<String>, subtitle: Option<impl Into<String>>) -> Self {
98        self.header_title = Some(title.into());
99        self.header_subtitle = subtitle.map(|s| s.into());
100        self
101    }
102
103    /// Set media area content.
104    pub fn media_area<F>(mut self, content: F) -> Self
105    where
106        F: FnOnce(&mut Ui) + 'a,
107    {
108        self.media_content = Some(Box::new(move |ui| {
109            content(ui);
110            ui.allocate_response(Vec2::ZERO, Sense::hover())
111        }));
112        self
113    }
114
115    /// Set media area height.
116    pub fn media_height(mut self, height: f32) -> Self {
117        self.media_height = height;
118        self
119    }
120
121    /// Set main content for the card.
122    pub fn content<F>(mut self, content: F) -> Self
123    where
124        F: FnOnce(&mut Ui) + 'a,
125    {
126        self.main_content = Some(Box::new(move |ui| {
127            content(ui);
128            ui.allocate_response(Vec2::ZERO, Sense::hover())
129        }));
130        self
131    }
132
133    /// Set actions area content.
134    pub fn actions<F>(mut self, content: F) -> Self
135    where
136        F: FnOnce(&mut Ui) + 'a,
137    {
138        self.actions_content = Some(Box::new(move |ui| {
139            content(ui);
140            ui.allocate_response(Vec2::ZERO, Sense::hover())
141        }));
142        self
143    }
144
145    /// Set the minimum size of the card.
146    pub fn min_size(mut self, min_size: Vec2) -> Self {
147        self.min_size = min_size;
148        self
149    }
150
151    /// Set the corner radius of the card.
152    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
153        self.corner_radius = corner_radius.into();
154        self
155    }
156
157    /// Make the card clickable.
158    pub fn clickable(mut self, clickable: bool) -> Self {
159        self.clickable = clickable;
160        self
161    }
162
163    /// Set the elevation of the card.
164    /// For Material 3: Elevated = 1.0, Filled = 0.0, Outlined = 0.0
165    pub fn elevation(mut self, elevation: f32) -> Self {
166        self.elevation = Some(elevation.max(0.0));
167        self
168    }
169
170    /// Set the surface tint color for elevation overlay.
171    /// In Material 3, this color is overlaid on the surface to indicate elevation.
172    pub fn surface_tint_color(mut self, color: Color32) -> Self {
173        self.surface_tint_color = Some(color);
174        self
175    }
176
177    /// Set the shadow color.
178    pub fn shadow_color(mut self, color: Color32) -> Self {
179        self.shadow_color = Some(color);
180        self
181    }
182
183    /// Set the margin around the card.
184    pub fn margin(mut self, margin: f32) -> Self {
185        self.margin = margin;
186        self
187    }
188
189    /// Set whether to clip the card content.
190    pub fn clip_behavior(mut self, clip: bool) -> Self {
191        self.clip_behavior = clip;
192        self
193    }
194
195    /// Set whether the border should be painted on foreground.
196    pub fn border_on_foreground(mut self, on_foreground: bool) -> Self {
197        self.border_on_foreground = on_foreground;
198        self
199    }
200
201    fn get_card_style(&self) -> (Color32, Option<Stroke>, f32) {
202        // Material Design 3 theme colors and elevation defaults
203        let md_surface = get_global_color("surface");
204        let md_surface_container_low = get_global_color("surfaceContainerLow");
205        let md_surface_container_highest = get_global_color("surfaceContainerHighest");
206        let md_outline_variant = get_global_color("outlineVariant");
207
208        match self.variant {
209            Card2Variant::Elevated => {
210                // Elevated card: surfaceContainerLow with 1.0 elevation
211                let default_elevation = self.elevation.unwrap_or(1.0);
212                (md_surface_container_low, None, default_elevation)
213            }
214            Card2Variant::Filled => {
215                // Filled card: surfaceContainerHighest with 0.0 elevation
216                let default_elevation = self.elevation.unwrap_or(0.0);
217                (md_surface_container_highest, None, default_elevation)
218            }
219            Card2Variant::Outlined => {
220                // Outlined card: surface with outline and 0.0 elevation
221                let stroke = Some(Stroke::new(1.0, md_outline_variant));
222                let default_elevation = self.elevation.unwrap_or(0.0);
223                (md_surface, stroke, default_elevation)
224            }
225        }
226    }
227
228    /// Calculate surface tint overlay based on elevation level.
229    /// Material 3 uses elevation levels: 0 (0%), 1 (5%), 2 (8%), 3 (11%), 4 (12%), 5 (14%)
230    fn calculate_tint_overlay(&self, elevation: f32) -> f32 {
231        let opacity = match elevation as i32 {
232            0 => 0.0,
233            1 => 0.05,
234            2..=3 => 0.08,
235            4..=6 => 0.11,
236            7..=8 => 0.12,
237            _ => 0.14,
238        };
239        opacity
240    }
241
242    /// Blend surface tint color with base color based on elevation.
243    fn apply_surface_tint(&self, base_color: Color32, elevation: f32) -> Color32 {
244        if elevation <= 0.0 {
245            return base_color;
246        }
247
248        let tint_color = self.surface_tint_color.unwrap_or_else(|| get_global_color("primary"));
249        let tint_opacity = self.calculate_tint_overlay(elevation);
250
251        // Blend tint color over base color
252        Color32::from_rgba_premultiplied(
253            (base_color.r() as f32 * (1.0 - tint_opacity) + tint_color.r() as f32 * tint_opacity) as u8,
254            (base_color.g() as f32 * (1.0 - tint_opacity) + tint_color.g() as f32 * tint_opacity) as u8,
255            (base_color.b() as f32 * (1.0 - tint_opacity) + tint_color.b() as f32 * tint_opacity) as u8,
256            255,
257        )
258    }
259}
260
261impl<'a> Default for MaterialCard2<'a> {
262    fn default() -> Self {
263        Self::elevated()
264    }
265}
266
267impl Widget for MaterialCard2<'_> {
268    fn ui(self, ui: &mut Ui) -> Response {
269        let (base_color, stroke, elevation) = self.get_card_style();
270        let shadow_color = self.shadow_color.unwrap_or_else(|| get_global_color("shadow"));
271        
272        // Apply surface tint overlay based on elevation
273        let background_color = self.apply_surface_tint(base_color, elevation);
274
275        let MaterialCard2 {
276            variant: _,
277            header_title,
278            header_subtitle,
279            media_content,
280            main_content,
281            actions_content,
282            min_size,
283            corner_radius,
284            clickable,
285            media_height,
286            elevation: _,
287            surface_tint_color: _,
288            shadow_color: _,
289            margin,
290            clip_behavior,
291            border_on_foreground,
292        } = self;
293
294        let sense = if clickable {
295            Sense::click()
296        } else {
297            Sense::hover()
298        };
299
300        // Calculate total height based on content
301        let header_height = if header_title.is_some() { 72.0 } else { 0.0 };
302        let media_height_actual = if media_content.is_some() {
303            media_height
304        } else {
305            0.0
306        };
307        let content_height = 80.0; // Default content area height
308        let actions_height = if actions_content.is_some() { 52.0 } else { 0.0 };
309
310        let total_height = header_height + media_height_actual + content_height + actions_height;
311        let card_size = Vec2::new(min_size.x, total_height.max(min_size.y));
312
313        // Apply margin to available space
314        let available_with_margin = ui.available_size() - Vec2::new(
315            margin * 2.0,
316            margin * 2.0,
317        );
318        let desired_size = available_with_margin.max(card_size);
319        
320        let (margin_rect, mut response) = ui.allocate_exact_size(desired_size + Vec2::new(
321            margin * 2.0,
322            margin * 2.0,
323        ), sense);
324        
325        // Apply margin inset
326        let rect = Rect::from_min_size(
327            margin_rect.min + Vec2::new(margin, margin),
328            desired_size,
329        );
330
331        if ui.is_rect_visible(rect) {
332            // Draw shadow based on elevation
333            if elevation > 0.0 {
334                let shadow_offset = (elevation * 0.5).min(4.0);
335                let shadow_blur = elevation * 0.5;
336                let shadow_alpha = (elevation * 3.0).min(30.0) as u8;
337                
338                let shadow_rect = Rect::from_min_size(
339                    rect.min + Vec2::new(0.0, shadow_offset),
340                    rect.size(),
341                );
342                ui.painter().rect_filled(
343                    shadow_rect,
344                    corner_radius,
345                    Color32::from_rgba_unmultiplied(
346                        shadow_color.r(),
347                        shadow_color.g(),
348                        shadow_color.b(),
349                        shadow_alpha,
350                    ),
351                );
352            }
353
354            // Draw border behind if needed
355            if !border_on_foreground {
356                if let Some(stroke) = &stroke {
357                    ui.painter().rect_stroke(
358                        rect,
359                        corner_radius,
360                        *stroke,
361                        egui::epaint::StrokeKind::Outside,
362                    );
363                }
364            }
365
366            // Draw card background
367            ui.painter()
368                .rect_filled(rect, corner_radius, background_color);
369
370            let mut current_y = rect.min.y;
371
372            // Draw header
373            if let Some(title) = &header_title {
374                let _header_rect = Rect::from_min_size(
375                    egui::pos2(rect.min.x, current_y),
376                    Vec2::new(rect.width(), header_height),
377                );
378
379                // Title
380                let title_pos = egui::pos2(rect.min.x + 16.0, current_y + 16.0);
381                ui.painter().text(
382                    title_pos,
383                    egui::Align2::LEFT_TOP,
384                    title,
385                    egui::FontId::proportional(20.0),
386                    get_global_color("onSurface"),
387                );
388
389                // Subtitle if present
390                if let Some(subtitle) = &header_subtitle {
391                    let subtitle_pos = egui::pos2(rect.min.x + 16.0, current_y + 44.0);
392                    ui.painter().text(
393                        subtitle_pos,
394                        egui::Align2::LEFT_TOP,
395                        subtitle,
396                        egui::FontId::proportional(14.0),
397                        get_global_color("onSurfaceVariant"),
398                    );
399                }
400
401                current_y += header_height;
402            }
403
404            // Draw media area
405            if let Some(media_fn) = media_content {
406                let media_rect = Rect::from_min_size(
407                    egui::pos2(rect.min.x, current_y),
408                    Vec2::new(rect.width(), media_height),
409                );
410
411                // Clip media content to card bounds
412                let mut media_ui_builder = egui::UiBuilder::new().max_rect(media_rect);
413                if clip_behavior {
414                    // Enable clipping for media area
415                    media_ui_builder = media_ui_builder.sense(Sense::hover());
416                }
417                
418                let media_response = ui.scope_builder(media_ui_builder, |ui| {
419                    // Draw media background
420                    ui.painter().rect_filled(
421                        media_rect,
422                        CornerRadius::ZERO,
423                        get_global_color("surfaceVariant"),
424                    );
425
426                    media_fn(ui)
427                });
428
429                response = response.union(media_response.response);
430                current_y += media_height;
431            }
432
433            // Draw main content
434            if let Some(content_fn) = main_content {
435                let content_rect = Rect::from_min_size(
436                    egui::pos2(rect.min.x, current_y),
437                    Vec2::new(rect.width(), content_height),
438                );
439
440                let content_response = ui.scope_builder(
441                    egui::UiBuilder::new().max_rect(content_rect.shrink(16.0)),
442                    |ui| content_fn(ui),
443                );
444
445                response = response.union(content_response.response);
446                current_y += content_height;
447            }
448
449            // Draw actions area
450            if let Some(actions_fn) = actions_content {
451                let actions_rect = Rect::from_min_size(
452                    egui::pos2(rect.min.x, current_y),
453                    Vec2::new(rect.width(), actions_height),
454                );
455
456                let actions_response = ui.scope_builder(
457                    egui::UiBuilder::new().max_rect(actions_rect.shrink2(Vec2::new(8.0, 8.0))),
458                    |ui| {
459                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
460                            actions_fn(ui)
461                        })
462                        .inner
463                    },
464                );
465
466                response = response.union(actions_response.response);
467            }
468
469            // Draw border on foreground if needed
470            if border_on_foreground {
471                if let Some(stroke) = stroke {
472                    ui.painter().rect_stroke(
473                        rect,
474                        corner_radius,
475                        stroke,
476                        egui::epaint::StrokeKind::Outside,
477                    );
478                }
479            }
480        }
481
482        response
483    }
484}
485
486/// Convenience function to create an elevated enhanced card.
487pub fn elevated_card2() -> MaterialCard2<'static> {
488    MaterialCard2::elevated()
489}
490
491/// Convenience function to create a filled enhanced card.
492pub fn filled_card2() -> MaterialCard2<'static> {
493    MaterialCard2::filled()
494}
495
496/// Convenience function to create an outlined enhanced card.
497pub fn outlined_card2() -> MaterialCard2<'static> {
498    MaterialCard2::outlined()
499}