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#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum Card2Variant {
11 Elevated,
12 Filled,
13 Outlined,
14}
15
16#[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 pub fn elevated() -> Self {
62 Self::new_with_variant(Card2Variant::Elevated)
63 }
64
65 pub fn filled() -> Self {
67 Self::new_with_variant(Card2Variant::Filled)
68 }
69
70 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), 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 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 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 pub fn media_height(mut self, height: f32) -> Self {
117 self.media_height = height;
118 self
119 }
120
121 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 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 pub fn min_size(mut self, min_size: Vec2) -> Self {
147 self.min_size = min_size;
148 self
149 }
150
151 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
153 self.corner_radius = corner_radius.into();
154 self
155 }
156
157 pub fn clickable(mut self, clickable: bool) -> Self {
159 self.clickable = clickable;
160 self
161 }
162
163 pub fn elevation(mut self, elevation: f32) -> Self {
166 self.elevation = Some(elevation.max(0.0));
167 self
168 }
169
170 pub fn surface_tint_color(mut self, color: Color32) -> Self {
173 self.surface_tint_color = Some(color);
174 self
175 }
176
177 pub fn shadow_color(mut self, color: Color32) -> Self {
179 self.shadow_color = Some(color);
180 self
181 }
182
183 pub fn margin(mut self, margin: f32) -> Self {
185 self.margin = margin;
186 self
187 }
188
189 pub fn clip_behavior(mut self, clip: bool) -> Self {
191 self.clip_behavior = clip;
192 self
193 }
194
195 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 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 let default_elevation = self.elevation.unwrap_or(1.0);
212 (md_surface_container_low, None, default_elevation)
213 }
214 Card2Variant::Filled => {
215 let default_elevation = self.elevation.unwrap_or(0.0);
217 (md_surface_container_highest, None, default_elevation)
218 }
219 Card2Variant::Outlined => {
220 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 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 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 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 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 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; 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 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 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 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 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 ui.painter()
368 .rect_filled(rect, corner_radius, background_color);
369
370 let mut current_y = rect.min.y;
371
372 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 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 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 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 let mut media_ui_builder = egui::UiBuilder::new().max_rect(media_rect);
413 if clip_behavior {
414 media_ui_builder = media_ui_builder.sense(Sense::hover());
416 }
417
418 let media_response = ui.scope_builder(media_ui_builder, |ui| {
419 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 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 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 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
486pub fn elevated_card2() -> MaterialCard2<'static> {
488 MaterialCard2::elevated()
489}
490
491pub fn filled_card2() -> MaterialCard2<'static> {
493 MaterialCard2::filled()
494}
495
496pub fn outlined_card2() -> MaterialCard2<'static> {
498 MaterialCard2::outlined()
499}