Skip to main content

rustmotion_components/
rating.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Path, PathBuilder};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::paint_from_hex;
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12fn default_max() -> u32 {
13    5
14}
15fn default_star_size() -> f32 {
16    32.0
17}
18fn default_gap() -> f32 {
19    4.0
20}
21fn default_filled_color() -> String {
22    "#F59E0B".to_string()
23}
24fn default_empty_color() -> String {
25    "#374151".to_string()
26}
27fn default_animated() -> bool {
28    true
29}
30fn default_animation_duration() -> f64 {
31    1.0
32}
33
34#[derive(Debug, Serialize, Deserialize, JsonSchema)]
35#[deprecated(
36    since = "0.7.1",
37    note = "`rating` is a frozen composition (issue #333). Compose stars from repeated \
38            `icon`s in a `for-each`, with a partial fill via two overlapping clipped \
39            copies — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
40            compatibility; scheduled for removal in a future major version via \
41            `rustmotion migrate` (#335)."
42)]
43pub struct Rating {
44    #[serde(default)]
45    pub value: f64,
46    #[serde(default = "default_max")]
47    pub max: u32,
48    #[serde(default = "default_star_size")]
49    pub size: f32,
50    #[serde(default = "default_gap")]
51    pub gap: f32,
52    #[serde(default = "default_filled_color")]
53    pub filled_color: String,
54    #[serde(default = "default_empty_color")]
55    pub empty_color: String,
56    #[serde(default = "default_animated")]
57    pub animated: bool,
58    #[serde(default = "default_animation_duration")]
59    pub animation_duration: f64,
60    #[serde(flatten)]
61    pub timing: TimingConfig,
62    #[serde(default)]
63    pub style: CssStyle,
64    #[serde(default)]
65    pub timeline: Vec<TimelineStep>,
66    #[serde(default)]
67    pub stagger: Option<f32>,
68}
69
70rustmotion_core::impl_traits!(Rating {
71    Animatable => animation,
72    Timed => timing,
73    Styled => style,
74});
75
76impl Rating {
77    fn ease_out_cubic(t: f64) -> f64 {
78        1.0 - (1.0 - t).powi(3)
79    }
80
81    fn displayed_value_at(&self, time: f64) -> f64 {
82        if !self.animated {
83            return self.value;
84        }
85        let progress = (time / self.animation_duration).clamp(0.0, 1.0);
86        let eased = Self::ease_out_cubic(progress);
87        self.value * eased
88    }
89
90    fn star_path(cx: f32, cy: f32, outer_radius: f32) -> Path {
91        let inner_radius = outer_radius * 0.4;
92        let mut path = PathBuilder::new();
93
94        for i in 0..10 {
95            let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
96            let r = if i % 2 == 0 {
97                outer_radius
98            } else {
99                inner_radius
100            };
101            let x = cx + r * angle.cos();
102            let y = cy + r * angle.sin();
103
104            if i == 0 {
105                path.move_to((x, y));
106            } else {
107                path.line_to((x, y));
108            }
109        }
110        path.close();
111        path.detach()
112    }
113}
114
115impl Rating {
116    fn paint(&self, canvas: &Canvas, time: f64) {
117        let displayed = self.displayed_value_at(time);
118        let outer_radius = self.size / 2.0;
119
120        for i in 0..self.max {
121            let cx = outer_radius + i as f32 * (self.size + self.gap);
122            let cy = outer_radius;
123            let star_fill = displayed - i as f64;
124
125            if star_fill >= 1.0 {
126                let path = Self::star_path(cx, cy, outer_radius);
127                let mut paint = paint_from_hex(&self.filled_color);
128                paint.set_style(PaintStyle::Fill);
129                paint.set_anti_alias(true);
130                canvas.draw_path(&path, &paint);
131            } else if star_fill > 0.0 {
132                let path = Self::star_path(cx, cy, outer_radius);
133                let mut empty_paint = paint_from_hex(&self.empty_color);
134                empty_paint.set_style(PaintStyle::Fill);
135                empty_paint.set_anti_alias(true);
136                canvas.draw_path(&path, &empty_paint);
137
138                let fill_fraction = star_fill as f32;
139                let clip_x = cx - outer_radius;
140                let clip_w = self.size * fill_fraction;
141                let clip_rect = skia_safe::Rect::from_xywh(clip_x, 0.0, clip_w, self.size);
142
143                canvas.save();
144                canvas.clip_rect(clip_rect, skia_safe::ClipOp::Intersect, true);
145                let path = Self::star_path(cx, cy, outer_radius);
146                let mut fill_paint = paint_from_hex(&self.filled_color);
147                fill_paint.set_style(PaintStyle::Fill);
148                fill_paint.set_anti_alias(true);
149                canvas.draw_path(&path, &fill_paint);
150                canvas.restore();
151            } else {
152                let path = Self::star_path(cx, cy, outer_radius);
153                let mut paint = paint_from_hex(&self.empty_color);
154                paint.set_style(PaintStyle::Fill);
155                paint.set_anti_alias(true);
156                canvas.draw_path(&path, &paint);
157            }
158        }
159    }
160}
161
162impl Painter for Rating {
163    fn paint_content(
164        &self,
165        canvas: &Canvas,
166        _layout: &BoxLayout,
167        _props: &AnimatedProperties,
168        ctx: &PaintCtx,
169    ) {
170        self.paint(canvas, ctx.time);
171    }
172}