Skip to main content

rustmotion_components/
switch.rs

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/// `#[serde(from = "SwitchRaw")]`: `paint()` draws the label at
36/// `self.width + 8`, entirely outside the track — and, like `slider` and
37/// `countdown`, this painter draws from its own fields (`self.width`/
38/// `self.height`), never `layout.width`/`layout.height`, so the box
39/// `box_builder.rs` assigns (`css.width = Px(c.width)`, no label
40/// awareness — see #127) needs to be widened to fit the label, not the
41/// paint code changed to fit a box it doesn't consult. Going through a raw
42/// shadow struct lets that extra width be computed once (with the same
43/// font metrics `paint()` uses) and folded into `style.width` before
44/// `box_builder.rs` ever sees this component, without touching that file.
45#[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}