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#[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
162fn 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
176fn 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 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 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}