Skip to main content

frus_widgets/
slider.rs

1//! [`Slider`]: a value slider over a range, **controlled** and draggable.
2
3use 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;
20/// The default keyboard step (without `divisions`): an arrow moves by 5%.
21const KEY_STEP: f32 = 0.05;
22
23/// How far a thumb sits off the track at rest, and while it is held: the reference's round
24/// thumb on a slider (its slider parts, lines 680–681) and on a range slider (its range
25/// slider parts, lines 789–790).
26const THUMB_ELEVATION: f32 = 1.0;
27const THUMB_PRESSED_ELEVATION: f32 = 6.0;
28
29/// How far a thumb stands off the track: its resting height, rising to the pressed one as
30/// the press goes in. `told` is the widget's `(resting, pressed)`, each over the theme's and
31/// the reference's. The reference's round thumb casts its shadow enabled or not — its shape
32/// has no disabled height (its slider parts, lines 747–767) — and so does this one.
33fn 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
43/// The reference's 2023 slider (milestone 648): a 4-pixel track, 2 more for the travelled
44/// part, a 20-pixel thumb, a 48-pixel halo, all inset by the halo's radius at each end.
45const TRACK_HEIGHT: f32 = 4.0;
46const THUMB_RADIUS: f32 = 10.0;
47const OVERLAY_RADIUS: f32 = 24.0;
48/// The reference's shortest track: three touch targets.
49const MIN_TRACK: f32 = 144.0;
50/// The default width, where the room cannot be given: what the slider has always been.
51const DEFAULT_WIDTH: f32 = 220.0;
52
53/// When a slider shows its **value indicator** — the reference's `ShowValueIndicator`.
54///
55/// The indicator is the label a [`Slider::value_label`] formats, in a drop over the thumb.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
57pub enum ShowValueIndicator {
58    /// While it is dragged or focused, on a slider with divisions only — the default.
59    #[default]
60    OnlyForDiscrete,
61    /// While it is dragged or focused, on a slider without divisions only.
62    OnlyForContinuous,
63    /// While it is dragged or focused, whatever the slider.
64    OnDrag,
65    /// All the time.
66    AlwaysVisible,
67    /// Never.
68    Never,
69}
70
71/// **What a press and a slide may do to a slider** (milestone 650) — the reference's
72/// `SliderInteraction`.
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
74pub enum SliderInteraction {
75    /// A press moves the thumb to it, and a slide carries it on: the default.
76    #[default]
77    TapAndSlide,
78    /// A press moves the thumb to it; a slide does nothing.
79    TapOnly,
80    /// A press does nothing; a slide moves the thumb by its own length, from where it was,
81    /// wherever on the track it starts.
82    SlideOnly,
83    /// As [`SlideOnly`](Self::SlideOnly), and only for a slide that starts on the thumb.
84    SlideThumb,
85}
86
87/// A linear slider over `min..=max`, **controlled** and draggable — the reference's
88/// `Slider`.
89///
90/// It takes the width it is offered, as the reference's does, and is as tall as its halo:
91/// 48. The track is inset by the halo's radius at each end, so the thumb reaches both ends
92/// without leaving the box. In a right-to-left reading direction it runs from the right.
93///
94/// The defaults are the reference's 2023 design: the scheme's `primary` for the
95/// travelled part and the thumb, `surface_container_highest` for the rest; tick marks on
96/// a slider with [`divisions`](Self::divisions); a halo on hover, focus and drag; and the
97/// value, when [`value_label`](Self::value_label) formats one, in a drop above the thumb
98/// while it is moved — on a slider with divisions, unless
99/// [`show_value_indicator`](Self::show_value_indicator) says otherwise.
100pub 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    /// Formats the value for the indicator over the thumb.
109    label: Option<Rc<dyn Fn(f32) -> String>>,
110    /// Formats the value for a screen reader, when it should hear it otherwise.
111    semantic_label: Option<Rc<dyn Fn(f32) -> String>>,
112    show_value_indicator: Option<ShowValueIndicator>,
113    allowed_interaction: Option<SliderInteraction>,
114    /// The theme's, as the layout last found it: a drag is answered without a theme.
115    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    /// Sent once, when a drag begins.
124    on_change_start: Option<Box<dyn Fn(f32) -> Msg>>,
125    /// Sent once, when it ends.
126    on_change_end: Option<Box<dyn Fn(f32) -> Msg>>,
127}
128
129/// What a slider was told about its own colours; unset entries fall through to the theme
130/// and then the scheme, resolved where they are painted rather than where they are built.
131#[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/// Everything a slider paints with, resolved.
141#[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    /// As the parts are given it.
154    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/// The parts a slider names itself (milestone 654); each unset one is the theme's, then the
168/// default.
169#[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    /// The reference's resolution, each colour the widget's, then the theme's, then the
180    /// 2023 default; disabled, the theme's disabled colours or the framework's disabled rule.
181    fn resolve(&self, theme: &Theme, enabled: bool) -> Palette {
182        let t = &theme.widgets.slider;
183        let s = &theme.scheme;
184        if !enabled {
185            // The travelled part and the thumb are content (38 %), the rest a container
186            // (12 %), as the reference's disabled tokens are; the thumb flattened onto the
187            // surface, so the track does not show through it.
188            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            // The thumb is the travelled part's colour unless it is named: the reference's
215            // `thumbColor ?? activeColor`.
216            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            // The reference swaps them when a colour is given: ticks on the travelled part
223            // take the inactive colour, and the others the active one.
224            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    /// Creates a slider at the given value, over `0.0..=1.0` until
238    /// [`range`](Slider::range) says otherwise.
239    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    /// The travelled part of the track, and the thumb unless
266    /// [`thumb_color`](Slider::thumb_color) names it; the scheme's `primary` otherwise.
267    pub fn active_color(mut self, color: Color) -> Self {
268        self.colors.active_track = Some(color);
269        self
270    }
271
272    /// The part of the track still to travel; the scheme's `surface_container_highest`
273    /// otherwise.
274    pub fn inactive_color(mut self, color: Color) -> Self {
275        self.colors.inactive_track = Some(color);
276        self
277    }
278
279    /// The part between the value and the [secondary value](Self::secondary_track_value);
280    /// the scheme's `primary` at 54 % otherwise.
281    pub fn secondary_active_color(mut self, color: Color) -> Self {
282        self.colors.secondary_track = Some(color);
283        self
284    }
285
286    /// The thumb's fill; the active colour otherwise.
287    pub fn thumb_color(mut self, color: Color) -> Self {
288        self.colors.thumb = Some(color);
289        self
290    }
291
292    /// A ring around the thumb, two pixels wide. The reference's thumb has none, and
293    /// neither has this one unless it is asked for.
294    pub fn thumb_border_color(mut self, color: Color) -> Self {
295        self.colors.thumb_border = Some(color);
296        self
297    }
298
299    /// The halo around the thumb, per state: hovered, focused, dragged. Unset, the
300    /// theme's, then the active colour at 12 % when one is given, then the scheme's
301    /// `primary` at 8 % hovered and 10 % focused or dragged.
302    pub fn overlay_color(mut self, color: WidgetStateProperty<Color>) -> Self {
303        self.overlay_color = Some(color);
304        self
305    }
306
307    /// How far the thumb sits off the track at rest. Unset, the theme's, then the
308    /// reference's 1; `0.0` casts no shadow.
309    pub fn thumb_elevation(mut self, elevation: f32) -> Self {
310        self.thumb_elevation = Some(elevation);
311        self
312    }
313
314    /// How far it rises while it is held. Unset, the theme's, then the reference's 6.
315    pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
316        self.pressed_thumb_elevation = Some(elevation);
317        self
318    }
319
320    /// Paints the track with `shape`. Unset, the theme's, then the reference's rounded
321    /// rectangle.
322    pub fn track_shape(mut self, shape: impl SliderTrackShape + 'static) -> Self {
323        self.parts.track = Some(SliderShape::track(shape));
324        self
325    }
326
327    /// Paints the tick marks with `shape`. Unset, the theme's, then the reference's dots.
328    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    /// Paints the thumb with `shape`. Unset, the theme's, then the reference's disc.
334    pub fn thumb_shape(mut self, shape: impl SliderThumbShape + 'static) -> Self {
335        self.parts.thumb = Some(SliderShape::thumb(shape));
336        self
337    }
338
339    /// Paints the halo with `shape`. Unset, the theme's, then the reference's disc.
340    pub fn overlay_shape(mut self, shape: impl SliderOverlayShape + 'static) -> Self {
341        self.parts.overlay = Some(SliderShape::overlay(shape));
342        self
343    }
344
345    /// Paints the value indicator with `shape`. Unset, the theme's, then the reference's
346    /// drop.
347    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    /// The travel this slider covers; `0.0..=1.0` by default.
356    ///
357    /// Everything the application sees is in **these** units — the value it is given,
358    /// the value a step lands on, what a reader is told — rather than a fraction it
359    /// would have to convert back on both sides of every message. The two arguments are
360    /// sorted, so a range written backwards is not a silent empty one.
361    ///
362    /// The value is held inside the travel rather than rejected: a caller that lowers
363    /// the ceiling under a value it already had gets the ceiling, not a panic.
364    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    /// A **second value**, shown as a lighter part of the track beyond the value: how
371    /// much of a video is buffered behind where it is playing. It is not dragged.
372    pub fn secondary_track_value(mut self, value: f32) -> Self {
373        self.secondary = Some(value);
374        self
375    }
376
377    /// Splits the travel into `n` **steps**: the value snaps to `min + k·(max−min)/n`,
378    /// a tick marks each, and an arrow key moves by one of them. Without this call the
379    /// travel is continuous and an arrow moves by 5 %.
380    pub fn divisions(mut self, n: usize) -> Self {
381        self.divisions = Some(n.max(1));
382        self
383    }
384
385    /// Formats the value for the **value indicator**, the drop over the thumb — a
386    /// percentage, a price, a duration. When it shows is
387    /// [`show_value_indicator`](Self::show_value_indicator)'s to say. A screen reader
388    /// hears it too, unless [`semantic_formatter`](Self::semantic_formatter) says
389    /// otherwise.
390    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    /// What a press and a slide may do. Unset, the theme's, then
396    /// [`SliderInteraction::TapAndSlide`], the reference's.
397    pub fn allowed_interaction(mut self, interaction: SliderInteraction) -> Self {
398        self.allowed_interaction = Some(interaction);
399        self
400    }
401
402    /// When the value indicator shows. Unset, the theme's, then
403    /// [`ShowValueIndicator::OnlyForDiscrete`], the reference's.
404    pub fn show_value_indicator(mut self, when: ShowValueIndicator) -> Self {
405        self.show_value_indicator = Some(when);
406        self
407    }
408
409    /// How a screen reader is told the value — the reference's `semanticFormatterCallback`.
410    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    /// The width, in logical pixels. Unset, the slider takes the width it is offered,
416    /// and 220 where none can be offered.
417    pub fn width(mut self, width: f32) -> Self {
418        self.width = width;
419        self.sized = true;
420        self
421    }
422
423    /// A closure producing a message from the new value, in the slider's units.
424    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    /// Sent **once**, when a drag begins — before the first
433    /// [`on_change`](Self::on_change), with the value the press landed on.
434    ///
435    /// With [`on_change_end`](Self::on_change_end) it brackets the stream.
436    /// `on_change` fires on every pixel of the movement, so an application that seeks a
437    /// video, writes a setting to disk or asks the network on each of them does it
438    /// sixty times a second; the bracket is what lets it show a preview while the
439    /// finger is down and commit when it lifts.
440    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    /// Sent **once**, when the drag ends — after the last
449    /// [`on_change`](Self::on_change), with the value it settled on.
450    ///
451    /// A press that never moved still gets one: it changed the value, and a caller
452    /// waiting for the release would otherwise never be told it happened.
453    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    /// Whether the slider can be moved. Disabled it is **inert** — it takes no drag and
462    /// answers no key — and it still shows where it is set.
463    ///
464    /// A slider is the control that makes the point of [`crate::disabled`]'s contract:
465    /// greying it out while it still answered a drag would leave it inert only to the
466    /// gesture nobody was using on it.
467    pub fn enabled(mut self, enabled: bool) -> Self {
468        self.enabled = enabled;
469        self
470    }
471
472    /// The value, held inside the travel.
473    fn held(&self) -> f32 {
474        self.value.clamp(self.min, self.max)
475    }
476
477    /// Where `value` sits along the travel, `0..=1`. An empty range pins it at the start
478    /// rather than dividing by nothing.
479    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    /// Where the thumb sits along the track, `0..=1`.
489    fn fraction(&self) -> f32 {
490        self.fraction_of(self.held())
491    }
492
493    /// Snaps a fraction to the nearest step, if there are steps.
494    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    /// The value a fraction along the track stands for.
502    fn at(&self, fraction: f32) -> f32 {
503        self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
504    }
505
506    /// One arrow's step, as a fraction of the travel: a division if there are
507    /// divisions, otherwise [`KEY_STEP`].
508    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    /// What a press and a slide may do: the widget's, the theme's, then the reference's.
516    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    /// Whether the indicator shows in this state, and how far it has come out.
527    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    /// The halo's colour in this state: the widget's, the active colour at 12 %, the
549    /// theme's, then the reference's.
550    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
586/// Where the thumb's centre falls along a track running from `left` over `width`, for a
587/// value `t` of the travel: the reference's `_calcThumbCenter`. A slider with divisions
588/// keeps its thumb half a track's height inside each end, so the first and last ticks are
589/// under it.
590fn 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    /// **All the room on offer** across, as the reference's takes, unless a width was
616    /// given.
617    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    /// The theme's interaction, kept for the drag, which is answered without one.
640    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        // The track, inset by the halo's radius at each end, centred in the box.
658        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        // The halo, grown by hover, focus or a press.
683        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        // The ticks, one per division, unless they would crowd each other.
699        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        // The thumb, over its shadow.
713        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        // The value, in a drop over the thumb, while the slider is moved.
728        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        // The value survives: a reader who cannot move the slider is still owed where it
751        // sits, which is the whole of what a slider says.
752        // In the caller's units, and said the caller's way when it gave a formatter:
753        // "42 €" is what is on screen, and a percentage of an unstated range is not an
754        // answer to "where is this set".
755        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    /// A drag lands on the track, inset by the halo's radius, and runs from the right in a
778    /// right-to-left reading direction; how a press and a slide move the value is the
779    /// [`SliderInteraction`] allowed. A slide starting on the thumb starts within its halo.
780    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        // A disabled slider cannot be focused, so this should be unreachable — but a key
804        // arriving from a stale focus must not move a value the caller has frozen.
805        if !self.enabled {
806            return KeyResponse::Ignored;
807        }
808        // Arrows: one step. Home/End: the two ends, which the clamp in `at` reaches
809        // without either bound having to be named here.
810        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        // `draggable` already says no, but a drag in flight when the caller disables the
823        // slider must not land either.
824        if !self.enabled {
825            return None;
826        }
827        self.on_change.as_ref().map(|make| make(self.at(fraction)))
828    }
829
830    /// The bracket opens on the value **as it was** before the press, as the reference's
831    /// `onChangeStart` does (milestone 650): a tap is then a start at the old value and an
832    /// end at the new one, and a slide that never moved the value starts and ends on it.
833    ///
834    /// In the caller's units, not as a fraction: a caller who asked for a range of
835    /// 0..=100 is owed a hundred here too.
836    fn on_drag_start(&self, _fraction: f32) -> Option<Msg> {
837        if !self.enabled {
838            return None;
839        }
840        // Snapped like everything the stream sends, as the reference's is.
841        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
855/// The reference's **drop value indicator** (`DropSliderValueIndicatorShape`): a pill
856/// 28 high, as wide as the text and a margin, over a small triangle pointing at the
857/// thumb's centre `at`, grown from the triangle's tip by `scale`.
858pub(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    // Everything is drawn about the tip, scaled from it.
876    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    // The text at full size once the drop is, centred in the pill.
894    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
908/// The drop's type: the theme's, else the reference's 2023 `labelMedium` in `on_primary`.
909fn 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
922/// The reference's smallest gap between a continuous range slider's thumbs, in logical
923/// pixels: they may touch, not cross or sit on each other.
924const MIN_THUMB_SEPARATION: f32 = 8.0;
925
926/// What a range slider and its two thumbs share: the interval, its travel, its look and
927/// its messages. Cloned into each thumb when the slider is built.
928struct 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    /// The track's length in logical pixels, as the slider's last paint found it: the
946    /// thumbs' keys keep them the reference's eight pixels apart, and only a paint knows
947    /// how wide eight pixels are along the travel.
948    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    /// The two values, held inside the travel and in order.
977    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    /// Where `value` sits along the travel, `0..=1`.
984    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    /// The value a fraction along the travel stands for, snapped.
1010    fn at(&self, fraction: f32) -> f32 {
1011        self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
1012    }
1013
1014    /// The least gap between the thumbs, as a fraction of the travel: nothing with
1015    /// divisions, eight pixels without — the reference's `minThumbSeparation`.
1016    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    /// The interval with thumb `thumb` (`0` the start) at `fraction`, held off the other
1026    /// by the least gap.
1027    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    /// One arrow's step, as a fraction of the travel.
1039    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    /// What a reader is told a thumb's value is.
1047    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    /// Whether the value indicators show while the slider is moved, by its policy.
1056    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    /// The reference's range slider colours (its 2023 default, which is its earlier
1081    /// design): the span between the thumbs in `primary`, the rest `primary` at 24 %.
1082    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    /// What the parts are painted with.
1128    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    /// The halo's shape: the slider's, the theme's, the reference's disc.
1148    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    /// The value indicator's shape: the slider's, the theme's, the reference's rectangle.
1157    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    /// The halo's colour: the widget's, the active colour at 12 %, the theme's, then the
1166    /// reference's `primary` at 12 %.
1167    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
1185/// What a range slider paints with, resolved.
1186struct RangePalette {
1187    active: Color,
1188    inactive: Color,
1189    thumb: Color,
1190    active_tick: Color,
1191    inactive_tick: Color,
1192}
1193
1194/// A **range** slider: two thumbs bounding an interval of `min..=max`, **controlled** and
1195/// draggable — the reference's `RangeSlider`.
1196///
1197/// Like [`Slider`] it takes the width it is offered and is as tall as its halo, its track
1198/// inset by the halo's radius, running from the right in a right-to-left reading
1199/// direction. The defaults are the reference's: the span between the thumbs in the
1200/// scheme's `primary`, the rest `primary` at 24 %; two round thumbs, a stroke between them
1201/// when they overlap; a tick per division; and the values, when
1202/// [`value_label`](Self::value_label) formats them, in a tooltip over each thumb while it
1203/// is moved — on a slider with divisions, unless
1204/// [`show_value_indicator`](Self::show_value_indicator) says otherwise.
1205///
1206/// A press takes the nearer thumb and the drag moves it, never past the other: the
1207/// reference's eight pixels apart on a continuous slider. A press on both, where they
1208/// meet, waits for the first movement: leftwards takes the start. Each thumb takes the
1209/// keyboard's focus and tells a screen reader its own value.
1210pub 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    /// A range slider from `start` to `end`, over `0.0..=1.0` until
1219    /// [`range`](Self::range) says otherwise. The two are put in order.
1220    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    /// The travel the two values cover; `0.0..=1.0` by default. Everything the
1256    /// application sees is in these units.
1257    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    /// Splits the travel into `n` steps: the values snap to them, a tick marks each, and
1265    /// an arrow moves a thumb by one.
1266    pub fn divisions(self, n: usize) -> Self {
1267        self.with(|s| s.divisions = Some(n.max(1)))
1268    }
1269
1270    /// The width, in logical pixels. Unset, the slider takes the width it is offered, and
1271    /// 220 where none can be.
1272    pub fn width(mut self, width: f32) -> Self {
1273        self.width = width;
1274        self.sized = true;
1275        self.rebuild();
1276        self
1277    }
1278
1279    /// Formats each thumb's value for its tooltip — the reference's `labels`, as one
1280    /// formatter rather than two strings, so it follows the values. A screen reader hears
1281    /// it too, unless [`semantic_formatter`](Self::semantic_formatter) says otherwise.
1282    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    /// How a screen reader is told each thumb's value.
1287    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    /// When the tooltips show. Unset, the theme's, then only on a slider with divisions.
1292    pub fn show_value_indicator(self, when: ShowValueIndicator) -> Self {
1293        self.with(|s| s.show_value_indicator = Some(when))
1294    }
1295
1296    /// The span between the thumbs, and the thumbs; the scheme's `primary` otherwise.
1297    pub fn active_color(self, color: Color) -> Self {
1298        self.with(|s| s.colors.active_track = Some(color))
1299    }
1300
1301    /// The track outside the span; `primary` at 24 % otherwise.
1302    pub fn inactive_color(self, color: Color) -> Self {
1303        self.with(|s| s.colors.inactive_track = Some(color))
1304    }
1305
1306    /// The thumbs' fill; the active colour otherwise.
1307    pub fn thumb_color(self, color: Color) -> Self {
1308        self.with(|s| s.colors.thumb = Some(color))
1309    }
1310
1311    /// The halo around a thumb, per state; the active colour at 12 % otherwise.
1312    pub fn overlay_color(self, color: WidgetStateProperty<Color>) -> Self {
1313        self.with(|s| s.overlay_color = Some(color))
1314    }
1315
1316    /// How far a thumb sits off the track at rest. Unset, the theme's, then the
1317    /// reference's 1; `0.0` casts no shadow.
1318    pub fn thumb_elevation(self, elevation: f32) -> Self {
1319        self.with(|s| s.elevations.0 = Some(elevation))
1320    }
1321
1322    /// How far it rises while it is held. Unset, the theme's, then the reference's 6.
1323    pub fn pressed_thumb_elevation(self, elevation: f32) -> Self {
1324        self.with(|s| s.elevations.1 = Some(elevation))
1325    }
1326
1327    /// Paints the track with `shape`, given both thumbs. Unset, the theme's, then the
1328    /// reference's rounded rectangle, square between the thumbs.
1329    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    /// Paints the tick marks with `shape`. Unset, the theme's, then the reference's dots.
1335    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    /// Paints both thumbs with `shape`. Unset, the theme's, then the reference's disc.
1341    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    /// Paints the halo with `shape`. Unset, the theme's, then the reference's disc.
1347    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    /// Paints the value indicators with `shape`. Unset, the theme's, then the reference's
1353    /// rectangle.
1354    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    /// Whether the slider can be moved. Disabled it takes no drag, no key and no focus,
1360    /// and still shows its interval.
1361    pub fn enabled(self, enabled: bool) -> Self {
1362        self.with(|s| s.enabled = enabled)
1363    }
1364
1365    /// A closure producing a message from the new interval, `(start, end)`, in the
1366    /// slider's units.
1367    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    /// Sent once when a drag picks a thumb, with the interval **as it was**, as the
1376    /// reference's `onChangeStart`.
1377    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    /// Sent once when the drag ends, with the interval it settled on.
1386    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    /// The thumbs' centres in `bounds`: the track's middle, and each thumb's place across,
1395    /// the start first.
1396    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    /// The thumbs, each placed at its value by an alignment across the slider's box: a
1414    /// 48-pixel thumb aligned at `2t − 1` has its centre at `24 + t·(width − 48)`, which is
1415    /// exactly where the inset track puts `t`. A directional alignment mirrors it right to
1416    /// left with the track.
1417    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            // A focused thumb's indicator stands over the slider, out of the stack.
1431            .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
1440/// One of a [`RangeSlider`]'s thumbs: what takes the keyboard's focus, answers its arrows,
1441/// tells a screen reader its value, and shows its halo and tooltip while hovered or
1442/// focused. The slider draws the thumb itself, under it.
1443struct RangeThumb<Msg> {
1444    /// `0` the start, `1` the end.
1445    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        // The alignment puts this box's centre where a continuous track puts the value; on
1483        // a slider with divisions the track pulls its ends in by half its height, and the
1484        // thumb with them.
1485        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        // Under the thumb, which the slider paints over its stops.
1506        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        // Focused, its tooltip comes out, as the reference's does; the slider shows both
1515        // while it is dragged.
1516        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        // A disabled thumb cannot be focused, so this should be unreachable — but a key
1558        // arriving from a stale focus must not move a value the caller has frozen.
1559        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    /// **All the room on offer** across, as the reference's takes, unless a width was
1590    /// given.
1591    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        // What the thumbs' keys measure the least gap with.
1621        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        // The ticks, one per division, unless they would crowd each other.
1638        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        // The thumb a drag took (the end, until one has) is the one on top, the one that
1656        // rises while held and wears the halo — the reference's last selection.
1657        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    /// The thumbs, and the values over them, over the thumbs' focus stops: a stop's halo
1676    /// belongs under its thumb, as the reference paints it.
1677    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        // The thumbs over their shadows, the one on top last. Where they overlap, it is
1687        // told so: the reference's rings it in the surface colour, so the two stay two.
1688        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        // Both tooltips while the slider is moved, as the reference's does, the one on top
1704        // last.
1705        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    /// The track, inset by the halo's radius and mirrored right to left, with the two
1739    /// thumbs on it: the shell picks the one a press takes.
1740    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
1782/// The reference's **rectangular value indicator** (`RectangularSliderValueIndicatorShape`),
1783/// a range slider's default: a box with corners of 4, sixteen wider and taller than its text,
1784/// over an eight-pixel triangle whose point is fourteen above the thumb's centre `at`; grown
1785/// from that point by `scale`. In the theme's indicator colour, else the scheme's
1786/// `on_surface` at 60 % over its `surface` at 90 %.
1787pub(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    /// A slider that was never asked for the bracket sends nothing at either end.
1856    /// The stream is what it always was.
1857    #[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    /// **The bracket is in the same units as the stream it brackets.** A caller who
1865    /// asked for `0..=100` gets a hundred from `on_change`, and would be owed one at
1866    /// each end too — a start in fractions inside a signature that looks symmetrical
1867    /// is a trap.
1868    ///
1869    /// The start is the value **before** the press (milestone 650), as the reference's
1870    /// `onChangeStart` is: a tap is a start at the old value and an end at the new one.
1871    #[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    /// The divisions apply at the ends too: a start or an end landing between two
1884    /// stops would name a value the stream can never produce.
1885    #[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        // 0.3 lies between the 0.25 and 0.5 stops, nearer the first; the value before,
1893        // 0.4, nearer the second.
1894        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    /// **Each interaction names its drag mode** (milestone 650): tap and slide follows the
1900    /// pointer, tap only takes the press, the two slides move from where the value is, and
1901    /// the thumb's within its halo. The theme's is taken when the widget names none.
1902    #[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    /// A disabled slider is inert at **every** end. A drag in flight when the caller
1938    /// freezes the value must not land its release either.
1939    #[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        // Clamped.
1956        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    /// The track splits along the framework's one rule: the part still to travel is a
1974    /// container, the travelled part and the thumb are content on it.
1975    #[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            // The crisp rectangles: the thumb's shadow is painted just before the thumb.
1990            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            // Quieter means closer to the surface. Since milestone 329 both tokens are
2002            // opaque, so there is no alpha to compare — which is the point: a disabled
2003            // control flattens.
2004            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    /// **A thumb stands off its track, and higher while it is held.** The reference's round
2016    /// thumb rests at 1 and rises to 6 as the press goes in, on a slider and on each of a
2017    /// range slider's two. Neither cast anything here.
2018    #[test]
2019    fn a_thumb_casts_a_shadow_that_rises_while_it_is_held() {
2020        // The heights the painted shadows stand for (milestone 606).
2021        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    /// The application is handed **its own** units, not a fraction it has to convert.
2081    #[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        // Past the end of the track is the end of the range, not past it.
2088        assert_eq!(value_of(Widget::on_drag(&slider, 1.4)), 200.0);
2089    }
2090
2091    /// A range written backwards is sorted rather than left empty.
2092    #[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    /// A value outside the travel is held inside it, not rejected: a caller that lowers
2100    /// the ceiling under a value it already had gets the ceiling.
2101    #[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    /// Steps land on the divisions, in the caller's units.
2110    #[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    /// An arrow moves one division when there are divisions, 5 % of the travel when
2122    /// there are not — and neither runs off either end.
2123    #[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    /// It is a keyboard control now — but only when there is somebody to tell.
2200    #[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    /// A frozen slider answers no key, even one arriving from a stale focus.
2210    #[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    /// A reader is told where it is set, in the units on screen.
2226    #[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    /// The drop's type is the reference's 2023 one, `labelMedium` in `on_primary`, and
2252    /// comes from the theme when it names one (milestones 414 and 648).
2253    #[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        // And a theme can move it.
2295        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    /// **As tall as its halo**, label or not: the drop is drawn over what is above, as
2301    /// the reference's is, and takes no room.
2302    #[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    /// (rail, travelled, thumb, ring) as painted — the crisp rectangles, in order, and not
2321    /// the soft one under the thumb that is its shadow.
2322    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    /// Nothing said: the reference's 2023 colours — `primary` travelled and for the
2351    /// thumb, `surface_container_highest` for the rest, and no ring.
2352    #[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    /// The thumb follows the travelled track unless it is named — the reference's
2363    /// `thumbColor ?? activeColor` — and there is a ring only when one is asked for.
2364    #[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    /// The two halves of the track are separate.
2379    #[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    /// The theme answers when the instance does not, and loses when it does.
2393    #[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/// The reference's 2023 slider, painted and measured (milestone 648).
2406#[cfg(test)]
2407mod reference_tests {
2408    use super::*;
2409    use frus_core::Primitive;
2410
2411    /// A box 248 wide: a track of 200 from 24, the halo's radius in.
2412    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    /// The thumb: the crisp 20-pixel square.
2440    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    /// **The track is inset by the halo's radius**, so the thumb reaches both ends inside
2460    /// the box; the travelled part is two pixels thicker than the rest.
2461    #[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    /// **Right to left, it runs from the right**: a quarter is near the right end, and the
2477    /// travelled part is on the right.
2478    #[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    /// **A tick per division**, the first and last under the thumb at the ends, and none
2493    /// when they would crowd.
2494    #[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        // The travelled ticks and the rest differ.
2508        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        // The discrete thumb sits over the first and last ticks.
2516        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    /// **The secondary value** lightens the track from the thumb to it, when it is ahead.
2521    #[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    /// **When the drop shows**: on a slider with divisions while it is moved or focused,
2548    /// by default; the other policies as they say; never on a disabled one.
2549    #[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    /// **The drop**: a pill 28 high whose bottom is 22 above the thumb's centre, over a
2608    /// triangle pointing at it, in `primary`.
2609    #[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    /// **The halo**: on hover, focus and press, in `primary` at 8 % hovered and 10 %
2633    /// pressed; none at rest, none disabled; a colour given at 12 %.
2634    #[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    /// **Disabled**: the travelled part and the thumb as content, the rest as a
2676    /// container, the framework's disabled rule.
2677    #[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    /// **It takes the width it is offered**, at least three touch targets of track, unless
2688    /// a width is given.
2689    #[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    /// **A drag lands on the track**: inset by the halo's radius, mirrored right to left.
2708    #[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    /// **A screen reader hears the semantic formatter** over the indicator's label.
2730    #[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    /// **Each drag mode moves the value as the reference's interaction does** (milestone
2740    /// 650), on a track of 200 from 24: following the pointer; taking the press only;
2741    /// sliding from where the value was by the pointer's own movement, past the ends too;
2742    /// and only from a press on the thumb.
2743    #[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        // A slide that starts past the end still measures its whole length.
2792        // 1.08 to 0.83 of the travel: a quarter back from 0.2, held at the start.
2793        assert_eq!(slide.value_at(BOX, 240.0, 190.0, true, false), Some(0.0));
2794        // Right to left, the same rightward slide lowers it.
2795        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    /// A box 248 wide: a track of 200 from 24, the halo's radius in.
2833    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    /// The two thumbs, the start first: each the child of an alignment in the stack.
2843    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    /// What the slider paints under its thumbs' stops and over them, in that order.
2852    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    /// The thumbs' squares, in the order they were painted.
2873    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    /// **The track the shell drags is the slider's own**: inset by the halo's radius,
2912    /// mirrored right to left, with both thumbs on it at their places along the travel —
2913    /// in the slider's units, held in order.
2914    #[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    /// **A press takes the nearer thumb**, and one on both waits for the first movement:
2940    /// towards the start takes the start — leftwards, or rightwards right to left.
2941    #[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        // The thumbs at 64 and 184; between them the middle, 124, divides.
2952        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        // Right to left, the start is on the right.
2957        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        // At 124 and 134: a press at 129 is on both.
2968        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        // Off one thumb's touch radius, the nearer one, whatever the movement.
2978        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    /// **The drag's bracket**: the start reports the interval as it was, each movement and
2988    /// the end the interval with the picked thumb moved — in the slider's units, snapped.
2989    #[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    /// **A thumb stops at the other**: on a slider with divisions it may meet it; without,
3020    /// it stays the reference's eight pixels off — measured on the track the last paint
3021    /// found, 200 here.
3022    #[test]
3023    fn a_thumb_never_passes_the_other() {
3024        // Steps fine enough that eight pixels would still show after the snap.
3025        let discrete = slider(0.2, 0.8).divisions(100);
3026        // Painted, so that it knows its track: the gap is none all the same.
3027        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    /// **Each thumb is a stop of its own**: it takes the focus, tells a reader its value
3042    /// and its travel, and moves with the arrows — stopped by the other thumb.
3043    #[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    /// **Disabled, it is inert and still says where it sits**: no drag, no focus, no key.
3105    #[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    /// **The reference's colours**: the span between the thumbs in `primary`, a little
3128    /// thicker, the rest `primary` at 24 %, the thumbs `primary`; the widget's colours over
3129    /// them.
3130    #[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    /// **The thumb a drag took is the one on top**: painted last, raised while held, with
3187    /// the halo — the end until a drag has taken one. Where the two overlap, the one on top
3188    /// wears a ring in the surface colour.
3189    #[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    /// **Both values show while it is moved**, in the reference's rectangular indicator, by
3238    /// the same policy as a slider's; a focused thumb shows its own.
3239    #[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        // The indicator's box: sixteen wider and taller than its text, its point fourteen
3266        // above the thumb's centre.
3267        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        // With divisions the track pulls its ends in by half its height: 24 + 0.2 × 196 + 2.
3281        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        // A focused thumb shows its own value, not the other's.
3292        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    /// **It takes the width it is offered**, as tall as its halo, at least three touch
3302    /// targets of track; a width given is kept.
3303    #[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    /// **A thumb's halo is under it** (milestone 652): the slider paints its track under
3317    /// the thumbs' focus stops and the thumbs over them, so a hovered stop's halo — a full
3318    /// disc — lies between the two, as the reference paints it.
3319    #[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}