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    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")]
55pub struct Slider {
56    #[serde(default = "default_slider_value")]
57    pub value: f64,
58    #[serde(default)]
59    pub animate_to: Option<f64>,
60    #[serde(default)]
61    pub animate_at: Option<f64>,
62    #[serde(default = "default_animation_duration")]
63    pub animation_duration: f64,
64    #[serde(default = "default_slider_width")]
65    pub width: f32,
66    #[serde(default = "default_slider_height")]
67    pub height: f32,
68    #[serde(default = "default_track_color")]
69    pub track_color: String,
70    #[serde(default = "default_fill_color")]
71    pub fill_color: String,
72    #[serde(default = "default_thumb_size")]
73    pub thumb_size: f32,
74    #[serde(default = "default_thumb_color")]
75    pub thumb_color: String,
76    #[serde(default)]
77    pub show_value: bool,
78    #[serde(default)]
79    pub timing: TimingConfig,
80    #[serde(default)]
81    pub style: CssStyle,
82    #[serde(default)]
83    pub timeline: Vec<TimelineStep>,
84    #[serde(default)]
85    pub stagger: Option<f32>,
86}
87
88#[derive(Debug, Deserialize)]
89struct SliderRaw {
90    #[serde(default = "default_slider_value")]
91    value: f64,
92    #[serde(default)]
93    animate_to: Option<f64>,
94    #[serde(default)]
95    animate_at: Option<f64>,
96    #[serde(default = "default_animation_duration")]
97    animation_duration: f64,
98    #[serde(default = "default_slider_width")]
99    width: f32,
100    #[serde(default = "default_slider_height")]
101    height: f32,
102    #[serde(default = "default_track_color")]
103    track_color: String,
104    #[serde(default = "default_fill_color")]
105    fill_color: String,
106    #[serde(default = "default_thumb_size")]
107    thumb_size: f32,
108    #[serde(default = "default_thumb_color")]
109    thumb_color: String,
110    #[serde(default)]
111    show_value: bool,
112    #[serde(flatten)]
113    timing: TimingConfig,
114    #[serde(default)]
115    style: CssStyle,
116    #[serde(default)]
117    timeline: Vec<TimelineStep>,
118    #[serde(default)]
119    stagger: Option<f32>,
120}
121
122impl From<SliderRaw> for Slider {
123    fn from(raw: SliderRaw) -> Self {
124        let thumb_r = raw.thumb_size / 2.0;
125        let mut style = raw.style;
126
127        if style.width.is_none() {
128            let h_margin = slider_value_label_half_width(raw.thumb_size)
129                .unwrap_or(0.0)
130                .max(thumb_r);
131            style.width = Some(CSize::Length(CLP::Px(raw.width + h_margin * 2.0)));
132        }
133        if style.height.is_none() {
134            let top_margin = if raw.show_value {
135                slider_value_label_line_height(raw.thumb_size).unwrap_or(0.0)
136            } else {
137                0.0
138            };
139            style.height = Some(CSize::Length(CLP::Px(top_margin + raw.thumb_size)));
140        }
141
142        Slider {
143            value: raw.value,
144            animate_to: raw.animate_to,
145            animate_at: raw.animate_at,
146            animation_duration: raw.animation_duration,
147            width: raw.width,
148            height: raw.height,
149            track_color: raw.track_color,
150            fill_color: raw.fill_color,
151            thumb_size: raw.thumb_size,
152            thumb_color: raw.thumb_color,
153            show_value: raw.show_value,
154            timing: raw.timing,
155            style,
156            timeline: raw.timeline,
157            stagger: raw.stagger,
158        }
159    }
160}
161
162/// Half-width of the widest value label ("100%") `paint()` can draw,
163/// using its exact `font_size = (thumb_size*0.7).max(12.0)` formula — the
164/// thumb can sit centered at either end of the track, so this (or the
165/// thumb radius, whichever is larger) is how much horizontal margin the
166/// box needs past the track on both sides.
167fn slider_value_label_half_width(thumb_size: f32) -> Option<f32> {
168    let font_size = (thumb_size * 0.7).max(12.0);
169    let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
170    let font = skia_safe::Font::from_typeface(typeface, font_size);
171    let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
172    let w = measure_text_with_fallback("100%", &font, &emoji_font, 0.0);
173    Some(w / 2.0)
174}
175
176/// Vertical room `paint()`'s value label needs above the thumb (ascent +
177/// descent + the 4px gap it's drawn with), using the same font size.
178fn slider_value_label_line_height(thumb_size: f32) -> Option<f32> {
179    let font_size = (thumb_size * 0.7).max(12.0);
180    let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?;
181    let font = skia_safe::Font::from_typeface(typeface, font_size);
182    let (_, metrics) = font.metrics();
183    Some(-metrics.ascent + metrics.descent + 4.0)
184}
185
186rustmotion_core::impl_traits!(Slider {
187    Animatable => animation,
188    Timed => timing,
189    Styled => style,
190});
191
192impl Slider {
193    fn ease_out_cubic(t: f64) -> f64 {
194        1.0 - (1.0 - t).powi(3)
195    }
196
197    fn current_value_at(&self, time: f64) -> f64 {
198        match (self.animate_to, self.animate_at) {
199            (Some(target), Some(start_time)) if time >= start_time => {
200                let elapsed = time - start_time;
201                let progress = (elapsed / self.animation_duration).clamp(0.0, 1.0);
202                let eased = Self::ease_out_cubic(progress);
203                self.value + (target - self.value) * eased
204            }
205            _ => self.value,
206        }
207    }
208}
209
210impl Slider {
211    fn paint(&self, canvas: &Canvas, layout: &BoxLayout, time: f64) {
212        let w = self.width;
213        let h = self.height;
214        let thumb_r = self.thumb_size / 2.0;
215        let radius = h / 2.0;
216        let current = self.current_value_at(time).clamp(0.0, 1.0) as f32;
217
218        // The track used to be drawn flush with local (0,0) — the thumb
219        // (radius `thumb_r`, usually bigger than the thin track) and the
220        // `show_value` label both extend past that origin on every side
221        // (#127 measured a 396×7 assigned box vs. 417×31 painted). Offset
222        // everything by the same margins reserved on the `Slider` struct
223        // (see its `From<SliderRaw>` impl) so the whole thing — track,
224        // thumb at either end, and the widest possible "100%" label — sits
225        // inside `[0, layout.width] x [0, layout.height]`.
226        let h_margin = slider_value_label_half_width(self.thumb_size)
227            .unwrap_or(0.0)
228            .max(thumb_r);
229        let top_margin = if self.show_value {
230            slider_value_label_line_height(self.thumb_size).unwrap_or(0.0)
231        } else {
232            0.0
233        };
234
235        canvas.save();
236        if layout.width > 0.0 && layout.height > 0.0 {
237            canvas.clip_rect(
238                Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
239                skia_safe::ClipOp::Intersect,
240                true,
241            );
242        }
243        canvas.translate((h_margin, top_margin));
244
245        let track_y = thumb_r - h / 2.0;
246
247        let mut track_paint = paint_from_hex(&self.track_color);
248        track_paint.set_style(PaintStyle::Fill);
249        track_paint.set_anti_alias(true);
250
251        let track_rect = Rect::from_xywh(0.0, track_y, w, h);
252        let track_rrect = RRect::new_rect_xy(track_rect, radius, radius);
253        canvas.draw_rrect(track_rrect, &track_paint);
254
255        if current > 0.001 {
256            let mut fill_paint = paint_from_hex(&self.fill_color);
257            fill_paint.set_style(PaintStyle::Fill);
258            fill_paint.set_anti_alias(true);
259
260            let fill_w = w * current;
261            let fill_rect = Rect::from_xywh(0.0, track_y, fill_w, h);
262
263            canvas.save();
264            canvas.clip_rrect(track_rrect, skia_safe::ClipOp::Intersect, true);
265            canvas.draw_rect(fill_rect, &fill_paint);
266            canvas.restore();
267        }
268
269        let thumb_cx = w * current;
270        let thumb_cy = thumb_r;
271
272        let mut thumb_paint = paint_from_hex(&self.thumb_color);
273        thumb_paint.set_style(PaintStyle::Fill);
274        thumb_paint.set_anti_alias(true);
275        canvas.draw_circle((thumb_cx, thumb_cy), thumb_r, &thumb_paint);
276
277        if self.show_value {
278            let text = format!("{}%", (current * 100.0).round() as i32);
279            let font_size = (self.thumb_size * 0.7).max(12.0);
280            let font_style = skia_safe::FontStyle::normal();
281            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
282                canvas.restore();
283                return;
284            };
285            let font = skia_safe::Font::from_typeface(typeface, font_size);
286            let emoji_font =
287                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
288
289            let mut text_paint = paint_from_hex(&self.fill_color);
290            text_paint.set_anti_alias(true);
291
292            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
293            let (_, metrics) = font.metrics();
294            let text_x = thumb_cx - text_w / 2.0;
295            let text_y = thumb_cy - thumb_r - 4.0 - (-metrics.descent);
296
297            draw_text_with_fallback(
298                canvas,
299                &text,
300                &font,
301                &emoji_font,
302                0.0,
303                text_x,
304                text_y,
305                &text_paint,
306            );
307        }
308
309        canvas.restore();
310    }
311}
312
313impl Painter for Slider {
314    fn paint_content(
315        &self,
316        canvas: &Canvas,
317        layout: &BoxLayout,
318        _props: &AnimatedProperties,
319        ctx: &PaintCtx,
320    ) {
321        self.paint(canvas, layout, ctx.time);
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn parse(json: &str) -> Slider {
330        serde_json::from_str(json).expect("slider should deserialize")
331    }
332
333    fn px_width(s: &Slider) -> f32 {
334        let CSize::Length(CLP::Px(w)) = s.style.width.clone().expect("style.width should be set")
335        else {
336            panic!("expected an explicit px width");
337        };
338        w
339    }
340
341    fn px_height(s: &Slider) -> f32 {
342        let CSize::Length(CLP::Px(h)) = s.style.height.clone().expect("style.height should be set")
343        else {
344            panic!("expected an explicit px height");
345        };
346        h
347    }
348
349    #[test]
350    fn box_reserves_room_for_the_thumb_past_the_track() {
351        // #127: the thumb (`thumb_size`, usually bigger than the thin
352        // track) can be centered at either end of the track, so it always
353        // overhangs a box sized only to the track (396×7 assigned vs.
354        // 417×31 painted in the audit).
355        let s = parse(r#"{"type":"slider","width":396,"height":7,"thumb_size":20}"#);
356        let w = px_width(&s);
357        assert!(
358            w >= s.width + s.thumb_size,
359            "reserved width {w} should cover the track ({}) plus at least one full thumb ({})",
360            s.width,
361            s.thumb_size
362        );
363        let h = px_height(&s);
364        assert!(
365            h >= s.thumb_size,
366            "reserved height {h} should cover the thumb ({})",
367            s.thumb_size
368        );
369    }
370
371    #[test]
372    fn show_value_reserves_extra_height_above_the_thumb() {
373        let plain = parse(r#"{"type":"slider","thumb_size":20}"#);
374        let with_value = parse(r#"{"type":"slider","thumb_size":20,"show_value":true}"#);
375        assert!(
376            px_height(&with_value) > px_height(&plain),
377            "show_value must reserve extra height for the floating label"
378        );
379    }
380
381    #[test]
382    fn explicit_style_size_is_never_overridden() {
383        let s = parse(
384            r#"{"type":"slider","thumb_size":20,"show_value":true,"style":{"width":900,"height":90}}"#,
385        );
386        assert_eq!(px_width(&s), 900.0);
387        assert_eq!(px_height(&s), 90.0);
388    }
389}