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#[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}