1use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder};
4use frus_layout::Style;
5
6use crate::interaction::Status;
7use crate::theme::Theme;
8use crate::widget::Widget;
9
10pub const CARD_MARGIN: f32 = 4.0;
14pub const CARD_ELEVATION: f32 = 1.0;
18pub const CARD_PADDING: f32 = 16.0;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum CardVariant {
29 #[default]
31 Elevated,
32 Filled,
35 Outlined,
38}
39
40pub struct Card<Msg = crate::callback::Callback> {
54 variant: Option<CardVariant>,
56 padding: Option<f32>,
58 margin: Option<f32>,
60 elevation: Option<f32>,
62 color: Option<Color>,
64 shape: Option<ShapeBorder>,
66 children: Vec<Box<dyn Widget<Msg>>>,
67}
68
69impl<Msg> Card<Msg> {
70 pub fn new() -> Self {
72 Self {
73 variant: None,
74 padding: None,
75 margin: None,
76 elevation: None,
77 color: None,
78 shape: None,
79 children: Vec::new(),
80 }
81 }
82
83 pub fn variant(mut self, variant: CardVariant) -> Self {
85 self.variant = Some(variant);
86 self
87 }
88
89 pub fn filled(self) -> Self {
91 self.variant(CardVariant::Filled)
92 }
93
94 pub fn outlined(self) -> Self {
96 self.variant(CardVariant::Outlined)
97 }
98
99 pub fn padding(mut self, padding: f32) -> Self {
103 self.padding = Some(padding);
104 self
105 }
106
107 pub fn margin(mut self, margin: f32) -> Self {
110 self.margin = Some(margin);
111 self
112 }
113
114 pub fn elevation(mut self, elevation: f32) -> Self {
117 self.elevation = Some(elevation);
118 self
119 }
120
121 pub fn color(mut self, color: Color) -> Self {
123 self.color = Some(color);
124 self
125 }
126
127 #[must_use]
136 pub fn shape(mut self, shape: ShapeBorder) -> Self {
137 self.shape = Some(shape);
138 self
139 }
140
141 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
144 self.shape = Some(ShapeBorder::rounded(radius));
145 self
146 }
147
148 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
150 self.children.clear();
151 self.children.push(Box::new(child));
152 self
153 }
154
155 fn kind(&self, theme: Option<&Theme>) -> CardVariant {
157 self.variant
158 .or_else(|| theme.and_then(|t| t.widgets.card.variant))
159 .unwrap_or_default()
160 }
161
162 fn depth(&self, theme: &Theme) -> f32 {
165 self.elevation
166 .or(theme.widgets.card.elevation)
167 .unwrap_or(match self.kind(Some(theme)) {
168 CardVariant::Elevated => CARD_ELEVATION,
169 CardVariant::Filled | CardVariant::Outlined => 0.0,
170 })
171 }
172
173 fn background(&self, theme: &Theme) -> Color {
175 self.color
176 .or(theme.widgets.card.color)
177 .unwrap_or(match self.kind(Some(theme)) {
178 CardVariant::Elevated => theme.scheme.surface_container_low,
182 CardVariant::Filled => theme.scheme.surface_container_highest,
183 CardVariant::Outlined => theme.scheme.surface,
184 })
185 }
186}
187
188impl<Msg> Default for Card<Msg> {
189 fn default() -> Self {
190 Self::new()
191 }
192}
193
194impl<Msg> Card<Msg> {
195 fn spacing(&self, theme: Option<&Theme>) -> (f32, f32) {
197 let padding = self
198 .padding
199 .or_else(|| theme.and_then(|t| t.widgets.card.padding))
200 .unwrap_or(CARD_PADDING);
201 let margin = self
202 .margin
203 .or_else(|| theme.and_then(|t| t.widgets.card.margin))
204 .unwrap_or(CARD_MARGIN);
205 (padding, margin)
206 }
207}
208
209impl<Msg> Widget<Msg> for Card<Msg> {
210 fn style(&self) -> Style {
211 let (padding, margin) = self.spacing(None);
212 Style {
213 padding: Insets::uniform(padding),
214 margin: Insets::uniform(margin),
215 ..Default::default()
216 }
217 }
218
219 fn style_themed(&self, theme: &Theme) -> Style {
220 let (padding, margin) = self.spacing(Some(theme));
221 Style {
222 padding: Insets::uniform(padding),
223 margin: Insets::uniform(margin),
224 ..Default::default()
225 }
226 }
227
228 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
229 &self.children
230 }
231
232 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
233 let o = status.opacity;
234 let shape = crate::resolve_shape(
235 self.shape,
236 theme.widgets.card.shape,
237 theme.widgets.card.radius,
238 ShapeBorder::rounded(theme.radius),
239 );
240 let radius = shape
243 .as_rounded(bounds)
244 .map(|(_, radius)| radius)
245 .unwrap_or(BorderRadius::ZERO);
246 let depth = self.depth(theme);
247
248 if depth > 0.0 {
252 let blur = depth * 4.0 + 8.0;
253 scene.shadow(
254 Rect::new(
255 bounds.x - blur,
256 bounds.y + depth * 2.0 - blur,
257 bounds.width + 2.0 * blur,
258 bounds.height + 2.0 * blur,
259 ),
260 theme.scheme.shadow.with_alpha(0.30).fade(o),
261 radius.inflate(blur),
262 blur,
263 );
264 }
265
266 let border = if self.kind(Some(theme)) == CardVariant::Outlined {
269 (1.0, theme.scheme.outline_variant.fade(o))
270 } else {
271 (0.0, Color::TRANSPARENT)
272 };
273 scene.draw_shape(
274 bounds,
275 shape.with_side(frus_core::BorderSide::new(border.1, border.0)),
276 self.background(theme).fade(o),
277 );
278 }
279
280 fn on_click(&self) -> Option<Msg> {
281 None
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use frus_core::Primitive;
289
290 #[derive(Clone, Debug, PartialEq)]
291 enum Msg {}
292
293 #[test]
297 fn a_card_takes_a_whole_shape_and_not_only_a_corner() {
298 let corners = |card: &Card<Msg>| {
299 let mut scene = Scene::new();
300 Widget::<Msg>::paint(
301 card,
302 Rect::new(0.0, 0.0, 200.0, 100.0),
303 Status::default(),
304 &Theme::default(),
305 &mut scene,
306 );
307 scene.primitives().iter().find_map(|p| match p {
308 Primitive::Rect {
309 rect, radius, blur, ..
310 } if *blur == 0.0 && rect.width == 200.0 => Some(*radius),
311 _ => None,
312 })
313 };
314
315 assert_eq!(
316 corners(&Card::<Msg>::new().shape(ShapeBorder::stadium())),
317 Some(BorderRadius::uniform(50.0)),
318 "half the short side of 200x100"
319 );
320 assert_eq!(
322 corners(&Card::<Msg>::new().shape(ShapeBorder::stadium()).radius(4.0)),
323 Some(BorderRadius::uniform(4.0))
324 );
325 assert_eq!(
326 corners(&Card::<Msg>::new().radius(4.0).shape(ShapeBorder::stadium())),
327 Some(BorderRadius::uniform(50.0))
328 );
329 }
330
331 #[test]
334 fn a_theme_shapes_a_card_over_its_radius() {
335 let corners = |card: &Card<Msg>, theme: &Theme| {
336 let mut scene = Scene::new();
337 Widget::<Msg>::paint(
338 card,
339 Rect::new(0.0, 0.0, 200.0, 100.0),
340 Status::default(),
341 theme,
342 &mut scene,
343 );
344 scene.primitives().iter().find_map(|p| match p {
345 Primitive::Rect {
346 rect, radius, blur, ..
347 } if *blur == 0.0 && rect.width == 200.0 => Some(*radius),
348 _ => None,
349 })
350 };
351
352 let mut theme = Theme::default();
353 theme.widgets.card.radius = Some(BorderRadius::uniform(2.0));
354 let card = Card::<Msg>::new();
355 assert_eq!(
356 corners(&card, &theme),
357 Some(BorderRadius::uniform(2.0)),
358 "a plain radius still speaks"
359 );
360
361 theme.widgets.card.shape = Some(frus_core::ShapeBorder::stadium());
362 assert_eq!(
363 corners(&card, &theme),
364 Some(BorderRadius::uniform(50.0)),
365 "and a shape beside it outranks it: half the short side of 200x100"
366 );
367
368 assert_eq!(
369 corners(&Card::<Msg>::new().radius(6.0), &theme),
370 Some(BorderRadius::uniform(6.0)),
371 "and the card's own word outranks the theme"
372 );
373 }
374
375 fn primitives(card: &Card<Msg>) -> Vec<Primitive> {
376 let mut scene = Scene::new();
377 Widget::<Msg>::paint(
378 card,
379 Rect::new(0.0, 0.0, 200.0, 100.0),
380 Status::default(),
381 &Theme::default(),
382 &mut scene,
383 );
384 scene.primitives().to_vec()
385 }
386
387 fn shadows(card: &Card<Msg>) -> usize {
388 primitives(card)
389 .iter()
390 .filter(|p| matches!(p, Primitive::Rect { blur, .. } if *blur > 0.0))
391 .count()
392 }
393
394 fn border_width(card: &Card<Msg>) -> f32 {
395 primitives(card)
396 .iter()
397 .filter_map(|p| match p {
398 Primitive::Rect {
399 blur, border_width, ..
400 } if *blur == 0.0 => Some(*border_width),
401 _ => None,
402 })
403 .next()
404 .expect("the card paints its own surface")
405 }
406
407 #[test]
408 fn the_three_cards_are_three_different_things() {
409 let elevated = Card::<Msg>::new();
412 assert_eq!(shadows(&elevated), 1, "elevated: a shadow");
413 assert_eq!(border_width(&elevated), 0.0, "elevated: and no outline");
414
415 let filled = Card::<Msg>::new().filled();
416 assert_eq!(shadows(&filled), 0, "filled: neither");
417 assert_eq!(border_width(&filled), 0.0);
418
419 let outlined = Card::<Msg>::new().outlined();
420 assert_eq!(shadows(&outlined), 0, "outlined: no shadow");
421 assert!(border_width(&outlined) > 0.0, "outlined: an outline");
422 }
423
424 #[test]
425 fn the_three_cards_sit_on_three_different_tones() {
426 let theme = Theme::default();
427 let fill = |card: &Card<Msg>| {
428 primitives(card).iter().find_map(|p| match p {
429 Primitive::Rect { blur, color, .. } if *blur == 0.0 => Some(*color),
430 _ => None,
431 })
432 };
433 assert_eq!(
434 fill(&Card::new()),
435 Some(theme.scheme.surface_container_low),
436 "elevated"
437 );
438 assert_eq!(
439 fill(&Card::new().filled()),
440 Some(theme.scheme.surface_container_highest),
441 "filled"
442 );
443 assert_eq!(fill(&Card::new().outlined()), Some(theme.scheme.surface));
444 }
445
446 #[test]
447 fn elevation_is_a_height_and_zero_removes_the_shadow() {
448 assert_eq!(shadows(&Card::<Msg>::new().elevation(0.0)), 0);
449 assert_eq!(shadows(&Card::<Msg>::new().filled().elevation(3.0)), 1);
450 let extent = |depth: f32| {
452 primitives(&Card::<Msg>::new().elevation(depth))
453 .iter()
454 .find_map(|p| match p {
455 Primitive::Rect { blur, rect, .. } if *blur > 0.0 => Some((*blur, rect.y)),
456 _ => None,
457 })
458 .expect("a shadow")
459 };
460 let (low_blur, low_y) = extent(1.0);
461 let (high_blur, high_y) = extent(6.0);
462 assert!(high_blur > low_blur, "a taller card blurs wider");
463 assert!(
467 high_y + high_blur > low_y + low_blur,
468 "and drops further: {} against {}",
469 high_y + high_blur,
470 low_y + low_blur
471 );
472 }
473
474 #[test]
475 fn a_card_leaves_room_around_itself_unless_told_not_to() {
476 assert_eq!(
479 Widget::<Msg>::style(&Card::new()).margin,
480 Insets::uniform(CARD_MARGIN)
481 );
482 assert_eq!(
483 Widget::<Msg>::style(&Card::<Msg>::new().margin(0.0)).margin,
484 Insets::uniform(0.0)
485 );
486 }
487
488 #[test]
489 fn the_colour_and_the_rounding_are_the_callers() {
490 let mine = Color::rgb8(10, 200, 90);
491 let card = Card::<Msg>::new().color(mine).radius(20.0);
492 let found = primitives(&card).iter().find_map(|p| match p {
493 Primitive::Rect {
494 blur,
495 color,
496 radius,
497 ..
498 } if *blur == 0.0 => Some((*color, *radius)),
499 _ => None,
500 });
501 assert_eq!(found, Some((mine, BorderRadius::uniform(20.0))));
502 }
503}