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")]
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
136/// Extra width (gap + label text) `paint()` needs past the track — mirrors
137/// its `font_size = (h*0.5).max(12.0)` / `text_x = w + 8.0` exactly, so the
138/// box reserved here always matches what gets drawn. `None` on font-load
139/// failure (e.g. a headless test env with no fonts) — `style.width` is then
140/// left unset and `box_builder.rs`'s plain `c.width` fallback applies, same
141/// as before this fix.
142fn 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        // Interpolate track color
191        let track_color = if state > 0.5 {
192            &self.track_color_on
193        } else {
194            &self.track_color_off
195        };
196
197        // Track
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        // Thumb
207        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        // Label
219        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        // No extra room needed — box_builder.rs's plain `c.width` fallback
274        // should still apply, exactly as before this fix.
275        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        // #127: `paint()` draws the label at `self.width + 8`, entirely
282        // outside a box sized only for the track (64×34 assigned vs.
283        // 155×34 painted in the audit). `style.width` must now reserve at
284        // least `width` (the track) plus *some* label room.
285        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}