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}