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")]
47#[deprecated(
48 since = "0.7.1",
49 note = "`switch` is a frozen composition (issue #333). Compose a toggle from two \
50 `shape`s (track + thumb) with the thumb's position keyframed instead — see \
51 crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
52 scheduled for removal in a future major version via `rustmotion migrate` \
53 (#335)."
54)]
55pub struct Switch {
56 #[serde(default)]
57 pub value: bool,
58 #[serde(default)]
59 pub toggle_at: Option<f64>,
60 #[serde(default)]
61 pub label: Option<String>,
62 #[serde(default = "default_switch_width")]
63 pub width: f32,
64 #[serde(default = "default_switch_height")]
65 pub height: f32,
66 #[serde(default = "default_track_color_on")]
67 pub track_color_on: String,
68 #[serde(default = "default_track_color_off")]
69 pub track_color_off: String,
70 #[serde(default = "default_thumb_color")]
71 pub thumb_color: String,
72 #[serde(default = "default_transition_duration")]
73 pub transition_duration: f64,
74 #[serde(default)]
75 pub timing: TimingConfig,
76 #[serde(default)]
77 pub style: CssStyle,
78 #[serde(default)]
79 pub timeline: Vec<TimelineStep>,
80 #[serde(default)]
81 pub stagger: Option<f32>,
82}
83
84#[derive(Debug, Deserialize)]
85struct SwitchRaw {
86 #[serde(default)]
87 value: bool,
88 #[serde(default)]
89 toggle_at: Option<f64>,
90 #[serde(default)]
91 label: Option<String>,
92 #[serde(default = "default_switch_width")]
93 width: f32,
94 #[serde(default = "default_switch_height")]
95 height: f32,
96 #[serde(default = "default_track_color_on")]
97 track_color_on: String,
98 #[serde(default = "default_track_color_off")]
99 track_color_off: String,
100 #[serde(default = "default_thumb_color")]
101 thumb_color: String,
102 #[serde(default = "default_transition_duration")]
103 transition_duration: f64,
104 #[serde(flatten)]
105 timing: TimingConfig,
106 #[serde(default)]
107 style: CssStyle,
108 #[serde(default)]
109 timeline: Vec<TimelineStep>,
110 #[serde(default)]
111 stagger: Option<f32>,
112}
113
114impl From<SwitchRaw> for Switch {
115 fn from(raw: SwitchRaw) -> Self {
116 let mut style = raw.style;
117 if style.width.is_none() {
118 if let Some(extra) = raw
119 .label
120 .as_deref()
121 .and_then(|label| switch_label_extra_width(label, raw.height))
122 {
123 style.width = Some(CSize::Length(CLP::Px(raw.width + extra)));
124 }
125 }
126 Switch {
127 value: raw.value,
128 toggle_at: raw.toggle_at,
129 label: raw.label,
130 width: raw.width,
131 height: raw.height,
132 track_color_on: raw.track_color_on,
133 track_color_off: raw.track_color_off,
134 thumb_color: raw.thumb_color,
135 transition_duration: raw.transition_duration,
136 timing: raw.timing,
137 style,
138 timeline: raw.timeline,
139 stagger: raw.stagger,
140 }
141 }
142}
143
144fn switch_label_extra_width(label: &str, height: f32) -> Option<f32> {
145 let font_size = (height * 0.5).max(12.0);
146 let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
147 let font = skia_safe::Font::from_typeface(typeface, font_size);
148 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
149 let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
150 Some(8.0 + label_w)
151}
152
153rustmotion_core::impl_traits!(Switch {
154 Animatable => animation,
155 Timed => timing,
156 Styled => style,
157});
158
159impl Switch {
160 fn ease_out_cubic(t: f64) -> f64 {
161 1.0 - (1.0 - t).powi(3)
162 }
163
164 fn current_state_at(&self, time: f64) -> f64 {
165 let target = match self.toggle_at {
166 Some(toggle_time) if time >= toggle_time => !self.value,
167 _ => self.value,
168 };
169
170 let target_val = if target { 1.0 } else { 0.0 };
171
172 match self.toggle_at {
173 Some(toggle_time) if time >= toggle_time => {
174 let elapsed = time - toggle_time;
175 let progress = (elapsed / self.transition_duration).clamp(0.0, 1.0);
176 let eased = Self::ease_out_cubic(progress);
177 let start_val = if self.value { 1.0 } else { 0.0 };
178 start_val + (target_val - start_val) * eased
179 }
180 _ => target_val,
181 }
182 }
183}
184
185impl Switch {
186 fn paint(&self, canvas: &Canvas, time: f64) {
187 let w = self.width;
188 let h = self.height;
189 let radius = h / 2.0;
190 let state = self.current_state_at(time) as f32;
191
192 let track_color = if state > 0.5 {
193 &self.track_color_on
194 } else {
195 &self.track_color_off
196 };
197
198 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;
207 let thumb_x_min = 2.0 + thumb_radius;
208 let thumb_x_max = w - 2.0 - thumb_radius;
209 let thumb_cx = thumb_x_min + (thumb_x_max - thumb_x_min) * state;
210 let thumb_cy = h / 2.0;
211
212 let mut thumb_paint = paint_from_hex(&self.thumb_color);
213 thumb_paint.set_style(PaintStyle::Fill);
214 thumb_paint.set_anti_alias(true);
215 canvas.draw_circle((thumb_cx, thumb_cy), thumb_radius, &thumb_paint);
216
217 if let Some(label) = &self.label {
218 let font_size = (h * 0.5).max(12.0);
219 let font_style = skia_safe::FontStyle::normal();
220 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
221 return;
222 };
223 let font = skia_safe::Font::from_typeface(typeface, font_size);
224 let emoji_font =
225 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
226
227 let mut text_paint = paint_from_hex("#FFFFFF");
228 text_paint.set_anti_alias(true);
229
230 let (_, metrics) = font.metrics();
231 let text_x = w + 8.0;
232 let text_y = h / 2.0 + (-metrics.ascent) / 2.0;
233
234 draw_text_with_fallback(
235 canvas,
236 label,
237 &font,
238 &emoji_font,
239 0.0,
240 text_x,
241 text_y,
242 &text_paint,
243 );
244 }
245 }
246}
247
248impl Painter for Switch {
249 fn paint_content(
250 &self,
251 canvas: &Canvas,
252 _layout: &BoxLayout,
253 _props: &AnimatedProperties,
254 ctx: &PaintCtx,
255 ) {
256 self.paint(canvas, ctx.time);
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use rustmotion_core::css::style::Size as SzCheck;
264
265 fn parse(json: &str) -> Switch {
266 serde_json::from_str(json).expect("switch should deserialize")
267 }
268
269 #[test]
270 fn no_label_leaves_style_width_unset() {
271 let s = parse(r#"{"type":"switch"}"#);
272 assert!(s.style.width.is_none());
273 }
274
275 #[test]
276 fn label_widens_style_width_past_the_track() {
277 let s = parse(r#"{"type":"switch","label":"Dark mode","width":64,"height":34}"#);
278 let SzCheck::Length(rustmotion_core::css::LengthPercentage::Px(w)) =
279 s.style.width.expect("label should reserve style.width")
280 else {
281 panic!("expected an explicit px width");
282 };
283 assert!(
284 w > s.width,
285 "reserved width {w} should exceed the bare track width {}",
286 s.width
287 );
288 }
289
290 #[test]
291 fn explicit_style_width_is_never_overridden() {
292 let s = parse(
293 r#"{"type":"switch","label":"Dark mode","width":64,"height":34,"style":{"width":500}}"#,
294 );
295 let SzCheck::Length(rustmotion_core::css::LengthPercentage::Px(w)) =
296 s.style.width.expect("width should still be set")
297 else {
298 panic!("expected an explicit px width");
299 };
300 assert_eq!(w, 500.0, "author's explicit style.width must win");
301 }
302}