Skip to main content

iced_cube/layout/
card.rs

1//! Raised surfaces that group related content.
2
3use iced::widget::{column, container, text};
4use iced::{Background, Border, Color, Element, Length, Shadow, Vector};
5
6use crate::theme::{Tokens, fade, mix, space, text_size};
7
8/// Headings break inside a word only when the word alone is wider than the
9/// card, so they never run past the border.
10const HEADER_WRAPPING: text::Wrapping = text::Wrapping::WordOrGlyph;
11
12/// A card builder. Convert it into an [`Element`] to render.
13pub struct Card<'a, Message> {
14    title: Option<text::Fragment<'a>>,
15    description: Option<text::Fragment<'a>>,
16    body: Option<Element<'a, Message>>,
17    footer: Option<Element<'a, Message>>,
18    width: Length,
19}
20
21impl<Message> std::fmt::Debug for Card<'_, Message> {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("Card")
24            .field("title", &self.title)
25            .field("description", &self.description)
26            .field("body", &self.body.is_some())
27            .field("footer", &self.footer.is_some())
28            .field("width", &self.width)
29            .finish()
30    }
31}
32
33/// Creates an empty card. Add a header, body and footer with the builder.
34pub fn card<'a, Message>() -> Card<'a, Message> {
35    Card {
36        title: None,
37        description: None,
38        body: None,
39        footer: None,
40        width: Length::Shrink,
41    }
42}
43
44impl<'a, Message> Card<'a, Message> {
45    /// Sets the heading.
46    pub fn title(mut self, title: impl text::IntoFragment<'a>) -> Self {
47        self.title = Some(title.into_fragment());
48        self
49    }
50
51    /// Sets the muted line under the heading.
52    pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
53        self.description = Some(description.into_fragment());
54        self
55    }
56
57    /// Sets the main content.
58    pub fn body(mut self, body: impl Into<Element<'a, Message>>) -> Self {
59        self.body = Some(body.into());
60        self
61    }
62
63    /// Sets the content under the body, usually a row of buttons.
64    pub fn footer(mut self, footer: impl Into<Element<'a, Message>>) -> Self {
65        self.footer = Some(footer.into());
66        self
67    }
68
69    pub fn width(mut self, width: impl Into<Length>) -> Self {
70        self.width = width.into();
71        self
72    }
73
74    /// Whether the card has a title or description.
75    pub fn has_header(&self) -> bool {
76        self.title.is_some() || self.description.is_some()
77    }
78}
79
80impl<'a, Message: 'a> From<Card<'a, Message>> for Element<'a, Message> {
81    fn from(card: Card<'a, Message>) -> Self {
82        let has_header = card.has_header();
83        let mut sections = column![].spacing(space::LG).width(Length::Fill);
84
85        if has_header {
86            let mut header = column![].spacing(space::XS);
87            if let Some(title) = card.title {
88                header = header.push(
89                    text(title)
90                        .size(text_size::LG)
91                        .font(crate::theme::semibold())
92                        .wrapping(HEADER_WRAPPING),
93                );
94            }
95            if let Some(description) = card.description {
96                header = header.push(
97                    text(description)
98                        .size(text_size::SM)
99                        .wrapping(HEADER_WRAPPING)
100                        .style(|theme: &iced::Theme| text::Style {
101                            color: Some(Tokens::of(theme).muted_foreground),
102                        }),
103                );
104            }
105            sections = sections.push(header);
106        }
107        if let Some(body) = card.body {
108            sections = sections.push(body);
109        }
110        if let Some(footer) = card.footer {
111            sections = sections.push(footer);
112        }
113
114        // Content wider than the card is cut at the border, not drawn over it.
115        container(sections)
116            .padding(space::XL)
117            .width(card.width)
118            .clip(true)
119            .style(|theme| style(&Tokens::of(theme)))
120            .into()
121    }
122}
123
124/// Colours of a card's surface.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct Colours {
127    pub background: Color,
128    pub border: Color,
129    pub shadow: Color,
130}
131
132/// Resolves the card colours. In dark themes the surface is lifted slightly
133/// above the page background.
134pub fn colours(tokens: &Tokens) -> Colours {
135    if tokens.is_dark {
136        let background = mix(tokens.background, tokens.foreground, 0.055);
137        return Colours {
138            background,
139            border: mix(background, tokens.border, 0.65),
140            shadow: fade(tokens.background, 0.5),
141        };
142    }
143    Colours {
144        background: tokens.background,
145        border: mix(tokens.background, tokens.border, 0.65),
146        shadow: fade(tokens.foreground, 0.08),
147    }
148}
149
150/// The container style for a card.
151pub fn style(tokens: &Tokens) -> container::Style {
152    let colours = colours(tokens);
153    container::Style {
154        text_color: Some(tokens.foreground),
155        background: Some(Background::Color(colours.background)),
156        border: Border {
157            color: colours.border,
158            width: 1.0,
159            radius: 12.0.into(),
160        },
161        shadow: Shadow {
162            color: colours.shadow,
163            offset: Vector::new(0.0, 4.0),
164            blur_radius: 16.0,
165        },
166        snap: true,
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::theme::{Config, dark, light};
174
175    fn luminance(color: Color) -> f32 {
176        0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
177    }
178
179    #[test]
180    fn default_builder_is_empty_and_shrinks() {
181        let c: Card<'_, ()> = card();
182        assert!(!c.has_header());
183        assert!(c.body.is_none());
184        assert!(c.footer.is_none());
185        assert_eq!(c.width, Length::Shrink);
186    }
187
188    #[test]
189    fn title_or_description_makes_a_header() {
190        assert!(card::<()>().title("Team").has_header());
191        assert!(card::<()>().description("Members").has_header());
192    }
193
194    #[test]
195    fn dark_surface_is_lighter_than_background() {
196        let tokens = Tokens::of(&dark());
197        let colours = colours(&tokens);
198        assert!(luminance(colours.background) > luminance(tokens.background));
199    }
200
201    #[test]
202    fn dark_cards_stay_between_the_page_and_floating_surfaces() {
203        for theme in [
204            dark(),
205            Config::dark()
206                .background(iced::color!(0x18202b))
207                .foreground(iced::color!(0xe3e9f3))
208                .build(),
209        ] {
210            let tokens = Tokens::of(&theme);
211            let card = colours(&tokens);
212            assert!(luminance(card.background) > luminance(tokens.background));
213            assert!(luminance(card.background) < luminance(tokens.popover));
214            assert!(luminance(card.shadow) < luminance(card.background));
215            assert!(card.shadow.a > 0.0);
216        }
217    }
218
219    #[test]
220    fn light_surface_matches_background_and_casts_a_shadow() {
221        let tokens = Tokens::of(&light());
222        let colours = colours(&tokens);
223        assert_eq!(colours.background, tokens.background);
224        assert!(colours.shadow.a > 0.0);
225    }
226
227    #[test]
228    fn border_is_subtle_but_visible() {
229        for theme in [light(), dark()] {
230            let tokens = Tokens::of(&theme);
231            let colours = colours(&tokens);
232            assert_ne!(colours.border, colours.background);
233            let contrast = (luminance(colours.border) - luminance(colours.background)).abs();
234            assert!(contrast < 0.2, "{contrast}");
235            assert_eq!(style(&tokens).border.width, 1.0);
236        }
237    }
238}