1use 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
8const HEADER_WRAPPING: text::Wrapping = text::Wrapping::WordOrGlyph;
11
12pub 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
33pub 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 pub fn title(mut self, title: impl text::IntoFragment<'a>) -> Self {
47 self.title = Some(title.into_fragment());
48 self
49 }
50
51 pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
53 self.description = Some(description.into_fragment());
54 self
55 }
56
57 pub fn body(mut self, body: impl Into<Element<'a, Message>>) -> Self {
59 self.body = Some(body.into());
60 self
61 }
62
63 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct Colours {
127 pub background: Color,
128 pub border: Color,
129 pub shadow: Color,
130}
131
132pub 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
150pub 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}