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)]
44#[deprecated(
45    since = "0.7.1",
46    note = "`comparison` is a frozen composition (issue #333). Compose a before/after view \
47            from two clipped `card`/`image` panels and an animated `shape` divider instead \
48            — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
49            compatibility; scheduled for removal in a future major version via \
50            `rustmotion migrate` (#335)."
51)]
52pub struct Comparison {
53    #[serde(default = "default_left_color")]
54    pub left_color: String,
55    #[serde(default = "default_right_color")]
56    pub right_color: String,
57    #[serde(default)]
58    pub left_label: Option<String>,
59    #[serde(default)]
60    pub right_label: Option<String>,
61    #[serde(default = "default_divider_position")]
62    pub divider_position: f64,
63    #[serde(default)]
64    pub animate_from: Option<f64>,
65    #[serde(default)]
66    pub animate_to: Option<f64>,
67    #[serde(default)]
68    pub animate_at: Option<f64>,
69    #[serde(default = "default_animation_duration")]
70    pub animation_duration: f64,
71    #[serde(default = "default_divider_color")]
72    pub divider_color: String,
73    #[serde(default = "default_divider_width")]
74    pub divider_width: f32,
75    #[serde(default = "default_border_radius")]
76    pub border_radius: f32,
77    #[serde(flatten)]
78    pub timing: TimingConfig,
79    #[serde(default)]
80    pub style: CssStyle,
81    #[serde(default)]
82    pub timeline: Vec<TimelineStep>,
83    #[serde(default)]
84    pub stagger: Option<f32>,
85}
86
87rustmotion_core::impl_traits!(Comparison {
88    Animatable => animation,
89    Timed => timing,
90    Styled => style,
91});
92
93impl Comparison {
94    fn current_divider_position_at(&self, time: f64) -> f64 {
95        let from = self.animate_from.unwrap_or(self.divider_position);
96        let to = self.animate_to.unwrap_or(self.divider_position);
97        let start = self.animate_at.unwrap_or(0.0);
98
99        if time < start {
100            return from;
101        }
102
103        if self.animation_duration <= 0.0 {
104            return to;
105        }
106
107        let elapsed = time - start;
108        let t = (elapsed / self.animation_duration).clamp(0.0, 1.0);
109        let eased = 1.0 - (1.0 - t).powi(3);
110        from + (to - from) * eased
111    }
112}
113
114impl Comparison {
115    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
116        let w = layout_w;
117        let h = layout_h;
118        let pos = self.current_divider_position_at(time).clamp(0.0, 1.0) as f32;
119        let divider_x = w * pos;
120
121        let outer_rect = Rect::from_xywh(0.0, 0.0, w, h);
122        let outer_rrect = RRect::new_rect_xy(outer_rect, self.border_radius, self.border_radius);
123
124        canvas.save();
125        canvas.clip_rrect(outer_rrect, skia_safe::ClipOp::Intersect, true);
126
127        let mut left_paint = paint_from_hex(&self.left_color);
128        left_paint.set_style(PaintStyle::Fill);
129        let left_rect = Rect::from_xywh(0.0, 0.0, divider_x, h);
130        canvas.draw_rect(left_rect, &left_paint);
131
132        let mut right_paint = paint_from_hex(&self.right_color);
133        right_paint.set_style(PaintStyle::Fill);
134        let right_rect = Rect::from_xywh(divider_x, 0.0, w - divider_x, h);
135        canvas.draw_rect(right_rect, &right_paint);
136
137        let font_size = (h * 0.08).clamp(16.0, 36.0);
138        let font_style = skia_safe::FontStyle::bold();
139        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
140            canvas.restore();
141            return;
142        };
143        let font = skia_safe::Font::from_typeface(typeface, font_size);
144        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
145
146        let mut label_paint = paint_from_hex("#FFFFFF");
147        label_paint.set_anti_alias(true);
148
149        if let Some(ref label) = self.left_label {
150            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
151            let (_, metrics) = font.metrics();
152            let text_x = divider_x / 2.0 - text_w / 2.0;
153            let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
154            draw_text_with_fallback(
155                canvas,
156                label,
157                &font,
158                &emoji_font,
159                0.0,
160                text_x,
161                text_y,
162                &label_paint,
163            );
164        }
165
166        if let Some(ref label) = self.right_label {
167            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
168            let (_, metrics) = font.metrics();
169            let text_x = divider_x + (w - divider_x) / 2.0 - text_w / 2.0;
170            let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
171            draw_text_with_fallback(
172                canvas,
173                label,
174                &font,
175                &emoji_font,
176                0.0,
177                text_x,
178                text_y,
179                &label_paint,
180            );
181        }
182
183        let mut divider_paint = paint_from_hex(&self.divider_color);
184        divider_paint.set_style(PaintStyle::Stroke);
185        divider_paint.set_stroke_width(self.divider_width);
186        divider_paint.set_anti_alias(true);
187        canvas.draw_line(
188            skia_safe::Point::new(divider_x, 0.0),
189            skia_safe::Point::new(divider_x, h),
190            &divider_paint,
191        );
192
193        let handle_radius = (self.divider_width * 4.0).max(8.0);
194        let mut handle_paint = paint_from_hex(&self.divider_color);
195        handle_paint.set_style(PaintStyle::Fill);
196        handle_paint.set_anti_alias(true);
197        canvas.draw_circle(
198            skia_safe::Point::new(divider_x, h / 2.0),
199            handle_radius,
200            &handle_paint,
201        );
202
203        let (r, g, b, _) = parse_hex_color(&self.divider_color);
204        let mut border_paint = skia_safe::Paint::default();
205        border_paint.set_style(PaintStyle::Stroke);
206        border_paint.set_stroke_width(2.0);
207        border_paint.set_anti_alias(true);
208        border_paint.set_color(skia_safe::Color::from_argb(80, r, g, b));
209        canvas.draw_circle(
210            skia_safe::Point::new(divider_x, h / 2.0),
211            handle_radius,
212            &border_paint,
213        );
214
215        canvas.restore();
216    }
217}
218
219impl Painter for Comparison {
220    fn paint_content(
221        &self,
222        canvas: &Canvas,
223        layout: &BoxLayout,
224        _props: &AnimatedProperties,
225        ctx: &PaintCtx,
226    ) {
227        self.paint(canvas, layout.width, layout.height, ctx.time);
228    }
229}