1use egui::{
2 ecolor::Color32,
3 epaint::{Stroke, CornerRadius},
4 Rect, Response, Sense, Ui, Vec2, Widget,
5};
6use crate::theme::get_global_color;
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}
52
53impl<'a> MaterialCard2<'a> {
54 pub fn elevated() -> Self {
56 Self::new_with_variant(Card2Variant::Elevated)
57 }
58
59 pub fn filled() -> Self {
61 Self::new_with_variant(Card2Variant::Filled)
62 }
63
64 pub fn outlined() -> Self {
66 Self::new_with_variant(Card2Variant::Outlined)
67 }
68
69 fn new_with_variant(variant: Card2Variant) -> Self {
70 Self {
71 variant,
72 header_title: None,
73 header_subtitle: None,
74 media_content: None,
75 main_content: None,
76 actions_content: None,
77 min_size: Vec2::new(280.0, 200.0), corner_radius: CornerRadius::from(12.0),
79 clickable: false,
80 media_height: 160.0,
81 }
82 }
83
84 pub fn header(mut self, title: impl Into<String>, subtitle: Option<impl Into<String>>) -> Self {
86 self.header_title = Some(title.into());
87 self.header_subtitle = subtitle.map(|s| s.into());
88 self
89 }
90
91 pub fn media_area<F>(mut self, content: F) -> Self
93 where
94 F: FnOnce(&mut Ui) + 'a,
95 {
96 self.media_content = Some(Box::new(move |ui| {
97 content(ui);
98 ui.allocate_response(Vec2::ZERO, Sense::hover())
99 }));
100 self
101 }
102
103 pub fn media_height(mut self, height: f32) -> Self {
105 self.media_height = height;
106 self
107 }
108
109 pub fn content<F>(mut self, content: F) -> Self
111 where
112 F: FnOnce(&mut Ui) + 'a,
113 {
114 self.main_content = Some(Box::new(move |ui| {
115 content(ui);
116 ui.allocate_response(Vec2::ZERO, Sense::hover())
117 }));
118 self
119 }
120
121 pub fn actions<F>(mut self, content: F) -> Self
123 where
124 F: FnOnce(&mut Ui) + 'a,
125 {
126 self.actions_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 min_size(mut self, min_size: Vec2) -> Self {
135 self.min_size = min_size;
136 self
137 }
138
139 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
141 self.corner_radius = corner_radius.into();
142 self
143 }
144
145 pub fn clickable(mut self, clickable: bool) -> Self {
147 self.clickable = clickable;
148 self
149 }
150
151 fn get_card_style(&self) -> (Color32, Option<Stroke>, bool) {
152 let md_surface = get_global_color("surface");
154 let md_surface_container_highest = get_global_color("surfaceContainerHighest");
155 let md_outline_variant = get_global_color("outlineVariant");
156
157 match self.variant {
158 Card2Variant::Elevated => {
159 (md_surface, None, true)
161 },
162 Card2Variant::Filled => {
163 (md_surface_container_highest, None, false)
165 },
166 Card2Variant::Outlined => {
167 let stroke = Some(Stroke::new(1.0, md_outline_variant));
169 (md_surface, stroke, false)
170 },
171 }
172 }
173}
174
175impl<'a> Default for MaterialCard2<'a> {
176 fn default() -> Self {
177 Self::elevated()
178 }
179}
180
181impl Widget for MaterialCard2<'_> {
182 fn ui(self, ui: &mut Ui) -> Response {
183 let (background_color, stroke, has_shadow) = self.get_card_style();
184
185 let MaterialCard2 {
186 variant: _,
187 header_title,
188 header_subtitle,
189 media_content,
190 main_content,
191 actions_content,
192 min_size,
193 corner_radius,
194 clickable,
195 media_height,
196 } = self;
197
198 let sense = if clickable {
199 Sense::click()
200 } else {
201 Sense::hover()
202 };
203
204 let header_height = if header_title.is_some() { 72.0 } else { 0.0 };
206 let media_height_actual = if media_content.is_some() { media_height } else { 0.0 };
207 let content_height = 80.0; let actions_height = if actions_content.is_some() { 52.0 } else { 0.0 };
209
210 let total_height = header_height + media_height_actual + content_height + actions_height;
211 let card_size = Vec2::new(min_size.x, total_height.max(min_size.y));
212
213 let desired_size = ui.available_size().max(card_size);
214 let mut response = ui.allocate_response(desired_size, sense);
215 let rect = response.rect;
216
217 if ui.is_rect_visible(rect) {
218 if has_shadow {
220 let shadow_rect = Rect::from_min_size(
221 rect.min + Vec2::new(0.0, 2.0),
222 rect.size(),
223 );
224 ui.painter().rect_filled(
225 shadow_rect,
226 corner_radius,
227 Color32::from_rgba_unmultiplied(0, 0, 0, 20),
228 );
229 }
230
231 ui.painter().rect_filled(rect, corner_radius, background_color);
233
234 if let Some(stroke) = stroke {
236 ui.painter().rect_stroke(rect, corner_radius, stroke, egui::epaint::StrokeKind::Outside);
237 }
238
239 let mut current_y = rect.min.y;
240
241 if let Some(title) = &header_title {
243 let _header_rect = Rect::from_min_size(
244 egui::pos2(rect.min.x, current_y),
245 Vec2::new(rect.width(), header_height)
246 );
247
248 let title_pos = egui::pos2(rect.min.x + 16.0, current_y + 16.0);
250 ui.painter().text(
251 title_pos,
252 egui::Align2::LEFT_TOP,
253 title,
254 egui::FontId::proportional(20.0),
255 get_global_color("onSurface")
256 );
257
258 if let Some(subtitle) = &header_subtitle {
260 let subtitle_pos = egui::pos2(rect.min.x + 16.0, current_y + 44.0);
261 ui.painter().text(
262 subtitle_pos,
263 egui::Align2::LEFT_TOP,
264 subtitle,
265 egui::FontId::proportional(14.0),
266 get_global_color("onSurfaceVariant")
267 );
268 }
269
270 current_y += header_height;
271 }
272
273 if let Some(media_fn) = media_content {
275 let media_rect = Rect::from_min_size(
276 egui::pos2(rect.min.x, current_y),
277 Vec2::new(rect.width(), media_height)
278 );
279
280 let media_response = ui.scope_builder(
282 egui::UiBuilder::new().max_rect(media_rect),
283 |ui| {
284 ui.painter().rect_filled(
286 media_rect,
287 CornerRadius::ZERO,
288 get_global_color("surfaceVariant")
289 );
290
291 media_fn(ui)
292 }
293 );
294
295 response = response.union(media_response.response);
296 current_y += media_height;
297 }
298
299 if let Some(content_fn) = main_content {
301 let content_rect = Rect::from_min_size(
302 egui::pos2(rect.min.x, current_y),
303 Vec2::new(rect.width(), content_height)
304 );
305
306 let content_response = ui.scope_builder(
307 egui::UiBuilder::new().max_rect(content_rect.shrink(16.0)),
308 |ui| {
309 content_fn(ui)
310 }
311 );
312
313 response = response.union(content_response.response);
314 current_y += content_height;
315 }
316
317 if let Some(actions_fn) = actions_content {
319 let actions_rect = Rect::from_min_size(
320 egui::pos2(rect.min.x, current_y),
321 Vec2::new(rect.width(), actions_height)
322 );
323
324 let actions_response = ui.scope_builder(
325 egui::UiBuilder::new().max_rect(actions_rect.shrink2(Vec2::new(8.0, 8.0))),
326 |ui| {
327 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
328 actions_fn(ui)
329 }).inner
330 }
331 );
332
333 response = response.union(actions_response.response);
334 }
335 }
336
337 response
338 }
339}
340
341pub fn elevated_card2() -> MaterialCard2<'static> {
343 MaterialCard2::elevated()
344}
345
346pub fn filled_card2() -> MaterialCard2<'static> {
348 MaterialCard2::filled()
349}
350
351pub fn outlined_card2() -> MaterialCard2<'static> {
353 MaterialCard2::outlined()
354}