1use std::rc::Rc;
4
5use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene};
6use frus_layout::{Dimension, Style};
7
8use crate::disabled::{disabled_container, disabled_content};
9use crate::interaction::{Key, KeyResponse, Status};
10use crate::slidershape::{
11 self as shapes, SliderOverlayShape, SliderPaint, SliderPalette, SliderShape, SliderThumb,
12 SliderThumbShape, SliderTickMarkShape, SliderTrackShape, SliderValueIndicatorShape,
13};
14use crate::theme::Theme;
15use crate::widget::Widget;
16use crate::widgetstate::{WidgetState, WidgetStateProperty};
17
18#[cfg(test)]
19const H: f32 = 24.0;
20const KEY_STEP: f32 = 0.05;
22
23const THUMB_ELEVATION: f32 = 1.0;
27const THUMB_PRESSED_ELEVATION: f32 = 6.0;
28
29fn elevation_of(told: (Option<f32>, Option<f32>), press: f32, theme: &Theme) -> f32 {
34 let t = &theme.widgets.slider;
35 let rest = told.0.or(t.thumb_elevation).unwrap_or(THUMB_ELEVATION);
36 let pressed = told
37 .1
38 .or(t.pressed_thumb_elevation)
39 .unwrap_or(THUMB_PRESSED_ELEVATION);
40 rest + (pressed - rest) * press.clamp(0.0, 1.0)
41}
42
43const TRACK_HEIGHT: f32 = 4.0;
46const THUMB_RADIUS: f32 = 10.0;
47const OVERLAY_RADIUS: f32 = 24.0;
48const MIN_TRACK: f32 = 144.0;
50const DEFAULT_WIDTH: f32 = 220.0;
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
57pub enum ShowValueIndicator {
58 #[default]
60 OnlyForDiscrete,
61 OnlyForContinuous,
63 OnDrag,
65 AlwaysVisible,
67 Never,
69}
70
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
74pub enum SliderInteraction {
75 #[default]
77 TapAndSlide,
78 TapOnly,
80 SlideOnly,
83 SlideThumb,
85}
86
87pub struct Slider<Msg = crate::callback::Callback> {
101 value: f32,
102 secondary: Option<f32>,
103 min: f32,
104 max: f32,
105 width: f32,
106 sized: bool,
107 divisions: Option<usize>,
108 label: Option<Rc<dyn Fn(f32) -> String>>,
110 semantic_label: Option<Rc<dyn Fn(f32) -> String>>,
112 show_value_indicator: Option<ShowValueIndicator>,
113 allowed_interaction: Option<SliderInteraction>,
114 theme_interaction: std::cell::Cell<Option<SliderInteraction>>,
116 enabled: bool,
117 colors: SliderColors,
118 overlay_color: Option<WidgetStateProperty<Color>>,
119 thumb_elevation: Option<f32>,
120 pressed_thumb_elevation: Option<f32>,
121 parts: SliderParts,
122 on_change: Option<Box<dyn Fn(f32) -> Msg>>,
123 on_change_start: Option<Box<dyn Fn(f32) -> Msg>>,
125 on_change_end: Option<Box<dyn Fn(f32) -> Msg>>,
127}
128
129#[derive(Clone, Copy, Debug, Default, PartialEq)]
132pub(crate) struct SliderColors {
133 pub active_track: Option<Color>,
134 pub inactive_track: Option<Color>,
135 pub secondary_track: Option<Color>,
136 pub thumb: Option<Color>,
137 pub thumb_border: Option<Color>,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq)]
142struct Palette {
143 active: Color,
144 inactive: Color,
145 secondary: Color,
146 thumb: Color,
147 ring: Option<Color>,
148 active_tick: Color,
149 inactive_tick: Color,
150}
151
152impl Palette {
153 fn shared(&self) -> SliderPalette {
155 SliderPalette {
156 active_track: self.active,
157 inactive_track: self.inactive,
158 secondary_track: self.secondary,
159 thumb: self.thumb,
160 thumb_border: self.ring,
161 active_tick_mark: self.active_tick,
162 inactive_tick_mark: self.inactive_tick,
163 }
164 }
165}
166
167#[derive(Clone, Default)]
170pub(crate) struct SliderParts {
171 track: Option<SliderShape<dyn SliderTrackShape>>,
172 tick_mark: Option<SliderShape<dyn SliderTickMarkShape>>,
173 thumb: Option<SliderShape<dyn SliderThumbShape>>,
174 overlay: Option<SliderShape<dyn SliderOverlayShape>>,
175 value_indicator: Option<SliderShape<dyn SliderValueIndicatorShape>>,
176}
177
178impl SliderColors {
179 fn resolve(&self, theme: &Theme, enabled: bool) -> Palette {
182 let t = &theme.widgets.slider;
183 let s = &theme.scheme;
184 if !enabled {
185 let content = disabled_content(theme);
189 let container = disabled_container(theme);
190 return Palette {
191 active: t.disabled_active_track_color.unwrap_or(content),
192 inactive: t.disabled_inactive_track_color.unwrap_or(container),
193 secondary: t.disabled_inactive_track_color.unwrap_or(container),
194 thumb: t.disabled_thumb_color.unwrap_or(content),
195 ring: None,
196 active_tick: content,
197 inactive_tick: content,
198 };
199 }
200 let active = self
201 .active_track
202 .or(t.active_track_color)
203 .unwrap_or(s.primary);
204 Palette {
205 active,
206 inactive: self
207 .inactive_track
208 .or(t.inactive_track_color)
209 .unwrap_or(s.surface_container_highest),
210 secondary: self
211 .secondary_track
212 .or(t.secondary_active_track_color)
213 .unwrap_or(s.primary.fade(0.54)),
214 thumb: self
217 .thumb
218 .or(self.active_track)
219 .or(t.thumb_color)
220 .unwrap_or(s.primary),
221 ring: self.thumb_border.or(t.thumb_border_color),
222 active_tick: self
225 .inactive_track
226 .or(t.active_tick_mark_color)
227 .unwrap_or(s.on_primary.fade(0.38)),
228 inactive_tick: self
229 .active_track
230 .or(t.inactive_tick_mark_color)
231 .unwrap_or(s.on_surface_variant.fade(0.38)),
232 }
233 }
234}
235
236impl<Msg> Slider<Msg> {
237 pub fn new(value: f32) -> Self {
240 Self {
241 value,
242 secondary: None,
243 min: 0.0,
244 max: 1.0,
245 width: DEFAULT_WIDTH,
246 sized: false,
247 divisions: None,
248 label: None,
249 semantic_label: None,
250 show_value_indicator: None,
251 allowed_interaction: None,
252 theme_interaction: std::cell::Cell::new(None),
253 enabled: true,
254 colors: SliderColors::default(),
255 overlay_color: None,
256 thumb_elevation: None,
257 pressed_thumb_elevation: None,
258 parts: SliderParts::default(),
259 on_change: None,
260 on_change_start: None,
261 on_change_end: None,
262 }
263 }
264
265 pub fn active_color(mut self, color: Color) -> Self {
268 self.colors.active_track = Some(color);
269 self
270 }
271
272 pub fn inactive_color(mut self, color: Color) -> Self {
275 self.colors.inactive_track = Some(color);
276 self
277 }
278
279 pub fn secondary_active_color(mut self, color: Color) -> Self {
282 self.colors.secondary_track = Some(color);
283 self
284 }
285
286 pub fn thumb_color(mut self, color: Color) -> Self {
288 self.colors.thumb = Some(color);
289 self
290 }
291
292 pub fn thumb_border_color(mut self, color: Color) -> Self {
295 self.colors.thumb_border = Some(color);
296 self
297 }
298
299 pub fn overlay_color(mut self, color: WidgetStateProperty<Color>) -> Self {
303 self.overlay_color = Some(color);
304 self
305 }
306
307 pub fn thumb_elevation(mut self, elevation: f32) -> Self {
310 self.thumb_elevation = Some(elevation);
311 self
312 }
313
314 pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
316 self.pressed_thumb_elevation = Some(elevation);
317 self
318 }
319
320 pub fn track_shape(mut self, shape: impl SliderTrackShape + 'static) -> Self {
323 self.parts.track = Some(SliderShape::track(shape));
324 self
325 }
326
327 pub fn tick_mark_shape(mut self, shape: impl SliderTickMarkShape + 'static) -> Self {
329 self.parts.tick_mark = Some(SliderShape::tick_mark(shape));
330 self
331 }
332
333 pub fn thumb_shape(mut self, shape: impl SliderThumbShape + 'static) -> Self {
335 self.parts.thumb = Some(SliderShape::thumb(shape));
336 self
337 }
338
339 pub fn overlay_shape(mut self, shape: impl SliderOverlayShape + 'static) -> Self {
341 self.parts.overlay = Some(SliderShape::overlay(shape));
342 self
343 }
344
345 pub fn value_indicator_shape(
348 mut self,
349 shape: impl SliderValueIndicatorShape + 'static,
350 ) -> Self {
351 self.parts.value_indicator = Some(SliderShape::value_indicator(shape));
352 self
353 }
354
355 pub fn range(mut self, min: f32, max: f32) -> Self {
365 self.min = min.min(max);
366 self.max = min.max(max);
367 self
368 }
369
370 pub fn secondary_track_value(mut self, value: f32) -> Self {
373 self.secondary = Some(value);
374 self
375 }
376
377 pub fn divisions(mut self, n: usize) -> Self {
381 self.divisions = Some(n.max(1));
382 self
383 }
384
385 pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
391 self.label = Some(Rc::new(label));
392 self
393 }
394
395 pub fn allowed_interaction(mut self, interaction: SliderInteraction) -> Self {
398 self.allowed_interaction = Some(interaction);
399 self
400 }
401
402 pub fn show_value_indicator(mut self, when: ShowValueIndicator) -> Self {
405 self.show_value_indicator = Some(when);
406 self
407 }
408
409 pub fn semantic_formatter(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
411 self.semantic_label = Some(Rc::new(format));
412 self
413 }
414
415 pub fn width(mut self, width: f32) -> Self {
418 self.width = width;
419 self.sized = true;
420 self
421 }
422
423 pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
425 mut self,
426 on_change: impl Fn(f32) -> R + 'static,
427 ) -> Self {
428 self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
429 self
430 }
431
432 pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
441 mut self,
442 on_start: impl Fn(f32) -> R + 'static,
443 ) -> Self {
444 self.on_change_start = Some(Box::new(crate::callback::handler1(on_start)));
445 self
446 }
447
448 pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
454 mut self,
455 on_end: impl Fn(f32) -> R + 'static,
456 ) -> Self {
457 self.on_change_end = Some(Box::new(crate::callback::handler1(on_end)));
458 self
459 }
460
461 pub fn enabled(mut self, enabled: bool) -> Self {
468 self.enabled = enabled;
469 self
470 }
471
472 fn held(&self) -> f32 {
474 self.value.clamp(self.min, self.max)
475 }
476
477 fn fraction_of(&self, value: f32) -> f32 {
480 let span = self.max - self.min;
481 if span <= 0.0 {
482 0.0
483 } else {
484 ((value.clamp(self.min, self.max) - self.min) / span).clamp(0.0, 1.0)
485 }
486 }
487
488 fn fraction(&self) -> f32 {
490 self.fraction_of(self.held())
491 }
492
493 fn snap(&self, fraction: f32) -> f32 {
495 match self.divisions {
496 Some(n) if n > 0 => (fraction * n as f32).round() / n as f32,
497 _ => fraction,
498 }
499 }
500
501 fn at(&self, fraction: f32) -> f32 {
503 self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
504 }
505
506 fn key_step(&self) -> f32 {
509 match self.divisions {
510 Some(n) if n > 0 => 1.0 / n as f32,
511 _ => KEY_STEP,
512 }
513 }
514
515 fn interaction(&self) -> SliderInteraction {
517 self.allowed_interaction
518 .or(self.theme_interaction.get())
519 .unwrap_or_default()
520 }
521
522 fn discrete(&self) -> bool {
523 self.divisions.is_some_and(|n| n > 0)
524 }
525
526 fn indicator_reveal(&self, status: &Status, theme: &Theme) -> f32 {
528 if !self.enabled || self.label.is_none() {
529 return 0.0;
530 }
531 let when = self
532 .show_value_indicator
533 .or(theme.widgets.slider.show_value_indicator)
534 .unwrap_or_default();
535 let moved = status
536 .press_progress
537 .max(status.focus_progress)
538 .clamp(0.0, 1.0);
539 match when {
540 ShowValueIndicator::AlwaysVisible => 1.0,
541 ShowValueIndicator::Never => 0.0,
542 ShowValueIndicator::OnlyForDiscrete if !self.discrete() => 0.0,
543 ShowValueIndicator::OnlyForContinuous if self.discrete() => 0.0,
544 _ => moved,
545 }
546 }
547
548 fn overlay(&self, status: &Status, theme: &Theme) -> Color {
551 let mut states = status.states();
552 if status.interaction == crate::interaction::Interaction::Pressed {
553 states = states.with(WidgetState::Dragged);
554 }
555 let scheme = &theme.scheme;
556 self.overlay_color
557 .as_ref()
558 .and_then(|p| p.resolve(states).copied())
559 .or(self.colors.active_track.map(|c| c.fade(0.12)))
560 .or_else(|| {
561 theme
562 .widgets
563 .slider
564 .overlay_color
565 .as_ref()
566 .and_then(|p| p.resolve(states).copied())
567 })
568 .unwrap_or_else(|| {
569 if states.contains(WidgetState::Dragged) {
570 scheme.primary.fade(0.1)
571 } else if states.contains(WidgetState::Hovered) {
572 scheme.primary.fade(0.08)
573 } else if states.contains(WidgetState::Focused) {
574 scheme.primary.fade(0.1)
575 } else {
576 Color::TRANSPARENT
577 }
578 })
579 }
580
581 fn track_height(&self, theme: &Theme) -> f32 {
582 theme.widgets.slider.track_height.unwrap_or(TRACK_HEIGHT)
583 }
584}
585
586fn thumb_x(left: f32, width: f32, t: f32, discrete: bool, track_height: f32) -> f32 {
591 let padding = if discrete { track_height } else { 0.0 };
592 let x = left + t * (width - padding) + padding / 2.0;
593 let inner = if padding > THUMB_RADIUS {
594 padding / 2.0
595 } else {
596 0.0
597 };
598 x.clamp(left + inner, left + width - inner)
599}
600
601impl<Msg> Widget<Msg> for Slider<Msg> {
602 fn style(&self) -> Style {
603 Style {
604 width: Dimension::Length(self.width),
605 min_width: Dimension::Length(if self.sized {
606 0.0
607 } else {
608 MIN_TRACK + 2.0 * OVERLAY_RADIUS
609 }),
610 height: Dimension::Length(2.0 * OVERLAY_RADIUS),
611 ..Default::default()
612 }
613 }
614
615 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
618 crate::widget::FillAxes {
619 horizontal: !self.sized,
620 vertical: false,
621 }
622 }
623
624 fn soft_extent(&self) -> crate::widget::FillAxes {
625 crate::widget::FillAxes {
626 horizontal: !self.sized,
627 vertical: false,
628 }
629 }
630
631 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
632 &[]
633 }
634
635 fn debug_name(&self) -> &'static str {
636 "Slider"
637 }
638
639 fn build_themed(&self, theme: &Theme) {
641 self.theme_interaction
642 .set(theme.widgets.slider.allowed_interaction);
643 }
644
645 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
646 let rtl = theme.direction.is_rtl();
647 let parts = &self.parts;
648 let t = &theme.widgets.slider;
649 let paint = SliderPaint {
650 theme,
651 palette: self.colors.resolve(theme, self.enabled).shared(),
652 enabled: self.enabled,
653 discrete: self.discrete(),
654 opacity: status.opacity,
655 };
656 let track_h = self.track_height(theme);
657 let left = bounds.x + OVERLAY_RADIUS;
659 let width = (bounds.width - 2.0 * OVERLAY_RADIUS).max(0.0);
660 let cy = bounds.y + bounds.height * 0.5;
661 let along = |t: f32| if rtl { 1.0 - t } else { t };
662 let discrete = self.discrete();
663 let thumb = thumb_x(left, width, along(self.fraction()), discrete, track_h);
664 let secondary = self.secondary.map(|secondary| {
665 thumb_x(
666 left,
667 width,
668 along(self.fraction_of(secondary)),
669 discrete,
670 track_h,
671 )
672 });
673 let track = Rect::new(left, cy - track_h * 0.5, width, track_h);
674 shapes::pick(&parts.track, &t.track_shape, &shapes::SLIDER_TRACK).paint(
675 scene,
676 track,
677 &[thumb],
678 secondary,
679 &paint,
680 );
681
682 let activation = status
684 .hover_progress
685 .max(status.focus_progress)
686 .max(status.press_progress)
687 .clamp(0.0, 1.0);
688 if self.enabled {
689 shapes::pick(&parts.overlay, &t.overlay_shape, &shapes::OVERLAY).paint(
690 scene,
691 Point::new(thumb, cy),
692 activation,
693 self.overlay(&status, theme),
694 &paint,
695 );
696 }
697
698 if let Some(n) = self.divisions.filter(|n| *n > 0) {
700 let tick = shapes::pick(&parts.tick_mark, &t.tick_mark_shape, &shapes::TICK_MARK);
701 let tick_w = tick.width(track_h);
702 let adjusted = width - track_h;
703 if tick_w > 0.0 && adjusted / n as f32 >= 3.0 * tick_w {
704 for i in 0..=n {
705 let x = left + i as f32 / n as f32 * adjusted + track_h / 2.0;
706 let travelled = if rtl { x >= thumb } else { x <= thumb };
707 tick.paint(scene, Point::new(x, cy), travelled, track_h, &paint);
708 }
709 }
710 }
711
712 shapes::pick(&parts.thumb, &t.thumb_shape, &shapes::THUMB).paint(
714 scene,
715 Point::new(thumb, cy),
716 SliderThumb {
717 elevation: elevation_of(
718 (self.thumb_elevation, self.pressed_thumb_elevation),
719 status.press_progress,
720 theme,
721 ),
722 on_top: false,
723 },
724 &paint,
725 );
726
727 let reveal = self.indicator_reveal(&status, theme);
729 if let (Some(label), true) = (self.label.as_ref(), reveal > 0.0) {
730 shapes::pick(
731 &parts.value_indicator,
732 &t.value_indicator_shape,
733 &shapes::DROP,
734 )
735 .paint(
736 scene,
737 Point::new(thumb, cy),
738 label(self.held()),
739 reveal,
740 &paint,
741 );
742 }
743 }
744
745 fn on_click(&self) -> Option<Msg> {
746 None
747 }
748
749 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
750 let held = self.held();
756 let spoken = match self.semantic_label.as_ref().or(self.label.as_ref()) {
757 Some(label) => label(held),
758 None if self.min == 0.0 && self.max == 1.0 => {
759 format!("{}%", (held * 100.0).round())
760 }
761 None => format!("{held}"),
762 };
763 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
764 .value(spoken)
765 .range(self.min, held, self.max);
766 Some(if self.enabled {
767 semantics
768 } else {
769 semantics.disabled(true)
770 })
771 }
772
773 fn draggable(&self) -> bool {
774 self.enabled
775 }
776
777 fn drag_track(&self) -> crate::widget::DragTrack {
781 use crate::widget::DragMode;
782 let origin = self.fraction();
783 crate::widget::DragTrack {
784 inset: OVERLAY_RADIUS,
785 mirrored: true,
786 mode: match self.interaction() {
787 SliderInteraction::TapAndSlide => DragMode::Follow,
788 SliderInteraction::TapOnly => DragMode::TapOnly,
789 SliderInteraction::SlideOnly => DragMode::Slide { origin },
790 SliderInteraction::SlideThumb => DragMode::SlideThumb {
791 origin,
792 radius: OVERLAY_RADIUS,
793 },
794 },
795 }
796 }
797
798 fn focusable(&self) -> bool {
799 self.enabled && self.on_change.is_some()
800 }
801
802 fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
803 if !self.enabled {
806 return KeyResponse::Ignored;
807 }
808 let delta = match key {
811 Key::Left { .. } => -self.key_step(),
812 Key::Right { .. } => self.key_step(),
813 Key::Home { .. } => -2.0,
814 Key::End { .. } => 2.0,
815 _ => return KeyResponse::Ignored,
816 };
817 let moved = self.at(self.fraction() + delta);
818 KeyResponse::Handled(self.on_change.as_ref().map(|make| make(moved)))
819 }
820
821 fn on_drag(&self, fraction: f32) -> Option<Msg> {
822 if !self.enabled {
825 return None;
826 }
827 self.on_change.as_ref().map(|make| make(self.at(fraction)))
828 }
829
830 fn on_drag_start(&self, _fraction: f32) -> Option<Msg> {
837 if !self.enabled {
838 return None;
839 }
840 let before = self.at(self.fraction());
842 self.on_change_start.as_ref().map(|make| make(before))
843 }
844
845 fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
846 if !self.enabled {
847 return None;
848 }
849 self.on_change_end
850 .as_ref()
851 .map(|make| make(self.at(fraction)))
852 }
853}
854
855pub(crate) fn paint_drop(
859 at: Point,
860 text: String,
861 theme: &Theme,
862 scale: f32,
863 o: f32,
864 scene: &mut Scene,
865) {
866 const TRIANGLE: f32 = 10.0;
867 const PADDING: f32 = 8.0;
868 const MIN_LABEL: f32 = 20.0;
869 const RECT_H: f32 = 28.0;
870 const RECT_Y: f32 = 6.0;
871 const TIP_Y: f32 = 16.0;
872 let style = drop_style(theme);
873 let text_w = frus_text::measure_resolved(&text, &style).width;
874 let rect_w = MIN_LABEL.max(text_w) + PADDING;
875 let tip = Point::new(at.x, at.y - TIP_Y);
877 let s = |dx: f32, dy: f32| Point::new(tip.x + dx * scale, tip.y + dy * scale);
878 let color = theme
879 .widgets
880 .slider
881 .value_indicator_color
882 .unwrap_or(theme.scheme.primary)
883 .fade(o);
884 let top_left = s(-rect_w / 2.0, -RECT_Y - RECT_H);
885 let pill = Rect::new(top_left.x, top_left.y, rect_w * scale, RECT_H * scale);
886 let path = frus_core::Path::new()
887 .move_to(s(0.0, 0.0))
888 .line_to(s(-TRIANGLE, -TRIANGLE))
889 .line_to(s(TRIANGLE, -TRIANGLE))
890 .close();
891 scene.fill_path(&path, color);
892 scene.draw_rect(pill, color, pill.height * 0.5, 0.0, Color::TRANSPARENT);
893 if scale >= 0.999 {
895 let text_color = style.color.unwrap_or(theme.scheme.on_primary);
896 scene.text(
897 Point::new(
898 pill.x + (pill.width - text_w) * 0.5,
899 pill.y + (pill.height - style.line_height()) * 0.5,
900 ),
901 text,
902 &style,
903 text_color.fade(o),
904 );
905 }
906}
907
908fn drop_style(theme: &Theme) -> ResolvedTextStyle {
910 theme
911 .widgets
912 .slider
913 .value_indicator_text_style
914 .unwrap_or_else(|| {
915 crate::theme::type_scale(Some(theme))
916 .label_medium
917 .color(theme.scheme.on_primary)
918 })
919 .resolved()
920}
921
922const MIN_THUMB_SEPARATION: f32 = 8.0;
925
926struct RangeState<Msg> {
929 start: f32,
930 end: f32,
931 min: f32,
932 max: f32,
933 divisions: Option<usize>,
934 label: Option<Rc<dyn Fn(f32) -> String>>,
935 semantic_label: Option<Rc<dyn Fn(f32) -> String>>,
936 show_value_indicator: Option<ShowValueIndicator>,
937 enabled: bool,
938 colors: SliderColors,
939 overlay_color: Option<WidgetStateProperty<Color>>,
940 elevations: (Option<f32>, Option<f32>),
941 parts: SliderParts,
942 on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
943 on_change_start: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
944 on_change_end: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
945 track: Rc<std::cell::Cell<f32>>,
949}
950
951impl<Msg> Clone for RangeState<Msg> {
952 fn clone(&self) -> Self {
953 Self {
954 start: self.start,
955 end: self.end,
956 min: self.min,
957 max: self.max,
958 divisions: self.divisions,
959 label: self.label.clone(),
960 semantic_label: self.semantic_label.clone(),
961 show_value_indicator: self.show_value_indicator,
962 enabled: self.enabled,
963 colors: self.colors,
964 overlay_color: self.overlay_color.clone(),
965 elevations: self.elevations,
966 parts: self.parts.clone(),
967 on_change: self.on_change.clone(),
968 on_change_start: self.on_change_start.clone(),
969 on_change_end: self.on_change_end.clone(),
970 track: self.track.clone(),
971 }
972 }
973}
974
975impl<Msg> RangeState<Msg> {
976 fn held(&self) -> (f32, f32) {
978 let start = self.start.clamp(self.min, self.max);
979 let end = self.end.clamp(self.min, self.max);
980 (start.min(end), start.max(end))
981 }
982
983 fn fraction_of(&self, value: f32) -> f32 {
985 let span = self.max - self.min;
986 if span <= 0.0 {
987 0.0
988 } else {
989 ((value - self.min) / span).clamp(0.0, 1.0)
990 }
991 }
992
993 fn fractions(&self) -> [f32; 2] {
994 let (start, end) = self.held();
995 [self.fraction_of(start), self.fraction_of(end)]
996 }
997
998 fn discrete(&self) -> bool {
999 self.divisions.is_some_and(|n| n > 0)
1000 }
1001
1002 fn snap(&self, fraction: f32) -> f32 {
1003 match self.divisions {
1004 Some(n) if n > 0 => (fraction * n as f32).round() / n as f32,
1005 _ => fraction,
1006 }
1007 }
1008
1009 fn at(&self, fraction: f32) -> f32 {
1011 self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
1012 }
1013
1014 fn separation(&self) -> f32 {
1017 let track = self.track.get();
1018 if self.discrete() || track <= 0.0 {
1019 0.0
1020 } else {
1021 MIN_THUMB_SEPARATION / track
1022 }
1023 }
1024
1025 fn moved(&self, thumb: usize, fraction: f32) -> (f32, f32) {
1028 let [s, e] = self.fractions();
1029 let gap = self.separation();
1030 let f = self.snap(fraction.clamp(0.0, 1.0));
1031 if thumb == 0 {
1032 (self.at(f.min(e - gap).max(0.0)), self.at(e))
1033 } else {
1034 (self.at(s), self.at(f.max(s + gap).min(1.0)))
1035 }
1036 }
1037
1038 fn key_step(&self) -> f32 {
1040 match self.divisions {
1041 Some(n) if n > 0 => 1.0 / n as f32,
1042 _ => KEY_STEP,
1043 }
1044 }
1045
1046 fn spoken(&self, value: f32) -> String {
1048 match self.semantic_label.as_ref().or(self.label.as_ref()) {
1049 Some(label) => label(value),
1050 None if self.min == 0.0 && self.max == 1.0 => format!("{}%", (value * 100.0).round()),
1051 None => format!("{value}"),
1052 }
1053 }
1054
1055 fn indicates(&self, theme: &Theme) -> bool {
1057 if !self.enabled || self.label.is_none() {
1058 return false;
1059 }
1060 match self
1061 .show_value_indicator
1062 .or(theme.widgets.slider.show_value_indicator)
1063 .unwrap_or_default()
1064 {
1065 ShowValueIndicator::AlwaysVisible | ShowValueIndicator::OnDrag => true,
1066 ShowValueIndicator::Never => false,
1067 ShowValueIndicator::OnlyForDiscrete => self.discrete(),
1068 ShowValueIndicator::OnlyForContinuous => !self.discrete(),
1069 }
1070 }
1071
1072 fn always_indicates(&self, theme: &Theme) -> bool {
1073 self.indicates(theme)
1074 && self
1075 .show_value_indicator
1076 .or(theme.widgets.slider.show_value_indicator)
1077 == Some(ShowValueIndicator::AlwaysVisible)
1078 }
1079
1080 fn palette(&self, theme: &Theme) -> RangePalette {
1083 let t = &theme.widgets.slider;
1084 let s = &theme.scheme;
1085 if !self.enabled {
1086 let content = disabled_content(theme);
1087 let container = disabled_container(theme);
1088 return RangePalette {
1089 active: t.disabled_active_track_color.unwrap_or(content),
1090 inactive: t.disabled_inactive_track_color.unwrap_or(container),
1091 thumb: t.disabled_thumb_color.unwrap_or(content),
1092 active_tick: content,
1093 inactive_tick: content,
1094 };
1095 }
1096 let active = self
1097 .colors
1098 .active_track
1099 .or(t.active_track_color)
1100 .unwrap_or(s.primary);
1101 RangePalette {
1102 active,
1103 inactive: self
1104 .colors
1105 .inactive_track
1106 .or(t.inactive_track_color)
1107 .unwrap_or(s.primary.fade(0.24)),
1108 thumb: self
1109 .colors
1110 .thumb
1111 .or(self.colors.active_track)
1112 .or(t.thumb_color)
1113 .unwrap_or(s.primary),
1114 active_tick: self
1115 .colors
1116 .inactive_track
1117 .or(t.active_tick_mark_color)
1118 .unwrap_or(s.on_primary.fade(0.54)),
1119 inactive_tick: self
1120 .colors
1121 .active_track
1122 .or(t.inactive_tick_mark_color)
1123 .unwrap_or(s.primary.fade(0.54)),
1124 }
1125 }
1126
1127 fn paint<'a>(&self, theme: &'a Theme, opacity: f32) -> SliderPaint<'a> {
1129 let palette = self.palette(theme);
1130 SliderPaint {
1131 theme,
1132 palette: SliderPalette {
1133 active_track: palette.active,
1134 inactive_track: palette.inactive,
1135 secondary_track: palette.inactive,
1136 thumb: palette.thumb,
1137 thumb_border: None,
1138 active_tick_mark: palette.active_tick,
1139 inactive_tick_mark: palette.inactive_tick,
1140 },
1141 enabled: self.enabled,
1142 discrete: self.discrete(),
1143 opacity,
1144 }
1145 }
1146
1147 fn overlay<'a>(&'a self, theme: &'a Theme) -> &'a dyn SliderOverlayShape {
1149 shapes::pick(
1150 &self.parts.overlay,
1151 &theme.widgets.slider.overlay_shape,
1152 &shapes::OVERLAY,
1153 )
1154 }
1155
1156 fn indicator<'a>(&'a self, theme: &'a Theme) -> &'a dyn SliderValueIndicatorShape {
1158 shapes::pick(
1159 &self.parts.value_indicator,
1160 &theme.widgets.slider.range_value_indicator_shape,
1161 &shapes::RECTANGULAR,
1162 )
1163 }
1164
1165 fn halo(&self, status: &Status, theme: &Theme) -> Color {
1168 let states = status.states();
1169 self.overlay_color
1170 .as_ref()
1171 .and_then(|p| p.resolve(states).copied())
1172 .or(self.colors.active_track.map(|c| c.fade(0.12)))
1173 .or_else(|| {
1174 theme
1175 .widgets
1176 .slider
1177 .overlay_color
1178 .as_ref()
1179 .and_then(|p| p.resolve(states).copied())
1180 })
1181 .unwrap_or_else(|| theme.scheme.primary.fade(0.12))
1182 }
1183}
1184
1185struct RangePalette {
1187 active: Color,
1188 inactive: Color,
1189 thumb: Color,
1190 active_tick: Color,
1191 inactive_tick: Color,
1192}
1193
1194pub struct RangeSlider<Msg = crate::callback::Callback> {
1211 state: RangeState<Msg>,
1212 width: f32,
1213 sized: bool,
1214 children: Vec<Box<dyn Widget<Msg>>>,
1215}
1216
1217impl<Msg: Clone + 'static> RangeSlider<Msg> {
1218 pub fn new(start: f32, end: f32) -> Self {
1221 let mut slider = Self {
1222 state: RangeState {
1223 start: start.min(end),
1224 end: start.max(end),
1225 min: 0.0,
1226 max: 1.0,
1227 divisions: None,
1228 label: None,
1229 semantic_label: None,
1230 show_value_indicator: None,
1231 enabled: true,
1232 colors: SliderColors::default(),
1233 overlay_color: None,
1234 elevations: (None, None),
1235 parts: SliderParts::default(),
1236 on_change: None,
1237 on_change_start: None,
1238 on_change_end: None,
1239 track: Rc::new(std::cell::Cell::new(0.0)),
1240 },
1241 width: DEFAULT_WIDTH,
1242 sized: false,
1243 children: Vec::new(),
1244 };
1245 slider.rebuild();
1246 slider
1247 }
1248
1249 fn with(mut self, change: impl FnOnce(&mut RangeState<Msg>)) -> Self {
1250 change(&mut self.state);
1251 self.rebuild();
1252 self
1253 }
1254
1255 pub fn range(self, min: f32, max: f32) -> Self {
1258 self.with(|s| {
1259 s.min = min.min(max);
1260 s.max = min.max(max);
1261 })
1262 }
1263
1264 pub fn divisions(self, n: usize) -> Self {
1267 self.with(|s| s.divisions = Some(n.max(1)))
1268 }
1269
1270 pub fn width(mut self, width: f32) -> Self {
1273 self.width = width;
1274 self.sized = true;
1275 self.rebuild();
1276 self
1277 }
1278
1279 pub fn value_label(self, label: impl Fn(f32) -> String + 'static) -> Self {
1283 self.with(|s| s.label = Some(Rc::new(label)))
1284 }
1285
1286 pub fn semantic_formatter(self, format: impl Fn(f32) -> String + 'static) -> Self {
1288 self.with(|s| s.semantic_label = Some(Rc::new(format)))
1289 }
1290
1291 pub fn show_value_indicator(self, when: ShowValueIndicator) -> Self {
1293 self.with(|s| s.show_value_indicator = Some(when))
1294 }
1295
1296 pub fn active_color(self, color: Color) -> Self {
1298 self.with(|s| s.colors.active_track = Some(color))
1299 }
1300
1301 pub fn inactive_color(self, color: Color) -> Self {
1303 self.with(|s| s.colors.inactive_track = Some(color))
1304 }
1305
1306 pub fn thumb_color(self, color: Color) -> Self {
1308 self.with(|s| s.colors.thumb = Some(color))
1309 }
1310
1311 pub fn overlay_color(self, color: WidgetStateProperty<Color>) -> Self {
1313 self.with(|s| s.overlay_color = Some(color))
1314 }
1315
1316 pub fn thumb_elevation(self, elevation: f32) -> Self {
1319 self.with(|s| s.elevations.0 = Some(elevation))
1320 }
1321
1322 pub fn pressed_thumb_elevation(self, elevation: f32) -> Self {
1324 self.with(|s| s.elevations.1 = Some(elevation))
1325 }
1326
1327 pub fn track_shape(self, shape: impl SliderTrackShape + 'static) -> Self {
1330 let shape = SliderShape::track(shape);
1331 self.with(|s| s.parts.track = Some(shape))
1332 }
1333
1334 pub fn tick_mark_shape(self, shape: impl SliderTickMarkShape + 'static) -> Self {
1336 let shape = SliderShape::tick_mark(shape);
1337 self.with(|s| s.parts.tick_mark = Some(shape))
1338 }
1339
1340 pub fn thumb_shape(self, shape: impl SliderThumbShape + 'static) -> Self {
1342 let shape = SliderShape::thumb(shape);
1343 self.with(|s| s.parts.thumb = Some(shape))
1344 }
1345
1346 pub fn overlay_shape(self, shape: impl SliderOverlayShape + 'static) -> Self {
1348 let shape = SliderShape::overlay(shape);
1349 self.with(|s| s.parts.overlay = Some(shape))
1350 }
1351
1352 pub fn value_indicator_shape(self, shape: impl SliderValueIndicatorShape + 'static) -> Self {
1355 let shape = SliderShape::value_indicator(shape);
1356 self.with(|s| s.parts.value_indicator = Some(shape))
1357 }
1358
1359 pub fn enabled(self, enabled: bool) -> Self {
1362 self.with(|s| s.enabled = enabled)
1363 }
1364
1365 pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
1368 self,
1369 on_change: impl Fn(f32, f32) -> R + 'static,
1370 ) -> Self {
1371 let make = Rc::new(crate::callback::handler2(on_change));
1372 self.with(|s| s.on_change = Some(make))
1373 }
1374
1375 pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
1378 self,
1379 on_start: impl Fn(f32, f32) -> R + 'static,
1380 ) -> Self {
1381 let make = Rc::new(crate::callback::handler2(on_start));
1382 self.with(|s| s.on_change_start = Some(make))
1383 }
1384
1385 pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
1387 self,
1388 on_end: impl Fn(f32, f32) -> R + 'static,
1389 ) -> Self {
1390 let make = Rc::new(crate::callback::handler2(on_end));
1391 self.with(|s| s.on_change_end = Some(make))
1392 }
1393
1394 fn thumb_centres(&self, bounds: Rect, theme: &Theme) -> (f32, [f32; 2]) {
1397 let rtl = theme.direction.is_rtl();
1398 let track_h = theme.widgets.slider.track_height.unwrap_or(TRACK_HEIGHT);
1399 let left = bounds.x + OVERLAY_RADIUS;
1400 let width = (bounds.width - 2.0 * OVERLAY_RADIUS).max(0.0);
1401 let along = |t: f32| if rtl { 1.0 - t } else { t };
1402 let discrete = self.state.discrete();
1403 let [s, e] = self.state.fractions();
1404 (
1405 bounds.y + bounds.height * 0.5,
1406 [
1407 thumb_x(left, width, along(s), discrete, track_h),
1408 thumb_x(left, width, along(e), discrete, track_h),
1409 ],
1410 )
1411 }
1412
1413 fn rebuild(&mut self) {
1418 let [s, e] = self.state.fractions();
1419 let thumb = |side: usize, t: f32| {
1420 crate::align::Aligned::new(
1421 frus_core::AlignmentDirectional::new(2.0 * t - 1.0, 0.0),
1422 RangeThumb {
1423 side,
1424 state: self.state.clone(),
1425 },
1426 )
1427 };
1428 let stack = crate::Stack::new()
1429 .fit(crate::StackFit::Expand)
1430 .clip_behavior(crate::Clip::None)
1432 .height(2.0 * OVERLAY_RADIUS)
1433 .flex(1.0)
1434 .layer(thumb(0, s))
1435 .layer(thumb(1, e));
1436 self.children = vec![Box::new(stack)];
1437 }
1438}
1439
1440struct RangeThumb<Msg> {
1444 side: usize,
1446 state: RangeState<Msg>,
1447}
1448
1449impl<Msg> RangeThumb<Msg> {
1450 fn value(&self) -> f32 {
1451 let (start, end) = self.state.held();
1452 if self.side == 0 {
1453 start
1454 } else {
1455 end
1456 }
1457 }
1458}
1459
1460impl<Msg: Clone> Widget<Msg> for RangeThumb<Msg> {
1461 fn style(&self) -> Style {
1462 Style {
1463 width: Dimension::Length(2.0 * OVERLAY_RADIUS),
1464 height: Dimension::Length(2.0 * OVERLAY_RADIUS),
1465 ..Default::default()
1466 }
1467 }
1468
1469 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1470 &[]
1471 }
1472
1473 fn debug_name(&self) -> &'static str {
1474 "RangeSliderThumb"
1475 }
1476
1477 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1478 if !self.state.enabled {
1479 return;
1480 }
1481 let o = status.opacity;
1482 let padding = if self.state.discrete() {
1486 theme.widgets.slider.track_height.unwrap_or(TRACK_HEIGHT)
1487 } else {
1488 0.0
1489 };
1490 let t = self.state.fractions()[self.side];
1491 let along = padding * (0.5 - t);
1492 let shift = if theme.direction.is_rtl() {
1493 -along
1494 } else {
1495 along
1496 };
1497 let centre = Point::new(
1498 bounds.x + bounds.width * 0.5 + shift,
1499 bounds.y + bounds.height * 0.5,
1500 );
1501 let activation = status
1502 .hover_progress
1503 .max(status.focus_progress)
1504 .clamp(0.0, 1.0);
1505 let paint = self.state.paint(theme, o);
1507 self.state.overlay(theme).paint(
1508 scene,
1509 centre,
1510 activation,
1511 self.state.halo(&status, theme),
1512 &paint,
1513 );
1514 if let Some(label) = self.state.label.as_ref() {
1517 let reveal = if self.state.always_indicates(theme) {
1518 0.0
1519 } else if self.state.indicates(theme) {
1520 status.focus_progress.clamp(0.0, 1.0)
1521 } else {
1522 0.0
1523 };
1524 if reveal > 0.0 {
1525 self.state.indicator(theme).paint(
1526 scene,
1527 centre,
1528 label(self.value()),
1529 reveal,
1530 &paint,
1531 );
1532 }
1533 }
1534 }
1535
1536 fn on_click(&self) -> Option<Msg> {
1537 None
1538 }
1539
1540 fn focusable(&self) -> bool {
1541 self.state.enabled && self.state.on_change.is_some()
1542 }
1543
1544 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1545 let value = self.value();
1546 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
1547 .value(self.state.spoken(value))
1548 .range(self.state.min, value, self.state.max);
1549 Some(if self.state.enabled {
1550 semantics
1551 } else {
1552 semantics.disabled(true)
1553 })
1554 }
1555
1556 fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
1557 if !self.state.enabled {
1560 return KeyResponse::Ignored;
1561 }
1562 let here = self.state.fractions()[self.side];
1563 let to = match key {
1564 Key::Left { .. } => here - self.state.key_step(),
1565 Key::Right { .. } => here + self.state.key_step(),
1566 Key::Home { .. } => -1.0,
1567 Key::End { .. } => 2.0,
1568 _ => return KeyResponse::Ignored,
1569 };
1570 let (start, end) = self.state.moved(self.side, to);
1571 KeyResponse::Handled(self.state.on_change.as_ref().map(|make| make(start, end)))
1572 }
1573}
1574
1575impl<Msg: Clone + 'static> Widget<Msg> for RangeSlider<Msg> {
1576 fn style(&self) -> Style {
1577 Style {
1578 width: Dimension::Length(self.width),
1579 min_width: Dimension::Length(if self.sized {
1580 0.0
1581 } else {
1582 MIN_TRACK + 2.0 * OVERLAY_RADIUS
1583 }),
1584 height: Dimension::Length(2.0 * OVERLAY_RADIUS),
1585 ..Default::default()
1586 }
1587 }
1588
1589 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1592 crate::widget::FillAxes {
1593 horizontal: !self.sized,
1594 vertical: false,
1595 }
1596 }
1597
1598 fn soft_extent(&self) -> crate::widget::FillAxes {
1599 crate::widget::FillAxes {
1600 horizontal: !self.sized,
1601 vertical: false,
1602 }
1603 }
1604
1605 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1606 &self.children
1607 }
1608
1609 fn debug_name(&self) -> &'static str {
1610 "RangeSlider"
1611 }
1612
1613 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1614 let state = &self.state;
1615 let t = &theme.widgets.slider;
1616 let paint = state.paint(theme, status.opacity);
1617 let track_h = t.track_height.unwrap_or(TRACK_HEIGHT);
1618 let left = bounds.x + OVERLAY_RADIUS;
1619 let width = (bounds.width - 2.0 * OVERLAY_RADIUS).max(0.0);
1620 state.track.set(width);
1622 let (cy, [x0, x1]) = self.thumb_centres(bounds, theme);
1623 let (lo, hi) = (x0.min(x1), x0.max(x1));
1624 shapes::pick(
1625 &state.parts.track,
1626 &t.range_track_shape,
1627 &shapes::RANGE_TRACK,
1628 )
1629 .paint(
1630 scene,
1631 Rect::new(left, cy - track_h * 0.5, width, track_h),
1632 &[x0, x1],
1633 None,
1634 &paint,
1635 );
1636
1637 if let Some(n) = state.divisions.filter(|n| *n > 0) {
1639 let tick = shapes::pick(
1640 &state.parts.tick_mark,
1641 &t.range_tick_mark_shape,
1642 &shapes::TICK_MARK,
1643 );
1644 let tick_w = tick.width(track_h);
1645 let adjusted = width - track_h;
1646 if tick_w > 0.0 && adjusted / n as f32 >= 3.0 * tick_w {
1647 for i in 0..=n {
1648 let x = left + i as f32 / n as f32 * adjusted + track_h / 2.0;
1649 let between = x >= lo && x <= hi;
1650 tick.paint(scene, Point::new(x, cy), between, track_h, &paint);
1651 }
1652 }
1653 }
1654
1655 let top = status.thumb.unwrap_or(1).min(1);
1658 let xs = [x0, x1];
1659 let press = status.press_progress.clamp(0.0, 1.0);
1660 if state.enabled && press > 0.0 {
1661 state.overlay(theme).paint(
1662 scene,
1663 Point::new(xs[top], cy),
1664 press,
1665 state.halo(&status, theme),
1666 &paint,
1667 );
1668 }
1669 }
1670
1671 fn paints_over(&self) -> bool {
1672 true
1673 }
1674
1675 fn paint_over(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1678 let state = &self.state;
1679 let t = &theme.widgets.slider;
1680 let paint = state.paint(theme, status.opacity);
1681 let (cy, xs) = self.thumb_centres(bounds, theme);
1682 let [x0, x1] = xs;
1683 let top = status.thumb.unwrap_or(1).min(1);
1684 let press = status.press_progress.clamp(0.0, 1.0);
1685
1686 let overlap = (x1 - x0).abs() < 2.0 * THUMB_RADIUS;
1689 let thumb = shapes::pick(&state.parts.thumb, &t.range_thumb_shape, &shapes::THUMB);
1690 for i in [1 - top, top] {
1691 let held = if i == top { press } else { 0.0 };
1692 thumb.paint(
1693 scene,
1694 Point::new(xs[i], cy),
1695 SliderThumb {
1696 elevation: elevation_of(state.elevations, held, theme),
1697 on_top: overlap && i == top,
1698 },
1699 &paint,
1700 );
1701 }
1702
1703 if let Some(label) = state.label.as_ref() {
1706 let reveal = if state.always_indicates(theme) {
1707 1.0
1708 } else if state.indicates(theme) {
1709 press
1710 } else {
1711 0.0
1712 };
1713 if reveal > 0.0 {
1714 let (start, end) = state.held();
1715 let values = [start, end];
1716 let indicator = state.indicator(theme);
1717 for i in [1 - top, top] {
1718 indicator.paint(
1719 scene,
1720 Point::new(xs[i], cy),
1721 label(values[i]),
1722 reveal,
1723 &paint,
1724 );
1725 }
1726 }
1727 }
1728 }
1729
1730 fn on_click(&self) -> Option<Msg> {
1731 None
1732 }
1733
1734 fn draggable(&self) -> bool {
1735 self.state.enabled && self.state.on_change.is_some()
1736 }
1737
1738 fn drag_track(&self) -> crate::widget::DragTrack {
1741 crate::widget::DragTrack {
1742 inset: OVERLAY_RADIUS,
1743 mirrored: true,
1744 mode: crate::widget::DragMode::Thumbs {
1745 values: self.state.fractions(),
1746 radius: OVERLAY_RADIUS,
1747 },
1748 }
1749 }
1750
1751 fn on_thumb_drag(
1752 &self,
1753 thumb: usize,
1754 moment: crate::widget::ThumbDrag,
1755 fraction: f32,
1756 ) -> Option<Msg> {
1757 if !self.state.enabled {
1758 return None;
1759 }
1760 let state = &self.state;
1761 match moment {
1762 crate::widget::ThumbDrag::Start => {
1763 let (start, end) = state.held();
1764 let (start, end) = (
1765 state.at(state.fraction_of(start)),
1766 state.at(state.fraction_of(end)),
1767 );
1768 state.on_change_start.as_ref().map(|make| make(start, end))
1769 }
1770 crate::widget::ThumbDrag::Move => {
1771 let (start, end) = state.moved(thumb, fraction);
1772 state.on_change.as_ref().map(|make| make(start, end))
1773 }
1774 crate::widget::ThumbDrag::End => {
1775 let (start, end) = state.moved(thumb, fraction);
1776 state.on_change_end.as_ref().map(|make| make(start, end))
1777 }
1778 }
1779 }
1780}
1781
1782pub(crate) fn paint_rect_indicator(
1788 at: Point,
1789 text: String,
1790 theme: &Theme,
1791 scale: f32,
1792 o: f32,
1793 scene: &mut Scene,
1794) {
1795 const TRIANGLE: f32 = 8.0;
1796 const PADDING: f32 = 16.0;
1797 const MIN_LABEL: f32 = 16.0;
1798 const TIP_Y: f32 = 14.0;
1799 let style = theme
1800 .widgets
1801 .slider
1802 .value_indicator_text_style
1803 .unwrap_or_else(|| {
1804 crate::theme::type_scale(Some(theme))
1805 .body_large
1806 .color(theme.scheme.on_primary)
1807 })
1808 .resolved();
1809 let measured = frus_text::measure_resolved(&text, &style);
1810 let rect_w = MIN_LABEL.max(measured.width) + 2.0 * PADDING;
1811 let rect_h = measured.height + PADDING;
1812 let tip = Point::new(at.x, at.y - TIP_Y);
1813 let s = |dx: f32, dy: f32| Point::new(tip.x + dx * scale, tip.y + dy * scale);
1814 let scheme = &theme.scheme;
1815 let color = theme
1816 .widgets
1817 .slider
1818 .value_indicator_color
1819 .unwrap_or_else(|| scheme.surface.fade(0.90).lerp(scheme.on_surface, 0.60))
1820 .fade(o);
1821 let top_left = s(-rect_w / 2.0, -TRIANGLE - rect_h);
1822 let body = Rect::new(top_left.x, top_left.y, rect_w * scale, rect_h * scale);
1823 let path = frus_core::Path::new()
1824 .move_to(s(0.0, 0.0))
1825 .line_to(s(-TRIANGLE, -TRIANGLE))
1826 .line_to(s(TRIANGLE, -TRIANGLE))
1827 .close();
1828 scene.fill_path(&path, color);
1829 scene.draw_rect(body, color, 4.0 * scale, 0.0, Color::TRANSPARENT);
1830 if scale >= 0.999 {
1831 let text_color = style.color.unwrap_or(scheme.on_primary);
1832 scene.text(
1833 Point::new(
1834 body.x + (body.width - measured.width) * 0.5,
1835 body.y + (body.height - measured.height) * 0.5,
1836 ),
1837 text,
1838 &style,
1839 text_color.fade(o),
1840 );
1841 }
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846 use super::*;
1847
1848 #[derive(Clone, Debug, PartialEq)]
1849 enum Msg {
1850 Value(f32),
1851 Start(f32),
1852 End(f32),
1853 }
1854
1855 #[test]
1858 fn a_slider_that_was_not_asked_sends_no_bracket() {
1859 let slider = Slider::new(0.0).on_change(Msg::Value);
1860 assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1861 assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1862 }
1863
1864 #[test]
1872 fn the_bracket_speaks_in_values_not_fractions() {
1873 let slider = Slider::new(10.0)
1874 .range(0.0, 100.0)
1875 .on_change(Msg::Value)
1876 .on_change_start(Msg::Start)
1877 .on_change_end(Msg::End);
1878 assert_eq!(Widget::on_drag_start(&slider, 0.25), Some(Msg::Start(10.0)));
1879 assert_eq!(Widget::on_drag(&slider, 0.25), Some(Msg::Value(25.0)));
1880 assert_eq!(Widget::on_drag_end(&slider, 0.75), Some(Msg::End(75.0)));
1881 }
1882
1883 #[test]
1886 fn the_bracket_snaps_like_the_stream() {
1887 let slider = Slider::new(0.4)
1888 .divisions(4)
1889 .on_change(Msg::Value)
1890 .on_change_start(Msg::Start)
1891 .on_change_end(Msg::End);
1892 assert_eq!(Widget::on_drag(&slider, 0.3), Some(Msg::Value(0.25)));
1895 assert_eq!(Widget::on_drag_start(&slider, 0.3), Some(Msg::Start(0.5)));
1896 assert_eq!(Widget::on_drag_end(&slider, 0.3), Some(Msg::End(0.25)));
1897 }
1898
1899 #[test]
1903 fn each_interaction_names_its_drag_mode() {
1904 use crate::widget::DragMode;
1905 let mode = |s: Slider<Msg>| Widget::<Msg>::drag_track(&s).mode;
1906 let at = || Slider::<Msg>::new(0.3);
1907 assert_eq!(mode(at()), DragMode::Follow, "tap and slide by default");
1908 assert_eq!(
1909 mode(at().allowed_interaction(SliderInteraction::TapOnly)),
1910 DragMode::TapOnly
1911 );
1912 assert_eq!(
1913 mode(at().allowed_interaction(SliderInteraction::SlideOnly)),
1914 DragMode::Slide { origin: 0.3 }
1915 );
1916 assert_eq!(
1917 mode(at().allowed_interaction(SliderInteraction::SlideThumb)),
1918 DragMode::SlideThumb {
1919 origin: 0.3,
1920 radius: 24.0
1921 }
1922 );
1923 let mut theme = Theme::default();
1924 theme.widgets.slider.allowed_interaction = Some(SliderInteraction::TapOnly);
1925 let themed = at();
1926 Widget::<Msg>::build_themed(&themed, &theme);
1927 assert_eq!(mode(themed), DragMode::TapOnly, "from the theme");
1928 let mine = at().allowed_interaction(SliderInteraction::SlideOnly);
1929 Widget::<Msg>::build_themed(&mine, &theme);
1930 assert_eq!(
1931 mode(mine),
1932 DragMode::Slide { origin: 0.3 },
1933 "over the theme's"
1934 );
1935 }
1936
1937 #[test]
1940 fn a_disabled_slider_brackets_nothing() {
1941 let slider = Slider::new(0.0)
1942 .enabled(false)
1943 .on_change(Msg::Value)
1944 .on_change_start(Msg::Start)
1945 .on_change_end(Msg::End);
1946 assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1947 assert_eq!(Widget::on_drag(&slider, 0.5), None);
1948 assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1949 }
1950
1951 #[test]
1952 fn drag_maps_to_value() {
1953 let slider = Slider::new(0.0).on_change(Msg::Value);
1954 assert_eq!(Widget::on_drag(&slider, 0.5), Some(Msg::Value(0.5)));
1955 assert_eq!(Widget::on_drag(&slider, 1.5), Some(Msg::Value(1.0)));
1957 }
1958
1959 #[test]
1960 fn a_disabled_slider_is_inert_but_still_says_where_it_sits() {
1961 let dead = Slider::new(0.4).on_change(Msg::Value).enabled(false);
1962 assert!(!Widget::<Msg>::draggable(&dead), "it takes no drag");
1963 assert_eq!(
1964 Widget::on_drag(&dead, 0.9),
1965 None,
1966 "and a drag in flight does not land"
1967 );
1968 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1969 assert!(semantics.disabled, "announced as unavailable");
1970 assert_eq!(semantics.value.as_deref(), Some("40%"), "value survives");
1971 }
1972
1973 #[test]
1976 fn a_disabled_slider_takes_both_halves_of_the_rule() {
1977 for theme in [Theme::dark(), Theme::light()] {
1978 let mut scene = Scene::new();
1979 Widget::<Msg>::paint(
1980 &Slider::<Msg>::new(0.5).enabled(false),
1981 Rect::new(0.0, 0.0, 200.0, H),
1982 Status {
1983 opacity: 1.0,
1984 ..Default::default()
1985 },
1986 &theme,
1987 &mut scene,
1988 );
1989 let fills: Vec<frus_core::Color> = scene
1991 .primitives()
1992 .iter()
1993 .filter_map(|p| match p {
1994 frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1995 _ => None,
1996 })
1997 .collect();
1998 assert_eq!(fills[0], disabled_container(&theme), "the rail");
1999 assert_eq!(fills[1], disabled_content(&theme), "the travelled part");
2000 assert_eq!(fills[2], disabled_content(&theme), "the thumb");
2001 let from_surface = |c: Color| {
2005 (c.r - theme.scheme.surface.r).abs()
2006 + (c.g - theme.scheme.surface.g).abs()
2007 + (c.b - theme.scheme.surface.b).abs()
2008 };
2009 assert!(
2010 from_surface(fills[0]) < from_surface(fills[1]),
2011 "the rail is the quieter of the two"
2012 );
2013 }
2014 }
2015 #[test]
2019 fn a_thumb_casts_a_shadow_that_rises_while_it_is_held() {
2020 fn heights(widget: &dyn Widget<Msg>, bounds: Rect, press: f32, theme: &Theme) -> Vec<f32> {
2022 let status = Status {
2023 opacity: 1.0,
2024 press_progress: press,
2025 ..Default::default()
2026 };
2027 let mut scene = Scene::new();
2028 widget.paint(bounds, status, theme, &mut scene);
2029 crate::shadowprobe::heights(scene.primitives())
2030 }
2031 let theme = Theme::default();
2032 let track = Rect::new(0.0, 0.0, 220.0, H);
2033 let slider = Slider::new(0.5).on_change(Msg::Value);
2034 assert_eq!(heights(&slider, track, 0.0, &theme), vec![1.0]);
2035 assert_eq!(heights(&slider, track, 1.0, &theme), vec![6.0]);
2036 assert_eq!(
2037 heights(&slider, track, 0.5, &theme),
2038 vec![3.5],
2039 "on its way up"
2040 );
2041 let grounded = Slider::new(0.5)
2042 .on_change(Msg::Value)
2043 .thumb_elevation(0.0)
2044 .pressed_thumb_elevation(0.0);
2045 assert!(heights(&grounded, track, 1.0, &theme).is_empty());
2046
2047 let mut flat = Theme::default();
2048 flat.widgets.slider.thumb_elevation = Some(0.0);
2049 flat.widgets.slider.pressed_thumb_elevation = Some(0.0);
2050 assert!(
2051 heights(&slider, track, 1.0, &flat).is_empty(),
2052 "the theme's word"
2053 );
2054 let told = Slider::new(0.5).on_change(Msg::Value).thumb_elevation(2.0);
2055 assert_eq!(
2056 heights(&told, track, 0.0, &flat),
2057 vec![2.0],
2058 "and the slider's over it"
2059 );
2060 }
2061}
2062
2063#[cfg(test)]
2064mod range_tests {
2065 use super::*;
2066 use crate::interaction::{Key, KeyResponse};
2067
2068 #[derive(Clone, Debug, PartialEq)]
2069 enum Msg {
2070 Value(f32),
2071 }
2072
2073 fn value_of(msg: Option<Msg>) -> f32 {
2074 match msg {
2075 Some(Msg::Value(v)) => v,
2076 other => panic!("expected a value, got {other:?}"),
2077 }
2078 }
2079
2080 #[test]
2082 fn a_drag_lands_in_the_caller_s_units() {
2083 let slider = Slider::new(20.0).range(20.0, 200.0).on_change(Msg::Value);
2084 assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
2085 assert_eq!(value_of(Widget::on_drag(&slider, 0.5)), 110.0);
2086 assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
2087 assert_eq!(value_of(Widget::on_drag(&slider, 1.4)), 200.0);
2089 }
2090
2091 #[test]
2093 fn a_backwards_range_is_taken_the_way_round_it_makes_sense() {
2094 let slider = Slider::new(0.0).range(200.0, 20.0).on_change(Msg::Value);
2095 assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
2096 assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
2097 }
2098
2099 #[test]
2102 fn a_value_outside_the_travel_is_held_inside_it() {
2103 let low = Slider::<Msg>::new(-5.0).range(0.0, 10.0);
2104 assert_eq!(low.fraction(), 0.0);
2105 let high = Slider::<Msg>::new(40.0).range(0.0, 10.0);
2106 assert_eq!(high.fraction(), 1.0);
2107 }
2108
2109 #[test]
2111 fn divisions_snap_the_value() {
2112 let slider = Slider::new(0.0)
2113 .range(0.0, 100.0)
2114 .divisions(4)
2115 .on_change(Msg::Value);
2116 assert_eq!(value_of(Widget::on_drag(&slider, 0.30)), 25.0);
2117 assert_eq!(value_of(Widget::on_drag(&slider, 0.40)), 50.0);
2118 assert_eq!(value_of(Widget::on_drag(&slider, 0.99)), 100.0);
2119 }
2120
2121 #[test]
2124 fn the_arrows_move_by_a_step() {
2125 let free = Slider::new(50.0).range(0.0, 100.0).on_change(Msg::Value);
2126 let step = |s: &Slider<Msg>, key: Key| match Widget::on_key(s, &key) {
2127 KeyResponse::Handled(msg) => value_of(msg),
2128 other => panic!("expected the key to be taken, got {other:?}"),
2129 };
2130 assert_eq!(
2131 step(
2132 &free,
2133 Key::Right {
2134 shift: false,
2135 word: false
2136 }
2137 ),
2138 55.0
2139 );
2140 assert_eq!(
2141 step(
2142 &free,
2143 Key::Left {
2144 shift: false,
2145 word: false
2146 }
2147 ),
2148 45.0
2149 );
2150
2151 let stepped = Slider::new(50.0)
2152 .range(0.0, 100.0)
2153 .divisions(4)
2154 .on_change(Msg::Value);
2155 assert_eq!(
2156 step(
2157 &stepped,
2158 Key::Right {
2159 shift: false,
2160 word: false
2161 }
2162 ),
2163 75.0
2164 );
2165
2166 let at_end = Slider::new(100.0).range(0.0, 100.0).on_change(Msg::Value);
2167 assert_eq!(
2168 step(
2169 &at_end,
2170 Key::Right {
2171 shift: false,
2172 word: false
2173 }
2174 ),
2175 100.0
2176 );
2177 assert_eq!(
2178 step(
2179 &at_end,
2180 Key::Home {
2181 shift: false,
2182 doc: false
2183 }
2184 ),
2185 0.0
2186 );
2187 assert_eq!(
2188 step(
2189 &at_end,
2190 Key::End {
2191 shift: false,
2192 doc: false
2193 }
2194 ),
2195 100.0
2196 );
2197 }
2198
2199 #[test]
2201 fn it_takes_the_focus_only_when_it_can_answer() {
2202 assert!(!Widget::<Msg>::focusable(&Slider::<Msg>::new(0.5)));
2203 assert!(Widget::focusable(&Slider::new(0.5).on_change(Msg::Value)));
2204 assert!(!Widget::focusable(
2205 &Slider::new(0.5).on_change(Msg::Value).enabled(false)
2206 ));
2207 }
2208
2209 #[test]
2211 fn a_frozen_slider_answers_no_key() {
2212 let slider = Slider::new(0.5).on_change(Msg::Value).enabled(false);
2213 assert!(matches!(
2214 Widget::on_key(
2215 &slider,
2216 &Key::Right {
2217 shift: false,
2218 word: false
2219 }
2220 ),
2221 KeyResponse::Ignored
2222 ));
2223 }
2224
2225 #[test]
2227 fn a_reader_is_told_the_real_value() {
2228 let plain = Slider::<Msg>::new(0.25);
2229 let s = Widget::<Msg>::semantics(&plain).expect("a slider says where it is");
2230 assert_eq!(
2231 s.value.as_deref(),
2232 Some("25%"),
2233 "a bare 0..1 reads as a share"
2234 );
2235
2236 let ranged = Slider::<Msg>::new(110.0).range(20.0, 200.0);
2237 let s = Widget::<Msg>::semantics(&ranged).expect("a slider says where it is");
2238 assert_eq!(s.range, Some((20.0, 110.0, 200.0)));
2239
2240 let priced = Slider::<Msg>::new(110.0)
2241 .range(20.0, 200.0)
2242 .value_label(|v| format!("{v} EUR"));
2243 let s = Widget::<Msg>::semantics(&priced).expect("a slider says where it is");
2244 assert_eq!(
2245 s.value.as_deref(),
2246 Some("110 EUR"),
2247 "said the caller's way when it gave a formatter"
2248 );
2249 }
2250
2251 #[test]
2254 fn the_value_drop_wears_the_reference_s_type() {
2255 let slider = Slider::<Msg>::new(0.3)
2256 .divisions(10)
2257 .value_label(|v| format!("{v}"));
2258 let styles = |theme: &Theme| {
2259 let mut scene = Scene::new();
2260 Widget::<Msg>::paint(
2261 &slider,
2262 Rect::new(0.0, 0.0, 220.0, 48.0),
2263 Status {
2264 focused: true,
2265 focus_progress: 1.0,
2266 ..Status::default()
2267 },
2268 theme,
2269 &mut scene,
2270 );
2271 scene
2272 .primitives()
2273 .iter()
2274 .filter_map(|p| match p {
2275 frus_core::Primitive::Text {
2276 size,
2277 weight,
2278 color,
2279 ..
2280 } => Some((*size, weight.to_u16(), *color)),
2281 _ => None,
2282 })
2283 .collect::<Vec<_>>()
2284 };
2285 let plain = Theme::default();
2286 assert_eq!(
2287 styles(&plain),
2288 vec![(
2289 plain.text.label_medium.size.unwrap(),
2290 frus_core::FontWeight::Medium.to_u16(),
2291 plain.scheme.on_primary
2292 )]
2293 );
2294 let mut themed = Theme::default();
2296 themed.widgets.slider.value_indicator_text_style = Some(frus_core::TextStyle::new(25.0));
2297 assert_eq!(styles(&themed).first().map(|s| s.0), Some(25.0));
2298 }
2299
2300 #[test]
2303 fn the_slider_is_as_tall_as_its_halo() {
2304 let bare = Widget::<Msg>::style(&Slider::<Msg>::new(0.5)).height;
2305 let labelled =
2306 Widget::<Msg>::style(&Slider::<Msg>::new(0.5).value_label(|v| format!("{v}"))).height;
2307 assert_eq!(bare, frus_layout::Dimension::Length(48.0));
2308 assert_eq!(labelled, bare);
2309 }
2310}
2311
2312#[cfg(test)]
2313mod color_tests {
2314 use super::*;
2315 use frus_core::Primitive;
2316
2317 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
2318 const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
2319
2320 fn painted(slider: &Slider<()>, theme: &Theme) -> (Color, Color, Color, Color) {
2323 let mut scene = Scene::new();
2324 Widget::<()>::paint(
2325 slider,
2326 Rect::new(0.0, 0.0, 220.0, H),
2327 Status {
2328 opacity: 1.0,
2329 ..Default::default()
2330 },
2331 theme,
2332 &mut scene,
2333 );
2334 let rects: Vec<(Color, Color)> = scene
2335 .primitives()
2336 .iter()
2337 .filter_map(|p| match p {
2338 Primitive::Rect {
2339 color,
2340 border_color,
2341 blur,
2342 ..
2343 } if *blur == 0.0 => Some((*color, *border_color)),
2344 _ => None,
2345 })
2346 .collect();
2347 (rects[0].0, rects[1].0, rects[2].0, rects[2].1)
2348 }
2349
2350 #[test]
2353 fn the_defaults_are_the_reference_s() {
2354 let theme = Theme::default();
2355 let (rail, filled, thumb, ring) = painted(&Slider::<()>::new(0.5), &theme);
2356 assert_eq!(rail, theme.scheme.surface_container_highest);
2357 assert_eq!(filled, theme.scheme.primary);
2358 assert_eq!(thumb, theme.scheme.primary);
2359 assert_eq!(ring, Color::TRANSPARENT);
2360 }
2361
2362 #[test]
2365 fn the_thumb_follows_the_track_it_was_not_told_about() {
2366 let theme = Theme::default();
2367 let (_, filled, thumb, ring) = painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme);
2368 assert_eq!((filled, thumb, ring), (BRAND, BRAND, Color::TRANSPARENT));
2369 let (_, _, _, named) = painted(
2370 &Slider::<()>::new(0.5)
2371 .active_color(BRAND)
2372 .thumb_border_color(RAIL),
2373 &theme,
2374 );
2375 assert_eq!(named, RAIL, "unless it is named");
2376 }
2377
2378 #[test]
2380 fn each_half_of_the_track_takes_its_own_colour() {
2381 let theme = Theme::default();
2382 let (rail, filled, thumb, _) = painted(
2383 &Slider::<()>::new(0.5)
2384 .active_color(BRAND)
2385 .inactive_color(RAIL)
2386 .thumb_color(RAIL),
2387 &theme,
2388 );
2389 assert_eq!((rail, filled, thumb), (RAIL, BRAND, RAIL));
2390 }
2391
2392 #[test]
2394 fn the_theme_answers_and_the_instance_overrules_it() {
2395 let mut theme = Theme::default();
2396 theme.widgets.slider.active_track_color = Some(RAIL);
2397 assert_eq!(painted(&Slider::<()>::new(0.5), &theme).1, RAIL);
2398 assert_eq!(
2399 painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme).1,
2400 BRAND
2401 );
2402 }
2403}
2404
2405#[cfg(test)]
2407mod reference_tests {
2408 use super::*;
2409 use frus_core::Primitive;
2410
2411 const BOX: Rect = Rect::new(0.0, 0.0, 248.0, 48.0);
2413
2414 fn rtl() -> Theme {
2415 Theme {
2416 direction: frus_core::TextDirection::Rtl,
2417 ..Theme::default()
2418 }
2419 }
2420
2421 fn scene_of(slider: &Slider<()>, status: Status, theme: &Theme) -> Vec<Primitive> {
2422 let mut scene = Scene::new();
2423 Widget::<()>::paint(slider, BOX, status, theme, &mut scene);
2424 scene.primitives().to_vec()
2425 }
2426
2427 fn rects(primitives: &[Primitive]) -> Vec<(Rect, Color)> {
2428 primitives
2429 .iter()
2430 .filter_map(|p| match p {
2431 Primitive::Rect {
2432 rect, color, blur, ..
2433 } if *blur == 0.0 => Some((*rect, *color)),
2434 _ => None,
2435 })
2436 .collect()
2437 }
2438
2439 fn thumb(primitives: &[Primitive]) -> Rect {
2441 rects(primitives)
2442 .into_iter()
2443 .map(|(r, _)| r)
2444 .rfind(|r| r.width == 20.0 && r.height == 20.0)
2445 .expect("a thumb")
2446 }
2447
2448 fn texts(primitives: &[Primitive]) -> usize {
2449 primitives
2450 .iter()
2451 .filter(|p| matches!(p, Primitive::Text { .. }))
2452 .count()
2453 }
2454
2455 fn rest() -> Status {
2456 Status::default()
2457 }
2458
2459 #[test]
2462 fn the_track_is_inset_and_the_travelled_part_thicker() {
2463 let theme = Theme::default();
2464 let painted = scene_of(&Slider::new(0.5), rest(), &theme);
2465 assert_eq!(thumb(&painted), Rect::new(114.0, 14.0, 20.0, 20.0));
2466 let at = |v: f32| thumb(&scene_of(&Slider::new(v), rest(), &theme)).x + 10.0;
2467 assert_eq!((at(0.0), at(1.0)), (24.0, 224.0), "the ends, a radius in");
2468 let track = rects(&painted);
2469 let (inactive, active) = (track[0].0, track[1].0);
2470 assert_eq!(inactive.height, 4.0);
2471 assert_eq!(active.height, 6.0);
2472 assert_eq!(active.x, 24.0, "from the start");
2473 assert_eq!(inactive.x + inactive.width, 224.0, "to the end");
2474 }
2475
2476 #[test]
2479 fn right_to_left_it_runs_from_the_right() {
2480 let painted = scene_of(&Slider::new(0.25), rest(), &rtl());
2481 assert_eq!(thumb(&painted).x + 10.0, 174.0);
2482 let track = rects(&painted);
2483 let (inactive, active) = (track[0].0, track[1].0);
2484 assert_eq!(
2485 active.x + active.width,
2486 224.0,
2487 "the travelled part ends at the right"
2488 );
2489 assert_eq!(inactive.x, 24.0, "the rest starts at the left");
2490 }
2491
2492 #[test]
2495 fn a_tick_marks_each_division() {
2496 let theme = Theme::default();
2497 let ticks = |slider: Slider<()>| {
2498 rects(&scene_of(&slider, rest(), &theme))
2499 .into_iter()
2500 .filter(|(r, _)| r.width == 2.0 && r.height == 2.0)
2501 .collect::<Vec<_>>()
2502 };
2503 let four = ticks(Slider::new(0.5).divisions(4));
2504 assert_eq!(four.len(), 5);
2505 assert_eq!(four[0].0.x + 1.0, 26.0, "half a track in");
2506 assert_eq!(four[4].0.x + 1.0, 222.0);
2507 assert_eq!(four[0].1, theme.scheme.on_primary.fade(0.38));
2509 assert_eq!(four[4].1, theme.scheme.on_surface_variant.fade(0.38));
2510 assert!(
2511 ticks(Slider::new(0.5).divisions(100)).is_empty(),
2512 "too dense"
2513 );
2514 assert!(ticks(Slider::new(0.5)).is_empty(), "continuous");
2515 let end = thumb(&scene_of(&Slider::new(1.0).divisions(4), rest(), &theme));
2517 assert_eq!(end.x + 10.0, 222.0);
2518 }
2519
2520 #[test]
2522 fn the_secondary_value_lightens_the_track_ahead() {
2523 let theme = Theme::default();
2524 let secondary = |slider: Slider<()>| {
2525 rects(&scene_of(&slider, rest(), &theme))
2526 .into_iter()
2527 .find(|(_, c)| *c == theme.scheme.primary.fade(0.54))
2528 };
2529 let (rect, _) =
2530 secondary(Slider::new(0.25).secondary_track_value(0.75)).expect("a secondary part");
2531 assert_eq!((rect.x, rect.x + rect.width), (74.0, 174.0));
2532 assert!(
2533 secondary(Slider::new(0.75).secondary_track_value(0.25)).is_none(),
2534 "behind"
2535 );
2536 let given = Color::rgb(0.2, 0.4, 0.6);
2537 let painted = scene_of(
2538 &Slider::new(0.25)
2539 .secondary_track_value(0.75)
2540 .secondary_active_color(given),
2541 rest(),
2542 &theme,
2543 );
2544 assert!(rects(&painted).iter().any(|(_, c)| *c == given));
2545 }
2546
2547 #[test]
2550 fn the_drop_shows_as_its_policy_says() {
2551 let theme = Theme::default();
2552 let focused = Status {
2553 focused: true,
2554 focus_progress: 1.0,
2555 ..Status::default()
2556 };
2557 let pressed = Status {
2558 press_progress: 1.0,
2559 ..Status::default()
2560 };
2561 let labelled = || Slider::<()>::new(0.5).value_label(|v| format!("{v}"));
2562 let shows = |s: Slider<()>, status: Status| texts(&scene_of(&s, status, &theme)) == 1;
2563 assert!(shows(labelled().divisions(4), focused));
2564 assert!(shows(labelled().divisions(4), pressed));
2565 assert!(!shows(labelled().divisions(4), rest()), "at rest, hidden");
2566 assert!(
2567 !shows(labelled(), focused),
2568 "continuous: only for discrete by default"
2569 );
2570 assert!(shows(
2571 labelled().show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2572 focused
2573 ));
2574 assert!(!shows(
2575 labelled()
2576 .divisions(4)
2577 .show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2578 focused
2579 ));
2580 assert!(shows(
2581 labelled().show_value_indicator(ShowValueIndicator::OnDrag),
2582 pressed
2583 ));
2584 assert!(shows(
2585 labelled().show_value_indicator(ShowValueIndicator::AlwaysVisible),
2586 rest()
2587 ));
2588 assert!(!shows(
2589 labelled()
2590 .divisions(4)
2591 .show_value_indicator(ShowValueIndicator::Never),
2592 focused
2593 ));
2594 assert!(
2595 !shows(labelled().divisions(4).enabled(false), focused),
2596 "disabled"
2597 );
2598 let mut themed = Theme::default();
2599 themed.widgets.slider.show_value_indicator = Some(ShowValueIndicator::AlwaysVisible);
2600 assert_eq!(
2601 texts(&scene_of(&labelled(), rest(), &themed)),
2602 1,
2603 "from the theme"
2604 );
2605 }
2606
2607 #[test]
2610 fn the_drop_sits_over_the_thumb() {
2611 let theme = Theme::default();
2612 let slider = Slider::<()>::new(0.5)
2613 .show_value_indicator(ShowValueIndicator::AlwaysVisible)
2614 .value_label(|_| "50".to_string());
2615 let painted = scene_of(&slider, rest(), &theme);
2616 let pill = rects(&painted)
2617 .into_iter()
2618 .find(|(r, _)| r.height == 28.0)
2619 .expect("the pill");
2620 assert_eq!(pill.0.y + pill.0.height, 24.0 - 22.0);
2621 assert!(
2622 (pill.0.x + pill.0.width / 2.0 - 124.0).abs() < 0.01,
2623 "centred on the thumb"
2624 );
2625 assert_eq!(pill.1, theme.scheme.primary);
2626 assert!(
2627 painted.iter().any(|p| matches!(p, Primitive::Path { .. })),
2628 "the point"
2629 );
2630 }
2631
2632 #[test]
2635 fn the_halo_follows_the_state() {
2636 let theme = Theme::default();
2637 let halo = |slider: Slider<()>, status: Status| {
2638 rects(&scene_of(&slider, status, &theme))
2639 .into_iter()
2640 .find(|(r, _)| r.width == 48.0)
2641 .map(|(_, c)| c)
2642 };
2643 let hovered = Status {
2644 interaction: crate::interaction::Interaction::Hovered,
2645 hover_progress: 1.0,
2646 ..Status::default()
2647 };
2648 let pressed = Status {
2649 interaction: crate::interaction::Interaction::Pressed,
2650 press_progress: 1.0,
2651 ..Status::default()
2652 };
2653 assert_eq!(halo(Slider::new(0.5), rest()), None);
2654 assert_eq!(
2655 halo(Slider::new(0.5), hovered),
2656 Some(theme.scheme.primary.fade(0.08))
2657 );
2658 assert_eq!(
2659 halo(Slider::new(0.5), pressed),
2660 Some(theme.scheme.primary.fade(0.1))
2661 );
2662 assert_eq!(halo(Slider::new(0.5).enabled(false), hovered), None);
2663 let given = Color::rgb(0.0, 0.6, 0.3);
2664 assert_eq!(
2665 halo(Slider::new(0.5).active_color(given), hovered),
2666 Some(given.fade(0.12))
2667 );
2668 let property = WidgetStateProperty::new().otherwise(Color::rgb(1.0, 0.0, 0.0));
2669 assert_eq!(
2670 halo(Slider::new(0.5).overlay_color(property), hovered),
2671 Some(Color::rgb(1.0, 0.0, 0.0))
2672 );
2673 }
2674
2675 #[test]
2678 fn a_disabled_slider_wears_the_disabled_rule() {
2679 let theme = Theme::default();
2680 let painted = scene_of(&Slider::new(0.5).enabled(false), rest(), &theme);
2681 let r = rects(&painted);
2682 assert_eq!(r[0].1, disabled_container(&theme));
2683 assert_eq!(r[1].1, disabled_content(&theme));
2684 assert_eq!(r.last().map(|x| x.1), Some(disabled_content(&theme)));
2685 }
2686
2687 #[test]
2690 fn it_takes_the_width_it_is_offered() {
2691 let theme = Theme::default();
2692 let open = Slider::<()>::new(0.5);
2693 assert!(Widget::<()>::fill_axes(&open, &theme).horizontal);
2694 assert!(Widget::<()>::soft_extent(&open).horizontal);
2695 assert_eq!(
2696 Widget::<()>::style(&open).min_width,
2697 frus_layout::Dimension::Length(192.0)
2698 );
2699 let fixed = Slider::<()>::new(0.5).width(120.0);
2700 assert!(!Widget::<()>::fill_axes(&fixed, &theme).horizontal);
2701 assert_eq!(
2702 Widget::<()>::style(&fixed).width,
2703 frus_layout::Dimension::Length(120.0)
2704 );
2705 }
2706
2707 #[test]
2709 fn a_drag_lands_on_the_inset_track() {
2710 let track = Widget::<()>::drag_track(&Slider::<()>::new(0.5));
2711 assert_eq!(track.inset, 24.0);
2712 assert!(track.mirrored);
2713 assert_eq!(track.fraction(BOX, 24.0, false), 0.0);
2714 assert_eq!(track.fraction(BOX, 124.0, false), 0.5);
2715 assert_eq!(track.fraction(BOX, 224.0, false), 1.0);
2716 assert_eq!(track.fraction(BOX, 74.0, true), 0.75, "from the right");
2717 assert_eq!(track.fraction(BOX, 0.0, false), 0.0, "held at the ends");
2718 assert_eq!(track.fraction(BOX, 248.0, false), 1.0);
2719 let whole = crate::widget::DragTrack::WHOLE;
2720 assert_eq!(
2721 whole.fraction(BOX, 62.0, true),
2722 0.25,
2723 "no opinion: left to right"
2724 );
2725 let empty = Rect::new(0.0, 0.0, 40.0, 48.0);
2726 assert_eq!(track.fraction(empty, 20.0, false), 0.0, "no travel");
2727 }
2728
2729 #[test]
2731 fn a_reader_hears_the_semantic_formatter() {
2732 let slider = Slider::<()>::new(0.5)
2733 .value_label(|v| format!("{v}"))
2734 .semantic_formatter(|v| format!("{} percent", (v * 100.0).round()));
2735 let said = Widget::<()>::semantics(&slider).and_then(|s| s.value);
2736 assert_eq!(said.as_deref(), Some("50 percent"));
2737 }
2738
2739 #[test]
2744 fn the_drag_modes_move_the_value_as_the_reference_s_do() {
2745 use crate::widget::{DragMode, DragTrack};
2746 let track = |mode| DragTrack {
2747 inset: 24.0,
2748 mirrored: true,
2749 mode,
2750 };
2751 let follow = track(DragMode::Follow);
2752 assert_eq!(
2753 follow.value_at(BOX, 124.0, 124.0, false, false),
2754 Some(0.5),
2755 "the press"
2756 );
2757 assert_eq!(
2758 follow.value_at(BOX, 124.0, 174.0, true, false),
2759 Some(0.75),
2760 "the slide"
2761 );
2762 assert_eq!(follow.released_at(BOX, 124.0, 174.0, false), 0.75);
2763 assert!(follow.engages(BOX, 0.0, false));
2764
2765 let tap = track(DragMode::TapOnly);
2766 assert_eq!(tap.value_at(BOX, 124.0, 124.0, false, false), Some(0.5));
2767 assert_eq!(
2768 tap.value_at(BOX, 124.0, 174.0, true, false),
2769 None,
2770 "no slide"
2771 );
2772 assert_eq!(
2773 tap.released_at(BOX, 124.0, 174.0, false),
2774 0.5,
2775 "where the press set it"
2776 );
2777
2778 let slide = track(DragMode::Slide { origin: 0.2 });
2779 assert_eq!(
2780 slide.value_at(BOX, 124.0, 124.0, false, false),
2781 None,
2782 "no jump"
2783 );
2784 assert_eq!(slide.value_at(BOX, 124.0, 174.0, true, false), Some(0.45));
2785 assert_eq!(slide.released_at(BOX, 124.0, 174.0, false), 0.45);
2786 assert_eq!(
2787 slide.value_at(BOX, 124.0, 1000.0, true, false),
2788 Some(1.0),
2789 "held at the end"
2790 );
2791 assert_eq!(slide.value_at(BOX, 240.0, 190.0, true, false), Some(0.0));
2794 assert_eq!(slide.value_at(BOX, 124.0, 174.0, true, true), Some(0.0));
2796 let rtl_slide = track(DragMode::Slide { origin: 0.6 });
2797 let moved = rtl_slide.value_at(BOX, 174.0, 124.0, true, true).unwrap();
2798 assert!(
2799 (moved - 0.85).abs() < 1e-6,
2800 "a slide leftwards raises it right to left"
2801 );
2802
2803 let thumb = track(DragMode::SlideThumb {
2804 origin: 0.2,
2805 radius: 24.0,
2806 });
2807 assert!(thumb.engages(BOX, 64.0, false), "on the thumb");
2808 assert!(thumb.engages(BOX, 88.0, false), "at the halo's edge");
2809 assert!(!thumb.engages(BOX, 124.0, false), "off it");
2810 assert!(
2811 thumb.engages(BOX, 184.0, true),
2812 "right to left, the thumb is on the right"
2813 );
2814 assert!(!thumb.engages(BOX, 64.0, true));
2815 assert_eq!(thumb.value_at(BOX, 70.0, 120.0, true, false), Some(0.45));
2816 }
2817}
2818
2819#[cfg(test)]
2820mod range_slider_tests {
2821 use super::*;
2822 use crate::widget::{DragMode, DragTrack, ThumbDrag};
2823 use frus_core::Primitive;
2824
2825 #[derive(Clone, Debug, PartialEq)]
2826 enum Msg {
2827 Range(f32, f32),
2828 Start(f32, f32),
2829 End(f32, f32),
2830 }
2831
2832 const BOX: Rect = Rect::new(0.0, 0.0, 248.0, 48.0);
2834
2835 fn slider(start: f32, end: f32) -> RangeSlider<Msg> {
2836 RangeSlider::new(start, end)
2837 .on_change(Msg::Range)
2838 .on_change_start(Msg::Start)
2839 .on_change_end(Msg::End)
2840 }
2841
2842 fn thumbs(slider: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
2844 Widget::<Msg>::children(slider)[0]
2845 .children()
2846 .iter()
2847 .map(|aligned| aligned.children()[0].as_ref())
2848 .collect()
2849 }
2850
2851 fn paint(slider: &RangeSlider<Msg>, status: Status, theme: &Theme) -> Vec<Primitive> {
2853 let mut scene = Scene::new();
2854 Widget::<Msg>::paint(slider, BOX, status, theme, &mut scene);
2855 assert!(Widget::<Msg>::paints_over(slider));
2856 Widget::<Msg>::paint_over(slider, BOX, status, theme, &mut scene);
2857 scene.primitives().to_vec()
2858 }
2859
2860 fn rects(primitives: &[Primitive]) -> Vec<(Rect, Color)> {
2861 primitives
2862 .iter()
2863 .filter_map(|p| match p {
2864 Primitive::Rect {
2865 rect, color, blur, ..
2866 } if *blur == 0.0 => Some((*rect, *color)),
2867 _ => None,
2868 })
2869 .collect()
2870 }
2871
2872 fn thumb_rects(primitives: &[Primitive]) -> Vec<Rect> {
2874 rects(primitives)
2875 .into_iter()
2876 .map(|(r, _)| r)
2877 .filter(|r| r.width == 20.0 && r.height == 20.0)
2878 .collect()
2879 }
2880
2881 fn texts(primitives: &[Primitive]) -> usize {
2882 primitives
2883 .iter()
2884 .filter(|p| matches!(p, Primitive::Text { .. }))
2885 .count()
2886 }
2887
2888 fn close(a: (f32, f32), b: (f32, f32)) -> bool {
2889 (a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4
2890 }
2891
2892 fn range(msg: Option<Msg>) -> (f32, f32) {
2893 match msg {
2894 Some(Msg::Range(s, e)) => (s, e),
2895 other => panic!("expected a range, got {other:?}"),
2896 }
2897 }
2898
2899 fn keyed(response: KeyResponse<Msg>) -> (f32, f32) {
2900 match response {
2901 KeyResponse::Handled(msg) => range(msg),
2902 other => panic!("expected a handled key, got {other:?}"),
2903 }
2904 }
2905
2906 const RIGHT: Key = Key::Right {
2907 shift: false,
2908 word: false,
2909 };
2910
2911 #[test]
2915 fn the_drag_track_carries_both_thumbs() {
2916 let s = slider(30.0, 80.0).range(20.0, 120.0);
2917 assert!(Widget::<Msg>::draggable(&s));
2918 let track = Widget::<Msg>::drag_track(&s);
2919 assert_eq!(track.inset, 24.0);
2920 assert!(track.mirrored);
2921 assert_eq!(
2922 track.mode,
2923 DragMode::Thumbs {
2924 values: [0.1, 0.6],
2925 radius: 24.0
2926 }
2927 );
2928 let backwards = slider(0.9, 0.1);
2929 assert_eq!(
2930 Widget::<Msg>::drag_track(&backwards).mode,
2931 DragMode::Thumbs {
2932 values: [0.1, 0.9],
2933 radius: 24.0
2934 },
2935 "put in order"
2936 );
2937 }
2938
2939 #[test]
2942 fn a_press_picks_the_nearer_thumb() {
2943 let track = DragTrack {
2944 inset: 24.0,
2945 mirrored: true,
2946 mode: DragMode::Thumbs {
2947 values: [0.2, 0.8],
2948 radius: 24.0,
2949 },
2950 };
2951 assert_eq!(track.pick_thumb(BOX, 30.0, 0.0, false), Some(0));
2953 assert_eq!(track.pick_thumb(BOX, 123.0, 0.0, false), Some(0));
2954 assert_eq!(track.pick_thumb(BOX, 125.0, 0.0, false), Some(1));
2955 assert_eq!(track.pick_thumb(BOX, 240.0, 0.0, false), Some(1));
2956 assert_eq!(track.pick_thumb(BOX, 184.0, 0.0, true), Some(0));
2958 assert_eq!(track.pick_thumb(BOX, 64.0, 0.0, true), Some(1));
2959
2960 let met = DragTrack {
2961 mode: DragMode::Thumbs {
2962 values: [0.5, 0.55],
2963 radius: 24.0,
2964 },
2965 ..track
2966 };
2967 assert_eq!(met.pick_thumb(BOX, 129.0, 0.0, false), None, "undecided");
2969 assert_eq!(met.pick_thumb(BOX, 129.0, -3.0, false), Some(0));
2970 assert_eq!(met.pick_thumb(BOX, 129.0, 3.0, false), Some(1));
2971 assert_eq!(
2972 met.pick_thumb(BOX, 119.0, 3.0, true),
2973 Some(0),
2974 "rightwards is towards the start, right to left"
2975 );
2976 assert_eq!(met.pick_thumb(BOX, 119.0, -3.0, true), Some(1));
2977 assert_eq!(met.pick_thumb(BOX, 100.0, 5.0, false), Some(0));
2979 let single = DragTrack {
2980 mode: DragMode::Follow,
2981 ..track
2982 };
2983 assert_eq!(single.pick_thumb(BOX, 64.0, 0.0, false), None);
2984 assert_eq!(met.value_at(BOX, 129.0, 174.0, true, false), Some(0.75));
2985 }
2986
2987 #[test]
2990 fn a_thumb_drag_reports_the_interval() {
2991 let s = slider(30.0, 80.0).range(20.0, 120.0).divisions(10);
2992 assert_eq!(
2993 Widget::on_thumb_drag(&s, 0, ThumbDrag::Start, 0.9),
2994 Some(Msg::Start(30.0, 80.0)),
2995 "as it was"
2996 );
2997 assert_eq!(
2998 Widget::on_thumb_drag(&s, 0, ThumbDrag::Move, 0.22),
2999 Some(Msg::Range(40.0, 80.0)),
3000 "the start moves, snapped"
3001 );
3002 assert_eq!(
3003 Widget::on_thumb_drag(&s, 1, ThumbDrag::Move, 0.87),
3004 Some(Msg::Range(30.0, 110.0)),
3005 "the end moves"
3006 );
3007 assert_eq!(
3008 Widget::on_thumb_drag(&s, 1, ThumbDrag::End, 1.4),
3009 Some(Msg::End(30.0, 120.0)),
3010 "held to the travel"
3011 );
3012 let dead = slider(30.0, 80.0).range(20.0, 120.0).enabled(false);
3013 assert!(!Widget::<Msg>::draggable(&dead));
3014 assert_eq!(Widget::on_thumb_drag(&dead, 0, ThumbDrag::Move, 0.5), None);
3015 let silent = RangeSlider::<Msg>::new(0.2, 0.8);
3016 assert!(!Widget::<Msg>::draggable(&silent), "nothing to tell");
3017 }
3018
3019 #[test]
3023 fn a_thumb_never_passes_the_other() {
3024 let discrete = slider(0.2, 0.8).divisions(100);
3026 paint(&discrete, Status::default(), &Theme::default());
3028 let (s, e) = range(Widget::on_thumb_drag(&discrete, 0, ThumbDrag::Move, 0.95));
3029 assert!(close((s, e), (0.8, 0.8)), "met ({s}, {e})");
3030
3031 let continuous = slider(0.2, 0.8);
3032 paint(&continuous, Status::default(), &Theme::default());
3033 let (s, e) = range(Widget::on_thumb_drag(&continuous, 0, ThumbDrag::Move, 0.95));
3034 assert!(close((s, e), (0.76, 0.8)), "eight pixels off ({s}, {e})");
3035 let (s, e) = range(Widget::on_thumb_drag(&continuous, 1, ThumbDrag::Move, 0.0));
3036 assert!(close((s, e), (0.2, 0.24)), "from above too ({s}, {e})");
3037 let (s, e) = range(Widget::on_thumb_drag(&continuous, 1, ThumbDrag::Move, 0.5));
3038 assert!(close((s, e), (0.2, 0.5)), "free in between ({s}, {e})");
3039 }
3040
3041 #[test]
3044 fn each_thumb_takes_the_keyboard_and_a_reader() {
3045 let s = slider(0.4, 0.6).divisions(10);
3046 let t = thumbs(&s);
3047 assert_eq!(t.len(), 2);
3048 assert!(t[0].focusable() && t[1].focusable());
3049 assert_eq!(Widget::<Msg>::semantics(&s), None, "the thumbs speak");
3050 let start = t[0].semantics().expect("the start");
3051 assert_eq!(start.role, frus_core::Role::Slider);
3052 assert_eq!(start.value.as_deref(), Some("40%"));
3053 assert_eq!(start.range, Some((0.0, 0.4, 1.0)));
3054 assert_eq!(
3055 t[1].semantics().and_then(|s| s.value).as_deref(),
3056 Some("60%")
3057 );
3058
3059 assert!(close(keyed(t[0].on_key(&RIGHT)), (0.5, 0.6)));
3060 assert!(close(
3061 keyed(t[1].on_key(&Key::Left {
3062 shift: false,
3063 word: false
3064 })),
3065 (0.4, 0.5)
3066 ));
3067 let home = Key::Home {
3068 shift: false,
3069 doc: false,
3070 };
3071 let end = Key::End {
3072 shift: false,
3073 doc: false,
3074 };
3075 assert!(close(keyed(t[0].on_key(&home)), (0.0, 0.6)));
3076 assert!(close(keyed(t[0].on_key(&end)), (0.6, 0.6)), "stopped");
3077 assert!(close(keyed(t[1].on_key(&end)), (0.4, 1.0)));
3078 assert!(close(keyed(t[1].on_key(&home)), (0.4, 0.4)), "stopped");
3079 assert!(matches!(t[0].on_key(&Key::Enter), KeyResponse::Ignored));
3080
3081 let labelled = slider(20.0, 60.0)
3082 .range(0.0, 100.0)
3083 .value_label(|v| format!("{v} °C"));
3084 let t = thumbs(&labelled);
3085 assert_eq!(
3086 t[1].semantics().and_then(|s| s.value).as_deref(),
3087 Some("60 °C")
3088 );
3089 assert_eq!(
3090 t[1].semantics().and_then(|s| s.range),
3091 Some((0.0, 60.0, 100.0)),
3092 "in the slider's units"
3093 );
3094 let told = labelled.semantic_formatter(|v| format!("{v} degrees"));
3095 assert_eq!(
3096 thumbs(&told)[0]
3097 .semantics()
3098 .and_then(|s| s.value)
3099 .as_deref(),
3100 Some("20 degrees")
3101 );
3102 }
3103
3104 #[test]
3106 fn a_disabled_range_slider_takes_no_drag_no_key_and_no_focus() {
3107 let dead = slider(0.2, 0.8).enabled(false);
3108 assert!(!Widget::<Msg>::draggable(&dead));
3109 for (i, thumb) in thumbs(&dead).into_iter().enumerate() {
3110 assert!(!thumb.focusable(), "thumb {i} takes focus");
3111 assert!(
3112 matches!(thumb.on_key(&RIGHT), KeyResponse::Ignored),
3113 "thumb {i} answers an arrow"
3114 );
3115 let said = thumb.semantics().expect("still announced");
3116 assert!(said.disabled);
3117 }
3118 assert_eq!(
3119 thumbs(&dead)[0]
3120 .semantics()
3121 .and_then(|s| s.value)
3122 .as_deref(),
3123 Some("20%")
3124 );
3125 }
3126
3127 #[test]
3131 fn the_track_is_active_between_the_thumbs() {
3132 let theme = Theme::default();
3133 let primary = theme.scheme.primary;
3134 let found = rects(&paint(&slider(0.2, 0.8), Status::default(), &theme));
3135 let outer: Vec<_> = found
3136 .iter()
3137 .filter(|(_, c)| *c == primary.fade(0.24))
3138 .collect();
3139 assert_eq!(outer.len(), 2, "the two outer parts");
3140 assert_eq!(outer[0].0.x, 24.0);
3141 assert_eq!(outer[1].0.x + outer[1].0.width, 224.0);
3142 let span = found
3143 .iter()
3144 .find(|(r, c)| *c == primary && r.height == 6.0)
3145 .expect("the span");
3146 assert_eq!((span.0.x, span.0.width), (64.0, 120.0));
3147 assert_eq!(
3148 found
3149 .iter()
3150 .filter(|(r, c)| *c == primary && r.width == 20.0)
3151 .count(),
3152 2,
3153 "two thumbs"
3154 );
3155
3156 let rtl = Theme {
3157 direction: frus_core::TextDirection::Rtl,
3158 ..Theme::default()
3159 };
3160 let found = rects(&paint(&slider(0.1, 0.4), Status::default(), &rtl));
3161 let span = found
3162 .iter()
3163 .find(|(r, c)| *c == primary && r.height == 6.0)
3164 .expect("the span");
3165 assert_eq!((span.0.x, span.0.width), (144.0, 60.0), "from the right");
3166
3167 let red = Color::rgb(1.0, 0.0, 0.0);
3168 let blue = Color::rgb(0.0, 0.0, 1.0);
3169 let found = rects(&paint(
3170 &slider(0.2, 0.8).active_color(red).inactive_color(blue),
3171 Status::default(),
3172 &theme,
3173 ));
3174 assert!(found.iter().any(|(r, c)| *c == red && r.height == 6.0));
3175 assert_eq!(found.iter().filter(|(_, c)| *c == blue).count(), 2);
3176 assert_eq!(
3177 found
3178 .iter()
3179 .filter(|(r, c)| *c == red && r.width == 20.0)
3180 .count(),
3181 2,
3182 "the thumbs take the active colour"
3183 );
3184 }
3185
3186 #[test]
3190 fn the_thumb_last_taken_is_on_top() {
3191 let theme = Theme::default();
3192 let held = |thumb| Status {
3193 press_progress: 1.0,
3194 thumb,
3195 ..Default::default()
3196 };
3197 let s = slider(0.2, 0.8);
3198 let order = thumb_rects(&paint(&s, held(None), &theme));
3199 assert_eq!(order[1].x, 174.0, "the end on top");
3200 let order = thumb_rects(&paint(&s, held(Some(0)), &theme));
3201 assert_eq!(order[1].x, 54.0, "the start, once taken");
3202
3203 let heights = |status| crate::shadowprobe::heights(&paint(&s, status, &theme));
3204 assert_eq!(heights(held(Some(0))), vec![1.0, 6.0], "only it rises");
3205 assert_eq!(heights(Status::default()), vec![1.0, 1.0]);
3206 let raised = slider(0.2, 0.8).thumb_elevation(3.0);
3207 assert_eq!(
3208 crate::shadowprobe::heights(&paint(&raised, Status::default(), &theme)),
3209 vec![3.0, 3.0]
3210 );
3211
3212 let halo = theme.scheme.primary.fade(0.12);
3213 let found = rects(&paint(&s, held(Some(0)), &theme));
3214 let ring = found.iter().find(|(_, c)| *c == halo).expect("the halo");
3215 assert_eq!(ring.0, Rect::new(40.0, 0.0, 48.0, 48.0), "on the start");
3216 let found = rects(&paint(&s, held(None), &theme));
3217 let ring = found.iter().find(|(_, c)| *c == halo).expect("the halo");
3218 assert_eq!(
3219 ring.0,
3220 Rect::new(160.0, 0.0, 48.0, 48.0),
3221 "on the end, until one is taken"
3222 );
3223
3224 let ringed = |s: &RangeSlider<Msg>| {
3225 paint(s, Status::default(), &theme)
3226 .iter()
3227 .filter(|p| {
3228 matches!(p, Primitive::Rect { border_width, border_color, .. }
3229 if *border_width == 1.0 && *border_color == theme.scheme.surface)
3230 })
3231 .count()
3232 };
3233 assert_eq!(ringed(&slider(0.5, 0.55)), 1, "overlapping");
3234 assert_eq!(ringed(&slider(0.2, 0.8)), 0, "apart");
3235 }
3236
3237 #[test]
3240 fn the_values_show_while_it_is_moved() {
3241 let theme = Theme::default();
3242 let pressed = Status {
3243 press_progress: 1.0,
3244 ..Default::default()
3245 };
3246 let labelled = |s: RangeSlider<Msg>| s.value_label(|v| format!("{v:.1}"));
3247 let discrete = labelled(slider(0.2, 0.8).divisions(10));
3248 assert_eq!(texts(&paint(&discrete, pressed, &theme)), 2, "both");
3249 assert_eq!(texts(&paint(&discrete, Status::default(), &theme)), 0);
3250 let continuous = labelled(slider(0.2, 0.8));
3251 assert_eq!(
3252 texts(&paint(&continuous, pressed, &theme)),
3253 0,
3254 "only for discrete, by default"
3255 );
3256 let always =
3257 labelled(slider(0.2, 0.8)).show_value_indicator(ShowValueIndicator::AlwaysVisible);
3258 assert_eq!(texts(&paint(&always, Status::default(), &theme)), 2);
3259 assert_eq!(
3260 texts(&paint(&slider(0.2, 0.8).divisions(10), pressed, &theme)),
3261 0,
3262 "nothing to say"
3263 );
3264
3265 let p = paint(&discrete, pressed, &theme);
3268 let indicator = theme
3269 .scheme
3270 .surface
3271 .fade(0.90)
3272 .lerp(theme.scheme.on_surface, 0.60);
3273 let boxes: Vec<Rect> = rects(&p)
3274 .into_iter()
3275 .filter(|(_, c)| *c == indicator)
3276 .map(|(r, _)| r)
3277 .collect();
3278 assert_eq!(boxes.len(), 2);
3279 let centre = boxes[0].x + boxes[0].width / 2.0;
3280 assert!(
3282 (centre - 65.2).abs() < 1e-3,
3283 "over the start, under: {centre}"
3284 );
3285 let bottom = boxes[0].y + boxes[0].height;
3286 assert!(
3287 (bottom - 2.0).abs() < 1e-3,
3288 "eight over the point: {bottom}"
3289 );
3290
3291 let focused = Status {
3293 focus_progress: 1.0,
3294 ..Default::default()
3295 };
3296 let mut scene = Scene::new();
3297 thumbs(&discrete)[0].paint(Rect::new(0.0, 0.0, 48.0, 48.0), focused, &theme, &mut scene);
3298 assert_eq!(texts(scene.primitives()), 1);
3299 }
3300
3301 #[test]
3304 fn it_fills_the_width_it_is_offered() {
3305 let theme = Theme::default();
3306 let s = slider(0.2, 0.8);
3307 assert!(Widget::<Msg>::fill_axes(&s, &theme).horizontal);
3308 let style = Widget::<Msg>::style(&s);
3309 assert_eq!(style.height, Dimension::Length(48.0));
3310 assert_eq!(style.min_width, Dimension::Length(192.0));
3311 let sized = slider(0.2, 0.8).width(120.0);
3312 assert!(!Widget::<Msg>::fill_axes(&sized, &theme).horizontal);
3313 assert_eq!(Widget::<Msg>::style(&sized).width, Dimension::Length(120.0));
3314 }
3315
3316 #[test]
3320 fn a_hovered_thumb_s_halo_is_under_the_thumb() {
3321 use crate::{build_ui, Runtime, Size};
3322 let theme = Theme::default();
3323 let root = slider(0.2, 0.8).width(248.0);
3324 let size = Size::new(248.0, 48.0);
3325 let first = build_ui(&root, size, &Runtime::default(), &theme);
3326 let stop = first.focus_next(None, true).expect("the start's stop");
3327 let mut runtime = Runtime::default();
3328 runtime.input.hovered = Some(stop);
3329 runtime.advance(1.0);
3330 let ui = build_ui(&root, size, &runtime, &theme);
3331 let found = rects(ui.scene().primitives());
3332 let halo = theme.scheme.primary.fade(0.12);
3333 let at = |wanted: &dyn Fn(&(Rect, Color)) -> bool| {
3334 found.iter().position(wanted).expect("painted")
3335 };
3336 let disc = at(&|(r, c)| *c == halo && r.width == 48.0);
3337 let thumb = at(&|(r, c)| *c == theme.scheme.primary && r.width == 20.0);
3338 let track = at(&|(_, c)| *c == theme.scheme.primary.fade(0.24));
3339 assert!(
3340 track < disc && disc < thumb,
3341 "track {track}, halo {disc}, thumb {thumb}"
3342 );
3343 let (r, _) = found[disc];
3344 assert!(
3345 (r.x + 24.0 - 64.0).abs() < 1e-3 && r.y == 0.0,
3346 "round the start: {r:?}"
3347 );
3348 }
3349}