liora_components/
skeleton.rs1use 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 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}