1use std::rc::Rc;
37
38use frust_core::accesskit::Role;
39use frust_core::{
40 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
41 PaintScene, PointerPhase, SemanticsCtx, View, Widget,
42};
43use frust_theme::{DesignLanguage, GlassMaterial, GlassScale, Theme};
44use kurbo::{Point, Size};
45use peniko::{Brush, Color, Gradient};
46
47use crate::authoring::presses;
48
49const HEIGHT: f64 = 24.0;
51const DEFAULT_WIDTH: f64 = 200.0;
53const TRACK_H: f64 = 4.0;
57const THUMB: f64 = 18.0;
60const CUPERTINO_THUMB: f64 = 28.0;
68const CUPERTINO_THUMB_FILL: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
74const SPECULAR_PEAK_SCALE: f32 = 1.8;
78const TRACK: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
81const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
84const THUMB_FILL: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
86
87fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
93 match theme {
94 Some(theme) => {
95 let scheme = theme.scheme();
96 (
97 scheme.surface_container_highest,
98 scheme.primary,
99 scheme.primary,
100 )
101 }
102 None => (TRACK, FILL, THUMB_FILL),
103 }
104}
105
106fn is_cupertino(theme: Option<&Theme>) -> bool {
111 theme.map(|t| t.design_language) == Some(DesignLanguage::Cupertino)
112}
113
114fn resolve_control_glass(theme: Option<&Theme>) -> GlassMaterial {
125 theme
126 .map(|t| t.glass.control.clone())
127 .unwrap_or_else(|| GlassScale::opaque_material().control)
128}
129
130fn with_alpha(color: Color, alpha: f32) -> Color {
133 let c = color.components;
134 Color::new([c[0], c[1], c[2], alpha])
135}
136
137type OnChange<State> = Rc<dyn Fn(&mut State, f64)>;
139
140pub struct SliderView<State: 'static> {
142 value: f64,
143 on_change: OnChange<State>,
144}
145
146pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
149 value: f64,
150 on_change: F,
151) -> SliderView<State> {
152 SliderView {
153 value: value.clamp(0.0, 1.0),
154 on_change: Rc::new(on_change),
155 }
156}
157
158#[allow(non_snake_case)]
160pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
161 value: f64,
162 on_change: F,
163) -> SliderView<State> {
164 slider(value, on_change)
165}
166
167pub struct SliderWidget {
169 value: f64,
170 captured: bool,
174 on_change: crate::authoring::ErasedArgCallback<f64>,
175}
176
177fn value_from_x(x: f64, width: f64) -> f64 {
179 if width <= 0.0 {
180 0.0
181 } else {
182 (x / width).clamp(0.0, 1.0)
183 }
184}
185
186impl<State: 'static> View<State> for SliderView<State> {
187 type Element = SliderWidget;
188
189 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
190 SliderWidget {
191 value: self.value,
192 captured: false,
193 on_change: crate::authoring::erase_callback_arg(&self.on_change),
194 }
195 }
196
197 fn rebuild(
198 &self,
199 prev: &Self,
200 element: &mut SliderWidget,
201 _ctx: &mut BuildCtx<'_>,
202 ) -> ChangeFlags {
203 element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
204 if prev.value != self.value {
205 element.value = self.value;
206 ChangeFlags::PAINT
207 } else {
208 ChangeFlags::NONE
209 }
210 }
211}
212
213impl Widget for SliderWidget {
214 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
215 let width = if bc.max().width.is_finite() {
216 bc.max().width
217 } else {
218 DEFAULT_WIDTH
219 };
220 bc.constrain(Size::new(width, HEIGHT))
221 }
222
223 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
224 let theme = Theme::from_paint_ctx(ctx);
225 let (track, fill, thumb_fill) = resolve_colors(theme);
226 let o = ctx.origin();
227 let w = ctx.size().width;
228 let mid_y = o.y + ctx.size().height / 2.0;
229 scene.fill_rounded_rect(
231 Point::new(o.x, mid_y - TRACK_H / 2.0),
232 Size::new(w, TRACK_H),
233 TRACK_H / 2.0,
234 track,
235 );
236 let thumb_x = o.x + self.value.clamp(0.0, 1.0) * w;
238 scene.fill_rounded_rect(
239 Point::new(o.x, mid_y - TRACK_H / 2.0),
240 Size::new((thumb_x - o.x).max(0.0), TRACK_H),
241 TRACK_H / 2.0,
242 fill,
243 );
244
245 if is_cupertino(theme) {
246 let diam = CUPERTINO_THUMB;
249 let thumb_origin = Point::new(thumb_x - diam / 2.0, mid_y - diam / 2.0);
250 let thumb_size = Size::new(diam, diam);
251 let glass_control = resolve_control_glass(theme);
252 scene.draw_shadow(
253 Point::new(
254 thumb_origin.x,
255 thumb_origin.y + glass_control.shadow.y_offset,
256 ),
257 thumb_size,
258 diam / 2.0,
259 glass_control.shadow.blur_std_dev,
260 with_alpha(Color::BLACK, glass_control.shadow.color_alpha),
261 );
262 scene.fill_rounded_rect(thumb_origin, thumb_size, diam / 2.0, CUPERTINO_THUMB_FILL);
263 let highlight_center = Point::new(thumb_x - diam * 0.18, mid_y - diam * 0.28);
264 let peak_alpha = (glass_control.hairline_alpha * SPECULAR_PEAK_SCALE).min(1.0);
265 let highlight = Gradient::new_radial(highlight_center, (diam * 0.65) as f32)
266 .with_stops([
267 (0.0f32, with_alpha(CUPERTINO_THUMB_FILL, peak_alpha)),
268 (1.0f32, with_alpha(CUPERTINO_THUMB_FILL, 0.0)),
269 ]);
270 scene.fill_rounded_rect_brush(
271 thumb_origin,
272 thumb_size,
273 diam / 2.0,
274 &Brush::Gradient(highlight),
275 );
276 } else {
277 scene.fill_rounded_rect(
280 Point::new(thumb_x - THUMB / 2.0, mid_y - THUMB / 2.0),
281 Size::new(THUMB, THUMB),
282 THUMB / 2.0,
283 thumb_fill,
284 );
285 }
286 }
287
288 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
289 let InputEvent::Pointer(p) = event else {
290 return EventResult::Ignored;
291 };
292 match p.phase {
293 PointerPhase::Down => {
294 if !presses(p) {
295 return EventResult::Ignored;
296 }
297 self.captured = true;
298 ctx.capture_pointer();
299 let v = value_from_x(p.position.x, ctx.size().width);
300 (self.on_change)(ctx, v);
301 ctx.request_redraw();
302 EventResult::Handled
303 }
304 PointerPhase::Move => {
305 if !self.captured {
307 return EventResult::Ignored;
308 }
309 let v = value_from_x(p.position.x, ctx.size().width);
310 (self.on_change)(ctx, v);
311 ctx.request_redraw();
312 EventResult::Handled
313 }
314 PointerPhase::Up | PointerPhase::Cancel => {
315 if !self.captured {
316 return EventResult::Ignored;
317 }
318 self.captured = false;
319 ctx.request_redraw();
320 EventResult::Handled
321 }
322 }
323 }
324
325 fn semantics(&self, ctx: &mut SemanticsCtx) {
326 ctx.push_node(Role::Slider, |node| {
328 node.set_numeric_value(self.value);
329 node.set_min_numeric_value(0.0);
330 node.set_max_numeric_value(1.0);
331 });
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::any::Any;
339
340 #[derive(Default)]
341 struct Val {
342 value: f64,
343 changes: u32,
344 }
345
346 fn widget(value: f64) -> SliderWidget {
347 let view = slider::<Val, _>(value, |s: &mut Val, v: f64| {
348 s.value = v;
349 s.changes += 1;
350 });
351 let mut counter = 0u64;
352 View::<Val>::build(&view, &mut BuildCtx::new(&mut counter))
353 }
354
355 fn ev(phase: PointerPhase, x: f64) -> InputEvent {
356 InputEvent::Pointer(frust_core::PointerEvent {
357 phase,
358 position: Point::new(x, 12.0),
359 button: frust_core::PointerButton::Primary,
360 })
361 }
362
363 fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
365 InputEvent::Pointer(frust_core::PointerEvent {
366 phase,
367 position: Point::new(x, 12.0),
368 button: frust_core::PointerButton::Secondary,
369 })
370 }
371
372 fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) {
373 let state_any: &mut dyn Any = state;
374 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
375 w.event(&mut ctx, event);
376 }
377
378 #[test]
379 fn a_secondary_press_never_captures_or_reports_a_value() {
380 let mut w = widget(0.0);
381 let mut state = Val::default();
382 dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
383 assert!(!w.captured, "no capture opened");
384 assert_eq!(state.changes, 0, "and no value reported");
385 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
387 assert_eq!(state.changes, 0);
388
389 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
391 assert!(w.captured);
392 assert_eq!(state.value, 0.25);
393 }
394
395 #[test]
396 fn value_math_maps_position_to_fraction() {
397 assert_eq!(value_from_x(0.0, 200.0), 0.0);
398 assert_eq!(value_from_x(100.0, 200.0), 0.5);
399 assert_eq!(value_from_x(200.0, 200.0), 1.0);
400 assert_eq!(value_from_x(-40.0, 200.0), 0.0);
402 assert_eq!(value_from_x(9000.0, 200.0), 1.0);
403 assert_eq!(value_from_x(10.0, 0.0), 0.0);
405 }
406
407 #[test]
408 fn down_reports_value_at_position() {
409 let mut w = widget(0.0);
410 let mut state = Val::default();
411 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
412 assert_eq!(state.value, 0.25);
413 assert_eq!(state.changes, 1);
414 }
415
416 #[test]
417 fn drag_reports_continuously_and_clamps() {
418 let mut w = widget(0.0);
419 let mut state = Val::default();
420 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
421 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 300.0)); assert_eq!(state.value, 1.0);
423 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -20.0)); assert_eq!(state.value, 0.0);
425 assert_eq!(state.changes, 3);
426 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 0.0));
428 assert_eq!(state.changes, 3);
429 }
430
431 #[test]
432 fn hover_move_without_down_is_ignored_noop() {
433 let mut w = widget(0.3);
434 let mut state = Val::default();
435 let state_any: &mut dyn Any = &mut state;
436 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
437 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
438 assert!(matches!(result, EventResult::Ignored));
439 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
440 assert_eq!(state.changes, 0, "hover must not report a value");
441 }
442
443 #[test]
444 fn cancel_clears_captured_state() {
445 let mut w = widget(0.0);
446 let mut state = Val::default();
447 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
448 assert!(w.captured);
449 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
450 assert!(!w.captured, "Cancel disarms the drag");
451 let before = state.changes;
453 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
454 assert_eq!(state.changes, before, "hover after Cancel does not report");
455 }
456
457 #[derive(Default)]
462 struct TrackRecorder {
463 rrects: Vec<Color>,
464 shadows: Vec<(Point, Size, f64, f64, Color)>,
465 brushes: Vec<(Point, Size, f64, Brush)>,
466 }
467
468 impl PaintScene for TrackRecorder {
469 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
470 fn draw_text(&mut self, _o: Point, _t: &str) {}
471 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
472 self.rrects.push(color);
473 }
474 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
475 self.shadows.push((o, s, radius, std_dev, color));
476 }
477 fn fill_rounded_rect_brush(&mut self, o: Point, s: Size, radius: f64, brush: &Brush) {
478 self.brushes.push((o, s, radius, brush.clone()));
479 }
480 }
481
482 fn paint_colors(value: f64, theme: Option<&Theme>) -> Vec<Color> {
483 let mut w = widget(value);
484 let mut rec = TrackRecorder::default();
485 let mut ctx = match theme {
486 Some(t) => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(t),
487 None => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)),
488 };
489 w.paint(&mut ctx, &mut rec);
490 rec.rrects
491 }
492
493 #[test]
494 fn unthemed_paint_uses_fallback_constants() {
495 assert_eq!(paint_colors(0.5, None), vec![TRACK, FILL, THUMB_FILL]);
496 }
497
498 #[test]
499 fn themed_paint_resolves_the_scheme_s_slider_roles() {
500 let theme = Theme::neutral();
501 let scheme = theme.scheme();
502 assert_eq!(
503 paint_colors(0.5, Some(&theme)),
504 vec![
505 scheme.surface_container_highest,
506 scheme.primary,
507 scheme.primary
508 ],
509 );
510 }
511
512 #[test]
513 fn non_cupertino_and_unthemed_paint_have_no_reflective_treatment() {
514 let mut unthemed = widget(0.5);
518 let mut rec = TrackRecorder::default();
519 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT));
520 unthemed.paint(&mut ctx, &mut rec);
521 assert_eq!(rec.rrects.len(), 3, "track + fill + thumb, nothing else");
522 assert!(rec.shadows.is_empty());
523 assert!(rec.brushes.is_empty());
524
525 let theme = Theme::neutral();
526 assert_ne!(
527 theme.design_language,
528 DesignLanguage::Cupertino,
529 "the default path under test must not be the Cupertino one"
530 );
531 let mut themed = widget(0.5);
532 let mut rec = TrackRecorder::default();
533 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
534 themed.paint(&mut ctx, &mut rec);
535 assert_eq!(rec.rrects.len(), 3);
536 assert!(rec.shadows.is_empty());
537 assert!(rec.brushes.is_empty());
538 }
539
540 fn cupertino_theme() -> Theme {
547 Theme::builder(Theme::neutral())
548 .design_language(DesignLanguage::Cupertino)
549 .map_glass(|mut g| {
550 g.control = GlassMaterial {
551 blur_radius_intent: 15.0,
552 fills_light: Vec::new(),
553 fills_dark: Vec::new(),
554 hairline_alpha: 0.3,
555 shadow: frust_theme::ShadowSpec {
556 y_offset: 2.0,
557 blur_std_dev: 4.0,
558 color_alpha: 0.12,
559 },
560 };
561 g
562 })
563 .build()
564 }
565
566 #[test]
567 fn cupertino_themed_paint_uses_the_larger_reflective_knob() {
568 let theme = cupertino_theme();
569 assert_eq!(theme.design_language, DesignLanguage::Cupertino);
570 let mut w = widget(0.5);
571 let mut rec = TrackRecorder::default();
572 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
573 w.paint(&mut ctx, &mut rec);
574
575 assert_eq!(rec.rrects.len(), 3);
578 assert_eq!(rec.rrects[2], CUPERTINO_THUMB_FILL);
579
580 assert_eq!(rec.shadows.len(), 1, "one drop shadow under the knob");
581 let expected = theme.glass.control.shadow;
582 assert_eq!(
583 rec.shadows[0].2,
584 CUPERTINO_THUMB / 2.0,
585 "circular, knob radius"
586 );
587 assert_eq!(rec.shadows[0].3, expected.blur_std_dev);
588
589 assert_eq!(rec.brushes.len(), 1, "one specular gradient highlight");
590 assert_eq!(
591 rec.brushes[0].2,
592 CUPERTINO_THUMB / 2.0,
593 "circular, knob radius"
594 );
595 match &rec.brushes[0].3 {
596 Brush::Gradient(g) => assert_eq!(g.stops.len(), 2, "peak + fade-to-transparent"),
597 _ => panic!("expected a gradient brush for the specular highlight"),
598 }
599 }
600
601 #[test]
602 fn rebuild_adopts_new_value_without_self_mutation() {
603 let mut counter = 0u64;
604 let prev = slider::<Val, _>(0.2, |_s, _v| {});
605 let mut w = View::<Val>::build(&prev, &mut BuildCtx::new(&mut counter));
606 assert_eq!(w.value, 0.2);
607 let next = slider::<Val, _>(0.8, |_s, _v| {});
608 View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
609 assert_eq!(w.value, 0.8);
610 }
611}