Skip to main content

frus_widgets/
card.rs

1//! [`Card`]: a themed surface — background, radius, shadow or outline — with one child.
2
3use 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
10/// The room a card leaves around itself, in logical pixels, when the caller has not
11/// said otherwise — the reference's figure. Cards are usually stacked, and two of them
12/// flush against each other read as one.
13pub const CARD_MARGIN: f32 = 4.0;
14/// The default depth of an [`CardVariant::Elevated`] card. Elevation is a **height**,
15/// not a blur radius: [`Card::elevation`] says how far off the surface the card sits,
16/// and the shadow is derived from it.
17pub const CARD_ELEVATION: f32 = 1.0;
18/// The room a card leaves **inside** itself by default. An addition of this framework:
19/// the reference's card has no padding and leaves it to the content.
20pub const CARD_PADDING: f32 = 16.0;
21
22/// Which of the three cards this is.
23///
24/// The reference has three, and they are not interchangeable: each says something
25/// different about how far the card should stand off what is behind it. This used to be
26/// one widget drawing a shadow **and** an outline, which is none of the three.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum CardVariant {
29    /// Lifted off the surface by a shadow, no outline. The default.
30    #[default]
31    Elevated,
32    /// A flatter tonal surface: no shadow, no outline. For a card inside something
33    /// already elevated, where a second shadow only adds noise.
34    Filled,
35    /// An outline and no shadow — the quietest of the three, and the one that survives
36    /// on a background a shadow would be invisible against.
37    Outlined,
38}
39
40/// A card: a surface container, in the theme's colours.
41///
42/// ```ignore
43/// Card::new().child(content)                          // elevated, the default
44/// Card::new().outlined().child(content)               // an outline, no shadow
45/// Card::new().elevation(6.0).radius(20.0)             // further off the surface
46/// Card::new().color(theme.scheme.primary_container)   // its own colour
47/// Card::new().margin(0.0)                             // flush, for a caller that
48///                                                     // spaces its own children
49/// ```
50///
51/// Everything here is a **default**, not a rule: the colour, the rounding, the depth,
52/// the margin and the padding are all the caller's to set.
53pub struct Card<Msg = crate::callback::Callback> {
54    /// `None` = the theme's, then [`CardVariant::Elevated`].
55    variant: Option<CardVariant>,
56    /// `None` = the theme's, then 16.
57    padding: Option<f32>,
58    /// `None` = [`CARD_MARGIN`].
59    margin: Option<f32>,
60    /// `None` = [`CARD_ELEVATION`] for an elevated card, and no shadow for the others.
61    elevation: Option<f32>,
62    /// `None` = the theme's surface for this variant.
63    color: Option<Color>,
64    /// `None` = the theme's shape, then its radius, then the framework's corner.
65    shape: Option<ShapeBorder>,
66    children: Vec<Box<dyn Widget<Msg>>>,
67}
68
69impl<Msg> Card<Msg> {
70    /// Creates an elevated card, with a default padding of 16.
71    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    /// Chooses the variant.
84    pub fn variant(mut self, variant: CardVariant) -> Self {
85        self.variant = Some(variant);
86        self
87    }
88
89    /// A flat tonal card: no shadow, no outline.
90    pub fn filled(self) -> Self {
91        self.variant(CardVariant::Filled)
92    }
93
94    /// An outlined card: a hairline, no shadow.
95    pub fn outlined(self) -> Self {
96        self.variant(CardVariant::Outlined)
97    }
98
99    /// Uniform padding **inside** the card. An addition of this framework — the
100    /// reference's card has none and leaves it to the content — kept because a card
101    /// whose text touches its own edge is the more common mistake.
102    pub fn padding(mut self, padding: f32) -> Self {
103        self.padding = Some(padding);
104        self
105    }
106
107    /// Uniform room **outside** the card. Defaults to [`CARD_MARGIN`]; `0.0` for a
108    /// caller that spaces its children itself.
109    pub fn margin(mut self, margin: f32) -> Self {
110        self.margin = Some(margin);
111        self
112    }
113
114    /// How far off the surface the card sits. `0.0` removes the shadow; the default is
115    /// [`CARD_ELEVATION`] for an elevated card and nothing for the other two.
116    pub fn elevation(mut self, elevation: f32) -> Self {
117        self.elevation = Some(elevation);
118        self
119    }
120
121    /// Overrides the background colour.
122    pub fn color(mut self, color: Color) -> Self {
123        self.color = Some(color);
124        self
125    }
126
127    /// Overrides the corner radii (uniform via `f32`, per corner via [`BorderRadius`]).
128    /// **What shape the card is** — the reference's `shape` (`card.dart`), over the
129    /// corners [`radius`](Self::radius) names. They are **one** field, and the last of the
130    /// two called is the one that counts.
131    ///
132    /// Milestone 451 gave the card the field and the resolution and — the CHANGELOG
133    /// notwithstanding — never gave it this builder, so a caller could still only ask for
134    /// a corner. This is that builder.
135    #[must_use]
136    pub fn shape(mut self, shape: ShapeBorder) -> Self {
137        self.shape = Some(shape);
138        self
139    }
140
141    /// Rounds the card's corners with `radius`. It is the same field as
142    /// [`shape`](Self::shape): the last of the two called is the one that counts.
143    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
144        self.shape = Some(ShapeBorder::rounded(radius));
145        self
146    }
147
148    /// Sets the card's content.
149    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    /// Which of the three this is: what was asked for, then the theme's, then elevated.
156    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    /// The depth actually used: what was asked for, then the theme's, then the
163    /// variant's own.
164    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    /// The background actually used.
174    fn background(&self, theme: &Theme) -> Color {
175        self.color
176            .or(theme.widgets.card.color)
177            .unwrap_or(match self.kind(Some(theme)) {
178                // The two ends of the ladder the reference puts them on
179                // (`card.dart:313` and `:348`): a card off the page takes the least
180                // emphasis a container has, a filled one the most.
181                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    /// The two spacings, resolved together: caller, then theme, then ours.
196    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        // The shadow is a rectangle whatever the card is, so it takes the corners the
241        // shape resolves to and none from one with no rounded form.
242        let radius = shape
243            .as_rounded(bounds)
244            .map(|(_, radius)| radius)
245            .unwrap_or(BorderRadius::ZERO);
246        let depth = self.depth(theme);
247
248        // The shadow, when the card is off the surface at all. The blur grows with the
249        // depth and the drop is half of it: a card 1 px up casts a tight shadow under
250        // its own edge, one 6 px up casts a wide soft one below it.
251        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        // The outline belongs to exactly one of the three. A shadow *and* a hairline is
267        // the mash-up this widget used to be.
268        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    /// **A card takes a whole shape** — which milestone 451 built the field and the
294    /// resolution for, announced in the CHANGELOG, and never gave a builder. A caller
295    /// could still only ask for a corner.
296    #[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        // One field, two builders: the last one called counts.
321        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    /// **A theme can name a card's shape**, and it outranks the plain radius beside it
332    /// (`card.dart`). Until milestone 451 a card could only be given a corner.
333    #[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        // This used to be one card drawing a shadow *and* an outline, which is neither
410        // of the reference's three. Each now says one thing.
411        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        // Higher card, wider and lower shadow.
451        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        // The rectangle handed to `shadow` is the card's box **grown by the blur** on
464        // every side, so its own `y` runs the wrong way as the blur grows. The drop is
465        // what is left once that growth is taken back off.
466        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        // The reference's card carries its own margin; two flush against each other
477        // read as one surface.
478        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}