Skip to main content

rustmotion_components/
slider.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    subpixel_font, typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_slider_value() -> f64 {
17    0.5
18}
19fn default_animation_duration() -> f64 {
20    1.0
21}
22fn default_slider_width() -> f32 {
23    300.0
24}
25fn default_slider_height() -> f32 {
26    8.0
27}
28fn default_track_color() -> String {
29    "#333333".to_string()
30}
31fn default_fill_color() -> String {
32    "#3B82F6".to_string()
33}
34fn default_thumb_size() -> f32 {
35    20.0
36}
37fn default_thumb_color() -> String {
38    "#FFFFFF".to_string()
39}
40
41/// `#[serde(from = "SliderRaw")]`: like `switch`/`countdown`, `paint()`
42/// draws from this component's own fields, never `layout.width`/
43/// `layout.height` — box_builder.rs's `css.width/height = Px(c.width /
44/// c.height)` only budgets for the thin *track*, not the round thumb
45/// (`thumb_size`, typically bigger than the track) that can sit centered
46/// on either end, nor the `show_value` "N%" label that floats above it
47/// (#127 measured 396×7 assigned vs. 417×31 painted). The raw shadow
48/// struct computes the true bounding box once — same font metrics
49/// `paint()` uses — and folds it into `style.width`/`style.height` before
50/// `box_builder.rs` ever runs, without editing that file. `paint()` itself
51/// is updated below to offset its drawing into that box instead of
52/// treating local (0,0) as the thumb's top-left.
53#[derive(Debug, Serialize, Deserialize, JsonSchema)]
54#[serde(from = "SliderRaw")]
55#[deprecated(
56    since = "0.7.1",
57    note = "`slider` is a frozen composition (issue #333), the same recipe as `switch`: \
58            compose two `shape`s (track + thumb) with the thumb's position keyframed \
59            instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
60            compatibility; scheduled for removal in a future major version via \
61            `rustmotion migrate` (#335)."
62)]
63pub struct Slider {
64    #[serde(default = "default_slider_value")]
65    pub value: f64,
66    #[serde(default)]
67    pub animate_to: Option<f64>,
68    #[serde(default)]
69    pub animate_at: Option<f64>,
70    #[serde(default = "default_animation_duration")]
71    pub animation_duration: f64,
72    #[serde(default = "default_slider_width")]
73    pub width: f32,
74    #[serde(default = "default_slider_height")]
75    pub height: f32,
76    #[serde(default = "default_track_color")]
77    pub track_color: String,
78    #[serde(default = "default_fill_color")]
79    pub fill_color: String,
80    #[serde(default = "default_thumb_size")]
81    pub thumb_size: f32,
82    #[serde(default = "default_thumb_color")]
83    pub thumb_color: String,
84    #[serde(default)]
85    pub show_value: bool,
86    #[serde(default)]
87    pub timing: TimingConfig,
88    #[serde(default)]
89    pub style: CssStyle,
90    #[serde(default)]
91    pub timeline: Vec<TimelineStep>,
92    #[serde(default)]
93    pub stagger: Option<f32>,
94}
95
96#[derive(Debug, Deserialize)]
97struct SliderRaw {
98    #[serde(default = "default_slider_value")]
99    value: f64,
100    #[serde(default)]
101    animate_to: Option<f64>,
102    #[serde(default)]
103    animate_at: Option<f64>,
104    #[serde(default = "default_animation_duration")]
105    animation_duration: f64,
106    #[serde(default = "default_slider_width")]
107    width: f32,
108    #[serde(default = "default_slider_height")]
109    height: f32,
110    #[serde(default = "default_track_color")]
111    track_color: String,
112    #[serde(default = "default_fill_color")]
113    fill_color: String,
114    #[serde(default = "default_thumb_size")]
115    thumb_size: f32,
116    #[serde(default = "default_thumb_color")]
117    thumb_color: String,
118    #[serde(default)]
119    show_value: bool,
120    #[serde(flatten)]
121    timing: TimingConfig,
122    #[serde(default)]
123    style: CssStyle,
124    #[serde(default)]
125    timeline: Vec<TimelineStep>,
126    #[serde(default)]
127    stagger: Option<f32>,
128}
129
130impl From<SliderRaw> for Slider {
131    fn from(raw: SliderRaw) -> Self {
132        let thumb_r = raw.thumb_size / 2.0;
133        let mut style = raw.style;
134
135        if style.width.is_none() {
136            let h_margin = slider_value_label_half_width(raw.thumb_size)
137                .unwrap_or(0.0)
138                .max(thumb_r);
139            style.width = Some(CSize::Length(CLP::Px(raw.width + h_margin * 2.0)));
140        }
141        if style.height.is_none() {
142            let top_margin = if raw.show_value {
143                slider_value_label_line_height(raw.thumb_size).unwrap_or(0.0)
144            } else {
145                0.0
146            };
147            style.height = Some(CSize::Length(CLP::Px(top_margin + raw.thumb_size)));
148        }
149
150        Slider {
151            value: raw.value,
152            animate_to: raw.animate_to,
153            animate_at: raw.animate_at,
154            animation_duration: raw.animation_duration,
155            width: raw.width,
156            height: raw.height,
157            track_color: raw.track_color,
158            fill_color: raw.fill_color,
159            thumb_size: raw.thumb_size,
160            thumb_color: raw.thumb_color,
161            show_value: raw.show_value,
162            timing: raw.timing,
163            style,
164            timeline: raw.timeline,
165            stagger: raw.stagger,
166        }
167    }
168}
169
170fn slider_value_label_half_width(thumb_size: f32) -> Option<f32> {
171    let font_size = (thumb_size * 0.7).max(12.0);
172    let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
173    let font = subpixel_font(typeface, font_size);
174    let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
175    let w = measure_text_with_fallback("100%", &font, &emoji_font, 0.0);
176    Some(w / 2.0)
177}
178
179fn slider_value_label_line_height(thumb_size: f32) -> Option<f32> {
180    let font_size = (thumb_size * 0.7).max(12.0);
181    let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
182    let font = subpixel_font(typeface, font_size);
183    let (_, metrics) = font.metrics();
184    Some(-metrics.ascent + metrics.descent + 4.0)
185}
186
187rustmotion_core::impl_traits!(Slider {
188    Animatable => animation,
189    Timed => timing,
190    Styled => style,
191});
192
193impl Slider {
194    fn ease_out_cubic(t: f64) -> f64 {
195        1.0 - (1.0 - t).powi(3)
196    }
197
198    fn current_value_at(&self, time: f64) -> f64 {
199        match (self.animate_to, self.animate_at) {
200            (Some(target), Some(start_time)) if time >= start_time => {
201                let elapsed = time - start_time;
202                let progress = (elapsed / self.animation_duration).clamp(0.0, 1.0);
203                let eased = Self::ease_out_cubic(progress);
204                self.value + (target - self.value) * eased
205            }
206            _ => self.value,
207        }
208    }
209}
210
211impl Slider {
212    fn paint(&self, canvas: &Canvas, layout: &BoxLayout, time: f64) {
213        let w = self.width;
214        let h = self.height;
215        let thumb_r = self.thumb_size / 2.0;
216        let radius = h / 2.0;
217        let current = self.current_value_at(time).clamp(0.0, 1.0) as f32;
218
219        let h_margin = slider_value_label_half_width(self.thumb_size)
220            .unwrap_or(0.0)
221            .max(thumb_r);
222        let top_margin = if self.show_value {
223            slider_value_label_line_height(self.thumb_size).unwrap_or(0.0)
224        } else {
225            0.0
226        };
227
228        canvas.save();
229        if layout.width > 0.0 && layout.height > 0.0 {
230            canvas.clip_rect(
231                Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
232                skia_safe::ClipOp::Intersect,
233                true,
234            );
235        }
236        canvas.translate((h_margin, top_margin));
237
238        let track_y = thumb_r - h / 2.0;
239
240        let mut track_paint = paint_from_hex(&self.track_color);
241        track_paint.set_style(PaintStyle::Fill);
242        track_paint.set_anti_alias(true);
243
244        let track_rect = Rect::from_xywh(0.0, track_y, w, h);
245        let track_rrect = RRect::new_rect_xy(track_rect, radius, radius);
246        canvas.draw_rrect(track_rrect, &track_paint);
247
248        if current > 0.001 {
249            let mut fill_paint = paint_from_hex(&self.fill_color);
250            fill_paint.set_style(PaintStyle::Fill);
251            fill_paint.set_anti_alias(true);
252
253            let fill_w = w * current;
254            let fill_rect = Rect::from_xywh(0.0, track_y, fill_w, h);
255
256            canvas.save();
257            canvas.clip_rrect(track_rrect, skia_safe::ClipOp::Intersect, true);
258            canvas.draw_rect(fill_rect, &fill_paint);
259            canvas.restore();
260        }
261
262        let thumb_cx = w * current;
263        let thumb_cy = thumb_r;
264
265        let mut thumb_paint = paint_from_hex(&self.thumb_color);
266        thumb_paint.set_style(PaintStyle::Fill);
267        thumb_paint.set_anti_alias(true);
268        canvas.draw_circle((thumb_cx, thumb_cy), thumb_r, &thumb_paint);
269
270        if self.show_value {
271            let text = format!("{}%", (current * 100.0).round() as i32);
272            let font_size = (self.thumb_size * 0.7).max(12.0);
273            let font_style = skia_safe::FontStyle::normal();
274            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
275                canvas.restore();
276                return;
277            };
278            let font = subpixel_font(typeface, font_size);
279            let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
280
281            let mut text_paint = paint_from_hex(&self.fill_color);
282            text_paint.set_anti_alias(true);
283
284            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
285            let (_, metrics) = font.metrics();
286            let text_x = thumb_cx - text_w / 2.0;
287            let text_y = thumb_cy - thumb_r - 4.0 - (-metrics.descent);
288
289            draw_text_with_fallback(
290                canvas,
291                &text,
292                &font,
293                &emoji_font,
294                0.0,
295                text_x,
296                text_y,
297                &text_paint,
298            );
299        }
300
301        canvas.restore();
302    }
303}
304
305impl Painter for Slider {
306    fn paint_content(
307        &self,
308        canvas: &Canvas,
309        layout: &BoxLayout,
310        _props: &AnimatedProperties,
311        ctx: &PaintCtx,
312    ) {
313        self.paint(canvas, layout, ctx.time);
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn parse(json: &str) -> Slider {
322        serde_json::from_str(json).expect("slider should deserialize")
323    }
324
325    fn px_width(s: &Slider) -> f32 {
326        let CSize::Length(CLP::Px(w)) = s.style.width.clone().expect("style.width should be set")
327        else {
328            panic!("expected an explicit px width");
329        };
330        w
331    }
332
333    fn px_height(s: &Slider) -> f32 {
334        let CSize::Length(CLP::Px(h)) = s.style.height.clone().expect("style.height should be set")
335        else {
336            panic!("expected an explicit px height");
337        };
338        h
339    }
340
341    #[test]
342    fn box_reserves_room_for_the_thumb_past_the_track() {
343        let s = parse(r#"{"type":"slider","width":396,"height":7,"thumb_size":20}"#);
344        let w = px_width(&s);
345        assert!(
346            w >= s.width + s.thumb_size,
347            "reserved width {w} should cover the track ({}) plus at least one full thumb ({})",
348            s.width,
349            s.thumb_size
350        );
351        let h = px_height(&s);
352        assert!(
353            h >= s.thumb_size,
354            "reserved height {h} should cover the thumb ({})",
355            s.thumb_size
356        );
357    }
358
359    #[test]
360    fn show_value_reserves_extra_height_above_the_thumb() {
361        let plain = parse(r#"{"type":"slider","thumb_size":20}"#);
362        let with_value = parse(r#"{"type":"slider","thumb_size":20,"show_value":true}"#);
363        assert!(
364            px_height(&with_value) > px_height(&plain),
365            "show_value must reserve extra height for the floating label"
366        );
367    }
368
369    #[test]
370    fn explicit_style_size_is_never_overridden() {
371        let s = parse(
372            r#"{"type":"slider","thumb_size":20,"show_value":true,"style":{"width":900,"height":90}}"#,
373        );
374        assert_eq!(px_width(&s), 900.0);
375        assert_eq!(px_height(&s), 90.0);
376    }
377}