Skip to main content

liora_components/
skeleton.rs

1use crate::motion::pulse;
2use gpui::{AnyElement, App, DefiniteLength, IntoElement, RenderOnce, Window, div, prelude::*, px};
3use liora_core::Config;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum SkeletonVariant {
7    #[default]
8    Paragraph,
9    Circle,
10    Square,
11    Image,
12}
13
14pub struct SkeletonItem {
15    variant: SkeletonVariant,
16    width: Option<DefiniteLength>,
17}
18
19pub struct Skeleton {
20    loading: bool,
21    rows: u32,
22    animated: bool,
23    template: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
24    child: Option<AnyElement>,
25}
26
27impl SkeletonItem {
28    pub fn new(variant: SkeletonVariant) -> Self {
29        Self {
30            variant,
31            width: None,
32        }
33    }
34
35    pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
36        self.width = Some(width.into());
37        self
38    }
39
40    pub fn width_2_5(self) -> Self {
41        self.width(gpui::relative(0.4))
42    }
43}
44
45impl Skeleton {
46    pub fn new() -> Self {
47        Self {
48            loading: true,
49            rows: 3,
50            animated: true,
51            template: None,
52            child: None,
53        }
54    }
55
56    pub fn loading(mut self, l: bool) -> Self {
57        self.loading = l;
58        self
59    }
60
61    pub fn rows(mut self, r: u32) -> Self {
62        self.rows = r;
63        self
64    }
65
66    pub fn animated(mut self, a: bool) -> Self {
67        self.animated = a;
68        self
69    }
70
71    pub fn template<F>(mut self, f: F) -> Self
72    where
73        F: Fn(&mut Window, &mut App) -> AnyElement + 'static,
74    {
75        self.template = Some(Box::new(f));
76        self
77    }
78
79    pub fn child(mut self, child: impl IntoElement) -> Self {
80        self.child = Some(child.into_any_element());
81        self
82    }
83}
84
85impl RenderOnce for SkeletonItem {
86    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
87        let theme = cx.global::<Config>().theme.clone();
88        let skeleton_bg = theme.neutral.hover;
89
90        let item = match self.variant {
91            SkeletonVariant::Circle => div().w(px(40.0)).h(px(40.0)).bg(skeleton_bg).rounded_full(),
92            SkeletonVariant::Square => div()
93                .w_full()
94                .h(px(40.0))
95                .bg(skeleton_bg)
96                .rounded(px(theme.radius.sm)),
97            SkeletonVariant::Paragraph => {
98                div().w_full().h(px(16.0)).bg(skeleton_bg).rounded(px(4.0))
99            }
100            SkeletonVariant::Image => div()
101                .w(px(200.0))
102                .h(px(150.0))
103                .bg(skeleton_bg)
104                .rounded(px(theme.radius.sm)),
105        };
106
107        item.when_some(self.width, |s, width| s.w(width))
108    }
109}
110
111impl IntoElement for SkeletonItem {
112    type Element = gpui::Component<Self>;
113    fn into_element(self) -> Self::Element {
114        gpui::Component::new(self)
115    }
116}
117
118impl RenderOnce for Skeleton {
119    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
120        if !self.loading {
121            return div()
122                .child(self.child.unwrap_or_else(|| div().into_any_element()))
123                .into_any_element();
124        }
125
126        if let Some(template) = self.template {
127            return (template)(window, cx).into_any_element();
128        }
129
130        // Default: multiple rows of paragraph
131        let animated = self.animated;
132
133        div()
134            .flex()
135            .flex_col()
136            .gap_3()
137            .w_full()
138            .children((0..self.rows).map(|i| {
139                let width = if i == self.rows - 1 && self.rows > 1 {
140                    gpui::relative(0.6)
141                } else {
142                    gpui::relative(1.0)
143                };
144                let row = div()
145                    .w(width)
146                    .child(SkeletonItem::new(SkeletonVariant::Paragraph));
147
148                if animated {
149                    pulse(("liora-skeleton-row-motion", i as usize), row).into_any_element()
150                } else {
151                    row.into_any_element()
152                }
153            }))
154            .into_any_element()
155    }
156}
157
158impl IntoElement for Skeleton {
159    type Element = gpui::Component<Self>;
160    fn into_element(self) -> Self::Element {
161        gpui::Component::new(self)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn skeleton_item_width_2_5_sets_fraction_width() {
171        assert_eq!(
172            SkeletonItem::new(SkeletonVariant::Paragraph)
173                .width_2_5()
174                .width,
175            Some(gpui::relative(0.4))
176        );
177    }
178
179    #[test]
180    fn skeleton_default_rows_use_pulse_motion_when_animated() {
181        let source = include_str!("skeleton.rs")
182            .split("#[cfg(test)]")
183            .next()
184            .unwrap();
185
186        assert!(source.contains("pulse("));
187        assert!(source.contains("liora-skeleton-row-motion"));
188        assert!(source.contains("if animated"));
189    }
190}