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 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 ÷r_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}