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, subpixel_font, 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 = subpixel_font(typeface, font_size);
144 let emoji_font = emoji_typeface().map(|tf| subpixel_font(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 ÷r_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}