Skip to main content

iced_cube/feedback/
alert.rs

1//! Inline callouts with a title, description and icon.
2
3use iced::widget::{column, container, row, text};
4use iced::{Background, Border, Color, Element, Length, Shadow};
5
6use crate::icon::{Glyph, themed};
7use crate::theme::{Tokens, mix, radius, space, text_size};
8
9const ICON_SIZE: f32 = 16.0;
10
11/// The kind of message an alert carries.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub enum Variant {
14    #[default]
15    Info,
16    Success,
17    Warning,
18    Destructive,
19}
20
21impl Variant {
22    pub const ALL: [Variant; 4] = [
23        Variant::Info,
24        Variant::Success,
25        Variant::Warning,
26        Variant::Destructive,
27    ];
28
29    /// The icon shown when none is set.
30    pub const fn glyph(self) -> Glyph {
31        match self {
32            Variant::Info => crate::lucide!(Info),
33            Variant::Success => crate::lucide!(CircleCheck),
34            Variant::Warning => crate::lucide!(TriangleAlert),
35            Variant::Destructive => crate::lucide!(CircleAlert),
36        }
37    }
38}
39
40/// An alert builder. Convert it into an [`Element`] to render.
41#[derive(Debug)]
42pub struct Alert<'a> {
43    title: text::Fragment<'a>,
44    description: Option<text::Fragment<'a>>,
45    icon: Option<Glyph>,
46    variant: Variant,
47    width: Length,
48}
49
50/// Creates an alert with a title.
51pub fn alert<'a>(title: impl text::IntoFragment<'a>) -> Alert<'a> {
52    Alert {
53        title: title.into_fragment(),
54        description: None,
55        icon: None,
56        variant: Variant::default(),
57        width: Length::Fill,
58    }
59}
60
61impl<'a> Alert<'a> {
62    pub fn variant(mut self, variant: Variant) -> Self {
63        self.variant = variant;
64        self
65    }
66
67    /// Adds supporting text under the title.
68    pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
69        self.description = Some(description.into_fragment());
70        self
71    }
72
73    /// Replaces the variant's default icon.
74    pub fn icon(mut self, glyph: Glyph) -> Self {
75        self.icon = Some(glyph);
76        self
77    }
78
79    /// Overrides the width. Alerts fill the available width by default.
80    pub fn width(mut self, width: impl Into<Length>) -> Self {
81        self.width = width.into();
82        self
83    }
84
85    /// The icon that will be rendered.
86    pub fn glyph(&self) -> Glyph {
87        self.icon.unwrap_or(self.variant.glyph())
88    }
89}
90
91impl<'a, Message: 'a> From<Alert<'a>> for Element<'a, Message> {
92    fn from(alert: Alert<'a>) -> Self {
93        let variant = alert.variant;
94        let glyph = alert.glyph();
95
96        let icon = themed(glyph, ICON_SIZE, 1.0, move |theme| {
97            colours(&Tokens::of(theme), variant).accent
98        });
99
100        let title = text(alert.title)
101            .size(text_size::SM)
102            .style(move |theme| text::Style {
103                color: Some(colours(&Tokens::of(theme), variant).title),
104            });
105
106        let mut body = column![title].spacing(space::XS).width(Length::Fill);
107        if let Some(description) = alert.description {
108            body =
109                body.push(
110                    text(description)
111                        .size(text_size::SM)
112                        .style(move |theme| text::Style {
113                            color: Some(colours(&Tokens::of(theme), variant).description),
114                        }),
115                );
116        }
117
118        container(row![container(icon).padding([2, 0]), body].spacing(space::MD))
119            .padding([space::MD, space::LG])
120            .width(alert.width)
121            .style(move |theme| style(&Tokens::of(theme), variant))
122            .into()
123    }
124}
125
126/// Colours of an alert's surface and text.
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub struct Colours {
129    pub background: Color,
130    pub border: Color,
131    /// Icon colour.
132    pub accent: Color,
133    pub title: Color,
134    pub description: Color,
135}
136
137/// Resolves the colours of a variant.
138pub fn colours(tokens: &Tokens, variant: Variant) -> Colours {
139    let accent = match variant {
140        Variant::Info => tokens.foreground,
141        Variant::Success => tokens.success,
142        Variant::Warning => tokens.warning,
143        Variant::Destructive => tokens.destructive,
144    };
145    let tint = if tokens.is_dark { 0.12 } else { 0.06 };
146    let edge = if tokens.is_dark { 0.35 } else { 0.3 };
147    let (background, border) = match variant {
148        Variant::Info => (tokens.background, tokens.border),
149        _ => (
150            mix(tokens.background, accent, tint),
151            mix(tokens.background, accent, edge),
152        ),
153    };
154
155    Colours {
156        background,
157        border,
158        accent,
159        title: mix(accent, tokens.foreground, 0.25),
160        description: match variant {
161            Variant::Info => tokens.muted_foreground,
162            _ => mix(accent, tokens.foreground, 0.55),
163        },
164    }
165}
166
167/// The container style for a variant.
168pub fn style(tokens: &Tokens, variant: Variant) -> container::Style {
169    let colours = colours(tokens, variant);
170    container::Style {
171        text_color: Some(colours.title),
172        background: Some(Background::Color(colours.background)),
173        border: Border {
174            color: colours.border,
175            width: 1.0,
176            radius: radius::LG.into(),
177        },
178        shadow: Shadow::default(),
179        snap: true,
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::theme::{dark, light};
187
188    #[test]
189    fn default_builder_is_info_fill_width_with_variant_icon() {
190        let a = alert("Heads up");
191        assert_eq!(a.variant, Variant::Info);
192        assert_eq!(a.width, Length::Fill);
193        assert!(a.description.is_none());
194        assert_eq!(a.glyph().name(), "info");
195    }
196
197    #[test]
198    fn each_variant_has_its_own_icon() {
199        let mut names: Vec<_> = Variant::ALL.map(|v| v.glyph().name()).to_vec();
200        names.sort_unstable();
201        names.dedup();
202        assert_eq!(names.len(), Variant::ALL.len());
203    }
204
205    #[test]
206    fn custom_icon_overrides_variant_icon() {
207        let a = alert("Saved")
208            .variant(Variant::Success)
209            .icon(crate::lucide!(Save));
210        assert_eq!(a.glyph().name(), "save");
211    }
212
213    #[test]
214    fn status_variants_tint_the_surface() {
215        for theme in [light(), dark()] {
216            let tokens = Tokens::of(&theme);
217            for variant in [Variant::Success, Variant::Warning, Variant::Destructive] {
218                let colours = colours(&tokens, variant);
219                assert_ne!(colours.background, tokens.background, "{variant:?}");
220                assert_ne!(colours.border, tokens.border, "{variant:?}");
221            }
222        }
223    }
224
225    #[test]
226    fn info_uses_neutral_surface() {
227        for theme in [light(), dark()] {
228            let tokens = Tokens::of(&theme);
229            let colours = colours(&tokens, Variant::Info);
230            assert_eq!(colours.background, tokens.background);
231            assert_eq!(colours.accent, tokens.foreground);
232        }
233    }
234
235    #[test]
236    fn text_is_distinct_from_surface_in_every_variant() {
237        for theme in [light(), dark()] {
238            let tokens = Tokens::of(&theme);
239            for variant in Variant::ALL {
240                let colours = colours(&tokens, variant);
241                assert_ne!(colours.title, colours.background, "{variant:?}");
242                assert_ne!(colours.description, colours.background, "{variant:?}");
243                assert_eq!(style(&tokens, variant).border.width, 1.0);
244            }
245        }
246    }
247}