Skip to main content

rustmotion_components/
skeleton.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::gradient::{self, Colors, Gradient};
4use skia_safe::{Canvas, Color, Color4f, PaintStyle, Point, Rect};
5
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::paint_from_hex;
10use rustmotion_core::schema::TimelineStep;
11use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
12
13fn default_base_color() -> String {
14    "#1E293B".to_string()
15}
16
17fn default_shimmer_color() -> String {
18    "#334155".to_string()
19}
20
21fn default_border_radius() -> f32 {
22    8.0
23}
24
25fn default_speed() -> f32 {
26    1.5
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31#[derive(Default)]
32pub enum SkeletonVariant {
33    #[default]
34    Rectangle,
35    Circle,
36    Text,
37}
38
39#[derive(Debug, Serialize, Deserialize, JsonSchema)]
40#[deprecated(
41    since = "0.7.1",
42    note = "`skeleton` is a frozen composition (issue #333). Compose a loading placeholder \
43            from a plain `shape`/`card` with a `shimmer` finish instead — see \
44            crates/rustmotion/skills/rules/composition-recipes.md and \
45            rules/text-polish.md's `shimmer`. Kept for compatibility; scheduled for \
46            removal in a future major version via `rustmotion migrate` (#335)."
47)]
48pub struct Skeleton {
49    #[serde(default)]
50    pub variant: SkeletonVariant,
51    #[serde(default = "default_base_color")]
52    pub base_color: String,
53    #[serde(default = "default_shimmer_color")]
54    pub shimmer_color: String,
55    #[serde(default = "default_border_radius")]
56    pub border_radius: f32,
57    #[serde(default = "default_speed")]
58    pub speed: f32,
59    /// Number of text lines to render (only for `text` variant).
60    #[serde(default = "default_lines")]
61    pub lines: u32,
62    /// Line height for text variant.
63    #[serde(default = "default_line_height")]
64    pub line_height: f32,
65    /// Gap between text lines.
66    #[serde(default = "default_line_gap")]
67    pub line_gap: f32,
68    #[serde(flatten)]
69    pub timing: TimingConfig,
70    #[serde(default)]
71    pub style: CssStyle,
72    #[serde(default)]
73    pub timeline: Vec<TimelineStep>,
74    #[serde(default)]
75    pub stagger: Option<f32>,
76}
77
78fn default_lines() -> u32 {
79    3
80}
81
82fn default_line_height() -> f32 {
83    16.0
84}
85
86fn default_line_gap() -> f32 {
87    12.0
88}
89
90rustmotion_core::impl_traits!(Skeleton {
91    Animatable => animation,
92    Timed => timing,
93    Styled => style,
94});
95
96impl Skeleton {
97    fn draw_shimmer_rect(&self, canvas: &Canvas, rect: Rect, radius: f32, time: f64) {
98        let w = rect.width();
99
100        let mut base_paint = paint_from_hex(&self.base_color);
101        base_paint.set_style(PaintStyle::Fill);
102        base_paint.set_anti_alias(true);
103        let rrect = skia_safe::RRect::new_rect_xy(rect, radius, radius);
104        canvas.draw_rrect(rrect, &base_paint);
105
106        let cycle = (time as f32 / self.speed).fract();
107        let shimmer_w = w * 0.4;
108        let shimmer_x = rect.left + (w + shimmer_w) * cycle - shimmer_w;
109
110        let (r, g, b, _) = rustmotion_core::engine::renderer::parse_hex_color(&self.shimmer_color);
111        let transparent = Color::from_argb(0, r, g, b);
112        let highlight = Color::from_argb(180, r, g, b);
113
114        let colors4f = [
115            Color4f::from(transparent),
116            Color4f::from(highlight),
117            Color4f::from(transparent),
118        ];
119        let stops = Colors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
120        let grad = Gradient::new(stops, gradient::Interpolation::default());
121        let shader = gradient::shaders::linear_gradient(
122            (
123                Point::new(shimmer_x, 0.0),
124                Point::new(shimmer_x + shimmer_w, 0.0),
125            ),
126            &grad,
127            None,
128        );
129
130        if let Some(shader) = shader {
131            canvas.save();
132            canvas.clip_rrect(rrect, skia_safe::ClipOp::Intersect, true);
133            let mut shimmer_paint = skia_safe::Paint::default();
134            shimmer_paint.set_style(PaintStyle::Fill);
135            shimmer_paint.set_anti_alias(true);
136            shimmer_paint.set_shader(shader);
137            canvas.draw_rect(rect, &shimmer_paint);
138            canvas.restore();
139        }
140    }
141}
142
143impl Skeleton {
144    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
145        let w = layout_w;
146        let h = layout_h;
147
148        match self.variant {
149            SkeletonVariant::Rectangle => {
150                let rect = Rect::from_xywh(0.0, 0.0, w, h);
151                self.draw_shimmer_rect(canvas, rect, self.border_radius, time);
152            }
153            SkeletonVariant::Circle => {
154                let diameter = w.min(h);
155                let rect = Rect::from_xywh(0.0, 0.0, diameter, diameter);
156                self.draw_shimmer_rect(canvas, rect, diameter / 2.0, time);
157            }
158            SkeletonVariant::Text => {
159                let total_lines = self.lines.max(1);
160                for i in 0..total_lines {
161                    let y = i as f32 * (self.line_height + self.line_gap);
162                    let line_w = if i == total_lines - 1 { w * 0.6 } else { w };
163                    let rect = Rect::from_xywh(0.0, y, line_w, self.line_height);
164                    self.draw_shimmer_rect(canvas, rect, self.border_radius * 0.5, time);
165                }
166            }
167        }
168    }
169}
170
171impl Painter for Skeleton {
172    fn paint_content(
173        &self,
174        canvas: &Canvas,
175        layout: &BoxLayout,
176        _props: &AnimatedProperties,
177        ctx: &PaintCtx,
178    ) {
179        self.paint(canvas, layout.width, layout.height, ctx.time);
180    }
181}