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::flex::Flex;
10use crate::interaction::{Key, KeyResponse, Status};
11use crate::theme::Theme;
12use crate::widget::Widget;
13use crate::widgetstate::{WidgetState, WidgetStateProperty};
14
15const TIP_H: f32 = 20.0;
17const TIP_GAP: f32 = 6.0;
18fn tip_style(theme: Option<&Theme>) -> ResolvedTextStyle {
24 theme
25 .and_then(|t| t.widgets.slider.value_indicator_text_style)
26 .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
27 .resolved()
28}
29const KEY_STEP: f32 = 0.05;
31
32const H: f32 = 24.0;
33const TRACK_H: f32 = 6.0;
34const THUMB: f32 = 18.0;
35const THUMB_ELEVATION: f32 = 1.0;
39const THUMB_PRESSED_ELEVATION: f32 = 6.0;
40
41fn paint_thumb_shadow(
54 thumb: Rect,
55 told: (Option<f32>, Option<f32>),
56 status: &Status,
57 theme: &Theme,
58 scene: &mut Scene,
59) {
60 let t = &theme.widgets.slider;
61 let rest = told.0.or(t.thumb_elevation).unwrap_or(THUMB_ELEVATION);
62 let pressed = told
63 .1
64 .or(t.pressed_thumb_elevation)
65 .unwrap_or(THUMB_PRESSED_ELEVATION);
66 let depth = rest + (pressed - rest) * status.press_progress.clamp(0.0, 1.0);
67 if depth <= 0.0 {
68 return;
69 }
70 frus_core::paint_elevation(
72 scene,
73 thumb,
74 frus_core::BorderRadius::uniform(thumb.width * 0.5),
75 depth,
76 theme.scheme.shadow.fade(status.opacity),
77 );
78}
79
80const TRACK_HEIGHT: f32 = 4.0;
83const ACTIVE_EXTRA: f32 = 2.0;
84const THUMB_RADIUS: f32 = 10.0;
85const OVERLAY_RADIUS: f32 = 24.0;
86const MIN_TRACK: f32 = 144.0;
88const DEFAULT_WIDTH: f32 = 220.0;
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
95pub enum ShowValueIndicator {
96 #[default]
98 OnlyForDiscrete,
99 OnlyForContinuous,
101 OnDrag,
103 AlwaysVisible,
105 Never,
107}
108
109pub struct Slider<Msg = crate::callback::Callback> {
123 value: f32,
124 secondary: Option<f32>,
125 min: f32,
126 max: f32,
127 width: f32,
128 sized: bool,
129 divisions: Option<usize>,
130 label: Option<Rc<dyn Fn(f32) -> String>>,
132 semantic_label: Option<Rc<dyn Fn(f32) -> String>>,
134 show_value_indicator: Option<ShowValueIndicator>,
135 enabled: bool,
136 colors: SliderColors,
137 overlay_color: Option<WidgetStateProperty<Color>>,
138 thumb_elevation: Option<f32>,
139 pressed_thumb_elevation: Option<f32>,
140 on_change: Option<Box<dyn Fn(f32) -> Msg>>,
141 on_change_start: Option<Box<dyn Fn(f32) -> Msg>>,
143 on_change_end: Option<Box<dyn Fn(f32) -> Msg>>,
145}
146
147#[derive(Clone, Copy, Debug, Default, PartialEq)]
150pub(crate) struct SliderColors {
151 pub active_track: Option<Color>,
152 pub inactive_track: Option<Color>,
153 pub secondary_track: Option<Color>,
154 pub thumb: Option<Color>,
155 pub thumb_border: Option<Color>,
156}
157
158#[derive(Clone, Copy, Debug, PartialEq)]
160struct Palette {
161 active: Color,
162 inactive: Color,
163 secondary: Color,
164 thumb: Color,
165 ring: Option<Color>,
166 active_tick: Color,
167 inactive_tick: Color,
168}
169
170impl SliderColors {
171 fn resolve(&self, theme: &Theme, enabled: bool) -> Palette {
174 let t = &theme.widgets.slider;
175 let s = &theme.scheme;
176 if !enabled {
177 let content = disabled_content(theme);
181 let container = disabled_container(theme);
182 return Palette {
183 active: t.disabled_active_track_color.unwrap_or(content),
184 inactive: t.disabled_inactive_track_color.unwrap_or(container),
185 secondary: t.disabled_inactive_track_color.unwrap_or(container),
186 thumb: t.disabled_thumb_color.unwrap_or(content),
187 ring: None,
188 active_tick: content,
189 inactive_tick: content,
190 };
191 }
192 let active = self
193 .active_track
194 .or(t.active_track_color)
195 .unwrap_or(s.primary);
196 Palette {
197 active,
198 inactive: self
199 .inactive_track
200 .or(t.inactive_track_color)
201 .unwrap_or(s.surface_container_highest),
202 secondary: self
203 .secondary_track
204 .or(t.secondary_active_track_color)
205 .unwrap_or(s.primary.fade(0.54)),
206 thumb: self
209 .thumb
210 .or(self.active_track)
211 .or(t.thumb_color)
212 .unwrap_or(s.primary),
213 ring: self.thumb_border.or(t.thumb_border_color),
214 active_tick: self
217 .inactive_track
218 .or(t.active_tick_mark_color)
219 .unwrap_or(s.on_primary.fade(0.38)),
220 inactive_tick: self
221 .active_track
222 .or(t.inactive_tick_mark_color)
223 .unwrap_or(s.on_surface_variant.fade(0.38)),
224 }
225 }
226}
227
228impl<Msg> Slider<Msg> {
229 pub fn new(value: f32) -> Self {
232 Self {
233 value,
234 secondary: None,
235 min: 0.0,
236 max: 1.0,
237 width: DEFAULT_WIDTH,
238 sized: false,
239 divisions: None,
240 label: None,
241 semantic_label: None,
242 show_value_indicator: None,
243 enabled: true,
244 colors: SliderColors::default(),
245 overlay_color: None,
246 thumb_elevation: None,
247 pressed_thumb_elevation: None,
248 on_change: None,
249 on_change_start: None,
250 on_change_end: None,
251 }
252 }
253
254 pub fn active_color(mut self, color: Color) -> Self {
257 self.colors.active_track = Some(color);
258 self
259 }
260
261 pub fn inactive_color(mut self, color: Color) -> Self {
264 self.colors.inactive_track = Some(color);
265 self
266 }
267
268 pub fn secondary_active_color(mut self, color: Color) -> Self {
271 self.colors.secondary_track = Some(color);
272 self
273 }
274
275 pub fn thumb_color(mut self, color: Color) -> Self {
277 self.colors.thumb = Some(color);
278 self
279 }
280
281 pub fn thumb_border_color(mut self, color: Color) -> Self {
284 self.colors.thumb_border = Some(color);
285 self
286 }
287
288 pub fn overlay_color(mut self, color: WidgetStateProperty<Color>) -> Self {
292 self.overlay_color = Some(color);
293 self
294 }
295
296 pub fn thumb_elevation(mut self, elevation: f32) -> Self {
299 self.thumb_elevation = Some(elevation);
300 self
301 }
302
303 pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
305 self.pressed_thumb_elevation = Some(elevation);
306 self
307 }
308
309 pub fn range(mut self, min: f32, max: f32) -> Self {
319 self.min = min.min(max);
320 self.max = min.max(max);
321 self
322 }
323
324 pub fn secondary_track_value(mut self, value: f32) -> Self {
327 self.secondary = Some(value);
328 self
329 }
330
331 pub fn divisions(mut self, n: usize) -> Self {
335 self.divisions = Some(n.max(1));
336 self
337 }
338
339 pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
345 self.label = Some(Rc::new(label));
346 self
347 }
348
349 pub fn show_value_indicator(mut self, when: ShowValueIndicator) -> Self {
352 self.show_value_indicator = Some(when);
353 self
354 }
355
356 pub fn semantic_formatter(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
358 self.semantic_label = Some(Rc::new(format));
359 self
360 }
361
362 pub fn width(mut self, width: f32) -> Self {
365 self.width = width;
366 self.sized = true;
367 self
368 }
369
370 pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
372 mut self,
373 on_change: impl Fn(f32) -> R + 'static,
374 ) -> Self {
375 self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
376 self
377 }
378
379 pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
388 mut self,
389 on_start: impl Fn(f32) -> R + 'static,
390 ) -> Self {
391 self.on_change_start = Some(Box::new(crate::callback::handler1(on_start)));
392 self
393 }
394
395 pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
401 mut self,
402 on_end: impl Fn(f32) -> R + 'static,
403 ) -> Self {
404 self.on_change_end = Some(Box::new(crate::callback::handler1(on_end)));
405 self
406 }
407
408 pub fn enabled(mut self, enabled: bool) -> Self {
415 self.enabled = enabled;
416 self
417 }
418
419 fn held(&self) -> f32 {
421 self.value.clamp(self.min, self.max)
422 }
423
424 fn fraction_of(&self, value: f32) -> f32 {
427 let span = self.max - self.min;
428 if span <= 0.0 {
429 0.0
430 } else {
431 ((value.clamp(self.min, self.max) - self.min) / span).clamp(0.0, 1.0)
432 }
433 }
434
435 fn fraction(&self) -> f32 {
437 self.fraction_of(self.held())
438 }
439
440 fn snap(&self, fraction: f32) -> f32 {
442 match self.divisions {
443 Some(n) if n > 0 => (fraction * n as f32).round() / n as f32,
444 _ => fraction,
445 }
446 }
447
448 fn at(&self, fraction: f32) -> f32 {
450 self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
451 }
452
453 fn key_step(&self) -> f32 {
456 match self.divisions {
457 Some(n) if n > 0 => 1.0 / n as f32,
458 _ => KEY_STEP,
459 }
460 }
461
462 fn discrete(&self) -> bool {
463 self.divisions.is_some_and(|n| n > 0)
464 }
465
466 fn indicator_reveal(&self, status: &Status, theme: &Theme) -> f32 {
468 if !self.enabled || self.label.is_none() {
469 return 0.0;
470 }
471 let when = self
472 .show_value_indicator
473 .or(theme.widgets.slider.show_value_indicator)
474 .unwrap_or_default();
475 let moved = status
476 .press_progress
477 .max(status.focus_progress)
478 .clamp(0.0, 1.0);
479 match when {
480 ShowValueIndicator::AlwaysVisible => 1.0,
481 ShowValueIndicator::Never => 0.0,
482 ShowValueIndicator::OnlyForDiscrete if !self.discrete() => 0.0,
483 ShowValueIndicator::OnlyForContinuous if self.discrete() => 0.0,
484 _ => moved,
485 }
486 }
487
488 fn overlay(&self, status: &Status, theme: &Theme) -> Color {
491 let mut states = status.states();
492 if status.interaction == crate::interaction::Interaction::Pressed {
493 states = states.with(WidgetState::Dragged);
494 }
495 let scheme = &theme.scheme;
496 self.overlay_color
497 .as_ref()
498 .and_then(|p| p.resolve(states).copied())
499 .or(self.colors.active_track.map(|c| c.fade(0.12)))
500 .or_else(|| {
501 theme
502 .widgets
503 .slider
504 .overlay_color
505 .as_ref()
506 .and_then(|p| p.resolve(states).copied())
507 })
508 .unwrap_or_else(|| {
509 if states.contains(WidgetState::Dragged) {
510 scheme.primary.fade(0.1)
511 } else if states.contains(WidgetState::Hovered) {
512 scheme.primary.fade(0.08)
513 } else if states.contains(WidgetState::Focused) {
514 scheme.primary.fade(0.1)
515 } else {
516 Color::TRANSPARENT
517 }
518 })
519 }
520
521 fn track_height(&self, theme: &Theme) -> f32 {
522 theme.widgets.slider.track_height.unwrap_or(TRACK_HEIGHT)
523 }
524}
525
526fn thumb_x(left: f32, width: f32, t: f32, discrete: bool, track_height: f32) -> f32 {
531 let padding = if discrete { track_height } else { 0.0 };
532 let x = left + t * (width - padding) + padding / 2.0;
533 let inner = if padding > THUMB_RADIUS {
534 padding / 2.0
535 } else {
536 0.0
537 };
538 x.clamp(left + inner, left + width - inner)
539}
540
541impl<Msg> Widget<Msg> for Slider<Msg> {
542 fn style(&self) -> Style {
543 Style {
544 width: Dimension::Length(self.width),
545 min_width: Dimension::Length(if self.sized {
546 0.0
547 } else {
548 MIN_TRACK + 2.0 * OVERLAY_RADIUS
549 }),
550 height: Dimension::Length(2.0 * OVERLAY_RADIUS),
551 ..Default::default()
552 }
553 }
554
555 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
558 crate::widget::FillAxes {
559 horizontal: !self.sized,
560 vertical: false,
561 }
562 }
563
564 fn soft_extent(&self) -> crate::widget::FillAxes {
565 crate::widget::FillAxes {
566 horizontal: !self.sized,
567 vertical: false,
568 }
569 }
570
571 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
572 &[]
573 }
574
575 fn debug_name(&self) -> &'static str {
576 "Slider"
577 }
578
579 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
580 let o = status.opacity;
581 let rtl = theme.direction.is_rtl();
582 let palette = self.colors.resolve(theme, self.enabled);
583 let track_h = self.track_height(theme);
584 let left = bounds.x + OVERLAY_RADIUS;
586 let width = (bounds.width - 2.0 * OVERLAY_RADIUS).max(0.0);
587 let cy = bounds.y + bounds.height * 0.5;
588 let along = |t: f32| if rtl { 1.0 - t } else { t };
589 let discrete = self.discrete();
590 let thumb = thumb_x(left, width, along(self.fraction()), discrete, track_h);
591
592 if track_h > 0.0 {
593 let half = track_h * 0.5;
594 let active_half = (track_h + ACTIVE_EXTRA) * 0.5;
595 let right = left + width;
596 let (a0, a1) = if rtl {
599 (thumb - half, right)
600 } else {
601 (left, thumb + half)
602 };
603 let (i0, i1) = if rtl {
604 (left, thumb + half)
605 } else {
606 (thumb - half, right)
607 };
608 if i1 - i0 > track_h {
609 scene.draw_rect(
610 Rect::new(i0, cy - half, i1 - i0, track_h),
611 palette.inactive.fade(o),
612 half,
613 0.0,
614 Color::TRANSPARENT,
615 );
616 }
617 if a1 - a0 > track_h {
618 scene.draw_rect(
619 Rect::new(a0, cy - active_half, a1 - a0, 2.0 * active_half),
620 palette.active.fade(o),
621 active_half,
622 0.0,
623 Color::TRANSPARENT,
624 );
625 }
626 if let Some(secondary) = self.secondary {
628 let sx = thumb_x(
629 left,
630 width,
631 along(self.fraction_of(secondary)),
632 discrete,
633 track_h,
634 );
635 let beyond = if rtl { sx < thumb } else { sx > thumb };
636 if beyond {
637 let (s0, s1) = if rtl { (sx, thumb) } else { (thumb, sx) };
638 let radius = if rtl {
639 frus_core::BorderRadius {
640 top_left: half,
641 bottom_left: half,
642 ..frus_core::BorderRadius::ZERO
643 }
644 } else {
645 frus_core::BorderRadius {
646 top_right: half,
647 bottom_right: half,
648 ..frus_core::BorderRadius::ZERO
649 }
650 };
651 scene.draw_rect(
652 Rect::new(s0, cy - half, s1 - s0, track_h),
653 palette.secondary.fade(o),
654 radius,
655 0.0,
656 Color::TRANSPARENT,
657 );
658 }
659 }
660 }
661
662 let activation = status
664 .hover_progress
665 .max(status.focus_progress)
666 .max(status.press_progress)
667 .clamp(0.0, 1.0);
668 if self.enabled && activation > 0.0 {
669 let r = OVERLAY_RADIUS * activation;
670 let halo = self.overlay(&status, theme);
671 if halo.a > 0.0 {
672 scene.draw_rect(
673 Rect::new(thumb - r, cy - r, 2.0 * r, 2.0 * r),
674 halo.fade(o),
675 r,
676 0.0,
677 Color::TRANSPARENT,
678 );
679 }
680 }
681
682 if let Some(n) = self.divisions.filter(|n| *n > 0) {
684 let tick_r = track_h / 4.0;
685 let adjusted = width - track_h;
686 if tick_r > 0.0 && adjusted / n as f32 >= 3.0 * 2.0 * tick_r {
687 for i in 0..=n {
688 let x = left + i as f32 / n as f32 * adjusted + track_h / 2.0;
689 let travelled = if rtl { x >= thumb } else { x <= thumb };
690 let color = if travelled {
691 palette.active_tick
692 } else {
693 palette.inactive_tick
694 };
695 scene.draw_rect(
696 Rect::new(x - tick_r, cy - tick_r, 2.0 * tick_r, 2.0 * tick_r),
697 color.fade(o),
698 tick_r,
699 0.0,
700 Color::TRANSPARENT,
701 );
702 }
703 }
704 }
705
706 let thumb_box = Rect::new(
708 thumb - THUMB_RADIUS,
709 cy - THUMB_RADIUS,
710 2.0 * THUMB_RADIUS,
711 2.0 * THUMB_RADIUS,
712 );
713 paint_thumb_shadow(
714 thumb_box,
715 (self.thumb_elevation, self.pressed_thumb_elevation),
716 &status,
717 theme,
718 scene,
719 );
720 let (ring_width, ring) = match palette.ring {
721 Some(ring) => (2.0, ring.fade(o)),
722 None => (0.0, Color::TRANSPARENT),
723 };
724 scene.draw_rect(
725 thumb_box,
726 palette.thumb.fade(o),
727 THUMB_RADIUS,
728 ring_width,
729 ring,
730 );
731
732 let reveal = self.indicator_reveal(&status, theme);
734 if let (Some(label), true) = (self.label.as_ref(), reveal > 0.0) {
735 paint_drop(
736 Point::new(thumb, cy),
737 label(self.held()),
738 theme,
739 reveal,
740 o,
741 scene,
742 );
743 }
744 }
745
746 fn on_click(&self) -> Option<Msg> {
747 None
748 }
749
750 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
751 let held = self.held();
757 let spoken = match self.semantic_label.as_ref().or(self.label.as_ref()) {
758 Some(label) => label(held),
759 None if self.min == 0.0 && self.max == 1.0 => {
760 format!("{}%", (held * 100.0).round())
761 }
762 None => format!("{held}"),
763 };
764 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
765 .value(spoken)
766 .range(self.min, held, self.max);
767 Some(if self.enabled {
768 semantics
769 } else {
770 semantics.disabled(true)
771 })
772 }
773
774 fn draggable(&self) -> bool {
775 self.enabled
776 }
777
778 fn drag_track(&self) -> crate::widget::DragTrack {
781 crate::widget::DragTrack {
782 inset: OVERLAY_RADIUS,
783 mirrored: true,
784 }
785 }
786
787 fn focusable(&self) -> bool {
788 self.enabled && self.on_change.is_some()
789 }
790
791 fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
792 if !self.enabled {
795 return KeyResponse::Ignored;
796 }
797 let delta = match key {
800 Key::Left { .. } => -self.key_step(),
801 Key::Right { .. } => self.key_step(),
802 Key::Home { .. } => -2.0,
803 Key::End { .. } => 2.0,
804 _ => return KeyResponse::Ignored,
805 };
806 let moved = self.at(self.fraction() + delta);
807 KeyResponse::Handled(self.on_change.as_ref().map(|make| make(moved)))
808 }
809
810 fn on_drag(&self, fraction: f32) -> Option<Msg> {
811 if !self.enabled {
814 return None;
815 }
816 self.on_change.as_ref().map(|make| make(self.at(fraction)))
817 }
818
819 fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
820 if !self.enabled {
821 return None;
822 }
823 self.on_change_start
827 .as_ref()
828 .map(|make| make(self.at(fraction)))
829 }
830
831 fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
832 if !self.enabled {
833 return None;
834 }
835 self.on_change_end
836 .as_ref()
837 .map(|make| make(self.at(fraction)))
838 }
839}
840
841fn paint_drop(at: Point, text: String, theme: &Theme, scale: f32, o: f32, scene: &mut Scene) {
845 const TRIANGLE: f32 = 10.0;
846 const PADDING: f32 = 8.0;
847 const MIN_LABEL: f32 = 20.0;
848 const RECT_H: f32 = 28.0;
849 const RECT_Y: f32 = 6.0;
850 const TIP_Y: f32 = 16.0;
851 let style = drop_style(theme);
852 let text_w = frus_text::measure_resolved(&text, &style).width;
853 let rect_w = MIN_LABEL.max(text_w) + PADDING;
854 let tip = Point::new(at.x, at.y - TIP_Y);
856 let s = |dx: f32, dy: f32| Point::new(tip.x + dx * scale, tip.y + dy * scale);
857 let color = theme
858 .widgets
859 .slider
860 .value_indicator_color
861 .unwrap_or(theme.scheme.primary)
862 .fade(o);
863 let top_left = s(-rect_w / 2.0, -RECT_Y - RECT_H);
864 let pill = Rect::new(top_left.x, top_left.y, rect_w * scale, RECT_H * scale);
865 let path = frus_core::Path::new()
866 .move_to(s(0.0, 0.0))
867 .line_to(s(-TRIANGLE, -TRIANGLE))
868 .line_to(s(TRIANGLE, -TRIANGLE))
869 .close();
870 scene.fill_path(&path, color);
871 scene.draw_rect(pill, color, pill.height * 0.5, 0.0, Color::TRANSPARENT);
872 if scale >= 0.999 {
874 let text_color = style.color.unwrap_or(theme.scheme.on_primary);
875 scene.text(
876 Point::new(
877 pill.x + (pill.width - text_w) * 0.5,
878 pill.y + (pill.height - style.line_height()) * 0.5,
879 ),
880 text,
881 &style,
882 text_color.fade(o),
883 );
884 }
885}
886
887fn drop_style(theme: &Theme) -> ResolvedTextStyle {
889 theme
890 .widgets
891 .slider
892 .value_indicator_text_style
893 .unwrap_or_else(|| {
894 crate::theme::type_scale(Some(theme))
895 .label_medium
896 .color(theme.scheme.on_primary)
897 })
898 .resolved()
899}
900
901struct Spacer {
903 width: f32,
904 height: f32,
905}
906
907impl<Msg: Clone> Widget<Msg> for Spacer {
908 fn style(&self) -> Style {
909 Style {
910 width: Dimension::Length(self.width),
911 height: Dimension::Length(self.height),
912 ..Default::default()
913 }
914 }
915
916 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
917 &[]
918 }
919
920 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
921
922 fn on_click(&self) -> Option<Msg> {
923 None
924 }
925}
926
927#[derive(Copy, Clone)]
929enum Side {
930 Low,
931 High,
932}
933
934struct RangeThumb<Msg> {
937 side: Side,
938 low: f32,
939 high: f32,
940 track: f32,
942 height: f32,
945 divisions: Option<usize>,
946 label: Option<Rc<dyn Fn(f32) -> String>>,
948 enabled: bool,
950 elevations: (Option<f32>, Option<f32>),
952 on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
953}
954
955impl<Msg> RangeThumb<Msg> {
956 fn snap(&self, v: f32) -> f32 {
958 match self.divisions {
959 Some(n) if n > 0 => (v * n as f32).round() / n as f32,
960 _ => v,
961 }
962 }
963
964 fn value(&self) -> f32 {
966 match self.side {
967 Side::Low => self.low,
968 Side::High => self.high,
969 }
970 }
971
972 fn moved(&self, delta: f32) -> (f32, f32) {
975 match self.side {
976 Side::Low => (
977 self.snap((self.low + delta).clamp(0.0, self.high)),
978 self.high,
979 ),
980 Side::High => (
981 self.low,
982 self.snap((self.high + delta).clamp(self.low, 1.0)),
983 ),
984 }
985 }
986
987 fn key_step(&self) -> f32 {
989 match self.divisions {
990 Some(n) if n > 0 => 1.0 / n as f32,
991 _ => KEY_STEP,
992 }
993 }
994}
995
996impl<Msg: Clone> Widget<Msg> for RangeThumb<Msg> {
997 fn style(&self) -> Style {
998 Style {
999 width: Dimension::Length(THUMB),
1000 height: Dimension::Length(self.height),
1001 ..Default::default()
1002 }
1003 }
1004
1005 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1006 &[]
1007 }
1008
1009 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1010 let o = status.opacity;
1011 let y = bounds.y + bounds.height - H + (H - THUMB) * 0.5;
1013 let border = if status.focused && self.enabled {
1015 3.0
1016 } else {
1017 2.0
1018 };
1019 let (fill, ring) = if self.enabled {
1020 (Color::WHITE, theme.primary)
1021 } else {
1022 let dead = disabled_content(theme);
1023 (dead, dead)
1024 };
1025 let thumb = Rect::new(bounds.x, y, THUMB, THUMB);
1026 paint_thumb_shadow(thumb, self.elevations, &status, theme, scene);
1027 scene.draw_rect(thumb, fill.fade(o), THUMB * 0.5, border, ring.fade(o));
1028 if let Some(label) = self.label.as_ref().filter(|_| self.enabled) {
1031 let active = status.focused || status.hover_progress > 0.01;
1032 if active {
1033 let reveal = if status.focused {
1034 o
1035 } else {
1036 status.hover_progress * o
1037 };
1038 paint_tip(
1039 bounds.x + THUMB * 0.5,
1040 bounds.y,
1041 label(self.value()),
1042 theme,
1043 reveal,
1044 scene,
1045 );
1046 }
1047 }
1048 }
1049
1050 fn on_click(&self) -> Option<Msg> {
1051 None
1052 }
1053
1054 fn focusable(&self) -> bool {
1055 self.enabled && self.on_change.is_some()
1056 }
1057
1058 fn draggable(&self) -> bool {
1059 self.enabled
1060 }
1061
1062 fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
1063 if !self.enabled || dx == 0.0 || self.track <= 0.0 {
1064 return None;
1065 }
1066 let (low, high) = self.moved(dx / self.track);
1067 self.on_change.as_ref().map(|make| make(low, high))
1068 }
1069
1070 fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
1071 if !self.enabled {
1074 return KeyResponse::Ignored;
1075 }
1076 let delta = match key {
1079 Key::Left { .. } => -self.key_step(),
1080 Key::Right { .. } => self.key_step(),
1081 Key::Home { .. } => -2.0, Key::End { .. } => 2.0, _ => return KeyResponse::Ignored,
1084 };
1085 let (low, high) = self.moved(delta);
1086 KeyResponse::Handled(self.on_change.as_ref().map(|make| make(low, high)))
1087 }
1088}
1089
1090pub struct RangeSlider<Msg = crate::callback::Callback> {
1095 low: f32,
1096 high: f32,
1097 width: f32,
1098 divisions: Option<usize>,
1099 label: Option<Rc<dyn Fn(f32) -> String>>,
1102 enabled: bool,
1103 thumb_elevation: Option<f32>,
1104 pressed_thumb_elevation: Option<f32>,
1105 on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1106 on_change_start: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1108 on_change_end: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1110 children: Vec<Box<dyn Widget<Msg>>>,
1111}
1112
1113impl<Msg: Clone + 'static> RangeSlider<Msg> {
1114 pub fn new(low: f32, high: f32) -> Self {
1116 let low = low.clamp(0.0, 1.0);
1117 let high = high.clamp(0.0, 1.0);
1118 let mut slider = Self {
1119 low: low.min(high),
1120 high: low.max(high),
1121 width: 220.0,
1122 divisions: None,
1123 label: None,
1124 enabled: true,
1125 thumb_elevation: None,
1126 pressed_thumb_elevation: None,
1127 on_change: None,
1128 on_change_start: None,
1129 on_change_end: None,
1130 children: Vec::new(),
1131 };
1132 slider.rebuild();
1133 slider
1134 }
1135
1136 pub fn width(mut self, width: f32) -> Self {
1138 self.width = width;
1139 self.rebuild();
1140 self
1141 }
1142
1143 pub fn divisions(mut self, n: usize) -> Self {
1146 self.divisions = Some(n.max(1));
1147 self.rebuild();
1148 self
1149 }
1150
1151 pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
1153 mut self,
1154 on_change: impl Fn(f32, f32) -> R + 'static,
1155 ) -> Self {
1156 self.on_change = Some(Rc::new(crate::callback::handler2(on_change)));
1157 self.rebuild();
1158 self
1159 }
1160
1161 pub fn enabled(mut self, enabled: bool) -> Self {
1166 self.enabled = enabled;
1167 self.rebuild();
1168 self
1169 }
1170
1171 pub fn thumb_elevation(mut self, elevation: f32) -> Self {
1174 self.thumb_elevation = Some(elevation);
1175 self.rebuild();
1176 self
1177 }
1178
1179 pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
1181 self.pressed_thumb_elevation = Some(elevation);
1182 self.rebuild();
1183 self
1184 }
1185
1186 pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
1189 self.label = Some(Rc::new(label));
1190 self.rebuild();
1191 self
1192 }
1193
1194 pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
1199 mut self,
1200 on_start: impl Fn(f32, f32) -> R + 'static,
1201 ) -> Self {
1202 self.on_change_start = Some(Rc::new(crate::callback::handler2(on_start)));
1203 self
1204 }
1205
1206 pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
1209 mut self,
1210 on_end: impl Fn(f32, f32) -> R + 'static,
1211 ) -> Self {
1212 self.on_change_end = Some(Rc::new(crate::callback::handler2(on_end)));
1213 self
1214 }
1215
1216 fn interval_at(&self, fraction: f32) -> (f32, f32) {
1222 let f = self.snap(fraction.clamp(0.0, 1.0));
1223 if f <= self.low {
1224 (f, self.high)
1225 } else if f >= self.high {
1226 (self.low, f)
1227 } else if f - self.low <= self.high - f {
1228 (f, self.high)
1229 } else {
1230 (self.low, f)
1231 }
1232 }
1233
1234 fn rebuild(&mut self) {
1236 let height = self.content_h();
1237 let thumb = |side: Side| RangeThumb {
1238 side,
1239 low: self.low,
1240 high: self.high,
1241 track: self.width,
1242 height,
1243 divisions: self.divisions,
1244 label: self.label.clone(),
1245 enabled: self.enabled,
1246 elevations: (self.thumb_elevation, self.pressed_thumb_elevation),
1247 on_change: self.on_change.clone(),
1248 };
1249 let lo_gap = (self.low * self.width - THUMB * 0.5).max(0.0);
1250 let mid_gap = ((self.high - self.low) * self.width - THUMB).max(0.0);
1251 let row = Flex::row()
1252 .child(Spacer {
1253 width: lo_gap,
1254 height,
1255 })
1256 .child(thumb(Side::Low))
1257 .child(Spacer {
1258 width: mid_gap,
1259 height,
1260 })
1261 .child(thumb(Side::High));
1262 self.children = vec![Box::new(row)];
1263 }
1264}
1265
1266fn paint_tip(cx: f32, top: f32, text: String, theme: &Theme, o: f32, scene: &mut Scene) {
1268 let style = tip_style(Some(theme));
1269 let tw = frus_text::measure_resolved(&text, &style).width;
1270 let bw = tw + 12.0;
1271 let bx = cx - bw * 0.5;
1272 scene.draw_rect(
1273 Rect::new(bx, top, bw, TIP_H),
1274 theme.primary.fade(o),
1275 TIP_H * 0.5,
1276 0.0,
1277 Color::TRANSPARENT,
1278 );
1279 let ty = top + (TIP_H - style.line_height()) * 0.5;
1280 scene.text(
1281 Point::new(bx + 6.0, ty),
1282 text,
1283 &style,
1284 theme.on_primary.fade(o),
1285 );
1286}
1287
1288impl<Msg> RangeSlider<Msg> {
1289 fn content_h(&self) -> f32 {
1291 if self.label.is_some() {
1292 TIP_H + TIP_GAP + H
1293 } else {
1294 H
1295 }
1296 }
1297
1298 fn snap(&self, v: f32) -> f32 {
1300 match self.divisions {
1301 Some(n) if n > 0 => (v * n as f32).round() / n as f32,
1302 _ => v,
1303 }
1304 }
1305}
1306
1307impl<Msg: Clone + 'static> Widget<Msg> for RangeSlider<Msg> {
1308 fn style(&self) -> Style {
1309 Style {
1310 width: Dimension::Length(self.width),
1311 height: Dimension::Length(self.content_h()),
1312 ..Default::default()
1313 }
1314 }
1315
1316 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1317 &self.children
1318 }
1319
1320 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1321 let o = status.opacity;
1322 let base_y = bounds.y + bounds.height - H;
1324 let track_y = base_y + (H - TRACK_H) * 0.5;
1325 let (rail, span) = if self.enabled {
1327 (theme.scheme.secondary_container, theme.primary)
1328 } else {
1329 (disabled_container(theme), disabled_content(theme))
1330 };
1331 scene.draw_rect(
1332 Rect::new(bounds.x, track_y, bounds.width, TRACK_H),
1333 rail.fade(o),
1334 TRACK_H * 0.5,
1335 0.0,
1336 Color::TRANSPARENT,
1337 );
1338 let lo = bounds.x + bounds.width * self.low;
1339 let hi = bounds.x + bounds.width * self.high;
1340 scene.draw_rect(
1341 Rect::new(lo, track_y, (hi - lo).max(0.0), TRACK_H),
1342 span.fade(o),
1343 TRACK_H * 0.5,
1344 0.0,
1345 Color::TRANSPARENT,
1346 );
1347 }
1349
1350 fn on_click(&self) -> Option<Msg> {
1351 None
1352 }
1353
1354 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1355 let pct = |v: f32| (v * 100.0).round();
1357 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
1358 .value(format!("{}%–{}%", pct(self.low), pct(self.high)));
1359 Some(if self.enabled {
1360 semantics
1361 } else {
1362 semantics.disabled(true)
1363 })
1364 }
1365
1366 fn draggable(&self) -> bool {
1367 self.enabled && self.on_change.is_some()
1370 }
1371
1372 fn on_drag(&self, fraction: f32) -> Option<Msg> {
1373 if !self.enabled {
1374 return None;
1375 }
1376 let (low, high) = self.interval_at(fraction);
1378 self.on_change.as_ref().map(|make| make(low, high))
1379 }
1380
1381 fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
1382 if !self.enabled {
1383 return None;
1384 }
1385 let (low, high) = self.interval_at(fraction);
1386 self.on_change_start.as_ref().map(|make| make(low, high))
1387 }
1388
1389 fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
1390 if !self.enabled {
1391 return None;
1392 }
1393 let (low, high) = self.interval_at(fraction);
1394 self.on_change_end.as_ref().map(|make| make(low, high))
1395 }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400 use super::*;
1401
1402 #[derive(Clone, Debug, PartialEq)]
1403 enum Msg {
1404 Value(f32),
1405 Start(f32),
1406 End(f32),
1407 Range(f32, f32),
1408 RangeStart(f32, f32),
1409 RangeEnd(f32, f32),
1410 }
1411
1412 #[test]
1415 fn a_slider_that_was_not_asked_sends_no_bracket() {
1416 let slider = Slider::new(0.0).on_change(Msg::Value);
1417 assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1418 assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1419 }
1420
1421 #[test]
1426 fn the_bracket_speaks_in_values_not_fractions() {
1427 let slider = Slider::new(0.0)
1428 .range(0.0, 100.0)
1429 .on_change(Msg::Value)
1430 .on_change_start(Msg::Start)
1431 .on_change_end(Msg::End);
1432 assert_eq!(Widget::on_drag_start(&slider, 0.25), Some(Msg::Start(25.0)));
1433 assert_eq!(Widget::on_drag(&slider, 0.25), Some(Msg::Value(25.0)));
1434 assert_eq!(Widget::on_drag_end(&slider, 0.75), Some(Msg::End(75.0)));
1435 }
1436
1437 #[test]
1440 fn the_bracket_snaps_like_the_stream() {
1441 let slider = Slider::new(0.0)
1442 .divisions(4)
1443 .on_change(Msg::Value)
1444 .on_change_start(Msg::Start)
1445 .on_change_end(Msg::End);
1446 assert_eq!(Widget::on_drag(&slider, 0.3), Some(Msg::Value(0.25)));
1448 assert_eq!(Widget::on_drag_start(&slider, 0.3), Some(Msg::Start(0.25)));
1449 assert_eq!(Widget::on_drag_end(&slider, 0.3), Some(Msg::End(0.25)));
1450 }
1451
1452 #[test]
1455 fn a_disabled_slider_brackets_nothing() {
1456 let slider = Slider::new(0.0)
1457 .enabled(false)
1458 .on_change(Msg::Value)
1459 .on_change_start(Msg::Start)
1460 .on_change_end(Msg::End);
1461 assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1462 assert_eq!(Widget::on_drag(&slider, 0.5), None);
1463 assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1464 }
1465
1466 #[test]
1469 fn a_range_slider_brackets_the_interval() {
1470 let range = RangeSlider::new(0.2, 0.8)
1471 .on_change(Msg::Range)
1472 .on_change_start(Msg::RangeStart)
1473 .on_change_end(Msg::RangeEnd);
1474 assert_eq!(
1476 Widget::on_drag_start(&range, 0.3),
1477 Some(Msg::RangeStart(0.3, 0.8))
1478 );
1479 assert_eq!(Widget::on_drag(&range, 0.3), Some(Msg::Range(0.3, 0.8)));
1480 assert_eq!(
1482 Widget::on_drag_end(&range, 0.9),
1483 Some(Msg::RangeEnd(0.2, 0.9))
1484 );
1485 }
1486
1487 #[test]
1488 fn drag_maps_to_value() {
1489 let slider = Slider::new(0.0).on_change(Msg::Value);
1490 assert_eq!(Widget::on_drag(&slider, 0.5), Some(Msg::Value(0.5)));
1491 assert_eq!(Widget::on_drag(&slider, 1.5), Some(Msg::Value(1.0)));
1493 }
1494
1495 fn thumbs(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1497 let row = &Widget::<Msg>::children(rs)[0];
1498 row.children()
1499 .iter()
1500 .map(|c| c.as_ref())
1501 .filter(|c| c.draggable())
1502 .collect()
1503 }
1504
1505 fn range_of(msg: Option<Msg>) -> (f32, f32) {
1506 match msg {
1507 Some(Msg::Range(lo, hi)) => (lo, hi),
1508 other => panic!("expected Range, got {other:?}"),
1509 }
1510 }
1511
1512 fn range_of_key(resp: KeyResponse<Msg>) -> (f32, f32) {
1513 match resp {
1514 KeyResponse::Handled(msg) => range_of(msg),
1515 other => panic!("expected Handled, got {other:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn each_thumb_moves_its_own_side_and_sticks() {
1521 let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range); let t = thumbs(&rs);
1523 assert_eq!(t.len(), 2, "two draggable thumbs");
1524 let (lo, hi) = range_of(t[0].on_drag_delta(22.0));
1526 assert!(
1527 (lo - 0.3).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1528 "low moves ({lo}, {hi})"
1529 );
1530 let (lo, hi) = range_of(t[1].on_drag_delta(-22.0));
1532 assert!(
1533 (lo - 0.2).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1534 "high moves ({lo}, {hi})"
1535 );
1536 let (lo, hi) = range_of(t[0].on_drag_delta(10_000.0));
1538 assert!(
1539 (lo - 0.8).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1540 "stuck to the top ({lo}, {hi})"
1541 );
1542 assert_eq!(t[0].on_drag_delta(0.0), None);
1544 }
1545
1546 #[test]
1547 fn arrow_keys_move_focused_thumb_by_a_step() {
1548 let rs = RangeSlider::new(0.4, 0.6)
1549 .divisions(10)
1550 .on_change(Msg::Range); let t = thumbs(&rs);
1552 let (lo, hi) = range_of_key(t[0].on_key(&Key::Right {
1554 shift: false,
1555 word: false,
1556 }));
1557 assert!(
1558 (lo - 0.5).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1559 "→ ({lo}, {hi})"
1560 );
1561 let (lo, hi) = range_of_key(t[0].on_key(&Key::Left {
1562 shift: false,
1563 word: false,
1564 }));
1565 assert!(
1566 (lo - 0.3).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1567 "← ({lo}, {hi})"
1568 );
1569 assert!(t[0].focusable() && t[1].focusable());
1571 }
1572
1573 #[test]
1574 fn track_click_moves_nearest_thumb() {
1575 let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1576 assert!(Widget::<Msg>::draggable(&rs), "the track answers clicks");
1578 let (lo, hi) = range_of(Widget::on_drag(&rs, 0.25));
1579 assert!(
1580 (lo - 0.25).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1581 "near the low end ({lo}, {hi})"
1582 );
1583 let (lo, hi) = range_of(Widget::on_drag(&rs, 0.9));
1584 assert!(
1585 (lo - 0.2).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1586 "near the high end ({lo}, {hi})"
1587 );
1588 }
1589
1590 #[test]
1591 fn home_end_snap_thumb_to_bounds() {
1592 let rs = RangeSlider::new(0.3, 0.7).on_change(Msg::Range);
1593 let t = thumbs(&rs);
1594 let home = Key::Home {
1595 shift: false,
1596 doc: false,
1597 };
1598 let end = Key::End {
1599 shift: false,
1600 doc: false,
1601 };
1602 let (lo, hi) = range_of_key(t[0].on_key(&home));
1604 assert!(
1605 (lo - 0.0).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1606 "low Home ({lo}, {hi})"
1607 );
1608 let (lo, hi) = range_of_key(t[0].on_key(&end));
1609 assert!(
1610 (lo - 0.7).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1611 "low End ({lo}, {hi})"
1612 );
1613 let (lo, hi) = range_of_key(t[1].on_key(&end));
1615 assert!(
1616 (lo - 0.3).abs() < 1e-4 && (hi - 1.0).abs() < 1e-4,
1617 "upper End ({lo}, {hi})"
1618 );
1619 }
1620
1621 #[test]
1622 fn value_label_reserves_height() {
1623 let plain = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1624 let tipped = RangeSlider::new(0.2, 0.8)
1625 .on_change(Msg::Range)
1626 .value_label(|v| format!("{}", v));
1627 let h = |rs: &RangeSlider<Msg>| match Widget::<Msg>::style(rs).height {
1628 Dimension::Length(v) => v,
1629 _ => 0.0,
1630 };
1631 assert!(h(&tipped) > h(&plain), "the tooltip reserves height");
1632 }
1633
1634 #[test]
1635 fn divisions_snap_to_steps() {
1636 let rs = RangeSlider::new(0.0, 1.0)
1638 .width(200.0)
1639 .divisions(10)
1640 .on_change(Msg::Range);
1641 let (lo, _) = range_of(thumbs(&rs)[0].on_drag_delta(25.0));
1642 assert!((lo - 0.1).abs() < 1e-4, "snapped to step 0.1, got {lo}");
1643 }
1644
1645 #[test]
1646 fn range_new_orders_and_clamps() {
1647 let rs = RangeSlider::new(0.9, 0.1).on_change(Msg::Range);
1649 let (lo, hi) = range_of(thumbs(&rs)[0].on_drag_delta(0.05 * 220.0));
1651 assert!(
1652 (lo - 0.15).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1653 "reordered ({lo}, {hi})"
1654 );
1655 }
1656
1657 fn thumbs_by_position(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1661 let row = &Widget::<Msg>::children(rs)[0];
1662 let kids = row.children();
1663 vec![kids[1].as_ref(), kids[3].as_ref()]
1664 }
1665
1666 #[test]
1667 fn a_disabled_slider_is_inert_but_still_says_where_it_sits() {
1668 let dead = Slider::new(0.4).on_change(Msg::Value).enabled(false);
1669 assert!(!Widget::<Msg>::draggable(&dead), "it takes no drag");
1670 assert_eq!(
1671 Widget::on_drag(&dead, 0.9),
1672 None,
1673 "and a drag in flight does not land"
1674 );
1675 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1676 assert!(semantics.disabled, "announced as unavailable");
1677 assert_eq!(semantics.value.as_deref(), Some("40%"), "value survives");
1678 }
1679
1680 #[test]
1684 fn a_disabled_range_slider_takes_no_drag_no_key_and_no_focus() {
1685 let dead = RangeSlider::new(0.2, 0.8)
1686 .on_change(Msg::Range)
1687 .enabled(false);
1688 assert!(!Widget::<Msg>::draggable(&dead), "the track takes no drag");
1689 assert_eq!(Widget::on_drag(&dead, 0.5), None);
1690 for (i, thumb) in thumbs_by_position(&dead).into_iter().enumerate() {
1691 assert!(!thumb.draggable(), "thumb {i} still drags");
1692 assert!(!thumb.focusable(), "thumb {i} still takes focus");
1693 assert_eq!(thumb.on_drag_delta(20.0), None, "thumb {i} still moves");
1694 assert!(
1695 matches!(
1696 thumb.on_key(&Key::Right {
1697 shift: false,
1698 word: false
1699 }),
1700 KeyResponse::Ignored
1701 ),
1702 "thumb {i} still answers an arrow"
1703 );
1704 }
1705 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1706 assert!(semantics.disabled);
1707 assert_eq!(
1708 semantics.value.as_deref(),
1709 Some("20%–80%"),
1710 "interval survives"
1711 );
1712 }
1713
1714 #[test]
1717 fn a_disabled_slider_takes_both_halves_of_the_rule() {
1718 for theme in [Theme::dark(), Theme::light()] {
1719 let mut scene = Scene::new();
1720 Widget::<Msg>::paint(
1721 &Slider::<Msg>::new(0.5).enabled(false),
1722 Rect::new(0.0, 0.0, 200.0, H),
1723 Status {
1724 opacity: 1.0,
1725 ..Default::default()
1726 },
1727 &theme,
1728 &mut scene,
1729 );
1730 let fills: Vec<frus_core::Color> = scene
1732 .primitives()
1733 .iter()
1734 .filter_map(|p| match p {
1735 frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1736 _ => None,
1737 })
1738 .collect();
1739 assert_eq!(fills[0], disabled_container(&theme), "the rail");
1740 assert_eq!(fills[1], disabled_content(&theme), "the travelled part");
1741 assert_eq!(fills[2], disabled_content(&theme), "the thumb");
1742 let from_surface = |c: Color| {
1746 (c.r - theme.scheme.surface.r).abs()
1747 + (c.g - theme.scheme.surface.g).abs()
1748 + (c.b - theme.scheme.surface.b).abs()
1749 };
1750 assert!(
1751 from_surface(fills[0]) < from_surface(fills[1]),
1752 "the rail is the quieter of the two"
1753 );
1754 }
1755 }
1756 #[test]
1760 fn a_thumb_casts_a_shadow_that_rises_while_it_is_held() {
1761 fn heights(widget: &dyn Widget<Msg>, bounds: Rect, press: f32, theme: &Theme) -> Vec<f32> {
1763 let status = Status {
1764 opacity: 1.0,
1765 press_progress: press,
1766 ..Default::default()
1767 };
1768 let mut scene = Scene::new();
1769 widget.paint(bounds, status, theme, &mut scene);
1770 crate::shadowprobe::heights(scene.primitives())
1771 }
1772 let theme = Theme::default();
1773 let track = Rect::new(0.0, 0.0, 220.0, H);
1774 let slider = Slider::new(0.5).on_change(Msg::Value);
1775 assert_eq!(heights(&slider, track, 0.0, &theme), vec![1.0]);
1776 assert_eq!(heights(&slider, track, 1.0, &theme), vec![6.0]);
1777 assert_eq!(
1778 heights(&slider, track, 0.5, &theme),
1779 vec![3.5],
1780 "on its way up"
1781 );
1782 let grounded = Slider::new(0.5)
1783 .on_change(Msg::Value)
1784 .thumb_elevation(0.0)
1785 .pressed_thumb_elevation(0.0);
1786 assert!(heights(&grounded, track, 1.0, &theme).is_empty());
1787
1788 let mut flat = Theme::default();
1789 flat.widgets.slider.thumb_elevation = Some(0.0);
1790 flat.widgets.slider.pressed_thumb_elevation = Some(0.0);
1791 assert!(
1792 heights(&slider, track, 1.0, &flat).is_empty(),
1793 "the theme's word"
1794 );
1795 let told = Slider::new(0.5).on_change(Msg::Value).thumb_elevation(2.0);
1796 assert_eq!(
1797 heights(&told, track, 0.0, &flat),
1798 vec![2.0],
1799 "and the slider's over it"
1800 );
1801
1802 let cell = Rect::new(0.0, 0.0, THUMB, H);
1803 let range = RangeSlider::new(0.25, 0.75).on_change(Msg::Range);
1804 for thumb in thumbs(&range) {
1805 assert_eq!(heights(thumb, cell, 0.0, &theme), vec![1.0]);
1806 assert_eq!(heights(thumb, cell, 1.0, &theme), vec![6.0]);
1807 }
1808 let raised = RangeSlider::new(0.25, 0.75)
1809 .on_change(Msg::Range)
1810 .thumb_elevation(3.0);
1811 for thumb in thumbs(&raised) {
1812 assert_eq!(
1813 heights(thumb, cell, 0.0, &theme),
1814 vec![3.0],
1815 "told on the slider"
1816 );
1817 }
1818 }
1819}
1820
1821#[cfg(test)]
1822mod range_tests {
1823 use super::*;
1824 use crate::interaction::{Key, KeyResponse};
1825
1826 #[derive(Clone, Debug, PartialEq)]
1827 enum Msg {
1828 Value(f32),
1829 }
1830
1831 fn value_of(msg: Option<Msg>) -> f32 {
1832 match msg {
1833 Some(Msg::Value(v)) => v,
1834 other => panic!("expected a value, got {other:?}"),
1835 }
1836 }
1837
1838 #[test]
1840 fn a_drag_lands_in_the_caller_s_units() {
1841 let slider = Slider::new(20.0).range(20.0, 200.0).on_change(Msg::Value);
1842 assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1843 assert_eq!(value_of(Widget::on_drag(&slider, 0.5)), 110.0);
1844 assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1845 assert_eq!(value_of(Widget::on_drag(&slider, 1.4)), 200.0);
1847 }
1848
1849 #[test]
1851 fn a_backwards_range_is_taken_the_way_round_it_makes_sense() {
1852 let slider = Slider::new(0.0).range(200.0, 20.0).on_change(Msg::Value);
1853 assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1854 assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1855 }
1856
1857 #[test]
1860 fn a_value_outside_the_travel_is_held_inside_it() {
1861 let low = Slider::<Msg>::new(-5.0).range(0.0, 10.0);
1862 assert_eq!(low.fraction(), 0.0);
1863 let high = Slider::<Msg>::new(40.0).range(0.0, 10.0);
1864 assert_eq!(high.fraction(), 1.0);
1865 }
1866
1867 #[test]
1869 fn divisions_snap_the_value() {
1870 let slider = Slider::new(0.0)
1871 .range(0.0, 100.0)
1872 .divisions(4)
1873 .on_change(Msg::Value);
1874 assert_eq!(value_of(Widget::on_drag(&slider, 0.30)), 25.0);
1875 assert_eq!(value_of(Widget::on_drag(&slider, 0.40)), 50.0);
1876 assert_eq!(value_of(Widget::on_drag(&slider, 0.99)), 100.0);
1877 }
1878
1879 #[test]
1882 fn the_arrows_move_by_a_step() {
1883 let free = Slider::new(50.0).range(0.0, 100.0).on_change(Msg::Value);
1884 let step = |s: &Slider<Msg>, key: Key| match Widget::on_key(s, &key) {
1885 KeyResponse::Handled(msg) => value_of(msg),
1886 other => panic!("expected the key to be taken, got {other:?}"),
1887 };
1888 assert_eq!(
1889 step(
1890 &free,
1891 Key::Right {
1892 shift: false,
1893 word: false
1894 }
1895 ),
1896 55.0
1897 );
1898 assert_eq!(
1899 step(
1900 &free,
1901 Key::Left {
1902 shift: false,
1903 word: false
1904 }
1905 ),
1906 45.0
1907 );
1908
1909 let stepped = Slider::new(50.0)
1910 .range(0.0, 100.0)
1911 .divisions(4)
1912 .on_change(Msg::Value);
1913 assert_eq!(
1914 step(
1915 &stepped,
1916 Key::Right {
1917 shift: false,
1918 word: false
1919 }
1920 ),
1921 75.0
1922 );
1923
1924 let at_end = Slider::new(100.0).range(0.0, 100.0).on_change(Msg::Value);
1925 assert_eq!(
1926 step(
1927 &at_end,
1928 Key::Right {
1929 shift: false,
1930 word: false
1931 }
1932 ),
1933 100.0
1934 );
1935 assert_eq!(
1936 step(
1937 &at_end,
1938 Key::Home {
1939 shift: false,
1940 doc: false
1941 }
1942 ),
1943 0.0
1944 );
1945 assert_eq!(
1946 step(
1947 &at_end,
1948 Key::End {
1949 shift: false,
1950 doc: false
1951 }
1952 ),
1953 100.0
1954 );
1955 }
1956
1957 #[test]
1959 fn it_takes_the_focus_only_when_it_can_answer() {
1960 assert!(!Widget::<Msg>::focusable(&Slider::<Msg>::new(0.5)));
1961 assert!(Widget::focusable(&Slider::new(0.5).on_change(Msg::Value)));
1962 assert!(!Widget::focusable(
1963 &Slider::new(0.5).on_change(Msg::Value).enabled(false)
1964 ));
1965 }
1966
1967 #[test]
1969 fn a_frozen_slider_answers_no_key() {
1970 let slider = Slider::new(0.5).on_change(Msg::Value).enabled(false);
1971 assert!(matches!(
1972 Widget::on_key(
1973 &slider,
1974 &Key::Right {
1975 shift: false,
1976 word: false
1977 }
1978 ),
1979 KeyResponse::Ignored
1980 ));
1981 }
1982
1983 #[test]
1985 fn a_reader_is_told_the_real_value() {
1986 let plain = Slider::<Msg>::new(0.25);
1987 let s = Widget::<Msg>::semantics(&plain).expect("a slider says where it is");
1988 assert_eq!(
1989 s.value.as_deref(),
1990 Some("25%"),
1991 "a bare 0..1 reads as a share"
1992 );
1993
1994 let ranged = Slider::<Msg>::new(110.0).range(20.0, 200.0);
1995 let s = Widget::<Msg>::semantics(&ranged).expect("a slider says where it is");
1996 assert_eq!(s.range, Some((20.0, 110.0, 200.0)));
1997
1998 let priced = Slider::<Msg>::new(110.0)
1999 .range(20.0, 200.0)
2000 .value_label(|v| format!("{v} EUR"));
2001 let s = Widget::<Msg>::semantics(&priced).expect("a slider says where it is");
2002 assert_eq!(
2003 s.value.as_deref(),
2004 Some("110 EUR"),
2005 "said the caller's way when it gave a formatter"
2006 );
2007 }
2008
2009 #[test]
2012 fn the_value_drop_wears_the_reference_s_type() {
2013 let slider = Slider::<Msg>::new(0.3)
2014 .divisions(10)
2015 .value_label(|v| format!("{v}"));
2016 let styles = |theme: &Theme| {
2017 let mut scene = Scene::new();
2018 Widget::<Msg>::paint(
2019 &slider,
2020 Rect::new(0.0, 0.0, 220.0, 48.0),
2021 Status {
2022 focused: true,
2023 focus_progress: 1.0,
2024 ..Status::default()
2025 },
2026 theme,
2027 &mut scene,
2028 );
2029 scene
2030 .primitives()
2031 .iter()
2032 .filter_map(|p| match p {
2033 frus_core::Primitive::Text {
2034 size,
2035 weight,
2036 color,
2037 ..
2038 } => Some((*size, weight.to_u16(), *color)),
2039 _ => None,
2040 })
2041 .collect::<Vec<_>>()
2042 };
2043 let plain = Theme::default();
2044 assert_eq!(
2045 styles(&plain),
2046 vec![(
2047 plain.text.label_medium.size.unwrap(),
2048 frus_core::FontWeight::Medium.to_u16(),
2049 plain.scheme.on_primary
2050 )]
2051 );
2052 let mut themed = Theme::default();
2054 themed.widgets.slider.value_indicator_text_style = Some(frus_core::TextStyle::new(25.0));
2055 assert_eq!(styles(&themed).first().map(|s| s.0), Some(25.0));
2056 }
2057
2058 #[test]
2061 fn the_slider_is_as_tall_as_its_halo() {
2062 let bare = Widget::<Msg>::style(&Slider::<Msg>::new(0.5)).height;
2063 let labelled =
2064 Widget::<Msg>::style(&Slider::<Msg>::new(0.5).value_label(|v| format!("{v}"))).height;
2065 assert_eq!(bare, frus_layout::Dimension::Length(48.0));
2066 assert_eq!(labelled, bare);
2067 }
2068}
2069
2070#[cfg(test)]
2071mod color_tests {
2072 use super::*;
2073 use frus_core::Primitive;
2074
2075 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
2076 const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
2077
2078 fn painted(slider: &Slider<()>, theme: &Theme) -> (Color, Color, Color, Color) {
2081 let mut scene = Scene::new();
2082 Widget::<()>::paint(
2083 slider,
2084 Rect::new(0.0, 0.0, 220.0, H),
2085 Status {
2086 opacity: 1.0,
2087 ..Default::default()
2088 },
2089 theme,
2090 &mut scene,
2091 );
2092 let rects: Vec<(Color, Color)> = scene
2093 .primitives()
2094 .iter()
2095 .filter_map(|p| match p {
2096 Primitive::Rect {
2097 color,
2098 border_color,
2099 blur,
2100 ..
2101 } if *blur == 0.0 => Some((*color, *border_color)),
2102 _ => None,
2103 })
2104 .collect();
2105 (rects[0].0, rects[1].0, rects[2].0, rects[2].1)
2106 }
2107
2108 #[test]
2111 fn the_defaults_are_the_reference_s() {
2112 let theme = Theme::default();
2113 let (rail, filled, thumb, ring) = painted(&Slider::<()>::new(0.5), &theme);
2114 assert_eq!(rail, theme.scheme.surface_container_highest);
2115 assert_eq!(filled, theme.scheme.primary);
2116 assert_eq!(thumb, theme.scheme.primary);
2117 assert_eq!(ring, Color::TRANSPARENT);
2118 }
2119
2120 #[test]
2123 fn the_thumb_follows_the_track_it_was_not_told_about() {
2124 let theme = Theme::default();
2125 let (_, filled, thumb, ring) = painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme);
2126 assert_eq!((filled, thumb, ring), (BRAND, BRAND, Color::TRANSPARENT));
2127 let (_, _, _, named) = painted(
2128 &Slider::<()>::new(0.5)
2129 .active_color(BRAND)
2130 .thumb_border_color(RAIL),
2131 &theme,
2132 );
2133 assert_eq!(named, RAIL, "unless it is named");
2134 }
2135
2136 #[test]
2138 fn each_half_of_the_track_takes_its_own_colour() {
2139 let theme = Theme::default();
2140 let (rail, filled, thumb, _) = painted(
2141 &Slider::<()>::new(0.5)
2142 .active_color(BRAND)
2143 .inactive_color(RAIL)
2144 .thumb_color(RAIL),
2145 &theme,
2146 );
2147 assert_eq!((rail, filled, thumb), (RAIL, BRAND, RAIL));
2148 }
2149
2150 #[test]
2152 fn the_theme_answers_and_the_instance_overrules_it() {
2153 let mut theme = Theme::default();
2154 theme.widgets.slider.active_track_color = Some(RAIL);
2155 assert_eq!(painted(&Slider::<()>::new(0.5), &theme).1, RAIL);
2156 assert_eq!(
2157 painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme).1,
2158 BRAND
2159 );
2160 }
2161}
2162
2163#[cfg(test)]
2165mod reference_tests {
2166 use super::*;
2167 use frus_core::Primitive;
2168
2169 const BOX: Rect = Rect::new(0.0, 0.0, 248.0, 48.0);
2171
2172 fn rtl() -> Theme {
2173 Theme {
2174 direction: frus_core::TextDirection::Rtl,
2175 ..Theme::default()
2176 }
2177 }
2178
2179 fn scene_of(slider: &Slider<()>, status: Status, theme: &Theme) -> Vec<Primitive> {
2180 let mut scene = Scene::new();
2181 Widget::<()>::paint(slider, BOX, status, theme, &mut scene);
2182 scene.primitives().to_vec()
2183 }
2184
2185 fn rects(primitives: &[Primitive]) -> Vec<(Rect, Color)> {
2186 primitives
2187 .iter()
2188 .filter_map(|p| match p {
2189 Primitive::Rect {
2190 rect, color, blur, ..
2191 } if *blur == 0.0 => Some((*rect, *color)),
2192 _ => None,
2193 })
2194 .collect()
2195 }
2196
2197 fn thumb(primitives: &[Primitive]) -> Rect {
2199 rects(primitives)
2200 .into_iter()
2201 .map(|(r, _)| r)
2202 .rfind(|r| r.width == 20.0 && r.height == 20.0)
2203 .expect("a thumb")
2204 }
2205
2206 fn texts(primitives: &[Primitive]) -> usize {
2207 primitives
2208 .iter()
2209 .filter(|p| matches!(p, Primitive::Text { .. }))
2210 .count()
2211 }
2212
2213 fn rest() -> Status {
2214 Status::default()
2215 }
2216
2217 #[test]
2220 fn the_track_is_inset_and_the_travelled_part_thicker() {
2221 let theme = Theme::default();
2222 let painted = scene_of(&Slider::new(0.5), rest(), &theme);
2223 assert_eq!(thumb(&painted), Rect::new(114.0, 14.0, 20.0, 20.0));
2224 let at = |v: f32| thumb(&scene_of(&Slider::new(v), rest(), &theme)).x + 10.0;
2225 assert_eq!((at(0.0), at(1.0)), (24.0, 224.0), "the ends, a radius in");
2226 let track = rects(&painted);
2227 let (inactive, active) = (track[0].0, track[1].0);
2228 assert_eq!(inactive.height, 4.0);
2229 assert_eq!(active.height, 6.0);
2230 assert_eq!(active.x, 24.0, "from the start");
2231 assert_eq!(inactive.x + inactive.width, 224.0, "to the end");
2232 }
2233
2234 #[test]
2237 fn right_to_left_it_runs_from_the_right() {
2238 let painted = scene_of(&Slider::new(0.25), rest(), &rtl());
2239 assert_eq!(thumb(&painted).x + 10.0, 174.0);
2240 let track = rects(&painted);
2241 let (inactive, active) = (track[0].0, track[1].0);
2242 assert_eq!(
2243 active.x + active.width,
2244 224.0,
2245 "the travelled part ends at the right"
2246 );
2247 assert_eq!(inactive.x, 24.0, "the rest starts at the left");
2248 }
2249
2250 #[test]
2253 fn a_tick_marks_each_division() {
2254 let theme = Theme::default();
2255 let ticks = |slider: Slider<()>| {
2256 rects(&scene_of(&slider, rest(), &theme))
2257 .into_iter()
2258 .filter(|(r, _)| r.width == 2.0 && r.height == 2.0)
2259 .collect::<Vec<_>>()
2260 };
2261 let four = ticks(Slider::new(0.5).divisions(4));
2262 assert_eq!(four.len(), 5);
2263 assert_eq!(four[0].0.x + 1.0, 26.0, "half a track in");
2264 assert_eq!(four[4].0.x + 1.0, 222.0);
2265 assert_eq!(four[0].1, theme.scheme.on_primary.fade(0.38));
2267 assert_eq!(four[4].1, theme.scheme.on_surface_variant.fade(0.38));
2268 assert!(
2269 ticks(Slider::new(0.5).divisions(100)).is_empty(),
2270 "too dense"
2271 );
2272 assert!(ticks(Slider::new(0.5)).is_empty(), "continuous");
2273 let end = thumb(&scene_of(&Slider::new(1.0).divisions(4), rest(), &theme));
2275 assert_eq!(end.x + 10.0, 222.0);
2276 }
2277
2278 #[test]
2280 fn the_secondary_value_lightens_the_track_ahead() {
2281 let theme = Theme::default();
2282 let secondary = |slider: Slider<()>| {
2283 rects(&scene_of(&slider, rest(), &theme))
2284 .into_iter()
2285 .find(|(_, c)| *c == theme.scheme.primary.fade(0.54))
2286 };
2287 let (rect, _) =
2288 secondary(Slider::new(0.25).secondary_track_value(0.75)).expect("a secondary part");
2289 assert_eq!((rect.x, rect.x + rect.width), (74.0, 174.0));
2290 assert!(
2291 secondary(Slider::new(0.75).secondary_track_value(0.25)).is_none(),
2292 "behind"
2293 );
2294 let given = Color::rgb(0.2, 0.4, 0.6);
2295 let painted = scene_of(
2296 &Slider::new(0.25)
2297 .secondary_track_value(0.75)
2298 .secondary_active_color(given),
2299 rest(),
2300 &theme,
2301 );
2302 assert!(rects(&painted).iter().any(|(_, c)| *c == given));
2303 }
2304
2305 #[test]
2308 fn the_drop_shows_as_its_policy_says() {
2309 let theme = Theme::default();
2310 let focused = Status {
2311 focused: true,
2312 focus_progress: 1.0,
2313 ..Status::default()
2314 };
2315 let pressed = Status {
2316 press_progress: 1.0,
2317 ..Status::default()
2318 };
2319 let labelled = || Slider::<()>::new(0.5).value_label(|v| format!("{v}"));
2320 let shows = |s: Slider<()>, status: Status| texts(&scene_of(&s, status, &theme)) == 1;
2321 assert!(shows(labelled().divisions(4), focused));
2322 assert!(shows(labelled().divisions(4), pressed));
2323 assert!(!shows(labelled().divisions(4), rest()), "at rest, hidden");
2324 assert!(
2325 !shows(labelled(), focused),
2326 "continuous: only for discrete by default"
2327 );
2328 assert!(shows(
2329 labelled().show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2330 focused
2331 ));
2332 assert!(!shows(
2333 labelled()
2334 .divisions(4)
2335 .show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2336 focused
2337 ));
2338 assert!(shows(
2339 labelled().show_value_indicator(ShowValueIndicator::OnDrag),
2340 pressed
2341 ));
2342 assert!(shows(
2343 labelled().show_value_indicator(ShowValueIndicator::AlwaysVisible),
2344 rest()
2345 ));
2346 assert!(!shows(
2347 labelled()
2348 .divisions(4)
2349 .show_value_indicator(ShowValueIndicator::Never),
2350 focused
2351 ));
2352 assert!(
2353 !shows(labelled().divisions(4).enabled(false), focused),
2354 "disabled"
2355 );
2356 let mut themed = Theme::default();
2357 themed.widgets.slider.show_value_indicator = Some(ShowValueIndicator::AlwaysVisible);
2358 assert_eq!(
2359 texts(&scene_of(&labelled(), rest(), &themed)),
2360 1,
2361 "from the theme"
2362 );
2363 }
2364
2365 #[test]
2368 fn the_drop_sits_over_the_thumb() {
2369 let theme = Theme::default();
2370 let slider = Slider::<()>::new(0.5)
2371 .show_value_indicator(ShowValueIndicator::AlwaysVisible)
2372 .value_label(|_| "50".to_string());
2373 let painted = scene_of(&slider, rest(), &theme);
2374 let pill = rects(&painted)
2375 .into_iter()
2376 .find(|(r, _)| r.height == 28.0)
2377 .expect("the pill");
2378 assert_eq!(pill.0.y + pill.0.height, 24.0 - 22.0);
2379 assert!(
2380 (pill.0.x + pill.0.width / 2.0 - 124.0).abs() < 0.01,
2381 "centred on the thumb"
2382 );
2383 assert_eq!(pill.1, theme.scheme.primary);
2384 assert!(
2385 painted.iter().any(|p| matches!(p, Primitive::Path { .. })),
2386 "the point"
2387 );
2388 }
2389
2390 #[test]
2393 fn the_halo_follows_the_state() {
2394 let theme = Theme::default();
2395 let halo = |slider: Slider<()>, status: Status| {
2396 rects(&scene_of(&slider, status, &theme))
2397 .into_iter()
2398 .find(|(r, _)| r.width == 48.0)
2399 .map(|(_, c)| c)
2400 };
2401 let hovered = Status {
2402 interaction: crate::interaction::Interaction::Hovered,
2403 hover_progress: 1.0,
2404 ..Status::default()
2405 };
2406 let pressed = Status {
2407 interaction: crate::interaction::Interaction::Pressed,
2408 press_progress: 1.0,
2409 ..Status::default()
2410 };
2411 assert_eq!(halo(Slider::new(0.5), rest()), None);
2412 assert_eq!(
2413 halo(Slider::new(0.5), hovered),
2414 Some(theme.scheme.primary.fade(0.08))
2415 );
2416 assert_eq!(
2417 halo(Slider::new(0.5), pressed),
2418 Some(theme.scheme.primary.fade(0.1))
2419 );
2420 assert_eq!(halo(Slider::new(0.5).enabled(false), hovered), None);
2421 let given = Color::rgb(0.0, 0.6, 0.3);
2422 assert_eq!(
2423 halo(Slider::new(0.5).active_color(given), hovered),
2424 Some(given.fade(0.12))
2425 );
2426 let property = WidgetStateProperty::new().otherwise(Color::rgb(1.0, 0.0, 0.0));
2427 assert_eq!(
2428 halo(Slider::new(0.5).overlay_color(property), hovered),
2429 Some(Color::rgb(1.0, 0.0, 0.0))
2430 );
2431 }
2432
2433 #[test]
2436 fn a_disabled_slider_wears_the_disabled_rule() {
2437 let theme = Theme::default();
2438 let painted = scene_of(&Slider::new(0.5).enabled(false), rest(), &theme);
2439 let r = rects(&painted);
2440 assert_eq!(r[0].1, disabled_container(&theme));
2441 assert_eq!(r[1].1, disabled_content(&theme));
2442 assert_eq!(r.last().map(|x| x.1), Some(disabled_content(&theme)));
2443 }
2444
2445 #[test]
2448 fn it_takes_the_width_it_is_offered() {
2449 let theme = Theme::default();
2450 let open = Slider::<()>::new(0.5);
2451 assert!(Widget::<()>::fill_axes(&open, &theme).horizontal);
2452 assert!(Widget::<()>::soft_extent(&open).horizontal);
2453 assert_eq!(
2454 Widget::<()>::style(&open).min_width,
2455 frus_layout::Dimension::Length(192.0)
2456 );
2457 let fixed = Slider::<()>::new(0.5).width(120.0);
2458 assert!(!Widget::<()>::fill_axes(&fixed, &theme).horizontal);
2459 assert_eq!(
2460 Widget::<()>::style(&fixed).width,
2461 frus_layout::Dimension::Length(120.0)
2462 );
2463 }
2464
2465 #[test]
2467 fn a_drag_lands_on_the_inset_track() {
2468 let track = Widget::<()>::drag_track(&Slider::<()>::new(0.5));
2469 assert_eq!(track.inset, 24.0);
2470 assert!(track.mirrored);
2471 assert_eq!(track.fraction(BOX, 24.0, false), 0.0);
2472 assert_eq!(track.fraction(BOX, 124.0, false), 0.5);
2473 assert_eq!(track.fraction(BOX, 224.0, false), 1.0);
2474 assert_eq!(track.fraction(BOX, 74.0, true), 0.75, "from the right");
2475 assert_eq!(track.fraction(BOX, 0.0, false), 0.0, "held at the ends");
2476 assert_eq!(track.fraction(BOX, 248.0, false), 1.0);
2477 let whole = crate::widget::DragTrack::WHOLE;
2478 assert_eq!(
2479 whole.fraction(BOX, 62.0, true),
2480 0.25,
2481 "no opinion: left to right"
2482 );
2483 let empty = Rect::new(0.0, 0.0, 40.0, 48.0);
2484 assert_eq!(track.fraction(empty, 20.0, false), 0.0, "no travel");
2485 }
2486
2487 #[test]
2489 fn a_reader_hears_the_semantic_formatter() {
2490 let slider = Slider::<()>::new(0.5)
2491 .value_label(|v| format!("{v}"))
2492 .semantic_formatter(|v| format!("{} percent", (v * 100.0).round()));
2493 let said = Widget::<()>::semantics(&slider).and_then(|s| s.value);
2494 assert_eq!(said.as_deref(), Some("50 percent"));
2495 }
2496}