1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::style::Size as CSize;
6use rustmotion_core::css::{CssStyle, LengthPercentage as CLP};
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
11 typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_switch_width() -> f32 {
17 52.0
18}
19fn default_switch_height() -> f32 {
20 28.0
21}
22fn default_track_color_on() -> String {
23 "#4CAF50".to_string()
24}
25fn default_track_color_off() -> String {
26 "#CCCCCC".to_string()
27}
28fn default_thumb_color() -> String {
29 "#FFFFFF".to_string()
30}
31fn default_transition_duration() -> f64 {
32 0.3
33}
34
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
46#[serde(from = "SwitchRaw")]
47pub struct Switch {
48 #[serde(default)]
49 pub value: bool,
50 #[serde(default)]
51 pub toggle_at: Option<f64>,
52 #[serde(default)]
53 pub label: Option<String>,
54 #[serde(default = "default_switch_width")]
55 pub width: f32,
56 #[serde(default = "default_switch_height")]
57 pub height: f32,
58 #[serde(default = "default_track_color_on")]
59 pub track_color_on: String,
60 #[serde(default = "default_track_color_off")]
61 pub track_color_off: String,
62 #[serde(default = "default_thumb_color")]
63 pub thumb_color: String,
64 #[serde(default = "default_transition_duration")]
65 pub transition_duration: f64,
66 #[serde(default)]
67 pub timing: TimingConfig,
68 #[serde(default)]
69 pub style: CssStyle,
70 #[serde(default)]
71 pub timeline: Vec<TimelineStep>,
72 #[serde(default)]
73 pub stagger: Option<f32>,
74}
75
76#[derive(Debug, Deserialize)]
77struct SwitchRaw {
78 #[serde(default)]
79 value: bool,
80 #[serde(default)]
81 toggle_at: Option<f64>,
82 #[serde(default)]
83 label: Option<String>,
84 #[serde(default = "default_switch_width")]
85 width: f32,
86 #[serde(default = "default_switch_height")]
87 height: f32,
88 #[serde(default = "default_track_color_on")]
89 track_color_on: String,
90 #[serde(default = "default_track_color_off")]
91 track_color_off: String,
92 #[serde(default = "default_thumb_color")]
93 thumb_color: String,
94 #[serde(default = "default_transition_duration")]
95 transition_duration: f64,
96 #[serde(flatten)]
97 timing: TimingConfig,
98 #[serde(default)]
99 style: CssStyle,
100 #[serde(default)]
101 timeline: Vec<TimelineStep>,
102 #[serde(default)]
103 stagger: Option<f32>,
104}
105
106impl From<SwitchRaw> for Switch {
107 fn from(raw: SwitchRaw) -> Self {
108 let mut style = raw.style;
109 if style.width.is_none() {
110 if let Some(extra) = raw
111 .label
112 .as_deref()
113 .and_then(|label| switch_label_extra_width(label, raw.height))
114 {
115 style.width = Some(CSize::Length(CLP::Px(raw.width + extra)));
116 }
117 }
118 Switch {
119 value: raw.value,
120 toggle_at: raw.toggle_at,
121 label: raw.label,
122 width: raw.width,
123 height: raw.height,
124 track_color_on: raw.track_color_on,
125 track_color_off: raw.track_color_off,
126 thumb_color: raw.thumb_color,
127 transition_duration: raw.transition_duration,
128 timing: raw.timing,
129 style,
130 timeline: raw.timeline,
131 stagger: raw.stagger,
132 }
133 }
134}
135
136fn switch_label_extra_width(label: &str, height: f32) -> Option<f32> {
143 let font_size = (height * 0.5).max(12.0);
144 let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
145 let font = skia_safe::Font::from_typeface(typeface, font_size);
146 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
147 let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
148 Some(8.0 + label_w)
149}
150
151rustmotion_core::impl_traits!(Switch {
152 Animatable => animation,
153 Timed => timing,
154 Styled => style,
155});
156
157impl Switch {
158 fn ease_out_cubic(t: f64) -> f64 {
159 1.0 - (1.0 - t).powi(3)
160 }
161
162 fn current_state_at(&self, time: f64) -> f64 {
163 let target = match self.toggle_at {
164 Some(toggle_time) if time >= toggle_time => !self.value,
165 _ => self.value,
166 };
167
168 let target_val = if target { 1.0 } else { 0.0 };
169
170 match self.toggle_at {
171 Some(toggle_time) if time >= toggle_time => {
172 let elapsed = time - toggle_time;
173 let progress = (elapsed / self.transition_duration).clamp(0.0, 1.0);
174 let eased = Self::ease_out_cubic(progress);
175 let start_val = if self.value { 1.0 } else { 0.0 };
176 start_val + (target_val - start_val) * eased
177 }
178 _ => target_val,
179 }
180 }
181}
182
183impl Switch {
184 fn paint(&self, canvas: &Canvas, time: f64) {
185 let w = self.width;
186 let h = self.height;
187 let radius = h / 2.0;
188 let state = self.current_state_at(time) as f32;
189
190 let track_color = if state > 0.5 {
192 &self.track_color_on
193 } else {
194 &self.track_color_off
195 };
196
197 let mut track_paint = paint_from_hex(track_color);
199 track_paint.set_style(PaintStyle::Fill);
200 track_paint.set_anti_alias(true);
201
202 let track_rect = Rect::from_xywh(0.0, 0.0, w, h);
203 let track_rrect = RRect::new_rect_xy(track_rect, radius, radius);
204 canvas.draw_rrect(track_rrect, &track_paint);
205
206 let thumb_radius = (h - 4.0) / 2.0;
208 let thumb_x_min = 2.0 + thumb_radius;
209 let thumb_x_max = w - 2.0 - thumb_radius;
210 let thumb_cx = thumb_x_min + (thumb_x_max - thumb_x_min) * state;
211 let thumb_cy = h / 2.0;
212
213 let mut thumb_paint = paint_from_hex(&self.thumb_color);
214 thumb_paint.set_style(PaintStyle::Fill);
215 thumb_paint.set_anti_alias(true);
216 canvas.draw_circle((thumb_cx, thumb_cy), thumb_radius, &thumb_paint);
217
218 if let Some(label) = &self.label {
220 let font_size = (h * 0.5).max(12.0);
221 let font_style = skia_safe::FontStyle::normal();
222 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
223 return;
224 };
225 let font = skia_safe::Font::from_typeface(typeface, font_size);
226 let emoji_font =
227 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
228
229 let mut text_paint = paint_from_hex("#FFFFFF");
230 text_paint.set_anti_alias(true);
231
232 let (_, metrics) = font.metrics();
233 let text_x = w + 8.0;
234 let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
235
236 draw_text_with_fallback(
237 canvas,
238 label,
239 &font,
240 &emoji_font,
241 0.0,
242 text_x,
243 text_y,
244 &text_paint,
245 );
246 }
247 }
248}
249
250impl Painter for Switch {
251 fn paint_content(
252 &self,
253 canvas: &Canvas,
254 _layout: &BoxLayout,
255 _props: &AnimatedProperties,
256 ctx: &PaintCtx,
257 ) {
258 self.paint(canvas, ctx.time);
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use rustmotion_core::css::style::Size as SzCheck;
266
267 fn parse(json: &str) -> Switch {
268 serde_json::from_str(json).expect("switch should deserialize")
269 }
270
271 #[test]
272 fn no_label_leaves_style_width_unset() {
273 let s = parse(r#"{"type":"switch"}"#);
276 assert!(s.style.width.is_none());
277 }
278
279 #[test]
280 fn label_widens_style_width_past_the_track() {
281 let s = parse(r#"{"type":"switch","label":"Dark mode","width":64,"height":34}"#);
286 let SzCheck::Length(rustmotion_core::css::LengthPercentage::Px(w)) =
287 s.style.width.expect("label should reserve style.width")
288 else {
289 panic!("expected an explicit px width");
290 };
291 assert!(
292 w > s.width,
293 "reserved width {w} should exceed the bare track width {}",
294 s.width
295 );
296 }
297
298 #[test]
299 fn explicit_style_width_is_never_overridden() {
300 let s = parse(
301 r#"{"type":"switch","label":"Dark mode","width":64,"height":34,"style":{"width":500}}"#,
302 );
303 let SzCheck::Length(rustmotion_core::css::LengthPercentage::Px(w)) =
304 s.style.width.expect("width should still be set")
305 else {
306 panic!("expected an explicit px width");
307 };
308 assert_eq!(w, 500.0, "author's explicit style.width must win");
309 }
310}