Skip to main content

gpui_cn/
skeleton.rs

1use std::time::Duration;
2
3use gpui_kit::{
4    App, ElementId, InteractiveElement as _, IntoElement, RenderOnce, StyleRefinement, Styled,
5    TestSupportExt as _, Window, base::StyledExt as _, bounce, div, ease_in_out,
6};
7
8use crate::{ActiveTheme as _, Theme, looping};
9
10/// A shadcn-style skeleton: a rounded block in the accent fill that pulses
11/// while content loads, so a list or a card keeps its shape before its
12/// data arrives.
13///
14/// The block is a line of text high and as wide as its parent; `Styled`
15/// sizes it into anything else, such as a circle for an avatar. Its fill
16/// is the theme's `accent`, shadcn's `bg-accent`; on a popover use the
17/// popover's own accent, so the block reads as a step above that surface
18/// rather than a hole in it.
19///
20/// ```
21/// use gpui_cn::Skeleton;
22/// use gpui_kit::Styled as _;
23///
24/// let _ = Skeleton::new("title").w_48();
25/// let _ = Skeleton::new("avatar").size_8().rounded_full();
26/// ```
27///
28/// The pulse is shadcn's `animate-pulse`: opacity from full to half and
29/// back over two seconds, eased both ways, repeating while the block is
30/// rendered and still under reduced motion. It repaints at 30 fps on the
31/// app's shared loop clock, not at the display rate: a slow fade shows no
32/// steps at that rate, and every block on screen repaints on the same
33/// tick, in step.
34#[derive(IntoElement)]
35pub struct Skeleton {
36    id: ElementId,
37    style: StyleRefinement,
38}
39
40impl Skeleton {
41    /// A block with a stable id.
42    pub fn new(id: impl Into<ElementId>) -> Self {
43        Self {
44            id: id.into(),
45            style: StyleRefinement::default(),
46        }
47    }
48}
49
50impl Styled for Skeleton {
51    fn style(&mut self) -> &mut StyleRefinement {
52        &mut self.style
53    }
54}
55
56/// How long one pulse takes, up and back: shadcn's `animate-pulse` is two
57/// seconds.
58const PULSE: Duration = Duration::from_secs(2);
59
60/// The opacity the pulse falls to: half, as shadcn's.
61const PULSE_FLOOR: f32 = 0.5;
62
63/// How often the pulse repaints: 30 fps, where a two-second fade between
64/// full and half opacity moves too little per step to show the steps.
65const PULSE_FPS: u32 = 30;
66
67impl RenderOnce for Skeleton {
68    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
69        let (fill, radius, height) = {
70            let theme = cx.theme();
71            (
72                theme.accent(),
73                theme.radius_md(),
74                theme.text_control.line_height,
75            )
76        };
77        let block = div()
78            .id(self.id)
79            .test_support()
80            .w_full()
81            .h(height)
82            .rounded(radius)
83            .bg(fill)
84            .refine_style(&self.style);
85        if Theme::holds_still(cx) {
86            return block.opacity(PULSE_FLOOR);
87        }
88        let delta = bounce(ease_in_out)(looping::phase(PULSE, PULSE_FPS, window, cx));
89        block.opacity(1. - delta * (1. - PULSE_FLOOR))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn builders_refine_the_block() {
99        let skeleton = Skeleton::new("s").w_full();
100        assert!(skeleton.style.size.width.is_some());
101    }
102}