1mod core;
105mod range;
106mod variants;
107
108use std::rc::Rc;
109
110use frust::authoring::text::{TextContext, TextLayout, TextStyle};
111use frust::authoring::{
112 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
113 PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
114};
115use frust::{AnimationController, Curve, IconData, IconSource, Theme};
116use kurbo::{Affine, Point, RoundedRect, Shape, Size, Vec2};
117use peniko::{Brush, Color};
118
119use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
120use crate::press::presses;
121
122use self::core::{
123 DEFAULT_EXTENT, DISABLED_ACTIVE_OPACITY, DISABLED_INACTIVE_OPACITY, Drag, DragCtx, DragOutcome,
124 FOCUS_STROKE, HANDLE_GAP, HANDLE_HEIGHT, HANDLE_WIDTH, HapticCadence, HapticScheduler,
125 ICON_EDGE_INSET, ICON_SIZE_DEFAULT, IconDockInput, PRESS_MORPH_DURATION, PRESSED_HANDLE_WIDTH,
126 STOP_INDICATOR_SIZE, STOP_INDICATOR_TRAILING_SPACE, SegmentRole, SliderGeometry, TICK_SIZE,
127 TRACK_CORNER_RADIUS, TRACK_HEIGHT, TRACK_ICON_SIZE, TrackMetrics, VALUE_INDICATOR_PAD_X,
128 VALUE_INDICATOR_PAD_Y, VALUE_INDICATOR_RADIUS, ValueSpec,
129};
130use self::variants::{AxisMap, SliderAxis, WAVE_AMPLITUDE, WaveClock, WaveOverrides};
131
132pub use self::core::{SliderIconPosition, SliderTrackKind};
133pub use self::range::{
134 RangeSliderView, RangeSliderWidget, SliderRange, range_slider, wavy_range_slider,
135};
136
137const PATH_TOLERANCE: f64 = 0.1;
140
141const LABEL_LARGE_SIZE: f32 = 14.0;
144const SHAPING_INK: Color = Color::BLACK;
148
149const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
151const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
153const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
156const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
158const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
161const INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
164const INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
167
168#[derive(Clone, Copy, Debug, PartialEq)]
171struct SliderColors {
172 thumb: Color,
173 active_track: Color,
174 inactive_track: Color,
175 active_tick: Color,
176 inactive_tick: Color,
177 value_indicator: Color,
178 value_indicator_label: Color,
179 on_inactive: Color,
182 on_active: Color,
184}
185
186fn resolve_colors(theme: Option<&Theme>, enabled: bool) -> SliderColors {
192 let (
193 primary,
194 on_primary,
195 secondary_container,
196 on_surface,
197 on_surface_variant,
198 inverse,
199 on_inverse,
200 ) = match theme {
201 Some(theme) => {
202 let s = theme.scheme();
203 (
204 s.primary,
205 s.on_primary,
206 s.secondary_container,
207 s.on_surface,
208 s.on_surface_variant,
209 s.inverse_surface,
210 s.inverse_on_surface,
211 )
212 }
213 None => (
214 PRIMARY,
215 ON_PRIMARY,
216 SECONDARY_CONTAINER,
217 ON_SURFACE,
218 ON_SURFACE_VARIANT,
219 INVERSE_SURFACE,
220 INVERSE_ON_SURFACE,
221 ),
222 };
223 let active = |c: Color| {
224 if enabled {
225 c
226 } else {
227 on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
228 }
229 };
230 let inactive = |c: Color| {
231 if enabled {
232 c
233 } else {
234 on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
235 }
236 };
237 let active_track = active(primary);
238 let inactive_track = inactive(secondary_container);
239 SliderColors {
240 thumb: active(primary),
241 active_track,
242 inactive_track,
243 active_tick: inactive_track,
244 inactive_tick: active_track,
245 value_indicator: inverse,
246 value_indicator_label: on_inverse,
247 on_inactive: active(on_surface_variant),
252 on_active: active(on_primary),
253 }
254}
255
256#[derive(Clone, Copy, Debug)]
260pub struct SliderTrackIcons {
261 pub active_start: Option<IconSource>,
263 pub active_end: Option<IconSource>,
265 pub inactive_start: Option<IconSource>,
267 pub inactive_end: Option<IconSource>,
269 pub size: f64,
271}
272
273impl PartialEq for SliderTrackIcons {
277 fn eq(&self, other: &Self) -> bool {
278 self.size == other.size
279 && same_icon_source(self.active_start, other.active_start)
280 && same_icon_source(self.active_end, other.active_end)
281 && same_icon_source(self.inactive_start, other.inactive_start)
282 && same_icon_source(self.inactive_end, other.inactive_end)
283 }
284}
285
286impl Default for SliderTrackIcons {
287 fn default() -> Self {
290 Self::new()
291 }
292}
293
294impl SliderTrackIcons {
295 pub fn new() -> Self {
297 Self {
298 active_start: None,
299 active_end: None,
300 inactive_start: None,
301 inactive_end: None,
302 size: TRACK_ICON_SIZE,
303 }
304 }
305
306 fn present(&self) -> [bool; 4] {
309 [
310 self.active_start.is_some(),
311 self.active_end.is_some(),
312 self.inactive_start.is_some(),
313 self.inactive_end.is_some(),
314 ]
315 }
316
317 fn size(&self) -> f64 {
320 if self.size > 0.0 {
321 self.size
322 } else {
323 TRACK_ICON_SIZE
324 }
325 }
326}
327
328#[derive(Clone, Copy, Debug, Default, PartialEq)]
332struct Overrides {
333 track_thickness: Option<f64>,
334 corner_radius: Option<f64>,
335 thumb_length: Option<f64>,
336 dot_size: Option<f64>,
337 dot_spacing: Option<f64>,
338 icon_size: Option<f64>,
339 icon_edge_inset: Option<f64>,
340}
341
342impl Overrides {
343 fn track_thickness(&self) -> f64 {
344 self.track_thickness.unwrap_or(TRACK_HEIGHT)
345 }
346 fn corner_radius(&self) -> f64 {
347 self.corner_radius.unwrap_or(TRACK_CORNER_RADIUS)
348 }
349 fn thumb_length(&self) -> f64 {
350 self.thumb_length.unwrap_or(HANDLE_HEIGHT)
351 }
352 fn dot_size(&self) -> f64 {
353 self.dot_size.unwrap_or(STOP_INDICATOR_SIZE)
354 }
355 fn tick_size(&self) -> f64 {
356 self.dot_size.unwrap_or(TICK_SIZE)
357 }
358 fn dot_spacing(&self) -> f64 {
359 self.dot_spacing.unwrap_or(STOP_INDICATOR_TRAILING_SPACE)
360 }
361 fn icon_size(&self) -> f64 {
362 self.icon_size.unwrap_or(ICON_SIZE_DEFAULT)
363 }
364 fn icon_edge_inset(&self) -> f64 {
365 self.icon_edge_inset.unwrap_or(ICON_EDGE_INSET)
366 }
367}
368
369struct Run {
373 content: String,
374 layout: Option<TextLayout>,
375 shaped_style: Option<TextStyle>,
376}
377
378impl Run {
379 fn new() -> Self {
380 Self {
381 content: String::new(),
382 layout: None,
383 shaped_style: None,
384 }
385 }
386
387 fn set_content(&mut self, content: &str) {
389 if self.content != content {
390 self.content = content.to_string();
391 self.layout = None;
392 }
393 }
394
395 fn size(&self) -> Size {
396 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
397 }
398
399 fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) {
401 if self.layout.is_some() && self.shaped_style.as_ref() == Some(style) {
402 return;
403 }
404 let text_ctx = ctx.text_context::<TextContext>();
405 self.layout = Some(text_ctx.layout(&self.content, style, None));
406 self.shaped_style = Some(style.clone());
407 }
408
409 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
412 let Some(layout) = &self.layout else {
413 return;
414 };
415 for mut run in layout.to_scene_runs(origin) {
416 run.brush = Brush::Solid(color);
417 scene.draw_glyph_run(run);
418 }
419 }
420}
421
422fn label_large(theme: Option<&Theme>) -> TextStyle {
426 let mut style = match theme {
427 Some(theme) => theme.type_scale.label_large.clone(),
428 None => TextStyle::new(LABEL_LARGE_SIZE, SHAPING_INK),
429 };
430 style.color = SHAPING_INK;
431 style
432}
433
434fn resolve_icon(source: Option<IconSource>) -> Option<(kurbo::BezPath, f64)> {
437 source.map(|s| IconData::from(s).resolve())
438}
439
440fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
442 match (a, b) {
443 (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
444 (None, None) => true,
445 _ => false,
446 }
447}
448
449type OnValue<State> = Rc<dyn Fn(&mut State, f64)>;
451
452pub(crate) type AmplitudeCurve = Rc<dyn Fn(f64) -> f64>;
457
458pub struct SliderView<State: 'static> {
460 value: f64,
461 spec: ValueSpec,
462 kind: SliderTrackKind,
463 axis: SliderAxis,
464 top_to_bottom: bool,
467 wavy: bool,
468 wave: WaveOverrides,
469 amplitude_for_progress: Option<AmplitudeCurve>,
470 enabled: bool,
471 label: Option<String>,
472 haptic: HapticSignal,
473 icon: Option<IconSource>,
474 icon_position: SliderIconPosition,
475 track_icons: Option<SliderTrackIcons>,
476 overrides: Overrides,
477 on_changed: OnValue<State>,
478 on_change_end: Option<OnValue<State>>,
479 haptic_sink: fn(HapticSignal),
486}
487
488pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
494 value: f64,
495 on_changed: F,
496) -> SliderView<State> {
497 SliderView {
498 value,
499 spec: ValueSpec::default(),
500 kind: SliderTrackKind::Standard,
501 axis: SliderAxis::Horizontal,
502 top_to_bottom: true,
503 wavy: false,
504 wave: WaveOverrides::default(),
505 amplitude_for_progress: None,
506 enabled: true,
507 label: None,
508 haptic: HapticSignal::None,
509 icon: None,
510 icon_position: SliderIconPosition::End,
511 track_icons: None,
512 overrides: Overrides::default(),
513 on_changed: Rc::new(on_changed),
514 on_change_end: None,
515 haptic_sink: MaterialHaptics::fire,
516 }
517}
518
519#[allow(non_snake_case)]
521pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
522 value: f64,
523 on_changed: F,
524) -> SliderView<State> {
525 slider(value, on_changed)
526}
527
528pub fn centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
531 value: f64,
532 on_changed: F,
533) -> SliderView<State> {
534 SliderView {
535 kind: SliderTrackKind::Centered,
536 ..slider(value, on_changed)
537 }
538}
539
540pub fn wavy_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
548 value: f64,
549 on_changed: F,
550) -> SliderView<State> {
551 SliderView {
552 wavy: true,
553 ..slider(value, on_changed)
554 }
555}
556
557pub fn wavy_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
560 value: f64,
561 on_changed: F,
562) -> SliderView<State> {
563 SliderView {
564 kind: SliderTrackKind::Centered,
565 wavy: true,
566 ..slider(value, on_changed)
567 }
568}
569
570pub fn vertical_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
577 value: f64,
578 on_changed: F,
579) -> SliderView<State> {
580 SliderView {
581 axis: SliderAxis::Vertical,
582 top_to_bottom: false,
583 ..slider(value, on_changed)
584 }
585}
586
587pub fn vertical_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
590 value: f64,
591 on_changed: F,
592) -> SliderView<State> {
593 SliderView {
594 kind: SliderTrackKind::Centered,
595 ..vertical_slider(value, on_changed)
596 }
597}
598
599impl<State: 'static> SliderView<State> {
600 pub fn range(mut self, min: f64, max: f64) -> Self {
606 debug_assert!(max > min, "a slider's max must be greater than its min");
607 self.spec.min = min;
608 self.spec.max = max;
609 self
610 }
611
612 pub fn divisions(mut self, divisions: u32) -> Self {
616 self.spec.divisions = Some(divisions);
617 self
618 }
619
620 pub fn on_change_end<F: Fn(&mut State, f64) + 'static>(mut self, on_change_end: F) -> Self {
624 self.on_change_end = Some(Rc::new(on_change_end));
625 self
626 }
627
628 pub fn label(mut self, label: impl Into<String>) -> Self {
632 self.label = Some(label.into());
633 self
634 }
635
636 pub fn enabled(mut self, enabled: bool) -> Self {
641 self.enabled = enabled;
642 self
643 }
644
645 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
648 self.haptic = haptic;
649 self
650 }
651
652 pub fn top_to_bottom(mut self, top_to_bottom: bool) -> Self {
657 self.top_to_bottom = top_to_bottom;
658 self
659 }
660
661 pub fn wavelength(mut self, wavelength: f64) -> Self {
664 self.wave.wavelength = Some(wavelength);
665 self
666 }
667
668 pub fn wave_speed(mut self, wave_speed: f64) -> Self {
672 self.wave.wave_speed = Some(wave_speed);
673 self
674 }
675
676 pub fn amplitude(mut self, amplitude: f64) -> Self {
680 self.wave.amplitude = Some(amplitude);
681 self
682 }
683
684 pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
689 self.amplitude_for_progress = Some(Rc::new(curve));
690 self
691 }
692
693 pub fn icon(mut self, icon: IconSource) -> Self {
701 self.icon = Some(icon);
702 self
703 }
704
705 pub fn icon_position(mut self, position: SliderIconPosition) -> Self {
708 self.icon_position = position;
709 self
710 }
711
712 pub fn icon_size(mut self, size: f64) -> Self {
715 self.overrides.icon_size = Some(size);
716 self
717 }
718
719 pub fn icon_edge_inset(mut self, inset: f64) -> Self {
722 self.overrides.icon_edge_inset = Some(inset);
723 self
724 }
725
726 pub fn track_icons(mut self, icons: SliderTrackIcons) -> Self {
728 self.track_icons = Some(icons);
729 self
730 }
731
732 pub fn track_thickness(mut self, thickness: f64) -> Self {
735 self.overrides.track_thickness = Some(thickness);
736 self
737 }
738
739 pub fn corner_radius(mut self, radius: f64) -> Self {
742 self.overrides.corner_radius = Some(radius);
743 self
744 }
745
746 pub fn thumb_length(mut self, length: f64) -> Self {
749 self.overrides.thumb_length = Some(length);
750 self
751 }
752
753 pub fn dot_size(mut self, size: f64) -> Self {
756 self.overrides.dot_size = Some(size);
757 self
758 }
759
760 pub fn dot_spacing(mut self, spacing: f64) -> Self {
763 self.overrides.dot_spacing = Some(spacing);
764 self
765 }
766
767 #[cfg(test)]
771 fn haptic_sink(mut self, sink: fn(HapticSignal)) -> Self {
772 self.haptic_sink = sink;
773 self
774 }
775
776 fn reverse(&self) -> bool {
780 self.axis == SliderAxis::Vertical && !self.top_to_bottom
781 }
782
783 fn indicator_text(&self) -> String {
786 match &self.label {
787 Some(label) => label.clone(),
788 None if self.spec.divisions.is_some() => format!("{}", self.value.round()),
789 None => format!("{:.2}", self.value),
790 }
791 }
792}
793
794pub struct SliderWidget {
796 value: f64,
798 spec: ValueSpec,
799 ticks: Vec<f64>,
800 kind: SliderTrackKind,
801 axis: SliderAxis,
802 reverse: bool,
805 wavy: bool,
806 wave: WaveOverrides,
807 amplitude_for_progress: Option<AmplitudeCurve>,
808 wave_clock: WaveClock,
810 enabled: bool,
811 overrides: Overrides,
812 haptic: HapticSignal,
813 icon: Option<IconSource>,
814 icon_geom: Option<(kurbo::BezPath, f64)>,
815 icon_position: SliderIconPosition,
816 track_icons: Option<SliderTrackIcons>,
817 track_icon_geom: [Option<(kurbo::BezPath, f64)>; 4],
818 drag: Drag,
820 interaction: InteractionState,
823 last_reported: Option<f64>,
826 press_anim: AnimationController,
829 press_target: bool,
830 dock_anim: AnimationController,
833 dock_target: bool,
834 haptics: HapticScheduler,
835 haptic_sink: fn(HapticSignal),
836 indicator: Run,
838 on_changed: frust::authoring::ErasedArgCallback<f64>,
839 on_change_end: Option<frust::authoring::ErasedArgCallback<f64>>,
840}
841
842impl<State: 'static> View<State> for SliderView<State> {
843 type Element = SliderWidget;
844
845 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
846 debug_assert!(
847 self.icon.is_none() || self.spec.divisions.is_none(),
848 "a relocating slider icon and discrete divisions are mutually exclusive"
849 );
850 let mut indicator = Run::new();
851 indicator.set_content(&self.indicator_text());
852 SliderWidget {
853 value: self.value,
854 spec: self.spec,
855 ticks: self.spec.tick_fractions(),
856 kind: self.kind,
857 axis: self.axis,
858 reverse: self.reverse(),
859 wavy: self.wavy,
860 wave: self.wave,
861 amplitude_for_progress: self.amplitude_for_progress.clone(),
862 wave_clock: WaveClock::new(),
863 enabled: self.enabled,
864 overrides: self.overrides,
865 haptic: self.haptic,
866 icon: self.icon,
867 icon_geom: resolve_icon(self.icon),
868 icon_position: self.icon_position,
869 track_icons: self.track_icons,
870 track_icon_geom: resolve_track_icons(self.track_icons.as_ref()),
871 drag: Drag::default(),
872 interaction: InteractionState::new(),
873 last_reported: None,
874 press_anim: AnimationController::new(PRESS_MORPH_DURATION).with_curve(Curve::EaseOut),
875 press_target: false,
876 dock_anim: AnimationController::new(std::time::Duration::ZERO),
877 dock_target: false,
878 haptics: HapticScheduler::default(),
879 haptic_sink: self.haptic_sink,
880 indicator,
881 on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
882 on_change_end: self
883 .on_change_end
884 .as_ref()
885 .map(frust::authoring::erase_callback_arg),
886 }
887 }
888
889 fn rebuild(
890 &self,
891 prev: &Self,
892 element: &mut SliderWidget,
893 _ctx: &mut BuildCtx<'_>,
894 ) -> ChangeFlags {
895 element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
896 element.on_change_end = self
897 .on_change_end
898 .as_ref()
899 .map(frust::authoring::erase_callback_arg);
900 element.haptic_sink = self.haptic_sink;
901 element.amplitude_for_progress = self.amplitude_for_progress.clone();
905 let mut flags = ChangeFlags::NONE;
906
907 let text = self.indicator_text();
911 if element.indicator.content != text {
912 element.indicator.set_content(&text);
913 flags |= ChangeFlags::LAYOUT;
914 }
915 if prev.value != self.value {
916 element.value = self.value;
917 element.last_reported = None;
920 flags |= ChangeFlags::PAINT;
921 }
922 if prev.spec != self.spec {
923 element.spec = self.spec;
924 element.ticks = self.spec.tick_fractions();
925 flags |= ChangeFlags::PAINT;
926 }
927 if prev.kind != self.kind {
928 element.kind = self.kind;
929 flags |= ChangeFlags::PAINT;
930 }
931 if prev.axis != self.axis || prev.top_to_bottom != self.top_to_bottom {
932 element.axis = self.axis;
933 element.reverse = self.reverse();
934 flags |= ChangeFlags::LAYOUT;
937 }
938 if prev.wavy != self.wavy || prev.wave != self.wave {
939 element.wavy = self.wavy;
940 element.wave = self.wave;
941 flags |= ChangeFlags::PAINT;
942 }
943 if prev.enabled != self.enabled {
944 element.enabled = self.enabled;
945 if !self.enabled {
946 element.drag = Drag::default();
951 element.interaction.set_pressed(false);
952 element.interaction.set_dragged(false);
953 element.last_reported = None;
954 }
955 flags |= ChangeFlags::PAINT;
956 }
957 if prev.haptic != self.haptic {
958 element.haptic = self.haptic;
959 }
960 if prev.overrides != self.overrides {
961 element.overrides = self.overrides;
962 flags |= ChangeFlags::LAYOUT;
963 }
964 if !same_icon_source(prev.icon, self.icon) {
965 element.icon = self.icon;
966 element.icon_geom = resolve_icon(self.icon);
967 flags |= ChangeFlags::PAINT;
968 }
969 if prev.icon_position != self.icon_position {
970 element.icon_position = self.icon_position;
971 flags |= ChangeFlags::PAINT;
972 }
973 if prev.track_icons != self.track_icons {
974 element.track_icons = self.track_icons;
975 element.track_icon_geom = resolve_track_icons(self.track_icons.as_ref());
976 flags |= ChangeFlags::PAINT;
977 }
978 flags
979 }
980}
981
982fn resolve_track_icons(icons: Option<&SliderTrackIcons>) -> [Option<(kurbo::BezPath, f64)>; 4] {
984 let Some(icons) = icons else {
985 return [None, None, None, None];
986 };
987 [
988 resolve_icon(icons.active_start),
989 resolve_icon(icons.active_end),
990 resolve_icon(icons.inactive_start),
991 resolve_icon(icons.inactive_end),
992 ]
993}
994
995impl SliderWidget {
996 fn drag_ctx(&self, extent: f64) -> DragCtx {
999 DragCtx {
1000 spec: self.spec,
1001 current: self.last_reported.unwrap_or(self.value),
1002 extent,
1003 reverse: self.reverse,
1004 }
1005 }
1006
1007 fn axis_map(&self, size: Size) -> AxisMap {
1009 AxisMap::new(self.axis, size, self.reverse)
1010 }
1011
1012 fn apply(&mut self, out: DragOutcome, ctx: &mut EventCtx) -> EventResult {
1018 if !out.handled {
1019 return EventResult::Ignored;
1020 }
1021 if out.capture {
1022 ctx.capture_pointer();
1023 }
1024 if let Some(value) = out.value {
1025 if self.haptic != HapticSignal::None {
1026 let cadence = if self.spec.divisions.is_some() {
1027 HapticCadence::Immediate
1028 } else {
1029 HapticCadence::Throttled
1030 };
1031 self.haptics.queue(self.haptic, cadence);
1032 }
1033 self.last_reported = Some(value);
1034 (self.on_changed)(ctx, value);
1035 }
1036 if out.end {
1037 let settled = self.last_reported.unwrap_or(self.value);
1038 if let Some(on_change_end) = &mut self.on_change_end {
1039 on_change_end(ctx, settled);
1040 }
1041 }
1042 self.interaction.set_pressed(self.drag.pressed);
1043 self.interaction.set_dragged(self.drag.dragging);
1044 ctx.request_redraw();
1045 EventResult::Handled
1046 }
1047}
1048
1049impl Widget for SliderWidget {
1050 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1051 let style = label_large(Theme::from_layout_ctx(ctx));
1055 self.indicator.layout(ctx, &style);
1056
1057 let cross = HANDLE_HEIGHT.max(self.overrides.thumb_length());
1064 let available = match self.axis {
1065 SliderAxis::Horizontal => bc.max().width,
1066 SliderAxis::Vertical => bc.max().height,
1067 };
1068 let extent = if available.is_finite() {
1069 available
1070 } else {
1071 DEFAULT_EXTENT
1072 };
1073 let size = match self.axis {
1074 SliderAxis::Horizontal => Size::new(extent, cross),
1075 SliderAxis::Vertical => Size::new(cross, extent),
1076 };
1077 bc.constrain(size)
1078 }
1079
1080 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1081 let theme = Theme::from_paint_ctx(ctx);
1082 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
1083 let colors = resolve_colors(theme, self.enabled);
1084 let focused = ctx.has_focus();
1085 let o = ctx.origin();
1086 let map = self.axis_map(ctx.size());
1087 let extent = map.extent();
1088
1089 let fraction = self.spec.fraction(self.value);
1090 let thumb_primary = fraction * extent;
1091 let handle_thickness = if self.drag.pressed {
1097 PRESSED_HANDLE_WIDTH
1098 } else {
1099 HANDLE_WIDTH
1100 };
1101 let track_thickness = self.overrides.track_thickness();
1102 let geometry = SliderGeometry {
1103 extent,
1104 active_start_fraction: 0.0,
1105 active_end_fraction: fraction,
1106 reverse: self.reverse,
1107 kind: self.kind,
1108 handle_thickness,
1109 handle_gap: HANDLE_GAP
1110 + if focused {
1111 core::FOCUS_HANDLE_GAP_EXTRA
1112 } else {
1113 0.0
1114 },
1115 track_thickness,
1116 corner_radius: self.overrides.corner_radius(),
1117 };
1118
1119 let dock = self.icon_dock(&geometry, map.flip(thumb_primary));
1123 if let Some(dock) = &dock
1124 && dock.docked != self.dock_target
1125 {
1126 let velocity = if dock.docked { 1e-3 } else { -1e-3 };
1128 self.dock_anim.fling(velocity, core::DOCK_SPRING);
1129 self.dock_target = dock.docked;
1130 }
1131 if self.drag.pressed != self.press_target {
1132 self.press_anim
1133 .animate_to(if self.drag.pressed { 1.0 } else { 0.0 });
1134 self.press_target = self.drag.pressed;
1135 }
1136 let press_animating = self.press_anim.advance(ctx.frame_time());
1137 let dock_animating = self.dock_anim.advance(ctx.frame_time());
1138 if press_animating || dock_animating {
1139 ctx.request_frame();
1140 }
1141 let wave = self.wavy.then(|| {
1142 let factor = self.amplitude_factor(fraction);
1143 wave_paint(&mut self.wave_clock, self.wave, factor, ctx, reduce_motion)
1144 });
1145 if let Some(signal) = self.haptics.take(ctx.frame_time()) {
1148 (self.haptic_sink)(signal);
1149 }
1150
1151 if let Some(metrics) = TrackMetrics::resolve(&geometry) {
1152 paint_track(scene, o, &map, &metrics, track_thickness, wave, &colors);
1153 if self.icon.is_none() {
1156 let spec = core::DotSpec {
1157 stop_size: self.overrides.dot_size(),
1158 tick_size: self.overrides.tick_size(),
1159 edge_inset: self.overrides.dot_spacing(),
1160 };
1161 paint_dots(scene, o, &map, &metrics, &self.ticks, &spec, &colors);
1162 }
1163 }
1164
1165 self.paint_track_icons(scene, o, &map, fraction, &colors);
1166
1167 let thumb_thickness =
1168 HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * self.press_anim.value_clamped();
1169 let thumb = map.place_thumb(
1170 thumb_primary,
1171 thumb_thickness,
1172 self.overrides.thumb_length(),
1173 );
1174 paint_thumb(
1175 scene,
1176 o,
1177 thumb,
1178 focused,
1179 self.interaction.resolve_opacity(),
1180 &colors,
1181 );
1182
1183 if let (Some(dock), Some((path, design))) = (dock, &self.icon_geom) {
1184 let center = dock.center_at(self.dock_anim.value_clamped());
1187 let icon_size = self.overrides.icon_size();
1188 let color = if dock.over_active(center, map.flip(thumb_primary), self.reverse) {
1189 colors.on_active
1190 } else {
1191 colors.on_inactive
1192 };
1193 paint_icon(
1194 scene,
1195 o,
1196 path,
1197 *design,
1198 map.widget_square(center, icon_size),
1199 icon_size,
1200 color,
1201 );
1202 }
1203
1204 if self.drag.pressed {
1207 paint_value_indicator(scene, o, &map, &self.indicator, thumb_primary, &colors);
1208 }
1209 }
1210
1211 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1212 let InputEvent::Pointer(p) = event else {
1213 return EventResult::Ignored;
1214 };
1215 let map = self.axis_map(ctx.size());
1216 let extent = map.extent();
1217 let primary = map.event_primary(p.position);
1218 match p.phase {
1219 PointerPhase::Down => {
1220 if !self.enabled || !presses(p) {
1221 return EventResult::Ignored;
1222 }
1223 let out = self.drag.down(primary, &self.drag_ctx(extent));
1224 self.apply(out, ctx)
1225 }
1226 PointerPhase::Move => {
1227 let out = self.drag.moved(primary, &self.drag_ctx(extent));
1228 self.apply(out, ctx)
1229 }
1230 PointerPhase::Up => {
1231 let out = self.drag.up();
1232 self.apply(out, ctx)
1233 }
1234 PointerPhase::Cancel => {
1235 let out = self.drag.cancel();
1237 if !out.handled {
1238 return EventResult::Ignored;
1239 }
1240 self.interaction.set_pressed(false);
1241 self.interaction.set_dragged(false);
1242 ctx.request_redraw();
1243 EventResult::Handled
1244 }
1245 }
1246 }
1247
1248 fn semantics(&self, ctx: &mut SemanticsCtx) {
1249 ctx.push_node(Role::Slider, |node| {
1250 node.set_numeric_value(self.value);
1251 node.set_min_numeric_value(self.spec.min);
1252 node.set_max_numeric_value(self.spec.max);
1253 });
1254 }
1255}
1256
1257impl SliderWidget {
1258 fn icon_dock(&self, geometry: &SliderGeometry, thumb_primary: f64) -> Option<core::IconDock> {
1263 if self.icon.is_none()
1264 || self.kind != SliderTrackKind::Standard
1265 || self.spec.divisions.is_some()
1266 {
1267 return None;
1268 }
1269 Some(core::icon_dock(&IconDockInput {
1270 extent: geometry.extent,
1271 thumb_primary,
1272 icon_size: self.overrides.icon_size(),
1273 edge_inset: self.overrides.icon_edge_inset(),
1274 handle_thickness: geometry.handle_thickness,
1275 position: self.icon_position,
1276 reverse: geometry.reverse,
1277 }))
1278 }
1279
1280 fn paint_track_icons(
1282 &self,
1283 scene: &mut dyn PaintScene,
1284 origin: Point,
1285 map: &AxisMap,
1286 fraction: f64,
1287 colors: &SliderColors,
1288 ) {
1289 let Some(icons) = &self.track_icons else {
1290 return;
1291 };
1292 let size = icons.size();
1293 for placement in
1294 core::track_icon_placements(map.extent(), fraction, self.kind, size, icons.present())
1295 {
1296 let Some((path, design)) = &self.track_icon_geom[placement.slot.index()] else {
1297 continue;
1298 };
1299 let color = if placement.active {
1300 colors.on_active
1301 } else {
1302 colors.on_inactive
1303 };
1304 paint_icon(
1305 scene,
1306 origin,
1307 path,
1308 *design,
1309 map.place_leading_square(placement.primary, size),
1310 size,
1311 color,
1312 );
1313 }
1314 }
1315
1316 fn amplitude_factor(&self, progress: f64) -> f64 {
1318 variants::amplitude_factor(
1319 progress,
1320 self.wave.amplitude,
1321 self.amplitude_for_progress.as_deref(),
1322 )
1323 }
1324}
1325
1326#[derive(Clone, Copy, Debug, PartialEq)]
1328struct WavePaint {
1329 amplitude: f64,
1332 wavelength: f64,
1333 phase: f64,
1335}
1336
1337fn wave_paint(
1346 clock: &mut WaveClock,
1347 wave: WaveOverrides,
1348 amplitude_factor: f64,
1349 ctx: &mut PaintCtx,
1350 reduce_motion: bool,
1351) -> WavePaint {
1352 if !reduce_motion {
1353 clock.step(ctx.frame_time());
1354 ctx.request_frame_paced();
1355 }
1356 WavePaint {
1357 amplitude: WAVE_AMPLITUDE * amplitude_factor,
1358 wavelength: wave.wavelength(),
1359 phase: clock.radians(&wave),
1360 }
1361}
1362
1363fn paint_track(
1366 scene: &mut dyn PaintScene,
1367 origin: Point,
1368 map: &AxisMap,
1369 metrics: &TrackMetrics,
1370 track_thickness: f64,
1371 wave: Option<WavePaint>,
1372 colors: &SliderColors,
1373) {
1374 for segment in metrics.segments(false) {
1377 let color = match segment.role {
1378 SegmentRole::Active => colors.active_track,
1379 SegmentRole::Inactive => colors.inactive_track,
1380 };
1381 if let Some(wave) = wave.filter(|_| segment.role == SegmentRole::Active)
1384 && let Some(path) = variants::wave_path(
1385 map,
1386 segment.start,
1387 segment.end,
1388 track_thickness,
1389 wave.amplitude,
1390 wave.wavelength,
1391 wave.phase,
1392 )
1393 {
1394 scene.stroke_path(origin, &path, track_thickness, &Brush::Solid(color));
1397 continue;
1398 }
1399 let placed = map.place_span(
1400 segment.start,
1401 segment.end,
1402 track_thickness,
1403 segment.start_corner,
1404 segment.end_corner,
1405 );
1406 scene.fill_rounded_rect_radii(
1407 Point::new(origin.x + placed.origin.x, origin.y + placed.origin.y),
1408 placed.size,
1409 placed.radii,
1410 color,
1411 );
1412 }
1413}
1414
1415fn paint_dots(
1417 scene: &mut dyn PaintScene,
1418 origin: Point,
1419 map: &AxisMap,
1420 metrics: &TrackMetrics,
1421 ticks: &[f64],
1422 spec: &core::DotSpec,
1423 colors: &SliderColors,
1424) {
1425 for dot in core::dots(metrics, ticks, spec) {
1426 let color = if dot.active {
1427 colors.active_tick
1428 } else {
1429 colors.inactive_tick
1430 };
1431 let at = map.place_square(dot.primary, dot.size);
1432 scene.fill_rounded_rect(
1433 Point::new(origin.x + at.x, origin.y + at.y),
1434 Size::new(dot.size, dot.size),
1435 dot.size / 2.0,
1436 color,
1437 );
1438 }
1439}
1440
1441fn paint_thumb(
1450 scene: &mut dyn PaintScene,
1451 origin: Point,
1452 thumb: kurbo::Rect,
1453 focused: bool,
1454 overlay: f32,
1455 colors: &SliderColors,
1456) {
1457 if overlay > 0.0 {
1458 let halo = core::focus_ring_rect(thumb);
1459 scene.fill_rounded_rect(
1460 Point::new(origin.x + halo.x0, origin.y + halo.y0),
1461 halo.size(),
1462 core::thumb_radius(halo),
1463 colors.thumb.multiply_alpha(overlay),
1464 );
1465 }
1466 scene.fill_rounded_rect(
1467 Point::new(origin.x + thumb.x0, origin.y + thumb.y0),
1468 thumb.size(),
1469 core::thumb_radius(thumb),
1470 colors.thumb,
1471 );
1472 if focused {
1473 let ring = core::focus_ring_rect(thumb);
1474 let inset = FOCUS_STROKE / 2.0;
1475 let path = RoundedRect::from_rect(
1476 ring.inset(-inset),
1477 (core::thumb_radius(ring) - inset).max(0.0),
1478 )
1479 .to_path(PATH_TOLERANCE);
1480 scene.stroke_path(origin, &path, FOCUS_STROKE, &Brush::Solid(colors.thumb));
1481 }
1482}
1483
1484fn paint_value_indicator(
1486 scene: &mut dyn PaintScene,
1487 origin: Point,
1488 map: &AxisMap,
1489 indicator: &Run,
1490 thumb_primary: f64,
1491 colors: &SliderColors,
1492) {
1493 let label = indicator.size();
1494 let at = map.value_indicator_origin(thumb_primary);
1495 let box_size = Size::new(
1496 label.width + 2.0 * VALUE_INDICATOR_PAD_X,
1497 label.height + 2.0 * VALUE_INDICATOR_PAD_Y,
1498 );
1499 let box_origin = Point::new(origin.x + at.x, origin.y + at.y);
1500 scene.fill_rounded_rect(
1501 box_origin,
1502 box_size,
1503 VALUE_INDICATOR_RADIUS,
1504 colors.value_indicator,
1505 );
1506 indicator.paint(
1507 Point::new(
1508 box_origin.x + VALUE_INDICATOR_PAD_X,
1509 box_origin.y + VALUE_INDICATOR_PAD_Y,
1510 ),
1511 colors.value_indicator_label,
1512 scene,
1513 );
1514}
1515
1516fn paint_icon(
1519 scene: &mut dyn PaintScene,
1520 origin: Point,
1521 path: &kurbo::BezPath,
1522 design: f64,
1523 local: Point,
1524 size: f64,
1525 color: Color,
1526) {
1527 let scale = if design > 0.0 { size / design } else { 1.0 };
1528 let transform = Affine::translate(Vec2::new(local.x, local.y)) * Affine::scale(scale);
1529 scene.fill_path(origin, &(transform * path.clone()), &Brush::Solid(color));
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535 use frust::FrameTime;
1536 use frust::authoring::scene::GlyphRun;
1537 use frust::authoring::{CornerRadii, PointerButton, PointerEvent};
1538 use kurbo::BezPath;
1539 use std::any::Any;
1540 use std::sync::Mutex;
1541
1542 const WIDTH: f64 = 200.0;
1544 const SIZE: Size = Size::new(WIDTH, HANDLE_HEIGHT);
1545
1546 #[derive(Default)]
1547 struct Val {
1548 value: f64,
1549 changes: Vec<f64>,
1550 ends: Vec<f64>,
1551 }
1552
1553 fn view(value: f64) -> SliderView<Val> {
1554 slider::<Val, _>(value, |s: &mut Val, v: f64| {
1555 s.value = v;
1556 s.changes.push(v);
1557 })
1558 .on_change_end(|s: &mut Val, v: f64| s.ends.push(v))
1559 }
1560
1561 fn build(view: &SliderView<Val>) -> SliderWidget {
1562 let mut counter = 0u64;
1563 View::<Val>::build(view, &mut BuildCtx::new(&mut counter))
1564 }
1565
1566 fn widget(value: f64) -> SliderWidget {
1567 build(&view(value))
1568 }
1569
1570 fn ev(phase: PointerPhase, x: f64) -> InputEvent {
1571 InputEvent::Pointer(PointerEvent {
1572 phase,
1573 position: Point::new(x, HANDLE_HEIGHT / 2.0),
1574 button: PointerButton::Primary,
1575 })
1576 }
1577
1578 fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
1579 InputEvent::Pointer(PointerEvent {
1580 phase,
1581 position: Point::new(x, HANDLE_HEIGHT / 2.0),
1582 button: PointerButton::Secondary,
1583 })
1584 }
1585
1586 fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
1587 let state_any: &mut dyn Any = state;
1588 let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
1589 w.event(&mut ctx, event)
1590 }
1591
1592 #[derive(Default)]
1594 struct Recorder {
1595 rrects: Vec<(Point, Size, f64, Color)>,
1596 radii: Vec<(Point, Size, CornerRadii, Color)>,
1597 strokes: Vec<(f64, Color)>,
1598 stroke_paths: Vec<BezPath>,
1600 paths: Vec<Color>,
1601 runs: Vec<(Point, Color)>,
1602 }
1603
1604 impl PaintScene for Recorder {
1605 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1606 fn draw_text(&mut self, _o: Point, _t: &str) {}
1607 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1608 self.rrects.push((o, s, radius, color));
1609 }
1610 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1611 self.radii.push((o, s, radii, color));
1612 }
1613 fn stroke_path(&mut self, _o: Point, p: &BezPath, width: f64, brush: &Brush) {
1614 let color = match brush {
1615 Brush::Solid(c) => *c,
1616 _ => Color::TRANSPARENT,
1617 };
1618 self.strokes.push((width, color));
1619 self.stroke_paths.push(p.clone());
1620 }
1621 fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
1622 if let Brush::Solid(color) = brush {
1623 self.paths.push(*color);
1624 }
1625 }
1626 fn draw_glyph_run(&mut self, run: GlyphRun) {
1627 let color = match run.brush {
1628 Brush::Solid(c) => c,
1629 _ => Color::TRANSPARENT,
1630 };
1631 let t = run.transform.translation();
1632 self.runs.push((Point::new(t.x, t.y), color));
1633 }
1634 }
1635
1636 impl Recorder {
1637 fn thumb(&self) -> (Point, Size, f64, Color) {
1640 *self
1641 .rrects
1642 .iter()
1643 .find(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1644 .expect("every pass paints a thumb")
1645 }
1646 }
1647
1648 fn layout(w: &mut SliderWidget) -> Size {
1649 let mut tcx = TextContext::new();
1650 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1651 w.layout(&mut ctx, &BoxConstraints::loose(SIZE))
1652 }
1653
1654 fn paint(w: &mut SliderWidget, theme: Option<&Theme>) -> Recorder {
1655 paint_frame(w, theme, SIZE).rec
1656 }
1657
1658 struct Frame {
1661 rec: Recorder,
1662 needs_frame: bool,
1663 paced_only: bool,
1664 }
1665
1666 fn paint_frame(w: &mut SliderWidget, theme: Option<&Theme>, size: Size) -> Frame {
1667 let mut rec = Recorder::default();
1668 let mut ctx = match theme {
1669 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
1670 None => PaintCtx::new(Point::ZERO, size),
1671 };
1672 w.paint(&mut ctx, &mut rec);
1673 Frame {
1674 needs_frame: ctx.needs_frame(),
1675 paced_only: ctx.needs_frame_paced_only(),
1676 rec,
1677 }
1678 }
1679
1680 #[test]
1683 fn down_move_up_reports_in_order_and_settles_once() {
1684 let mut w = widget(0.0);
1685 let mut state = Val::default();
1686 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
1687 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
1688 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 260.0));
1689 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 260.0));
1690 assert_eq!(state.changes, vec![0.25, 0.5, 1.0], "clamped at the end");
1691 assert_eq!(state.ends, vec![1.0], "settles on the last reported value");
1692 assert_eq!(
1693 w.value, 0.0,
1694 "a controlled slider never writes its own value"
1695 );
1696 }
1697
1698 #[test]
1699 fn a_secondary_press_never_captures_or_reports() {
1700 let mut w = widget(0.0);
1701 let mut state = Val::default();
1702 let result = dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
1703 assert!(matches!(result, EventResult::Ignored));
1704 assert!(!w.drag.dragging);
1705 assert!(state.changes.is_empty());
1706 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
1708 assert!(state.changes.is_empty());
1709 }
1710
1711 #[test]
1712 fn a_hover_move_is_ignored_and_requests_nothing() {
1713 let mut w = widget(0.3);
1714 let mut state = Val::default();
1715 let state_any: &mut dyn Any = &mut state;
1716 let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
1717 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
1718 assert!(matches!(result, EventResult::Ignored));
1719 assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
1720 assert!(state.changes.is_empty());
1721 }
1722
1723 #[test]
1724 fn cancel_disarms_without_reporting_or_settling() {
1725 let mut w = widget(0.0);
1726 let mut state = Val::default();
1727 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1728 assert!(w.drag.dragging);
1729 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
1730 assert!(!w.drag.dragging && !w.drag.pressed);
1731 assert!(state.ends.is_empty(), "a Cancel never reaches app state");
1732 let before = state.changes.len();
1734 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
1735 assert_eq!(state.changes.len(), before);
1736 }
1737
1738 #[test]
1739 fn disabled_ignores_every_pointer_phase() {
1740 let mut w = build(&view(0.0).enabled(false));
1741 let mut state = Val::default();
1742 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1743 assert!(!w.drag.dragging);
1744 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
1745 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 120.0));
1746 assert!(state.changes.is_empty() && state.ends.is_empty());
1747 }
1748
1749 #[test]
1750 fn disabling_mid_drag_disarms_the_machine() {
1751 let mut w = widget(0.0);
1752 let mut state = Val::default();
1753 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1754 assert!(w.drag.dragging);
1755
1756 let mut counter = 0u64;
1757 let prev = view(0.0);
1758 let next = view(0.0).enabled(false);
1759 View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1760 assert!(!w.drag.dragging && !w.drag.pressed);
1761
1762 let before = state.changes.len();
1763 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 160.0));
1764 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 160.0));
1765 assert_eq!(
1766 state.changes.len(),
1767 before,
1768 "a captured drag stops reporting"
1769 );
1770 assert!(state.ends.is_empty());
1771 }
1772
1773 #[test]
1774 fn a_discrete_drag_reports_only_when_it_crosses_a_step() {
1775 let mut w = build(&view(0.0).range(0.0, 10.0).divisions(10));
1776 let mut state = Val::default();
1777 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1778 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 103.0));
1780 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 121.0));
1781 assert_eq!(state.changes, vec![5.0, 6.0]);
1782 }
1783
1784 #[test]
1785 fn a_drag_deduplicates_against_its_own_last_report_until_a_rebuild_confirms_one() {
1786 let mut w = widget(0.0);
1787 let mut state = Val::default();
1788 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1789 assert_eq!(w.last_reported, Some(0.5));
1790 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
1791 assert_eq!(state.changes, vec![0.5], "the repeat is dropped");
1792
1793 let mut counter = 0u64;
1794 let prev = view(0.0);
1795 let next = view(0.5);
1796 View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1797 assert_eq!(w.value, 0.5);
1798 assert_eq!(w.last_reported, None, "the confirmed value takes over");
1799 }
1800
1801 #[test]
1802 fn rebuild_adopts_value_and_bounds_without_self_mutation() {
1803 let mut counter = 0u64;
1804 let prev = view(0.2);
1805 let mut w = build(&prev);
1806 assert_eq!(w.value, 0.2);
1807 let next = view(0.8).range(0.0, 10.0).divisions(5);
1808 let flags = View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1809 assert_eq!(w.value, 0.8);
1810 assert_eq!(w.spec.max, 10.0);
1811 assert_eq!(w.ticks.len(), 6, "divisions + 1 marks");
1812 assert!(flags.needs_paint());
1813 assert!(
1814 flags.needs_layout(),
1815 "the indicator label changed, so it must be re-shaped"
1816 );
1817 }
1818
1819 #[test]
1822 fn layout_fills_the_width_and_takes_the_handle_height() {
1823 let mut w = widget(0.5);
1824 assert_eq!(layout(&mut w), SIZE);
1825
1826 let mut tcx = TextContext::new();
1828 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1829 let unbounded = w.layout(
1830 &mut ctx,
1831 &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1832 );
1833 assert_eq!(unbounded.width, DEFAULT_EXTENT);
1834 }
1835
1836 #[test]
1837 fn the_track_paints_two_segments_with_asymmetric_radii_and_a_stop_dot() {
1838 let mut w = widget(0.5);
1839 layout(&mut w);
1840 let rec = paint(&mut w, None);
1841
1842 assert_eq!(rec.radii.len(), 2, "inactive remainder, then active span");
1843 let (inactive_origin, inactive_size, inactive_radii, inactive_color) = rec.radii[0];
1844 let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
1845 assert_eq!(active_color, PRIMARY);
1846 assert_eq!(inactive_color, SECONDARY_CONTAINER);
1847 assert_eq!(active_origin, Point::new(0.0, 14.0), "vertically centred");
1848 assert_eq!(active_size, Size::new(92.0, TRACK_HEIGHT));
1849 assert_eq!(inactive_origin.x, 108.0);
1850 assert_eq!(inactive_size.width, 92.0);
1851 assert_eq!(active_radii.top_left, TRACK_CORNER_RADIUS);
1853 assert_eq!(active_radii.top_right, core::TRACK_INSIDE_CORNER);
1854 assert_eq!(inactive_radii.top_left, core::TRACK_INSIDE_CORNER);
1855 assert_eq!(inactive_radii.top_right, TRACK_CORNER_RADIUS);
1856
1857 assert_eq!(rec.rrects.len(), 2, "stop dot, then thumb");
1859 let (dot_origin, dot_size, dot_radius, dot_color) = rec.rrects[0];
1860 assert_eq!(
1861 dot_size,
1862 Size::new(STOP_INDICATOR_SIZE, STOP_INDICATOR_SIZE)
1863 );
1864 assert_eq!(dot_radius, STOP_INDICATOR_SIZE / 2.0);
1865 assert_eq!(dot_origin, Point::new(190.0, 20.0), "centred at 192");
1866 assert_eq!(dot_color, PRIMARY, "a stop takes the active-track ink");
1867
1868 let (thumb_origin, thumb_size, thumb_radius, thumb_color) = rec.rrects[1];
1869 assert_eq!(thumb_size, Size::new(HANDLE_WIDTH, HANDLE_HEIGHT));
1870 assert_eq!(thumb_origin, Point::new(98.0, 0.0));
1871 assert_eq!(thumb_radius, HANDLE_HEIGHT / 2.0);
1872 assert_eq!(thumb_color, PRIMARY);
1873 }
1874
1875 #[test]
1876 fn a_centered_track_paints_three_segments() {
1877 let mut w = build(¢ered_slider::<Val, _>(0.75, |_s, _v| {}));
1878 layout(&mut w);
1879 let rec = paint(&mut w, None);
1880 assert_eq!(rec.radii.len(), 3);
1881 assert_eq!(rec.radii[2].3, PRIMARY, "the active span paints last");
1882 assert_eq!(rec.radii[2].0.x, 100.0, "growing from the midpoint");
1883 }
1884
1885 #[test]
1886 fn themed_paint_resolves_the_slider_roles() {
1887 let theme = crate::baseline();
1888 let scheme = theme.scheme();
1889 let mut w = widget(0.5);
1890 layout(&mut w);
1891 let rec = paint(&mut w, Some(&theme));
1892 assert_eq!(rec.radii[0].3, scheme.secondary_container);
1893 assert_eq!(rec.radii[1].3, scheme.primary);
1894 assert_eq!(rec.rrects[1].3, scheme.primary, "thumb");
1895 }
1896
1897 #[test]
1898 fn a_disabled_slider_dims_active_and_inactive_by_their_own_opacities() {
1899 let theme = crate::baseline();
1900 let scheme = theme.scheme();
1901 let mut w = build(&view(0.5).enabled(false));
1902 layout(&mut w);
1903 let rec = paint(&mut w, Some(&theme));
1904 assert_eq!(
1905 rec.radii[0].3,
1906 scheme.on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
1907 );
1908 assert_eq!(
1909 rec.radii[1].3,
1910 scheme.on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
1911 );
1912 }
1913
1914 #[test]
1915 fn discrete_ticks_paint_alongside_the_stops() {
1916 let mut w = build(&view(0.5).divisions(4));
1917 layout(&mut w);
1918 let rec = paint(&mut w, None);
1919 assert_eq!(rec.rrects.len(), 4);
1921 assert_eq!(rec.rrects[1].0.x, 52.0, "tick at 0.25, centred on 54");
1922 assert_eq!(
1923 rec.rrects[1].3, SECONDARY_CONTAINER,
1924 "a tick on the active track takes the inactive-track ink"
1925 );
1926 assert_eq!(rec.rrects[2].3, PRIMARY, "and vice versa past the thumb");
1927 }
1928
1929 #[test]
1930 fn the_pressed_thumb_compresses_over_the_morph_and_shows_the_value_indicator() {
1931 let mut w = widget(0.5);
1932 layout(&mut w);
1933 let mut state = Val::default();
1934
1935 let rec = paint(&mut w, None);
1937 assert_eq!(rec.rrects.len(), 2, "stop dot + thumb only");
1938 assert_eq!(rec.rrects[1].1.width, HANDLE_WIDTH);
1939 assert!(rec.runs.is_empty(), "no indicator label at rest");
1940
1941 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1942 let rec = paint(&mut w, None);
1945 assert!(w.press_anim.is_animating(), "a 100ms ease-out started");
1946 assert_eq!(rec.thumb().1.width, HANDLE_WIDTH);
1947 let halo = rec
1949 .rrects
1950 .iter()
1951 .find(|(_, s, _, _)| s.width == HANDLE_WIDTH + core::FOCUS_INFLATE)
1952 .expect("a dragged handle paints its halo");
1953 assert!(
1954 (halo.3.components[3] - crate::interaction::DRAGGED_OPACITY).abs() < 1e-6,
1955 "the halo takes the dragged precedence opacity"
1956 );
1957 assert_eq!(rec.runs.len(), 1, "the value indicator's label");
1958
1959 w.press_anim.advance(secs(0.05));
1962 let mid =
1963 HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * w.press_anim.value_clamped();
1964 assert!(mid < HANDLE_WIDTH && mid > PRESSED_HANDLE_WIDTH, "{mid}");
1965 w.press_anim.advance(secs(0.2));
1966 assert!(!w.press_anim.is_animating(), "settled inside 100ms");
1967 let rec = paint(&mut w, None);
1968 assert_eq!(rec.thumb().1.width, PRESSED_HANDLE_WIDTH);
1969
1970 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0));
1972 let rec = paint(&mut w, None);
1973 assert!(rec.runs.is_empty(), "the indicator hides on release");
1974 assert!(w.press_anim.is_animating(), "and the morph reverses");
1975 }
1976
1977 #[test]
1978 fn the_focus_ring_strokes_only_while_the_control_holds_focus() {
1979 let mut w = widget(0.5);
1982 layout(&mut w);
1983 assert!(paint(&mut w, None).strokes.is_empty());
1984
1985 let ring = core::focus_ring_rect(core::thumb_rect(
1986 100.0,
1987 HANDLE_HEIGHT / 2.0,
1988 HANDLE_WIDTH,
1989 HANDLE_HEIGHT,
1990 ));
1991 assert_eq!(ring.width(), HANDLE_WIDTH + core::FOCUS_INFLATE);
1992 assert_eq!(ring.height(), HANDLE_HEIGHT + core::FOCUS_INFLATE);
1993 }
1994
1995 #[test]
1996 fn a_relocating_icon_replaces_the_stop_dots_and_docks_beside_the_thumb() {
1997 let mut w = build(&view(0.9).icon(crate::icons::CHECK));
1998 layout(&mut w);
1999 let rec = paint(&mut w, None);
2000 assert_eq!(rec.rrects.len(), 1, "the thumb only — no stop dots");
2001 assert_eq!(rec.paths.len(), 1, "the icon paints as a path");
2002 assert!(w.dock_target, "a thumb at 180 is inside the dock limit");
2003 assert!(w.dock_anim.is_animating(), "the dock spring started");
2004
2005 let mut counter = 0u64;
2007 let prev = view(0.9).icon(crate::icons::CHECK);
2008 let next = view(0.1).icon(crate::icons::CHECK);
2009 View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
2010 paint(&mut w, None);
2011 assert!(!w.dock_target);
2012 }
2013
2014 #[test]
2015 fn inset_track_icons_paint_per_segment() {
2016 let icons = SliderTrackIcons {
2017 active_start: Some(crate::icons::CHECK),
2018 inactive_end: Some(crate::icons::CLOSE),
2019 ..SliderTrackIcons::new()
2020 };
2021 let mut w = build(&view(0.5).track_icons(icons));
2022 layout(&mut w);
2023 let rec = paint(&mut w, None);
2024 assert_eq!(rec.paths.len(), 2, "one per filled slot");
2025 assert_eq!(rec.paths[0], ON_PRIMARY, "over the active track");
2026 assert_eq!(rec.paths[1], ON_SURFACE_VARIANT, "over the inactive track");
2027 }
2028
2029 const V_SIZE: Size = Size::new(HANDLE_HEIGHT, WIDTH);
2035
2036 fn v_view(value: f64) -> SliderView<Val> {
2037 vertical_slider::<Val, _>(value, |s: &mut Val, v: f64| {
2038 s.value = v;
2039 s.changes.push(v);
2040 })
2041 }
2042
2043 fn v_layout(w: &mut SliderWidget) -> Size {
2044 let mut tcx = TextContext::new();
2045 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2046 w.layout(&mut ctx, &BoxConstraints::loose(V_SIZE))
2047 }
2048
2049 fn v_ev(phase: PointerPhase, y: f64) -> InputEvent {
2050 InputEvent::Pointer(PointerEvent {
2051 phase,
2052 position: Point::new(HANDLE_HEIGHT / 2.0, y),
2053 button: PointerButton::Primary,
2054 })
2055 }
2056
2057 fn v_dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
2058 let state_any: &mut dyn Any = state;
2059 let mut ctx = EventCtx::new(state_any, Point::ZERO, V_SIZE);
2060 w.event(&mut ctx, event)
2061 }
2062
2063 #[test]
2064 fn a_vertical_slider_fills_the_height_and_takes_the_handle_width_across_it() {
2065 let mut w = build(&v_view(0.5));
2066 assert_eq!(v_layout(&mut w), V_SIZE);
2067
2068 let mut tcx = TextContext::new();
2069 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2070 let unbounded = w.layout(
2071 &mut ctx,
2072 &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
2073 );
2074 assert_eq!(
2075 unbounded.height, DEFAULT_EXTENT,
2076 "the primary axis is y now"
2077 );
2078 assert_eq!(unbounded.width, HANDLE_HEIGHT);
2079 }
2080
2081 #[test]
2082 fn a_vertical_slider_maps_value_to_y_bottom_up() {
2083 let mut w = build(&v_view(0.25));
2084 v_layout(&mut w);
2085 let rec = paint_frame(&mut w, None, V_SIZE).rec;
2086
2087 assert_eq!(rec.radii.len(), 2);
2090 let (inactive_origin, inactive_size, inactive_radii, _) = rec.radii[0];
2091 let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
2092 assert_eq!(active_color, PRIMARY);
2093 assert_eq!(
2094 active_origin,
2095 Point::new(14.0, 158.0),
2096 "42 tall, at the foot"
2097 );
2098 assert_eq!(active_size, Size::new(TRACK_HEIGHT, 42.0));
2099 assert_eq!(inactive_origin, Point::new(14.0, 0.0));
2100 assert_eq!(inactive_size, Size::new(TRACK_HEIGHT, 142.0));
2101 assert_eq!(active_radii.bottom_left, TRACK_CORNER_RADIUS);
2104 assert_eq!(active_radii.top_left, core::TRACK_INSIDE_CORNER);
2105 assert_eq!(inactive_radii.top_left, TRACK_CORNER_RADIUS);
2106 assert_eq!(inactive_radii.bottom_left, core::TRACK_INSIDE_CORNER);
2107
2108 let thumb = rec
2110 .rrects
2111 .iter()
2112 .find(|(_, s, _, _)| s.width == HANDLE_HEIGHT)
2113 .expect("a vertical thumb spans the cross axis");
2114 assert_eq!(thumb.0, Point::new(0.0, 148.0), "centred on y = 150");
2115 assert_eq!(thumb.1, Size::new(HANDLE_HEIGHT, HANDLE_WIDTH));
2116
2117 let mut state = Val::default();
2119 v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 50.0));
2120 assert_eq!(state.changes, vec![0.75]);
2121 v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
2122 assert_eq!(state.changes.last(), Some(&1.0), "the top edge is the max");
2123 }
2124
2125 #[test]
2126 fn top_to_bottom_puts_the_minimum_at_the_top_instead() {
2127 let mut w = build(&v_view(0.25).top_to_bottom(true));
2128 v_layout(&mut w);
2129 let rec = paint_frame(&mut w, None, V_SIZE).rec;
2130 let (active_origin, active_size, _, _) = rec.radii[1];
2131 assert_eq!(active_origin, Point::new(14.0, 0.0), "grown from the top");
2132 assert_eq!(active_size.height, 42.0);
2133
2134 let mut state = Val::default();
2135 v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 100.0));
2136 assert_eq!(state.changes, vec![0.5], "y now runs with the value");
2137 v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
2138 assert_eq!(state.changes.last(), Some(&0.0), "the top edge is the min");
2139 }
2140
2141 #[test]
2142 fn a_vertical_centered_slider_grows_from_the_midpoint() {
2143 let mut w = build(&vertical_centered_slider::<Val, _>(0.75, |_s, _v| {}));
2144 v_layout(&mut w);
2145 let rec = paint_frame(&mut w, None, V_SIZE).rec;
2146 assert_eq!(
2147 rec.radii.len(),
2148 3,
2149 "both inactive ends plus the active span"
2150 );
2151 let (active_origin, active_size, _, _) = rec.radii[2];
2152 assert_eq!(active_origin, Point::new(14.0, 58.0));
2155 assert_eq!(active_size.height, 42.0);
2156 assert_eq!(active_origin.y + active_size.height, 100.0, "the midpoint");
2157 }
2158
2159 fn path_points(path: &BezPath) -> Vec<Point> {
2163 path.elements()
2164 .iter()
2165 .map(|el| match el {
2166 kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => *p,
2167 other => panic!("a sampled wave is moves and lines only: {other:?}"),
2168 })
2169 .collect()
2170 }
2171
2172 #[test]
2173 fn a_wavy_slider_strokes_its_active_span_instead_of_filling_it() {
2174 let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
2175 layout(&mut w);
2176 let frame = paint_frame(&mut w, None, SIZE);
2177
2178 assert_eq!(
2179 frame.rec.radii.len(),
2180 1,
2181 "only the inactive remainder fills"
2182 );
2183 assert_eq!(frame.rec.strokes.len(), 1, "the active span is a wave");
2184 assert_eq!(
2185 frame.rec.strokes[0],
2186 (TRACK_HEIGHT, PRIMARY),
2187 "stroked at the track's own thickness, in the active ink"
2188 );
2189 let points = path_points(&frame.rec.stroke_paths[0]);
2192 assert_eq!(points[0].x, TRACK_HEIGHT / 2.0);
2193 assert_eq!(points.last().unwrap().x, 92.0 - TRACK_HEIGHT / 2.0);
2194 assert!(
2195 points
2196 .iter()
2197 .any(|p| (p.y - HANDLE_HEIGHT / 2.0).abs() > 1.0),
2198 "and it actually leaves the centreline: {points:?}"
2199 );
2200 assert!(frame.paced_only, "a traveling wave is a cosmetic loop");
2201 }
2202
2203 #[test]
2204 fn the_wave_moves_as_its_phase_advances_and_freezes_under_reduce_motion() {
2205 let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
2206 layout(&mut w);
2207 let first = path_points(&paint(&mut w, None).stroke_paths[0]);
2208
2209 w.wave_clock.step(secs(0.05));
2213 let second = path_points(&paint(&mut w, None).stroke_paths[0]);
2214 assert_eq!(first.len(), second.len(), "the same span, re-sampled");
2215 assert!(
2216 first.iter().zip(second.iter()).all(|(a, b)| a.x == b.x),
2217 "the span itself does not move, only the wave on it"
2218 );
2219 let travel = first
2222 .iter()
2223 .zip(second.iter())
2224 .map(|(a, b)| (a.y - b.y).abs())
2225 .fold(0.0_f64, f64::max);
2226 assert!(
2227 travel > 0.1,
2228 "the wave must travel: {:?} vs {:?}",
2229 &first[..3],
2230 &second[..3]
2231 );
2232
2233 let mut theme = crate::baseline();
2236 theme.motion.reduce_motion = true;
2237 let before = w.wave_clock.seconds();
2238 let frame = paint_frame(&mut w, Some(&theme), SIZE);
2239 assert_eq!(frame.rec.strokes.len(), 1, "the frozen wave is still drawn");
2240 assert_eq!(w.wave_clock.seconds(), before, "and its phase stands still");
2241 assert!(!frame.needs_frame, "a frozen loop requests no frames");
2242 }
2243
2244 #[test]
2245 fn the_amplitude_ramp_flattens_a_nearly_full_track_unless_overridden() {
2246 let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}));
2249 layout(&mut w);
2250 let flat = path_points(&paint(&mut w, None).stroke_paths[0]);
2251 assert!(flat.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0), "{flat:?}");
2252
2253 let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}).amplitude(1.0));
2255 layout(&mut w);
2256 let waved = path_points(&paint(&mut w, None).stroke_paths[0]);
2257 assert!(waved.iter().any(|p| p.y != HANDLE_HEIGHT / 2.0));
2258
2259 let mut w = build(
2261 &wavy_slider::<Val, _>(0.5, |_s, _v| {})
2262 .amplitude(1.0)
2263 .amplitude_for_progress(|_| 0.0),
2264 );
2265 layout(&mut w);
2266 let curved = path_points(&paint(&mut w, None).stroke_paths[0]);
2267 assert!(
2268 curved.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0),
2269 "{curved:?}"
2270 );
2271 }
2272
2273 #[test]
2274 fn a_wavy_centered_slider_waves_only_its_active_span() {
2275 let mut w = build(&wavy_centered_slider::<Val, _>(0.75, |_s, _v| {}));
2276 layout(&mut w);
2277 let rec = paint(&mut w, None);
2278 assert_eq!(rec.radii.len(), 2, "both inactive spans stay flat rects");
2279 assert_eq!(rec.strokes.len(), 1, "the centred active span waves");
2280 let points = path_points(&rec.stroke_paths[0]);
2281 assert_eq!(points[0].x, 100.0 + TRACK_HEIGHT / 2.0, "from the midpoint");
2282 }
2283
2284 #[test]
2285 fn a_flat_slider_never_asks_for_a_frame_it_does_not_need() {
2286 let mut w = widget(0.5);
2287 layout(&mut w);
2288 let frame = paint_frame(&mut w, None, SIZE);
2289 assert!(frame.rec.strokes.is_empty());
2290 assert!(!frame.needs_frame, "a resting flat slider is static");
2291 }
2292
2293 #[test]
2294 fn semantics_reports_the_value_and_its_bounds() {
2295 fn logic(_state: &mut Val) -> SliderView<Val> {
2296 slider::<Val, _>(4.0, |_s, _v| {}).range(0.0, 10.0)
2297 }
2298 let mut root: frust_core::RenderRoot<Val, SliderView<Val>> = frust_core::RenderRoot::new();
2299 let mut state = Val::default();
2300 let mut tcx = TextContext::new();
2301 root.rebuild(&mut logic, &mut state);
2302 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
2306 let update = root.semantics();
2307 let (_, node) = update
2308 .nodes
2309 .iter()
2310 .find(|(_, n)| n.role() == Role::Slider)
2311 .expect("a slider contributes a Role::Slider node");
2312 assert_eq!(node.numeric_value(), Some(4.0));
2313 assert_eq!(node.min_numeric_value(), Some(0.0));
2314 assert_eq!(node.max_numeric_value(), Some(10.0));
2315 }
2316
2317 static RECORDED: Mutex<Vec<HapticSignal>> = Mutex::new(Vec::new());
2328
2329 fn recording_sink(signal: HapticSignal) {
2330 RECORDED.lock().unwrap().push(signal);
2331 }
2332
2333 fn secs(s: f64) -> FrameTime {
2334 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2335 }
2336
2337 fn paint_at(w: &mut SliderWidget, now: FrameTime) -> usize {
2342 let fired = w.haptics.take(now);
2343 if let Some(signal) = fired {
2344 (w.haptic_sink)(signal);
2345 }
2346 usize::from(fired.is_some())
2347 }
2348
2349 #[test]
2350 fn haptics_are_opt_in_then_paced_per_division_or_per_60ms() {
2351 RECORDED.lock().unwrap().clear();
2352
2353 let mut w = build(&view(0.0).haptic_sink(recording_sink));
2356 let mut state = Val::default();
2357 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
2358 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
2359 assert!(
2360 !w.haptics.has_pending(),
2361 "no haptic queued at HapticSignal::None"
2362 );
2363 assert!(RECORDED.lock().unwrap().is_empty());
2364
2365 let mut w = build(
2368 &view(0.0)
2369 .haptic(HapticSignal::SliderTick)
2370 .haptic_sink(recording_sink),
2371 );
2372 let mut state = Val::default();
2373 let mut fired = 0;
2374 for frame in 0..13 {
2375 dispatch(
2376 &mut w,
2377 &mut state,
2378 &ev(
2379 if frame == 0 {
2380 PointerPhase::Down
2381 } else {
2382 PointerPhase::Move
2383 },
2384 10.0 + f64::from(frame) * 10.0,
2385 ),
2386 );
2387 fired += paint_at(&mut w, secs(f64::from(frame) * 0.016));
2388 }
2389 assert_eq!(
2390 fired, 4,
2391 "192ms of drag: the first tick plus one per 60ms boundary"
2392 );
2393 assert_eq!(state.changes.len(), 13, "every frame still reports a value");
2394 assert_eq!(
2395 *RECORDED.lock().unwrap(),
2396 vec![HapticSignal::SliderTick; 4],
2397 "and each fired tick reached the sink"
2398 );
2399
2400 RECORDED.lock().unwrap().clear();
2402 let mut w = build(
2403 &view(0.0)
2404 .range(0.0, 10.0)
2405 .divisions(10)
2406 .haptic(HapticSignal::Medium)
2407 .haptic_sink(recording_sink),
2408 );
2409 let mut state = Val::default();
2410 let mut fired = 0;
2411 for frame in 0..5 {
2412 dispatch(
2413 &mut w,
2414 &mut state,
2415 &ev(
2416 if frame == 0 {
2417 PointerPhase::Down
2418 } else {
2419 PointerPhase::Move
2420 },
2421 20.0 * f64::from(frame + 1),
2422 ),
2423 );
2424 fired += paint_at(&mut w, secs(f64::from(frame) * 0.001));
2425 }
2426 assert_eq!(fired, 5, "one tick per crossed division, unthrottled");
2427 assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::Medium; 5]);
2428 }
2429}