1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
//! Skeleton — port of `@heroui/skeleton` (v3).
//!
//! `animationType` selects between the sweeping shimmer, a pulse, and no
//! motion; it defaults to the theme's `--skeleton-animation` token.
use std::time::Duration;
use gpui::{
div, prelude::*, px, Animation, AnimationExt, AnyElement, App, ElementId, IntoElement,
ParentElement, Pixels, RenderOnce, Styled, Window,
};
use herogpui_theme::{ActiveTheme, SkeletonAnimation};
/// Loading placeholder (`Skeleton`).
#[derive(IntoElement)]
pub struct Skeleton {
id: ElementId,
w: Option<Pixels>,
h: Option<Pixels>,
/// `animationType`. `None` defers to `--skeleton-animation`.
animation_type: Option<SkeletonAnimation>,
/// The corner radius, in place of the owning `hairline_radius` helper.
radius: Option<Pixels>,
children: Vec<AnyElement>,
/// The `sx` slot, refined over the root style at the end of render.
sx: Option<Box<gpui::StyleRefinement>>,
}
impl Skeleton {
pub fn new() -> Self {
Self {
id: "skeleton".into(),
w: None,
// A leaf keeps the pinned 24px fallback. A composed Skeleton
// sizes itself from its children unless the caller supplies an
// explicit height, which is how the upstream parent shimmer can
// cover a profile/card/list shape without collapsing it to one
// line.
h: None,
animation_type: None,
radius: None,
children: Vec::new(),
sx: None,
}
}
/// Distinct id per skeleton; required when several animate on one page.
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = id.into();
self
}
pub fn w(mut self, v: impl Into<Pixels>) -> Self {
self.w = Some(v.into());
self
}
pub fn h(mut self, v: impl Into<Pixels>) -> Self {
self.h = Some(v.into());
self
}
pub fn animation_type(mut self, animation: SkeletonAnimation) -> Self {
self.animation_type = Some(animation);
self
}
/// The corner radius, in place of the owning `hairline_radius` helper. Not
/// a v3 prop; the removed v2 `radius` prop is prohibited and this is a
/// per-component repository extension.
pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
self.radius = Some(radius.into());
self
}
/// The one slot for caller-owned low-level styling: GPUI's styling methods
/// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
/// applied to the skeleton's root element after every value the component
/// and the active theme chose, so they win.
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
self.sx = Some(crate::util::capture_sx(style));
self
}
}
impl Default for Skeleton {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for Skeleton {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Skeleton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = cx.colors();
let has_children = !self.children.is_empty();
// `.skeleton` is `bg-surface-tertiary/70`, not the solid token: the
// placeholder is meant to read as a tint of whatever it sits on. This
// painted it opaque, so every skeleton came out the full tertiary fill
// -- (234,234,235) on the light page against v3's (237,237,238).
let base_color = colors.surface_tertiary.alpha(0.7);
// Reduced motion collapses every animation type to `None`.
let animation = if ActiveTheme::reduce_motion(cx) {
SkeletonAnimation::None
} else {
self.animation_type
.unwrap_or(cx.layout().skeleton_animation)
};
// Hoisted so the shimmer band below can share the resolved value:
// vanilla GPUI clips `overflow_hidden()` to the rectangle, so the
// band carries this same radius (see `util::inner_fill_radius`).
let base_radius = match self.radius {
Some(radius) => radius,
None => crate::util::hairline_radius(cx),
};
let base = div()
.bg(base_color)
.rounded(base_radius)
.overflow_hidden()
.when_some(self.w, |el, w| el.w(w))
.when_some(
self.h.or_else(|| (!has_children).then(|| px(24.))),
|el, h| el.h(h),
)
// HeroUI keeps composed child skeletons visible and suppresses
// their individual shimmer in the gallery with
// `animationType="none"`; the parent owns the synchronized band.
.when(has_children, |el| el.children(self.children));
match animation {
SkeletonAnimation::None => crate::util::apply_sx(base, &self.sx).into_any_element(),
// `with_animation` hands back an `AnimationElement`, which has no
// style of its own to refine, so the slot lands on the box the
// animation wraps: the pulse keeps driving opacity, everything
// else the caller set holds.
SkeletonAnimation::Pulse => crate::util::apply_sx(base, &self.sx)
.with_animation(
self.id,
Animation::new(Duration::from_millis(1600)).repeat(),
move |el, delta| {
let t = (delta * std::f32::consts::TAU).sin();
el.opacity(0.55 + 0.25 * t)
},
)
.into_any_element(),
// A highlight band sweeping left to right, like v3's shimmer. The
// band itself is animated, so its position moves rather than its
// size.
SkeletonAnimation::Shimmer => {
let highlight = colors.background;
// The band sweeps edge to edge, so at each end of its travel
// it paints square pixels through the base's rounded corners
// (vanilla GPUI clips `overflow_hidden()` to the rectangle).
// Rounding the band to the base radius reproduces the CSS
// clip v3 gets from `overflow: hidden` + `border-radius`.
let band = div()
.absolute()
.top_0()
.bottom_0()
.w(gpui::relative(0.35))
.rounded(base_radius)
.bg(gpui::linear_gradient(
90.0,
gpui::linear_color_stop(highlight.alpha(0.0), 0.0),
gpui::linear_color_stop(highlight.alpha(0.7), 0.5),
))
.with_animation(
self.id,
Animation::new(Duration::from_millis(1400)).repeat(),
|el, delta| el.left(gpui::relative(delta)),
);
crate::util::apply_sx(base.child(band), &self.sx).into_any_element()
}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn composed_skeletons_keep_children_visible_for_a_parent_shimmer() {
let source = include_str!("skeleton.rs")
.split("#[cfg(test)]")
.next()
.expect("the implementation section is always present");
assert!(source.contains("self.h.or_else(|| (!has_children).then(|| px(24.)))"));
assert!(source.contains(".when(has_children, |el| el.children(self.children))"));
assert!(
!source.contains("div().opacity(0.).children(self.children)"),
"composed Skeleton children must remain visible beneath the parent shimmer"
);
}
}