Skip to main content

glassy_ui/
skeleton.rs

1//! Paper hex+alpha is grouped as `RRGGBB_AA`.
2#![allow(clippy::unusual_byte_groupings)]
3
4use std::f32::consts::TAU;
5use std::time::Duration;
6
7use crate::motion::StyledSlot;
8use crate::theme::{ActiveTheme, Theme};
9use gpui::{
10    div, px, Animation, AnimationExt as _, App, IntoElement, RenderOnce, SharedString,
11    StyleRefinement, Styled, Window,
12};
13
14use crate::button::ButtonVariant;
15use crate::chrome::{box_shadow, button_chrome};
16
17/// Paper Skeleton shapes and their default geometry.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum SkeletonShape {
20    #[default]
21    Text,
22    Avatar,
23    Control,
24}
25
26impl SkeletonShape {
27    fn metrics(self) -> (f32, f32, f32) {
28        match self {
29            Self::Text => (180.0, 12.0, 6.0),
30            Self::Avatar => (32.0, 32.0, 16.0),
31            Self::Control => (280.0, 36.0, 6.0),
32        }
33    }
34}
35
36/// Pulsing secondary-glass placeholder matching Paper `Glassy UI` → Skeletons.
37///
38/// GPUI automatically holds repeating animations at their first frame when
39/// reduced motion is enabled, so the static state remains the full-strength
40/// Paper material.
41#[derive(IntoElement)]
42pub struct Skeleton {
43    id: SharedString,
44    shape: SkeletonShape,
45    theme: Option<Theme>,
46    style: StyleRefinement,
47}
48
49impl Skeleton {
50    pub fn new(id: impl Into<SharedString>) -> Self {
51        Self {
52            id: id.into(),
53            shape: SkeletonShape::Text,
54            theme: None,
55            style: StyleRefinement::default(),
56        }
57    }
58
59    pub fn text(id: impl Into<SharedString>) -> Self {
60        Self::new(id)
61    }
62
63    pub fn avatar(id: impl Into<SharedString>) -> Self {
64        Self::new(id).shape(SkeletonShape::Avatar)
65    }
66
67    pub fn control(id: impl Into<SharedString>) -> Self {
68        Self::new(id).shape(SkeletonShape::Control)
69    }
70
71    pub fn shape(mut self, shape: SkeletonShape) -> Self {
72        self.shape = shape;
73        self
74    }
75
76    /// Override the active app theme for this skeleton only.
77    pub fn theme(mut self, theme: Theme) -> Self {
78        self.theme = Some(theme);
79        self
80    }
81}
82
83impl Styled for Skeleton {
84    fn style(&mut self) -> &mut StyleRefinement {
85        &mut self.style
86    }
87}
88
89impl RenderOnce for Skeleton {
90    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
91        let theme = self.theme.unwrap_or_else(|| cx.theme());
92        let chrome = button_chrome(theme, ButtonVariant::Secondary);
93        let (width, height, radius) = self.shape.metrics();
94
95        let shadows = vec![
96            box_shadow(0., 1., chrome.inset, 0., 0.),
97            box_shadow(0., chrome.shadow_y, chrome.shadow, chrome.shadow_blur, 0.),
98        ];
99
100        div()
101            .w(px(width))
102            .h(px(height))
103            .flex_shrink_0()
104            .rounded(px(radius))
105            .border_1()
106            .border_color(chrome.border)
107            .bg(chrome.bg)
108            .shadow(shadows)
109            .refine_style(&self.style)
110            .with_animation(
111                self.id,
112                Animation::new(Duration::from_millis(1600)).repeat(),
113                |el, delta| {
114                    // Full-strength at both ends makes the repeating seam invisible.
115                    let opacity = 0.8 + 0.2 * (delta * TAU).cos();
116                    el.opacity(opacity)
117                },
118            )
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::theme::{paint, Theme};
126
127    #[test]
128    fn presets_match_paper_geometry() {
129        assert_eq!(SkeletonShape::Text.metrics(), (180.0, 12.0, 6.0));
130        assert_eq!(SkeletonShape::Avatar.metrics(), (32.0, 32.0, 16.0));
131        assert_eq!(SkeletonShape::Control.metrics(), (280.0, 36.0, 6.0));
132    }
133
134    #[test]
135    fn secondary_glass_matches_paper() {
136        let light = button_chrome(Theme::light(), ButtonVariant::Secondary);
137        assert_eq!(light.bg, paint(0xFFFFFF_85));
138        assert_eq!(light.border, paint(0xFFFFFF_B8));
139        assert_eq!(light.inset, paint(0xFFFFFF_E6));
140        assert_eq!(light.shadow, paint(0x0F172A_0F));
141
142        let dark = button_chrome(Theme::dark(), ButtonVariant::Secondary);
143        assert_eq!(dark.bg, paint(0xFFFFFF_12));
144        assert_eq!(dark.border, paint(0xFFFFFF_1A));
145        assert_eq!(dark.inset, paint(0xFFFFFF_1F));
146        assert_eq!(dark.shadow, paint(0x000000_2E));
147    }
148}