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 frus_core::paint_elevation(scene, bounds, radius, depth, theme.scheme.shadow.fade(o));
254 }
255
256 let border = if self.kind(Some(theme)) == CardVariant::Outlined {
259 (1.0, theme.scheme.outline_variant.fade(o))
260 } else {
261 (0.0, Color::TRANSPARENT)
262 };
263 scene.draw_shape(
264 bounds,
265 shape.with_side(frus_core::BorderSide::new(border.1, border.0)),
266 self.background(theme).fade(o),
267 );
268 }
269
270 fn on_click(&self) -> Option<Msg> {
271 None
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use frus_core::Primitive;
279
280 #[derive(Clone, Debug, PartialEq)]
281 enum Msg {}
282
283 #[test]
287 fn a_card_takes_a_whole_shape_and_not_only_a_corner() {
288 let corners = |card: &Card<Msg>| {
289 let mut scene = Scene::new();
290 Widget::<Msg>::paint(
291 card,
292 Rect::new(0.0, 0.0, 200.0, 100.0),
293 Status::default(),
294 &Theme::default(),
295 &mut scene,
296 );
297 scene.primitives().iter().find_map(|p| match p {
298 Primitive::Rect {
299 rect, radius, blur, ..
300 } if *blur == 0.0 && rect.width == 200.0 => Some(*radius),
301 _ => None,
302 })
303 };
304
305 assert_eq!(
306 corners(&Card::<Msg>::new().shape(ShapeBorder::stadium())),
307 Some(BorderRadius::uniform(50.0)),
308 "half the short side of 200x100"
309 );
310 assert_eq!(
312 corners(&Card::<Msg>::new().shape(ShapeBorder::stadium()).radius(4.0)),
313 Some(BorderRadius::uniform(4.0))
314 );
315 assert_eq!(
316 corners(&Card::<Msg>::new().radius(4.0).shape(ShapeBorder::stadium())),
317 Some(BorderRadius::uniform(50.0))
318 );
319 }
320
321 #[test]
324 fn a_theme_shapes_a_card_over_its_radius() {
325 let corners = |card: &Card<Msg>, theme: &Theme| {
326 let mut scene = Scene::new();
327 Widget::<Msg>::paint(
328 card,
329 Rect::new(0.0, 0.0, 200.0, 100.0),
330 Status::default(),
331 theme,
332 &mut scene,
333 );
334 scene.primitives().iter().find_map(|p| match p {
335 Primitive::Rect {
336 rect, radius, blur, ..
337 } if *blur == 0.0 && rect.width == 200.0 => Some(*radius),
338 _ => None,
339 })
340 };
341
342 let mut theme = Theme::default();
343 theme.widgets.card.radius = Some(BorderRadius::uniform(2.0));
344 let card = Card::<Msg>::new();
345 assert_eq!(
346 corners(&card, &theme),
347 Some(BorderRadius::uniform(2.0)),
348 "a plain radius still speaks"
349 );
350
351 theme.widgets.card.shape = Some(frus_core::ShapeBorder::stadium());
352 assert_eq!(
353 corners(&card, &theme),
354 Some(BorderRadius::uniform(50.0)),
355 "and a shape beside it outranks it: half the short side of 200x100"
356 );
357
358 assert_eq!(
359 corners(&Card::<Msg>::new().radius(6.0), &theme),
360 Some(BorderRadius::uniform(6.0)),
361 "and the card's own word outranks the theme"
362 );
363 }
364
365 fn primitives(card: &Card<Msg>) -> Vec<Primitive> {
366 let mut scene = Scene::new();
367 Widget::<Msg>::paint(
368 card,
369 Rect::new(0.0, 0.0, 200.0, 100.0),
370 Status::default(),
371 &Theme::default(),
372 &mut scene,
373 );
374 scene.primitives().to_vec()
375 }
376
377 fn shadows(card: &Card<Msg>) -> usize {
379 crate::shadowprobe::casts(&primitives(card))
380 }
381
382 fn border_width(card: &Card<Msg>) -> f32 {
383 primitives(card)
384 .iter()
385 .filter_map(|p| match p {
386 Primitive::Rect {
387 blur, border_width, ..
388 } if *blur == 0.0 => Some(*border_width),
389 _ => None,
390 })
391 .next()
392 .expect("the card paints its own surface")
393 }
394
395 #[test]
396 fn the_three_cards_are_three_different_things() {
397 let elevated = Card::<Msg>::new();
400 assert_eq!(shadows(&elevated), 1, "elevated: a shadow");
401 assert_eq!(border_width(&elevated), 0.0, "elevated: and no outline");
402
403 let filled = Card::<Msg>::new().filled();
404 assert_eq!(shadows(&filled), 0, "filled: neither");
405 assert_eq!(border_width(&filled), 0.0);
406
407 let outlined = Card::<Msg>::new().outlined();
408 assert_eq!(shadows(&outlined), 0, "outlined: no shadow");
409 assert!(border_width(&outlined) > 0.0, "outlined: an outline");
410 }
411
412 #[test]
413 fn the_three_cards_sit_on_three_different_tones() {
414 let theme = Theme::default();
415 let fill = |card: &Card<Msg>| {
416 primitives(card).iter().find_map(|p| match p {
417 Primitive::Rect { blur, color, .. } if *blur == 0.0 => Some(*color),
418 _ => None,
419 })
420 };
421 assert_eq!(
422 fill(&Card::new()),
423 Some(theme.scheme.surface_container_low),
424 "elevated"
425 );
426 assert_eq!(
427 fill(&Card::new().filled()),
428 Some(theme.scheme.surface_container_highest),
429 "filled"
430 );
431 assert_eq!(fill(&Card::new().outlined()), Some(theme.scheme.surface));
432 }
433
434 #[test]
435 fn elevation_is_a_height_and_zero_removes_the_shadow() {
436 assert_eq!(shadows(&Card::<Msg>::new().elevation(0.0)), 0);
437 assert_eq!(shadows(&Card::<Msg>::new().filled().elevation(3.0)), 1);
438 let height = |depth: f32| {
440 crate::shadowprobe::height(&primitives(&Card::<Msg>::new().elevation(depth)))
441 };
442 assert_eq!(height(1.0), 1.0);
443 assert_eq!(height(6.0), 6.0);
444 }
445
446 #[test]
447 fn a_card_leaves_room_around_itself_unless_told_not_to() {
448 assert_eq!(
451 Widget::<Msg>::style(&Card::new()).margin,
452 Insets::uniform(CARD_MARGIN)
453 );
454 assert_eq!(
455 Widget::<Msg>::style(&Card::<Msg>::new().margin(0.0)).margin,
456 Insets::uniform(0.0)
457 );
458 }
459
460 #[test]
461 fn the_colour_and_the_rounding_are_the_callers() {
462 let mine = Color::rgb8(10, 200, 90);
463 let card = Card::<Msg>::new().color(mine).radius(20.0);
464 let found = primitives(&card).iter().find_map(|p| match p {
465 Primitive::Rect {
466 blur,
467 color,
468 radius,
469 ..
470 } if *blur == 0.0 => Some((*color, *radius)),
471 _ => None,
472 });
473 assert_eq!(found, Some((mine, BorderRadius::uniform(20.0))));
474 }
475}