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            // The reference's shadows for this height (milestone 606).
253            frus_core::paint_elevation(scene, bounds, radius, depth, theme.scheme.shadow.fade(o));
254        }
255
256        // The outline belongs to exactly one of the three. A shadow *and* a hairline is
257        // the mash-up this widget used to be.
258        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    /// **A card takes a whole shape** — which milestone 451 built the field and the
284    /// resolution for, announced in the CHANGELOG, and never gave a builder. A caller
285    /// could still only ask for a corner.
286    #[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        // One field, two builders: the last one called counts.
311        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    /// **A theme can name a card's shape**, and it outranks the plain radius beside it
322    /// (`card.dart`). Until milestone 451 a card could only be given a corner.
323    #[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    /// How many surfaces cast a shadow: the card's, or none.
378    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        // This used to be one card drawing a shadow *and* an outline, which is neither
398        // of the reference's three. Each now says one thing.
399        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        // The height it is given is the height its shadows are cast from (milestone 606).
439        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        // The reference's card carries its own margin; two flush against each other
449        // read as one surface.
450        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}