1use 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
8pub 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
29pub 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 pub fn title(mut self, title: impl text::IntoFragment<'a>) -> Self {
43 self.title = Some(title.into_fragment());
44 self
45 }
46
47 pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
49 self.description = Some(description.into_fragment());
50 self
51 }
52
53 pub fn body(mut self, body: impl Into<Element<'a, Message>>) -> Self {
55 self.body = Some(body.into());
56 self
57 }
58
59 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 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#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct Colours {
116 pub background: Color,
117 pub border: Color,
118 pub shadow: Color,
119}
120
121pub 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
139pub 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}