1use std::rc::Rc;
41
42use frust::Theme;
43use frust::authoring::{
44 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
45 PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
46};
47use kurbo::Size;
48
49use super::core::{
50 DEFAULT_EXTENT, Drag, DragCtx, HANDLE_GAP, HANDLE_HEIGHT, HANDLE_WIDTH, HapticCadence,
51 HapticScheduler, PRESS_MORPH_DURATION, PRESSED_HANDLE_WIDTH, SliderGeometry, SliderTrackKind,
52 TrackMetrics, ValueSpec, accept_value,
53};
54use super::variants::{AxisMap, SliderAxis, WaveClock, WaveOverrides};
55use super::{
56 AmplitudeCurve, Overrides, Run, label_large, paint_dots, paint_thumb, paint_track,
57 paint_value_indicator, resolve_colors, wave_paint,
58};
59use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
60use crate::press::presses;
61
62#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct SliderRange {
69 pub start: f64,
71 pub end: f64,
73}
74
75impl SliderRange {
76 pub fn new(start: f64, end: f64) -> Self {
83 debug_assert!(start <= end, "a slider range's start must be <= its end");
84 Self {
85 start: start.min(end),
86 end: start.max(end),
87 }
88 }
89}
90
91fn clamp_into_bounds(spec: &ValueSpec, value: f64) -> f64 {
97 if !(spec.min.is_finite() && spec.max.is_finite() && spec.max > spec.min) {
98 return if spec.min.is_finite() { spec.min } else { 0.0 };
99 }
100 if value.is_nan() {
101 return spec.min;
102 }
103 value.clamp(spec.min, spec.max)
104}
105
106fn normalize_range(spec: &ValueSpec, values: SliderRange) -> SliderRange {
128 let start = clamp_into_bounds(spec, values.start);
129 let end = clamp_into_bounds(spec, values.end);
130 SliderRange {
131 start: start.min(end),
132 end: start.max(end),
133 }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138enum Thumb {
139 Start,
140 End,
141}
142
143type OnRange<State> = Rc<dyn Fn(&mut State, SliderRange)>;
145
146pub struct RangeSliderView<State: 'static> {
148 values: SliderRange,
149 spec: ValueSpec,
150 wavy: bool,
151 wave: WaveOverrides,
152 amplitude_for_progress: Option<AmplitudeCurve>,
153 enabled: bool,
154 labels: Option<(String, String)>,
155 haptic: HapticSignal,
156 overrides: Overrides,
157 on_changed: OnRange<State>,
158 on_change_end: Option<OnRange<State>>,
159 haptic_sink: fn(HapticSignal),
162}
163
164pub fn range_slider<State: 'static, F: Fn(&mut State, SliderRange) + 'static>(
170 values: SliderRange,
171 on_changed: F,
172) -> RangeSliderView<State> {
173 RangeSliderView {
174 values,
175 spec: ValueSpec::default(),
176 wavy: false,
177 wave: WaveOverrides::default(),
178 amplitude_for_progress: None,
179 enabled: true,
180 labels: None,
181 haptic: HapticSignal::None,
182 overrides: Overrides::default(),
183 on_changed: Rc::new(on_changed),
184 on_change_end: None,
185 haptic_sink: MaterialHaptics::fire,
186 }
187}
188
189pub fn wavy_range_slider<State: 'static, F: Fn(&mut State, SliderRange) + 'static>(
194 values: SliderRange,
195 on_changed: F,
196) -> RangeSliderView<State> {
197 RangeSliderView {
198 wavy: true,
199 ..range_slider(values, on_changed)
200 }
201}
202
203impl<State: 'static> RangeSliderView<State> {
204 pub fn range(mut self, min: f64, max: f64) -> Self {
208 debug_assert!(max > min, "a slider's max must be greater than its min");
209 self.spec.min = min;
210 self.spec.max = max;
211 self
212 }
213
214 pub fn divisions(mut self, divisions: u32) -> Self {
217 self.spec.divisions = Some(divisions);
218 self
219 }
220
221 pub fn on_change_end<F: Fn(&mut State, SliderRange) + 'static>(
224 mut self,
225 on_change_end: F,
226 ) -> Self {
227 self.on_change_end = Some(Rc::new(on_change_end));
228 self
229 }
230
231 pub fn labels(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
234 self.labels = Some((start.into(), end.into()));
235 self
236 }
237
238 pub fn enabled(mut self, enabled: bool) -> Self {
241 self.enabled = enabled;
242 self
243 }
244
245 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
247 self.haptic = haptic;
248 self
249 }
250
251 pub fn wavelength(mut self, wavelength: f64) -> Self {
254 self.wave.wavelength = Some(wavelength);
255 self
256 }
257
258 pub fn wave_speed(mut self, wave_speed: f64) -> Self {
261 self.wave.wave_speed = Some(wave_speed);
262 self
263 }
264
265 pub fn amplitude(mut self, amplitude: f64) -> Self {
269 self.wave.amplitude = Some(amplitude);
270 self
271 }
272
273 pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
276 self.amplitude_for_progress = Some(Rc::new(curve));
277 self
278 }
279
280 pub fn track_thickness(mut self, thickness: f64) -> Self {
283 self.overrides.track_thickness = Some(thickness);
284 self
285 }
286
287 pub fn corner_radius(mut self, radius: f64) -> Self {
290 self.overrides.corner_radius = Some(radius);
291 self
292 }
293
294 pub fn thumb_length(mut self, length: f64) -> Self {
297 self.overrides.thumb_length = Some(length);
298 self
299 }
300
301 pub fn dot_size(mut self, size: f64) -> Self {
304 self.overrides.dot_size = Some(size);
305 self
306 }
307
308 pub fn dot_spacing(mut self, spacing: f64) -> Self {
311 self.overrides.dot_spacing = Some(spacing);
312 self
313 }
314
315 #[cfg(test)]
318 fn haptic_sink(mut self, sink: fn(HapticSignal)) -> Self {
319 self.haptic_sink = sink;
320 self
321 }
322
323 fn indicator_text(&self, thumb: Thumb) -> String {
325 if let Some((start, end)) = &self.labels {
326 return match thumb {
327 Thumb::Start => start.clone(),
328 Thumb::End => end.clone(),
329 };
330 }
331 let value = match thumb {
332 Thumb::Start => self.values.start,
333 Thumb::End => self.values.end,
334 };
335 if self.spec.divisions.is_some() {
336 format!("{}", value.round())
337 } else {
338 format!("{value:.2}")
339 }
340 }
341}
342
343pub struct RangeSliderWidget {
345 values: SliderRange,
347 spec: ValueSpec,
348 ticks: Vec<f64>,
349 wavy: bool,
350 wave: WaveOverrides,
351 amplitude_for_progress: Option<AmplitudeCurve>,
352 wave_clock: WaveClock,
353 enabled: bool,
354 overrides: Overrides,
355 haptic: HapticSignal,
356 drag: Drag,
360 active: Option<Thumb>,
361 interaction: InteractionState,
362 last_reported: Option<SliderRange>,
365 press_anim: frust::AnimationController,
368 press_target: bool,
369 haptics: HapticScheduler,
370 haptic_sink: fn(HapticSignal),
371 indicator: Run,
373 indicator_thumb: Thumb,
374 labels: (String, String),
377 on_changed: frust::authoring::ErasedArgCallback<SliderRange>,
378 on_change_end: Option<frust::authoring::ErasedArgCallback<SliderRange>>,
379}
380
381impl<State: 'static> View<State> for RangeSliderView<State> {
382 type Element = RangeSliderWidget;
383
384 fn build(&self, _ctx: &mut BuildCtx<'_>) -> RangeSliderWidget {
385 let labels = (
386 self.indicator_text(Thumb::Start),
387 self.indicator_text(Thumb::End),
388 );
389 let mut indicator = Run::new();
390 indicator.set_content(&labels.1);
391 RangeSliderWidget {
392 values: normalize_range(&self.spec, self.values),
393 spec: self.spec,
394 ticks: self.spec.tick_fractions(),
395 wavy: self.wavy,
396 wave: self.wave,
397 amplitude_for_progress: self.amplitude_for_progress.clone(),
398 wave_clock: WaveClock::new(),
399 enabled: self.enabled,
400 overrides: self.overrides,
401 haptic: self.haptic,
402 drag: Drag::default(),
403 active: None,
404 interaction: InteractionState::new(),
405 last_reported: None,
406 press_anim: frust::AnimationController::new(PRESS_MORPH_DURATION)
407 .with_curve(frust::Curve::EaseOut),
408 press_target: false,
409 haptics: HapticScheduler::default(),
410 haptic_sink: self.haptic_sink,
411 indicator,
412 indicator_thumb: Thumb::End,
413 labels,
414 on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
415 on_change_end: self
416 .on_change_end
417 .as_ref()
418 .map(frust::authoring::erase_callback_arg),
419 }
420 }
421
422 fn rebuild(
423 &self,
424 prev: &Self,
425 element: &mut RangeSliderWidget,
426 _ctx: &mut BuildCtx<'_>,
427 ) -> ChangeFlags {
428 element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
429 element.on_change_end = self
430 .on_change_end
431 .as_ref()
432 .map(frust::authoring::erase_callback_arg);
433 element.haptic_sink = self.haptic_sink;
434 element.amplitude_for_progress = self.amplitude_for_progress.clone();
435 let mut flags = ChangeFlags::NONE;
436
437 let labels = (
438 self.indicator_text(Thumb::Start),
439 self.indicator_text(Thumb::End),
440 );
441 if element.labels != labels {
442 element.labels = labels;
443 element.sync_indicator();
444 flags |= ChangeFlags::LAYOUT;
447 }
448 if prev.spec != self.spec {
449 element.spec = self.spec;
450 element.ticks = self.spec.tick_fractions();
451 flags |= ChangeFlags::PAINT;
452 }
453 if prev.values != self.values || prev.spec != self.spec {
454 element.values = normalize_range(&self.spec, self.values);
460 element.last_reported = None;
461 flags |= ChangeFlags::PAINT;
462 }
463 if prev.enabled != self.enabled {
464 element.enabled = self.enabled;
465 if !self.enabled {
466 element.drag = Drag::default();
469 element.active = None;
470 element.interaction.set_pressed(false);
471 element.interaction.set_dragged(false);
472 element.last_reported = None;
473 }
474 flags |= ChangeFlags::PAINT;
475 }
476 if prev.haptic != self.haptic {
477 element.haptic = self.haptic;
478 }
479 if prev.wavy != self.wavy || prev.wave != self.wave {
480 element.wavy = self.wavy;
481 element.wave = self.wave;
482 flags |= ChangeFlags::PAINT;
483 }
484 if prev.overrides != self.overrides {
485 element.overrides = self.overrides;
486 flags |= ChangeFlags::LAYOUT;
487 }
488 flags
489 }
490}
491
492impl RangeSliderWidget {
493 fn live(&self) -> SliderRange {
496 self.last_reported.unwrap_or(self.values)
497 }
498
499 fn thumb_positions(&self, extent: f64) -> (f64, f64) {
501 let values = self.live();
502 (
503 self.spec.fraction(values.start) * extent,
504 self.spec.fraction(values.end) * extent,
505 )
506 }
507
508 fn nearer_thumb(&self, primary: f64, extent: f64) -> Thumb {
511 let (start, end) = self.thumb_positions(extent);
512 if (primary - start).abs() <= (primary - end).abs() {
513 Thumb::Start
514 } else {
515 Thumb::End
516 }
517 }
518
519 fn accept(&self, thumb: Thumb, raw: f64) -> Option<SliderRange> {
541 let live = self.live();
542 let (clamped, current) = match thumb {
543 Thumb::Start => {
546 let (lo, hi) = (self.spec.min, live.end);
547 let (wlo, whi) = (lo.min(hi), lo.max(hi));
548 if !(wlo.is_finite() && whi.is_finite()) || raw.is_nan() {
549 return None;
550 }
551 (raw.clamp(wlo, whi), live.start)
552 }
553 Thumb::End => {
554 let (lo, hi) = (live.start, self.spec.max);
555 let (wlo, whi) = (lo.min(hi), lo.max(hi));
556 if !(wlo.is_finite() && whi.is_finite()) || raw.is_nan() {
557 return None;
558 }
559 (raw.clamp(wlo, whi), live.end)
560 }
561 };
562 let next = accept_value(&self.spec, current, clamped)?;
563 Some(match thumb {
564 Thumb::Start => SliderRange {
565 start: next,
566 end: live.end,
567 },
568 Thumb::End => SliderRange {
569 start: live.start,
570 end: next,
571 },
572 })
573 }
574
575 fn update(&mut self, primary: f64, extent: f64, ctx: &mut EventCtx) {
578 let Some(thumb) = self.active else {
579 return;
580 };
581 let raw = self.spec.value_from_offset(primary, extent, false);
582 let Some(values) = self.accept(thumb, raw) else {
583 return;
584 };
585 if self.haptic != HapticSignal::None {
586 let cadence = if self.spec.divisions.is_some() {
587 HapticCadence::Immediate
588 } else {
589 HapticCadence::Throttled
590 };
591 self.haptics.queue(self.haptic, cadence);
592 }
593 self.last_reported = Some(values);
594 (self.on_changed)(ctx, values);
595 }
596
597 fn sync_indicator(&mut self) {
599 let text = match self.indicator_thumb {
600 Thumb::Start => &self.labels.0,
601 Thumb::End => &self.labels.1,
602 };
603 let text = text.clone();
604 self.indicator.set_content(&text);
605 }
606
607 fn amplitude_factor(&self) -> f64 {
611 let values = self.live();
612 let progress =
613 (self.spec.fraction(values.end) - self.spec.fraction(values.start)).clamp(0.0, 1.0);
614 super::variants::amplitude_factor(
615 progress,
616 self.wave.amplitude,
617 self.amplitude_for_progress.as_deref(),
618 )
619 }
620
621 fn drag_ctx(&self, extent: f64) -> DragCtx {
629 DragCtx {
630 spec: self.spec,
631 current: self.live().end,
632 extent,
633 reverse: false,
634 }
635 }
636}
637
638impl Widget for RangeSliderWidget {
639 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
640 let style = label_large(Theme::from_layout_ctx(ctx));
641 self.indicator.layout(ctx, &style);
642 let width = if bc.max().width.is_finite() {
643 bc.max().width
644 } else {
645 DEFAULT_EXTENT
646 };
647 let height = HANDLE_HEIGHT.max(self.overrides.thumb_length());
648 bc.constrain(Size::new(width, height))
649 }
650
651 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
652 let theme = Theme::from_paint_ctx(ctx);
653 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
654 let colors = resolve_colors(theme, self.enabled);
655 let focused = ctx.has_focus();
656 let o = ctx.origin();
657 let map = AxisMap::new(SliderAxis::Horizontal, ctx.size(), false);
661 let extent = map.extent();
662
663 let values = self.live();
664 let (start_primary, end_primary) = self.thumb_positions(extent);
665 let handle_thickness = if self.drag.pressed {
666 PRESSED_HANDLE_WIDTH
667 } else {
668 HANDLE_WIDTH
669 };
670 let track_thickness = self.overrides.track_thickness();
671 let geometry = SliderGeometry {
672 extent,
673 active_start_fraction: self.spec.fraction(values.start),
674 active_end_fraction: self.spec.fraction(values.end),
675 reverse: false,
676 kind: SliderTrackKind::Range,
677 handle_thickness,
678 handle_gap: HANDLE_GAP
679 + if focused {
680 super::core::FOCUS_HANDLE_GAP_EXTRA
681 } else {
682 0.0
683 },
684 track_thickness,
685 corner_radius: self.overrides.corner_radius(),
686 };
687
688 if self.drag.pressed != self.press_target {
689 self.press_anim
690 .animate_to(if self.drag.pressed { 1.0 } else { 0.0 });
691 self.press_target = self.drag.pressed;
692 }
693 if self.press_anim.advance(ctx.frame_time()) {
694 ctx.request_frame();
695 }
696 let wave = self.wavy.then(|| {
697 let factor = self.amplitude_factor();
698 wave_paint(&mut self.wave_clock, self.wave, factor, ctx, reduce_motion)
699 });
700 if let Some(signal) = self.haptics.take(ctx.frame_time()) {
701 (self.haptic_sink)(signal);
702 }
703
704 if let Some(metrics) = TrackMetrics::resolve(&geometry) {
705 paint_track(scene, o, &map, &metrics, track_thickness, wave, &colors);
706 let spec = super::core::DotSpec {
707 stop_size: self.overrides.dot_size(),
708 tick_size: self.overrides.tick_size(),
709 edge_inset: self.overrides.dot_spacing(),
710 };
711 paint_dots(scene, o, &map, &metrics, &self.ticks, &spec, &colors);
712 }
713
714 let pressed_thickness =
717 HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * self.press_anim.value_clamped();
718 let length = self.overrides.thumb_length();
719 let overlay = self.interaction.resolve_opacity();
720 let focus_thumb = self.active.unwrap_or(Thumb::End);
723 for (thumb, primary) in [(Thumb::Start, start_primary), (Thumb::End, end_primary)] {
724 let dragged = self.active == Some(thumb);
725 let thickness = if dragged {
726 pressed_thickness
727 } else {
728 HANDLE_WIDTH
729 };
730 paint_thumb(
731 scene,
732 o,
733 map.place_thumb(primary, thickness, length),
734 focused && focus_thumb == thumb,
735 if dragged { overlay } else { 0.0 },
736 &colors,
737 );
738 }
739
740 if self.drag.pressed {
741 let primary = match self.indicator_thumb {
742 Thumb::Start => start_primary,
743 Thumb::End => end_primary,
744 };
745 paint_value_indicator(scene, o, &map, &self.indicator, primary, &colors);
746 }
747 }
748
749 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
750 let InputEvent::Pointer(p) = event else {
751 return EventResult::Ignored;
752 };
753 let extent = ctx.size().width;
754 let primary = p.position.x;
755 match p.phase {
756 PointerPhase::Down => {
757 if !self.enabled || !presses(p) {
758 return EventResult::Ignored;
759 }
760 let thumb = self.nearer_thumb(primary, extent);
763 self.active = Some(thumb);
764 self.indicator_thumb = thumb;
765 self.sync_indicator();
766 let out = self.drag.down(primary, &self.drag_ctx(extent));
767 debug_assert!(out.capture, "a press always opens a capture");
768 ctx.capture_pointer();
769 self.update(primary, extent, ctx);
770 self.settle(ctx, false)
771 }
772 PointerPhase::Move => {
773 if !self.drag.moved(primary, &self.drag_ctx(extent)).handled {
774 return EventResult::Ignored;
775 }
776 self.update(primary, extent, ctx);
777 self.settle(ctx, false)
778 }
779 PointerPhase::Up => {
780 if !self.drag.up().handled {
781 return EventResult::Ignored;
782 }
783 self.active = None;
784 self.settle(ctx, true)
785 }
786 PointerPhase::Cancel => {
787 if !self.drag.cancel().handled {
789 return EventResult::Ignored;
790 }
791 self.active = None;
792 self.settle(ctx, false)
793 }
794 }
795 }
796
797 fn semantics(&self, ctx: &mut SemanticsCtx) {
798 ctx.push_node(Role::Slider, |node| {
803 node.set_numeric_value(self.values.end);
804 node.set_min_numeric_value(self.spec.min);
805 node.set_max_numeric_value(self.spec.max);
806 node.set_value(format!("{} – {}", self.values.start, self.values.end));
807 });
808 }
809}
810
811impl RangeSliderWidget {
812 fn settle(&mut self, ctx: &mut EventCtx, ended: bool) -> EventResult {
815 if ended && let Some(on_change_end) = &mut self.on_change_end {
816 let settled = self.last_reported.unwrap_or(self.values);
817 on_change_end(ctx, settled);
818 }
819 self.interaction.set_pressed(self.drag.pressed);
820 self.interaction.set_dragged(self.drag.dragging);
821 ctx.request_redraw();
822 EventResult::Handled
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use frust::FrameTime;
830 use frust::authoring::scene::GlyphRun;
831 use frust::authoring::text::TextContext;
832 use frust::authoring::{CornerRadii, PointerButton, PointerEvent};
833 use kurbo::{BezPath, Point};
834 use peniko::{Brush, Color};
835 use std::any::Any;
836 use std::sync::Mutex;
837
838 const WIDTH: f64 = 200.0;
839 const SIZE: Size = Size::new(WIDTH, HANDLE_HEIGHT);
840
841 #[derive(Default)]
842 struct Val {
843 changes: Vec<SliderRange>,
844 ends: Vec<SliderRange>,
845 }
846
847 fn view(start: f64, end: f64) -> RangeSliderView<Val> {
848 range_slider::<Val, _>(
849 SliderRange::new(start, end),
850 |s: &mut Val, v: SliderRange| {
851 s.changes.push(v);
852 },
853 )
854 .on_change_end(|s: &mut Val, v: SliderRange| s.ends.push(v))
855 }
856
857 fn build(view: &RangeSliderView<Val>) -> RangeSliderWidget {
858 let mut counter = 0u64;
859 View::<Val>::build(view, &mut BuildCtx::new(&mut counter))
860 }
861
862 fn widget(start: f64, end: f64) -> RangeSliderWidget {
863 build(&view(start, end))
864 }
865
866 fn ev(phase: PointerPhase, x: f64) -> InputEvent {
867 InputEvent::Pointer(PointerEvent {
868 phase,
869 position: Point::new(x, HANDLE_HEIGHT / 2.0),
870 button: PointerButton::Primary,
871 })
872 }
873
874 fn dispatch(w: &mut RangeSliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
875 let state_any: &mut dyn Any = state;
876 let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
877 w.event(&mut ctx, event)
878 }
879
880 #[derive(Default)]
881 struct Recorder {
882 rrects: Vec<(Point, Size, f64, Color)>,
883 radii: Vec<(Point, Size, CornerRadii, Color)>,
884 strokes: Vec<(f64, Color)>,
885 runs: Vec<Point>,
886 }
887
888 impl PaintScene for Recorder {
889 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
890 fn draw_text(&mut self, _o: Point, _t: &str) {}
891 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
892 self.rrects.push((o, s, radius, color));
893 }
894 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
895 self.radii.push((o, s, radii, color));
896 }
897 fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
898 let color = match brush {
899 Brush::Solid(c) => *c,
900 _ => Color::TRANSPARENT,
901 };
902 self.strokes.push((width, color));
903 }
904 fn draw_glyph_run(&mut self, run: GlyphRun) {
905 let t = run.transform.translation();
906 self.runs.push(Point::new(t.x, t.y));
907 }
908 }
909
910 fn layout(w: &mut RangeSliderWidget) -> Size {
911 let mut tcx = TextContext::new();
912 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
913 w.layout(&mut ctx, &BoxConstraints::loose(SIZE))
914 }
915
916 fn paint(w: &mut RangeSliderWidget) -> Recorder {
917 let mut rec = Recorder::default();
918 let mut ctx = PaintCtx::new(Point::ZERO, SIZE);
919 w.paint(&mut ctx, &mut rec);
920 rec
921 }
922
923 #[test]
926 fn a_press_grabs_the_nearer_thumb_and_keeps_it_for_the_whole_drag() {
927 let mut w = widget(0.25, 0.75);
928 let mut state = Val::default();
929
930 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
932 assert_eq!(w.active, Some(Thumb::Start));
933 assert_eq!(state.changes, vec![SliderRange::new(0.3, 0.75)]);
934
935 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 180.0));
938 assert_eq!(
939 w.active,
940 Some(Thumb::Start),
941 "the grab survives the crossing"
942 );
943 assert_eq!(
944 state.changes.last(),
945 Some(&SliderRange::new(0.75, 0.75)),
946 "clamped to the high thumb, never past it"
947 );
948
949 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 180.0));
950 assert_eq!(w.active, None);
951 assert_eq!(state.ends, vec![SliderRange::new(0.75, 0.75)]);
952 }
953
954 #[test]
955 fn a_value_pair_outside_a_freshly_widened_range_normalizes_instead_of_panicking() {
956 let mut w = build(&view(0.0, 90.0).range(20.0, 80.0));
963 assert_eq!(
964 w.values,
965 SliderRange::new(20.0, 80.0),
966 "adoption clamps each side into the new bounds"
967 );
968 let mut state = Val::default();
969
970 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0));
972 assert_eq!(w.active, Some(Thumb::Start));
973 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 60.0));
974 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 60.0));
975 assert!(
976 state
977 .changes
978 .iter()
979 .all(|v| v.start >= 20.0 && v.start <= v.end),
980 "{:?}",
981 state.changes
982 );
983
984 let mut w = build(&view(0.0, 90.0).range(20.0, 80.0));
987 let mut state = Val::default();
988 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 195.0));
989 assert_eq!(w.active, Some(Thumb::End));
990 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 150.0));
991 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0));
992 assert!(
993 state
994 .changes
995 .iter()
996 .all(|v| v.end <= 80.0 && v.end >= v.start),
997 "{:?}",
998 state.changes
999 );
1000 }
1001
1002 #[test]
1003 fn a_rebuilds_narrowed_bounds_renormalize_a_stale_out_of_bounds_pair() {
1004 let mut counter = 0u64;
1011 let prev = view(0.0, 90.0).range(0.0, 100.0);
1012 let mut w = build(&prev);
1013 assert_eq!(w.values, SliderRange::new(0.0, 90.0));
1014
1015 let next = view(0.0, 90.0).range(20.0, 80.0);
1016 let flags = View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1017 assert_eq!(w.values, SliderRange::new(20.0, 80.0));
1018 assert!(flags.needs_paint());
1019
1020 let mut state = Val::default();
1022 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0));
1023 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0));
1024 }
1025
1026 #[test]
1029 fn an_in_bounds_off_grid_pair_survives_adoption_verbatim() {
1030 let mut w = build(&view(30.0, 70.0).range(0.0, 100.0).divisions(4));
1037 assert_eq!(
1038 w.values,
1039 SliderRange::new(30.0, 70.0),
1040 "an in-bounds off-grid pair must round-trip through adoption verbatim"
1041 );
1042
1043 layout(&mut w);
1044 let rec = paint(&mut w);
1045 let thumbs: Vec<&(Point, Size, f64, Color)> = rec
1046 .rrects
1047 .iter()
1048 .filter(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1049 .collect();
1050 assert_eq!(thumbs.len(), 2);
1051 assert_eq!(thumbs[0].0.x, 60.0 - HANDLE_WIDTH / 2.0, "low thumb at 30");
1054 assert_eq!(
1055 thumbs[1].0.x,
1056 140.0 - HANDLE_WIDTH / 2.0,
1057 "high thumb at 70"
1058 );
1059 }
1060
1061 #[test]
1062 fn normalize_range_clamps_an_out_of_bounds_pair_to_the_bounds() {
1063 let spec = ValueSpec {
1064 min: 20.0,
1065 max: 80.0,
1066 divisions: None,
1067 };
1068 assert_eq!(
1069 normalize_range(&spec, SliderRange::new(0.0, 90.0)),
1070 SliderRange::new(20.0, 80.0)
1071 );
1072 }
1073
1074 #[test]
1075 fn normalize_range_pins_a_nan_side_to_min() {
1076 let spec = ValueSpec {
1077 min: 20.0,
1078 max: 80.0,
1079 divisions: None,
1080 };
1081 assert_eq!(
1082 normalize_range(
1083 &spec,
1084 SliderRange {
1085 start: f64::NAN,
1086 end: 50.0,
1087 }
1088 ),
1089 SliderRange::new(20.0, 50.0)
1090 );
1091 }
1092
1093 #[test]
1096 fn a_nan_min_bound_never_panics_or_reports_a_nan_value() {
1097 let mut w = widget(0.3, 0.7);
1105 w.spec.min = f64::NAN;
1106 w.spec.max = 100.0;
1107 let mut state = Val::default();
1108
1109 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1110 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 90.0));
1111 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 90.0));
1112
1113 assert!(
1114 state
1115 .changes
1116 .iter()
1117 .all(|v| !v.start.is_nan() && !v.end.is_nan()),
1118 "no reported value may carry a NaN: {:?}",
1119 state.changes
1120 );
1121 assert!(
1122 state
1123 .ends
1124 .iter()
1125 .all(|v| !v.start.is_nan() && !v.end.is_nan()),
1126 "{:?}",
1127 state.ends
1128 );
1129 }
1130
1131 #[test]
1132 fn a_tie_goes_to_the_low_thumb_and_the_far_side_grabs_the_high_one() {
1133 let mut w = widget(0.25, 0.75);
1134 let mut state = Val::default();
1135 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1137 assert_eq!(w.active, Some(Thumb::Start), "a tie goes to the low thumb");
1138
1139 let mut w = widget(0.25, 0.75);
1140 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 140.0));
1141 assert_eq!(w.active, Some(Thumb::End));
1142 assert_eq!(state.changes.last(), Some(&SliderRange::new(0.25, 0.7)));
1143 }
1144
1145 #[test]
1146 fn the_high_thumb_cannot_be_dragged_below_the_low_one() {
1147 let mut w = widget(0.25, 0.75);
1148 let mut state = Val::default();
1149 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0));
1150 assert_eq!(w.active, Some(Thumb::End));
1151 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 0.0));
1152 assert_eq!(
1153 state.changes.last(),
1154 Some(&SliderRange::new(0.25, 0.25)),
1155 "it meets the low thumb and stops"
1156 );
1157 let before = state.changes.len();
1159 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -40.0));
1160 assert_eq!(state.changes.len(), before);
1161 }
1162
1163 #[test]
1164 fn callbacks_fire_in_order_and_settle_once() {
1165 let mut w = widget(0.2, 0.8);
1166 let mut state = Val::default();
1167 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 160.0));
1168 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 170.0));
1169 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 260.0));
1170 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 260.0));
1171 assert_eq!(
1172 state.changes,
1173 vec![SliderRange::new(0.2, 0.85), SliderRange::new(0.2, 1.0)],
1174 "the press landed on the value already held, so it reported nothing"
1175 );
1176 assert_eq!(state.ends, vec![SliderRange::new(0.2, 1.0)]);
1177 assert_eq!(
1178 w.values,
1179 SliderRange::new(0.2, 0.8),
1180 "a controlled slider never writes its own values"
1181 );
1182 }
1183
1184 #[test]
1185 fn cancel_disarms_without_reporting_or_settling() {
1186 let mut w = widget(0.25, 0.75);
1187 let mut state = Val::default();
1188 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1189 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 60.0));
1190 assert_eq!(w.active, None);
1191 assert!(!w.drag.dragging && !w.drag.pressed);
1192 assert!(state.ends.is_empty(), "a Cancel never reaches app state");
1193 }
1194
1195 #[test]
1196 fn a_disabled_range_slider_ignores_every_phase() {
1197 let mut w = build(&view(0.25, 0.75).enabled(false));
1198 let mut state = Val::default();
1199 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1200 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 90.0));
1201 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 90.0));
1202 assert!(state.changes.is_empty() && state.ends.is_empty());
1203 assert_eq!(w.active, None);
1204 }
1205
1206 #[test]
1207 fn a_discrete_range_reports_only_when_a_thumb_crosses_a_step() {
1208 let mut w = build(&view(2.0, 8.0).range(0.0, 10.0).divisions(10));
1209 let mut state = Val::default();
1210 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 43.0));
1212 assert!(state.changes.is_empty());
1213 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 63.0));
1214 assert_eq!(state.changes, vec![SliderRange::new(3.0, 8.0)]);
1215 }
1216
1217 #[test]
1220 fn the_active_span_runs_between_the_two_thumbs() {
1221 let mut w = widget(0.25, 0.75);
1222 layout(&mut w);
1223 let rec = paint(&mut w);
1224 assert_eq!(rec.radii.len(), 3, "two inactive ends plus the active span");
1225 let active = rec.radii[2];
1226 assert_eq!(active.0.x, 58.0, "low thumb at 50 plus its 8dp gap");
1227 assert_eq!(active.1.width, 84.0, "up to the high thumb's own gap");
1228
1229 let thumbs: Vec<&(Point, Size, f64, Color)> = rec
1231 .rrects
1232 .iter()
1233 .filter(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1234 .collect();
1235 assert_eq!(thumbs.len(), 2);
1236 assert_eq!(thumbs[0].0.x, 50.0 - HANDLE_WIDTH / 2.0);
1237 assert_eq!(thumbs[1].0.x, 150.0 - HANDLE_WIDTH / 2.0);
1238 }
1239
1240 #[test]
1241 fn only_the_dragged_thumb_shows_its_halo_and_indicator() {
1242 let mut w = widget(0.25, 0.75);
1243 layout(&mut w);
1244 let mut state = Val::default();
1245 assert!(paint(&mut w).runs.is_empty(), "no indicator at rest");
1246
1247 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0));
1248 let rec = paint(&mut w);
1249 assert_eq!(rec.runs.len(), 1, "the dragged thumb's label");
1250 let halos = rec
1251 .rrects
1252 .iter()
1253 .filter(|(_, s, _, _)| s.width == HANDLE_WIDTH + super::super::core::FOCUS_INFLATE)
1254 .count();
1255 assert_eq!(halos, 1, "one halo, on the grabbed thumb only");
1256 }
1257
1258 #[test]
1259 fn a_wavy_range_slider_strokes_its_active_span_and_paces_its_frames() {
1260 let mut w = build(&view(0.2, 0.8).amplitude(1.0));
1261 let mut wavy = build(&wavy_range_slider::<Val, _>(
1262 SliderRange::new(0.2, 0.8),
1263 |_s, _v| {},
1264 ));
1265 layout(&mut w);
1266 layout(&mut wavy);
1267
1268 assert!(paint(&mut w).strokes.is_empty(), "a flat span fills");
1269 let mut rec = Recorder::default();
1270 let mut ctx = PaintCtx::new(Point::ZERO, SIZE);
1271 wavy.paint(&mut ctx, &mut rec);
1272 assert_eq!(rec.radii.len(), 2, "the active span is no longer a rect");
1273 assert_eq!(rec.strokes.len(), 1, "it is a stroked wave instead");
1274 assert_eq!(rec.strokes[0].0, super::super::core::TRACK_HEIGHT);
1275 assert!(ctx.needs_frame_paced_only(), "a wave is a cosmetic loop");
1276 }
1277
1278 #[test]
1279 fn the_wave_amplitude_reads_the_span_between_the_thumbs() {
1280 let narrow = build(&wavy_range_slider::<Val, _>(
1283 SliderRange::new(0.5, 0.55),
1284 |_s, _v| {},
1285 ));
1286 assert_eq!(narrow.amplitude_factor(), 0.0);
1287 let wide = build(&wavy_range_slider::<Val, _>(
1288 SliderRange::new(0.1, 0.8),
1289 |_s, _v| {},
1290 ));
1291 assert_eq!(wide.amplitude_factor(), 1.0);
1292 }
1293
1294 static RECORDED: Mutex<Vec<HapticSignal>> = Mutex::new(Vec::new());
1297
1298 fn recording_sink(signal: HapticSignal) {
1299 RECORDED.lock().unwrap().push(signal);
1300 }
1301
1302 #[test]
1303 fn haptics_are_opt_in_and_pace_off_the_frame_clock() {
1304 RECORDED.lock().unwrap().clear();
1305 let mut w = build(&view(0.2, 0.8).haptic_sink(recording_sink));
1306 let mut state = Val::default();
1307 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 170.0));
1308 assert!(
1309 !w.haptics.has_pending(),
1310 "nothing queued at HapticSignal::None"
1311 );
1312
1313 let mut w = build(
1314 &view(0.2, 0.8)
1315 .haptic(HapticSignal::SliderTick)
1316 .haptic_sink(recording_sink),
1317 );
1318 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 170.0));
1319 assert!(w.haptics.has_pending(), "an opted-in change queues a tick");
1320 let fired = w.haptics.take(FrameTime::from_nanos(0)).expect("a tick");
1321 (w.haptic_sink)(fired);
1322 assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::SliderTick]);
1323 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 172.0));
1325 assert!(
1326 w.haptics.take(FrameTime::from_nanos(10_000_000)).is_none(),
1327 "a continuous drag ticks at most once per 60ms"
1328 );
1329 }
1330}