Skip to main content

iced_shadcn/
empty.rs

1use iced::advanced::text::Wrapping;
2use iced::alignment::{Horizontal, Vertical};
3use iced::border::Border;
4use iced::font::Weight;
5use iced::widget::{column, container, row, text};
6use iced::{Alignment, Background, Color, Element, Font, Length};
7
8use crate::theme::Theme;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum EmptyMediaVariant {
12    #[default]
13    Default,
14    Icon,
15}
16
17#[derive(Clone, Debug)]
18pub struct EmptyProps<'a> {
19    pub title: &'a str,
20    pub description: Option<&'a str>,
21    pub icon: Option<&'a str>,
22}
23
24impl<'a> EmptyProps<'a> {
25    pub fn new(title: &'a str) -> Self {
26        Self {
27            title,
28            description: None,
29            icon: None,
30        }
31    }
32
33    pub fn description(mut self, description: &'a str) -> Self {
34        self.description = Some(description);
35        self
36    }
37
38    pub fn icon(mut self, icon: &'a str) -> Self {
39        self.icon = Some(icon);
40        self
41    }
42}
43
44#[derive(Clone, Copy, Debug)]
45pub struct EmptyRootProps {
46    pub gap: f32,
47    pub padding: f32,
48    pub max_width: f32,
49    pub min_height: f32,
50    pub bordered: bool,
51    pub dashed: bool,
52    pub background: Option<Color>,
53}
54
55impl Default for EmptyRootProps {
56    fn default() -> Self {
57        Self {
58            gap: 0.0,
59            padding: 0.0,
60            max_width: 0.0,
61            min_height: 0.0,
62            bordered: false,
63            dashed: true,
64            background: None,
65        }
66    }
67}
68
69impl EmptyRootProps {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    pub fn gap(mut self, gap: f32) -> Self {
75        self.gap = gap;
76        self
77    }
78
79    pub fn padding(mut self, padding: f32) -> Self {
80        self.padding = padding;
81        self
82    }
83
84    pub fn max_width(mut self, max_width: f32) -> Self {
85        self.max_width = max_width;
86        self
87    }
88
89    pub fn min_height(mut self, min_height: f32) -> Self {
90        self.min_height = min_height;
91        self
92    }
93
94    pub fn bordered(mut self, bordered: bool) -> Self {
95        self.bordered = bordered;
96        self
97    }
98
99    pub fn dashed(mut self, dashed: bool) -> Self {
100        self.dashed = dashed;
101        self
102    }
103
104    pub fn background(mut self, background: Color) -> Self {
105        self.background = Some(background);
106        self
107    }
108}
109
110#[derive(Clone, Copy, Debug)]
111pub struct EmptyHeaderProps {
112    pub gap: f32,
113    pub max_width: f32,
114}
115
116impl Default for EmptyHeaderProps {
117    fn default() -> Self {
118        Self {
119            gap: 0.0,
120            max_width: 0.0,
121        }
122    }
123}
124
125impl EmptyHeaderProps {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    pub fn gap(mut self, gap: f32) -> Self {
131        self.gap = gap;
132        self
133    }
134
135    pub fn max_width(mut self, max_width: f32) -> Self {
136        self.max_width = max_width;
137        self
138    }
139}
140
141#[derive(Clone, Copy, Debug)]
142pub struct EmptyMediaProps {
143    pub variant: EmptyMediaVariant,
144    pub size: f32,
145    pub icon_size: f32,
146}
147
148impl Default for EmptyMediaProps {
149    fn default() -> Self {
150        Self {
151            variant: EmptyMediaVariant::Default,
152            size: 0.0,
153            icon_size: 0.0,
154        }
155    }
156}
157
158impl EmptyMediaProps {
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    pub fn variant(mut self, variant: EmptyMediaVariant) -> Self {
164        self.variant = variant;
165        self
166    }
167
168    pub fn size(mut self, size: f32) -> Self {
169        self.size = size;
170        self
171    }
172
173    pub fn icon_size(mut self, icon_size: f32) -> Self {
174        self.icon_size = icon_size;
175        self
176    }
177}
178
179#[derive(Clone, Copy, Debug)]
180pub struct EmptyTitleProps {
181    pub size: f32,
182}
183
184impl Default for EmptyTitleProps {
185    fn default() -> Self {
186        Self { size: 0.0 }
187    }
188}
189
190impl EmptyTitleProps {
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    pub fn size(mut self, size: f32) -> Self {
196        self.size = size;
197        self
198    }
199}
200
201#[derive(Clone, Copy, Debug)]
202pub struct EmptyDescriptionProps {
203    pub size: f32,
204    pub max_width: f32,
205}
206
207impl Default for EmptyDescriptionProps {
208    fn default() -> Self {
209        Self {
210            size: 0.0,
211            max_width: 0.0,
212        }
213    }
214}
215
216impl EmptyDescriptionProps {
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    pub fn size(mut self, size: f32) -> Self {
222        self.size = size;
223        self
224    }
225
226    pub fn max_width(mut self, max_width: f32) -> Self {
227        self.max_width = max_width;
228        self
229    }
230}
231
232#[derive(Clone, Copy, Debug)]
233pub struct EmptyContentProps {
234    pub gap: f32,
235    pub max_width: f32,
236}
237
238impl Default for EmptyContentProps {
239    fn default() -> Self {
240        Self {
241            gap: 0.0,
242            max_width: 0.0,
243        }
244    }
245}
246
247impl EmptyContentProps {
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    pub fn gap(mut self, gap: f32) -> Self {
253        self.gap = gap;
254        self
255    }
256
257    pub fn max_width(mut self, max_width: f32) -> Self {
258        self.max_width = max_width;
259        self
260    }
261}
262
263pub fn empty_root<'a, Message: 'a>(
264    content: impl Into<Element<'a, Message>>,
265    props: EmptyRootProps,
266    theme: &Theme,
267) -> Element<'a, Message> {
268    let gap = if props.gap > 0.0 {
269        props.gap
270    } else {
271        theme.styles.empty.root_gap
272    };
273    let padding = if props.padding > 0.0 {
274        props.padding
275    } else {
276        theme.styles.empty.root_padding
277    };
278    let max_width = if props.max_width > 0.0 {
279        props.max_width
280    } else {
281        theme.styles.empty.root_max_width
282    };
283    let border_color = if props.dashed {
284        apply_opacity(theme.palette.border, 0.9)
285    } else {
286        theme.palette.border
287    };
288    let background = props.background;
289    let radius = theme.radius.lg;
290    let min_height = if props.min_height > 0.0 {
291        props.min_height
292    } else {
293        theme.styles.empty.root_min_height
294    };
295
296    container(column![content.into()].spacing(gap).width(Length::Fill))
297        .padding(padding)
298        .width(Length::Fill)
299        .max_width(max_width)
300        .center_x(Length::Fill)
301        .style(move |_t| iced::widget::container::Style {
302            background: background.map(Background::Color),
303            border: Border {
304                color: if props.bordered {
305                    border_color
306                } else {
307                    Color::TRANSPARENT
308                },
309                width: if props.bordered { 1.0 } else { 0.0 },
310                radius: radius.into(),
311            },
312            ..Default::default()
313        })
314        .height(if min_height > 0.0 {
315            Length::Fixed(min_height)
316        } else {
317            Length::Shrink
318        })
319        .into()
320}
321
322pub fn empty_header<'a, Message: 'a>(
323    items: Vec<Element<'a, Message>>,
324    props: EmptyHeaderProps,
325    theme: &Theme,
326) -> Element<'a, Message> {
327    let gap = if props.gap > 0.0 {
328        props.gap
329    } else {
330        theme.styles.empty.header_gap
331    };
332    let max_width = if props.max_width > 0.0 {
333        props.max_width
334    } else {
335        theme.styles.empty.header_max_width
336    };
337
338    container(
339        column(items)
340            .spacing(gap)
341            .align_x(Alignment::Center)
342            .width(Length::Shrink),
343    )
344    .width(Length::Fill)
345    .center_x(Length::Fill)
346    .max_width(max_width)
347    .into()
348}
349
350pub fn empty_media<'a, Message: 'a>(
351    content: impl Into<Element<'a, Message>>,
352    props: EmptyMediaProps,
353    theme: &Theme,
354) -> Element<'a, Message> {
355    let size = if props.size > 0.0 {
356        props.size
357    } else {
358        theme.styles.empty.media_size
359    };
360    let icon_size = if props.icon_size > 0.0 {
361        props.icon_size
362    } else {
363        theme.styles.empty.media_icon_size
364    };
365    let background = match props.variant {
366        EmptyMediaVariant::Default => None,
367        EmptyMediaVariant::Icon => Some(Background::Color(theme.palette.muted)),
368    };
369    let text_color = match props.variant {
370        EmptyMediaVariant::Default => None,
371        EmptyMediaVariant::Icon => Some(theme.palette.foreground),
372    };
373    let radius = theme.radius.md;
374
375    container(content)
376        .padding(match props.variant {
377            EmptyMediaVariant::Default => 0.0,
378            EmptyMediaVariant::Icon => ((size - icon_size) / 2.0).max(theme.spacing.sm),
379        })
380        .width(Length::Fixed(size))
381        .height(Length::Fixed(size))
382        .align_x(Horizontal::Center)
383        .align_y(Vertical::Center)
384        .style(move |_t| iced::widget::container::Style {
385            background,
386            text_color,
387            border: Border {
388                color: Color::TRANSPARENT,
389                width: 0.0,
390                radius: radius.into(),
391            },
392            ..Default::default()
393        })
394        .into()
395}
396
397pub fn empty_title<'a, Message: 'a>(
398    value: impl Into<String>,
399    props: EmptyTitleProps,
400    theme: &'a Theme,
401) -> Element<'a, Message> {
402    let size = if props.size > 0.0 {
403        props.size
404    } else {
405        theme.styles.empty.title_size
406    };
407    text(value.into())
408        .size(size)
409        .font(Font {
410            weight: Weight::Medium,
411            ..Font::DEFAULT
412        })
413        .style(move |_t| iced::widget::text::Style {
414            color: Some(theme.palette.foreground),
415        })
416        .align_x(iced::alignment::Horizontal::Center)
417        .into()
418}
419
420pub fn empty_description<'a, Message: 'a>(
421    value: impl Into<String>,
422    props: EmptyDescriptionProps,
423    theme: &'a Theme,
424) -> Element<'a, Message> {
425    let size = if props.size > 0.0 {
426        props.size
427    } else {
428        theme.styles.empty.description_size
429    };
430    let max_width = if props.max_width > 0.0 {
431        props.max_width
432    } else {
433        theme.styles.empty.description_max_width
434    };
435    container(
436        text(value.into())
437            .size(size)
438            .wrapping(Wrapping::WordOrGlyph)
439            .style(move |_t| iced::widget::text::Style {
440                color: Some(theme.palette.muted_foreground),
441            })
442            .align_x(iced::alignment::Horizontal::Center),
443    )
444    .max_width(max_width)
445    .into()
446}
447
448pub fn empty_content<'a, Message: 'a>(
449    items: Vec<Element<'a, Message>>,
450    props: EmptyContentProps,
451    theme: &Theme,
452) -> Element<'a, Message> {
453    let gap = if props.gap > 0.0 {
454        props.gap
455    } else {
456        theme.styles.empty.content_gap
457    };
458    let max_width = if props.max_width > 0.0 {
459        props.max_width
460    } else {
461        theme.styles.empty.content_max_width
462    };
463
464    container(
465        column(items)
466            .spacing(gap)
467            .align_x(Alignment::Center)
468            .width(Length::Fill),
469    )
470    .width(Length::Fill)
471    .center_x(Length::Fill)
472    .max_width(max_width)
473    .into()
474}
475
476pub fn empty<'a, Message: 'a>(props: EmptyProps<'a>, theme: &'a Theme) -> Element<'a, Message> {
477    let mut header_items = Vec::new();
478
479    if let Some(icon) = props.icon {
480        header_items.push(empty_media(
481            text(icon)
482                .size(theme.styles.empty.media_icon_size)
483                .font(Font::with_name("lucide"))
484                .style(move |_t| iced::widget::text::Style {
485                    color: Some(theme.palette.foreground),
486                }),
487            EmptyMediaProps::new().variant(EmptyMediaVariant::Icon),
488            theme,
489        ));
490    }
491
492    header_items.push(empty_title(props.title, EmptyTitleProps::new(), theme));
493
494    if let Some(description) = props.description {
495        header_items.push(empty_description(
496            description,
497            EmptyDescriptionProps::new(),
498            theme,
499        ));
500    }
501
502    empty_root(
503        row![empty_header(header_items, EmptyHeaderProps::new(), theme)]
504            .width(Length::Fill)
505            .align_y(Alignment::Center),
506        EmptyRootProps::new(),
507        theme,
508    )
509}
510
511fn apply_opacity(color: Color, opacity: f32) -> Color {
512    Color {
513        a: color.a * opacity,
514        ..color
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn empty_props_builder() {
524        let props = EmptyProps::new("No results")
525            .description("Try adjusting your search.")
526            .icon("x");
527
528        assert_eq!(props.title, "No results");
529        assert_eq!(props.description, Some("Try adjusting your search."));
530        assert_eq!(props.icon, Some("x"));
531    }
532
533    #[test]
534    fn empty_root_props_builder() {
535        let props = EmptyRootProps::new()
536            .bordered(true)
537            .dashed(false)
538            .padding(32.0)
539            .gap(12.0)
540            .max_width(420.0)
541            .min_height(240.0);
542
543        assert!(props.bordered);
544        assert!(!props.dashed);
545        assert_eq!(props.padding, 32.0);
546        assert_eq!(props.gap, 12.0);
547        assert_eq!(props.max_width, 420.0);
548        assert_eq!(props.min_height, 240.0);
549    }
550
551    #[test]
552    fn empty_media_props_builder() {
553        let props = EmptyMediaProps::new()
554            .variant(EmptyMediaVariant::Icon)
555            .size(48.0)
556            .icon_size(20.0);
557
558        assert_eq!(props.variant, EmptyMediaVariant::Icon);
559        assert_eq!(props.size, 48.0);
560        assert_eq!(props.icon_size, 20.0);
561    }
562}