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