Skip to main content

rustmotion_components/
comparison.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10    parse_hex_color, typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15fn default_left_color() -> String {
16    "#3B82F6".to_string()
17}
18
19fn default_right_color() -> String {
20    "#EF4444".to_string()
21}
22
23fn default_divider_position() -> f64 {
24    0.5
25}
26
27fn default_animation_duration() -> f64 {
28    2.0
29}
30
31fn default_divider_color() -> String {
32    "#FFFFFF".to_string()
33}
34
35fn default_divider_width() -> f32 {
36    3.0
37}
38
39fn default_border_radius() -> f32 {
40    12.0
41}
42
43#[derive(Debug, Serialize, Deserialize, JsonSchema)]
44pub struct Comparison {
45    #[serde(default = "default_left_color")]
46    pub left_color: String,
47    #[serde(default = "default_right_color")]
48    pub right_color: String,
49    #[serde(default)]
50    pub left_label: Option<String>,
51    #[serde(default)]
52    pub right_label: Option<String>,
53    #[serde(default = "default_divider_position")]
54    pub divider_position: f64,
55    #[serde(default)]
56    pub animate_from: Option<f64>,
57    #[serde(default)]
58    pub animate_to: Option<f64>,
59    #[serde(default)]
60    pub animate_at: Option<f64>,
61    #[serde(default = "default_animation_duration")]
62    pub animation_duration: f64,
63    #[serde(default = "default_divider_color")]
64    pub divider_color: String,
65    #[serde(default = "default_divider_width")]
66    pub divider_width: f32,
67    #[serde(default = "default_border_radius")]
68    pub border_radius: f32,
69    #[serde(flatten)]
70    pub timing: TimingConfig,
71    #[serde(default)]
72    pub style: CssStyle,
73    #[serde(default)]
74    pub timeline: Vec<TimelineStep>,
75    #[serde(default)]
76    pub stagger: Option<f32>,
77}
78
79rustmotion_core::impl_traits!(Comparison {
80    Animatable => animation,
81    Timed => timing,
82    Styled => style,
83});
84
85impl Comparison {
86    fn current_divider_position_at(&self, time: f64) -> f64 {
87        let from = self.animate_from.unwrap_or(self.divider_position);
88        let to = self.animate_to.unwrap_or(self.divider_position);
89        let start = self.animate_at.unwrap_or(0.0);
90
91        if time < start {
92            return from;
93        }
94
95        if self.animation_duration <= 0.0 {
96            return to;
97        }
98
99        let elapsed = time - start;
100        let t = (elapsed / self.animation_duration).clamp(0.0, 1.0);
101        let eased = 1.0 - (1.0 - t).powi(3);
102        from + (to - from) * eased
103    }
104}
105
106impl Comparison {
107    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
108        let w = layout_w;
109        let h = layout_h;
110        let pos = self.current_divider_position_at(time).clamp(0.0, 1.0) as f32;
111        let divider_x = w * pos;
112
113        let outer_rect = Rect::from_xywh(0.0, 0.0, w, h);
114        let outer_rrect = RRect::new_rect_xy(outer_rect, self.border_radius, self.border_radius);
115
116        canvas.save();
117        canvas.clip_rrect(outer_rrect, skia_safe::ClipOp::Intersect, true);
118
119        let mut left_paint = paint_from_hex(&self.left_color);
120        left_paint.set_style(PaintStyle::Fill);
121        let left_rect = Rect::from_xywh(0.0, 0.0, divider_x, h);
122        canvas.draw_rect(left_rect, &left_paint);
123
124        let mut right_paint = paint_from_hex(&self.right_color);
125        right_paint.set_style(PaintStyle::Fill);
126        let right_rect = Rect::from_xywh(divider_x, 0.0, w - divider_x, h);
127        canvas.draw_rect(right_rect, &right_paint);
128
129        let font_size = (h * 0.08).clamp(16.0, 36.0);
130        let font_style = skia_safe::FontStyle::bold();
131        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
132            // Balance the canvas.save() above before bailing out.
133            canvas.restore();
134            return;
135        };
136        let font = skia_safe::Font::from_typeface(typeface, font_size);
137        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
138
139        let mut label_paint = paint_from_hex("#FFFFFF");
140        label_paint.set_anti_alias(true);
141
142        if let Some(ref label) = self.left_label {
143            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
144            let (_, metrics) = font.metrics();
145            let text_x = divider_x / 2.0 - text_w / 2.0;
146            let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
147            draw_text_with_fallback(
148                canvas,
149                label,
150                &font,
151                &emoji_font,
152                0.0,
153                text_x,
154                text_y,
155                &label_paint,
156            );
157        }
158
159        if let Some(ref label) = self.right_label {
160            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
161            let (_, metrics) = font.metrics();
162            let text_x = divider_x + (w - divider_x) / 2.0 - text_w / 2.0;
163            let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
164            draw_text_with_fallback(
165                canvas,
166                label,
167                &font,
168                &emoji_font,
169                0.0,
170                text_x,
171                text_y,
172                &label_paint,
173            );
174        }
175
176        let mut divider_paint = paint_from_hex(&self.divider_color);
177        divider_paint.set_style(PaintStyle::Stroke);
178        divider_paint.set_stroke_width(self.divider_width);
179        divider_paint.set_anti_alias(true);
180        canvas.draw_line(
181            skia_safe::Point::new(divider_x, 0.0),
182            skia_safe::Point::new(divider_x, h),
183            &divider_paint,
184        );
185
186        let handle_radius = (self.divider_width * 4.0).max(8.0);
187        let mut handle_paint = paint_from_hex(&self.divider_color);
188        handle_paint.set_style(PaintStyle::Fill);
189        handle_paint.set_anti_alias(true);
190        canvas.draw_circle(
191            skia_safe::Point::new(divider_x, h / 2.0),
192            handle_radius,
193            &handle_paint,
194        );
195
196        let (r, g, b, _) = parse_hex_color(&self.divider_color);
197        let mut border_paint = skia_safe::Paint::default();
198        border_paint.set_style(PaintStyle::Stroke);
199        border_paint.set_stroke_width(2.0);
200        border_paint.set_anti_alias(true);
201        border_paint.set_color(skia_safe::Color::from_argb(80, r, g, b));
202        canvas.draw_circle(
203            skia_safe::Point::new(divider_x, h / 2.0),
204            handle_radius,
205            &border_paint,
206        );
207
208        canvas.restore();
209    }
210}
211
212impl Painter for Comparison {
213    fn paint_content(
214        &self,
215        canvas: &Canvas,
216        layout: &BoxLayout,
217        _props: &AnimatedProperties,
218        ctx: &PaintCtx,
219    ) {
220        self.paint(canvas, layout.width, layout.height, ctx.time);
221    }
222}