use iced::widget::{column, container, text};
use iced::{Background, Border, Color, Element, Length, Shadow, Vector};
use crate::theme::{Tokens, fade, mix, space, text_size};
const HEADER_WRAPPING: text::Wrapping = text::Wrapping::WordOrGlyph;
pub struct Card<'a, Message> {
title: Option<text::Fragment<'a>>,
description: Option<text::Fragment<'a>>,
body: Option<Element<'a, Message>>,
footer: Option<Element<'a, Message>>,
width: Length,
}
impl<Message> std::fmt::Debug for Card<'_, Message> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Card")
.field("title", &self.title)
.field("description", &self.description)
.field("body", &self.body.is_some())
.field("footer", &self.footer.is_some())
.field("width", &self.width)
.finish()
}
}
pub fn card<'a, Message>() -> Card<'a, Message> {
Card {
title: None,
description: None,
body: None,
footer: None,
width: Length::Shrink,
}
}
impl<'a, Message> Card<'a, Message> {
pub fn title(mut self, title: impl text::IntoFragment<'a>) -> Self {
self.title = Some(title.into_fragment());
self
}
pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
self.description = Some(description.into_fragment());
self
}
pub fn body(mut self, body: impl Into<Element<'a, Message>>) -> Self {
self.body = Some(body.into());
self
}
pub fn footer(mut self, footer: impl Into<Element<'a, Message>>) -> Self {
self.footer = Some(footer.into());
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
pub fn has_header(&self) -> bool {
self.title.is_some() || self.description.is_some()
}
}
impl<'a, Message: 'a> From<Card<'a, Message>> for Element<'a, Message> {
fn from(card: Card<'a, Message>) -> Self {
let has_header = card.has_header();
let mut sections = column![].spacing(space::LG).width(Length::Fill);
if has_header {
let mut header = column![].spacing(space::XS);
if let Some(title) = card.title {
header = header.push(
text(title)
.size(text_size::LG)
.font(crate::theme::semibold())
.wrapping(HEADER_WRAPPING),
);
}
if let Some(description) = card.description {
header = header.push(
text(description)
.size(text_size::SM)
.wrapping(HEADER_WRAPPING)
.style(|theme: &iced::Theme| text::Style {
color: Some(Tokens::of(theme).muted_foreground),
}),
);
}
sections = sections.push(header);
}
if let Some(body) = card.body {
sections = sections.push(body);
}
if let Some(footer) = card.footer {
sections = sections.push(footer);
}
container(sections)
.padding(space::XL)
.width(card.width)
.clip(true)
.style(|theme| style(&Tokens::of(theme)))
.into()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Colours {
pub background: Color,
pub border: Color,
pub shadow: Color,
}
pub fn colours(tokens: &Tokens) -> Colours {
if tokens.is_dark {
let background = mix(tokens.background, tokens.foreground, 0.055);
return Colours {
background,
border: mix(background, tokens.border, 0.65),
shadow: fade(tokens.background, 0.5),
};
}
Colours {
background: tokens.background,
border: mix(tokens.background, tokens.border, 0.65),
shadow: fade(tokens.foreground, 0.08),
}
}
pub fn style(tokens: &Tokens) -> container::Style {
let colours = colours(tokens);
container::Style {
text_color: Some(tokens.foreground),
background: Some(Background::Color(colours.background)),
border: Border {
color: colours.border,
width: 1.0,
radius: 12.0.into(),
},
shadow: Shadow {
color: colours.shadow,
offset: Vector::new(0.0, 4.0),
blur_radius: 16.0,
},
snap: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{Config, dark, light};
fn luminance(color: Color) -> f32 {
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
}
#[test]
fn default_builder_is_empty_and_shrinks() {
let c: Card<'_, ()> = card();
assert!(!c.has_header());
assert!(c.body.is_none());
assert!(c.footer.is_none());
assert_eq!(c.width, Length::Shrink);
}
#[test]
fn title_or_description_makes_a_header() {
assert!(card::<()>().title("Team").has_header());
assert!(card::<()>().description("Members").has_header());
}
#[test]
fn dark_surface_is_lighter_than_background() {
let tokens = Tokens::of(&dark());
let colours = colours(&tokens);
assert!(luminance(colours.background) > luminance(tokens.background));
}
#[test]
fn dark_cards_stay_between_the_page_and_floating_surfaces() {
for theme in [
dark(),
Config::dark()
.background(iced::color!(0x18202b))
.foreground(iced::color!(0xe3e9f3))
.build(),
] {
let tokens = Tokens::of(&theme);
let card = colours(&tokens);
assert!(luminance(card.background) > luminance(tokens.background));
assert!(luminance(card.background) < luminance(tokens.popover));
assert!(luminance(card.shadow) < luminance(card.background));
assert!(card.shadow.a > 0.0);
}
}
#[test]
fn light_surface_matches_background_and_casts_a_shadow() {
let tokens = Tokens::of(&light());
let colours = colours(&tokens);
assert_eq!(colours.background, tokens.background);
assert!(colours.shadow.a > 0.0);
}
#[test]
fn border_is_subtle_but_visible() {
for theme in [light(), dark()] {
let tokens = Tokens::of(&theme);
let colours = colours(&tokens);
assert_ne!(colours.border, colours.background);
let contrast = (luminance(colours.border) - luminance(colours.background)).abs();
assert!(contrast < 0.2, "{contrast}");
assert_eq!(style(&tokens).border.width, 1.0);
}
}
}