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::flex::Flex;
10use crate::interaction::{Key, KeyResponse, Status};
11use crate::theme::Theme;
12use crate::widget::Widget;
13use crate::widgetstate::{WidgetState, WidgetStateProperty};
14
15/// The height of the value tooltip (above the thumbs) and its gap from the track.
16const TIP_H: f32 = 20.0;
17const TIP_GAP: f32 = 6.0;
18/// The value bubble's style: what the theme says, else the reference's — it calls this the
19/// *value indicator* and sets it in `labelLarge`.
20///
21/// **Resolved once**, so that the number the bubble is measured with is the number the
22/// digits are drawn at.
23fn tip_style(theme: Option<&Theme>) -> ResolvedTextStyle {
24    theme
25        .and_then(|t| t.widgets.slider.value_indicator_text_style)
26        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
27        .resolved()
28}
29/// The default keyboard step (without `divisions`): an arrow moves by 5%.
30const KEY_STEP: f32 = 0.05;
31
32const H: f32 = 24.0;
33const TRACK_H: f32 = 6.0;
34const THUMB: f32 = 18.0;
35/// How far a thumb sits off the track at rest, and while it is held: the reference's round
36/// thumb on a slider (its slider parts, lines 680–681) and on a range slider (its range
37/// slider parts, lines 789–790).
38const THUMB_ELEVATION: f32 = 1.0;
39const THUMB_PRESSED_ELEVATION: f32 = 6.0;
40
41/// The shadow under a thumb whose box is `thumb`: its resting height, rising to the pressed
42/// one as the press goes in.
43///
44/// Drawn the way a search bar draws its height — a blur of two pixels a step from four, and a
45/// drop of half a pixel a step — and **not** the way a card does. That is not taste: the
46/// shadow primitive's soft edge stops at its own rectangle, and around a circle this small a
47/// card's wider blur leaves that rectangle's corners in the fade, so the halo comes out square.
48/// At rest this one is round; while held, at six, the corners begin to show.
49///
50/// `told` is the widget's `(resting, pressed)`, each over the theme's and the reference's.
51/// The reference's round thumb casts it enabled or not — its shape has no disabled height
52/// (its slider parts, lines 747–767) — and so does this one.
53fn paint_thumb_shadow(
54    thumb: Rect,
55    told: (Option<f32>, Option<f32>),
56    status: &Status,
57    theme: &Theme,
58    scene: &mut Scene,
59) {
60    let t = &theme.widgets.slider;
61    let rest = told.0.or(t.thumb_elevation).unwrap_or(THUMB_ELEVATION);
62    let pressed = told
63        .1
64        .or(t.pressed_thumb_elevation)
65        .unwrap_or(THUMB_PRESSED_ELEVATION);
66    let depth = rest + (pressed - rest) * status.press_progress.clamp(0.0, 1.0);
67    if depth <= 0.0 {
68        return;
69    }
70    // The reference's shadows for this height (milestone 606).
71    frus_core::paint_elevation(
72        scene,
73        thumb,
74        frus_core::BorderRadius::uniform(thumb.width * 0.5),
75        depth,
76        theme.scheme.shadow.fade(status.opacity),
77    );
78}
79
80/// The reference's 2023 slider (milestone 648): a 4-pixel track, 2 more for the travelled
81/// part, a 20-pixel thumb, a 48-pixel halo, all inset by the halo's radius at each end.
82const TRACK_HEIGHT: f32 = 4.0;
83const ACTIVE_EXTRA: f32 = 2.0;
84const THUMB_RADIUS: f32 = 10.0;
85const OVERLAY_RADIUS: f32 = 24.0;
86/// The reference's shortest track: three touch targets.
87const MIN_TRACK: f32 = 144.0;
88/// The default width, where the room cannot be given: what the slider has always been.
89const DEFAULT_WIDTH: f32 = 220.0;
90
91/// When a slider shows its **value indicator** — the reference's `ShowValueIndicator`.
92///
93/// The indicator is the label a [`Slider::value_label`] formats, in a drop over the thumb.
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
95pub enum ShowValueIndicator {
96    /// While it is dragged or focused, on a slider with divisions only — the default.
97    #[default]
98    OnlyForDiscrete,
99    /// While it is dragged or focused, on a slider without divisions only.
100    OnlyForContinuous,
101    /// While it is dragged or focused, whatever the slider.
102    OnDrag,
103    /// All the time.
104    AlwaysVisible,
105    /// Never.
106    Never,
107}
108
109/// A linear slider over `min..=max`, **controlled** and draggable — the reference's
110/// `Slider`.
111///
112/// It takes the width it is offered, as the reference's does, and is as tall as its halo:
113/// 48. The track is inset by the halo's radius at each end, so the thumb reaches both ends
114/// without leaving the box. In a right-to-left reading direction it runs from the right.
115///
116/// The defaults are the reference's 2023 design: the scheme's `primary` for the
117/// travelled part and the thumb, `surface_container_highest` for the rest; tick marks on
118/// a slider with [`divisions`](Self::divisions); a halo on hover, focus and drag; and the
119/// value, when [`value_label`](Self::value_label) formats one, in a drop above the thumb
120/// while it is moved — on a slider with divisions, unless
121/// [`show_value_indicator`](Self::show_value_indicator) says otherwise.
122pub struct Slider<Msg = crate::callback::Callback> {
123    value: f32,
124    secondary: Option<f32>,
125    min: f32,
126    max: f32,
127    width: f32,
128    sized: bool,
129    divisions: Option<usize>,
130    /// Formats the value for the indicator over the thumb.
131    label: Option<Rc<dyn Fn(f32) -> String>>,
132    /// Formats the value for a screen reader, when it should hear it otherwise.
133    semantic_label: Option<Rc<dyn Fn(f32) -> String>>,
134    show_value_indicator: Option<ShowValueIndicator>,
135    enabled: bool,
136    colors: SliderColors,
137    overlay_color: Option<WidgetStateProperty<Color>>,
138    thumb_elevation: Option<f32>,
139    pressed_thumb_elevation: Option<f32>,
140    on_change: Option<Box<dyn Fn(f32) -> Msg>>,
141    /// Sent once, when a drag begins.
142    on_change_start: Option<Box<dyn Fn(f32) -> Msg>>,
143    /// Sent once, when it ends.
144    on_change_end: Option<Box<dyn Fn(f32) -> Msg>>,
145}
146
147/// What a slider was told about its own colours; unset entries fall through to the theme
148/// and then the scheme, resolved where they are painted rather than where they are built.
149#[derive(Clone, Copy, Debug, Default, PartialEq)]
150pub(crate) struct SliderColors {
151    pub active_track: Option<Color>,
152    pub inactive_track: Option<Color>,
153    pub secondary_track: Option<Color>,
154    pub thumb: Option<Color>,
155    pub thumb_border: Option<Color>,
156}
157
158/// Everything a slider paints with, resolved.
159#[derive(Clone, Copy, Debug, PartialEq)]
160struct Palette {
161    active: Color,
162    inactive: Color,
163    secondary: Color,
164    thumb: Color,
165    ring: Option<Color>,
166    active_tick: Color,
167    inactive_tick: Color,
168}
169
170impl SliderColors {
171    /// The reference's resolution, each colour the widget's, then the theme's, then the
172    /// 2023 default; disabled, the theme's disabled colours or the framework's disabled rule.
173    fn resolve(&self, theme: &Theme, enabled: bool) -> Palette {
174        let t = &theme.widgets.slider;
175        let s = &theme.scheme;
176        if !enabled {
177            // The travelled part and the thumb are content (38 %), the rest a container
178            // (12 %), as the reference's disabled tokens are; the thumb flattened onto the
179            // surface, so the track does not show through it.
180            let content = disabled_content(theme);
181            let container = disabled_container(theme);
182            return Palette {
183                active: t.disabled_active_track_color.unwrap_or(content),
184                inactive: t.disabled_inactive_track_color.unwrap_or(container),
185                secondary: t.disabled_inactive_track_color.unwrap_or(container),
186                thumb: t.disabled_thumb_color.unwrap_or(content),
187                ring: None,
188                active_tick: content,
189                inactive_tick: content,
190            };
191        }
192        let active = self
193            .active_track
194            .or(t.active_track_color)
195            .unwrap_or(s.primary);
196        Palette {
197            active,
198            inactive: self
199                .inactive_track
200                .or(t.inactive_track_color)
201                .unwrap_or(s.surface_container_highest),
202            secondary: self
203                .secondary_track
204                .or(t.secondary_active_track_color)
205                .unwrap_or(s.primary.fade(0.54)),
206            // The thumb is the travelled part's colour unless it is named: the reference's
207            // `thumbColor ?? activeColor`.
208            thumb: self
209                .thumb
210                .or(self.active_track)
211                .or(t.thumb_color)
212                .unwrap_or(s.primary),
213            ring: self.thumb_border.or(t.thumb_border_color),
214            // The reference swaps them when a colour is given: ticks on the travelled part
215            // take the inactive colour, and the others the active one.
216            active_tick: self
217                .inactive_track
218                .or(t.active_tick_mark_color)
219                .unwrap_or(s.on_primary.fade(0.38)),
220            inactive_tick: self
221                .active_track
222                .or(t.inactive_tick_mark_color)
223                .unwrap_or(s.on_surface_variant.fade(0.38)),
224        }
225    }
226}
227
228impl<Msg> Slider<Msg> {
229    /// Creates a slider at the given value, over `0.0..=1.0` until
230    /// [`range`](Slider::range) says otherwise.
231    pub fn new(value: f32) -> Self {
232        Self {
233            value,
234            secondary: None,
235            min: 0.0,
236            max: 1.0,
237            width: DEFAULT_WIDTH,
238            sized: false,
239            divisions: None,
240            label: None,
241            semantic_label: None,
242            show_value_indicator: None,
243            enabled: true,
244            colors: SliderColors::default(),
245            overlay_color: None,
246            thumb_elevation: None,
247            pressed_thumb_elevation: None,
248            on_change: None,
249            on_change_start: None,
250            on_change_end: None,
251        }
252    }
253
254    /// The travelled part of the track, and the thumb unless
255    /// [`thumb_color`](Slider::thumb_color) names it; the scheme's `primary` otherwise.
256    pub fn active_color(mut self, color: Color) -> Self {
257        self.colors.active_track = Some(color);
258        self
259    }
260
261    /// The part of the track still to travel; the scheme's `surface_container_highest`
262    /// otherwise.
263    pub fn inactive_color(mut self, color: Color) -> Self {
264        self.colors.inactive_track = Some(color);
265        self
266    }
267
268    /// The part between the value and the [secondary value](Self::secondary_track_value);
269    /// the scheme's `primary` at 54 % otherwise.
270    pub fn secondary_active_color(mut self, color: Color) -> Self {
271        self.colors.secondary_track = Some(color);
272        self
273    }
274
275    /// The thumb's fill; the active colour otherwise.
276    pub fn thumb_color(mut self, color: Color) -> Self {
277        self.colors.thumb = Some(color);
278        self
279    }
280
281    /// A ring around the thumb, two pixels wide. The reference's thumb has none, and
282    /// neither has this one unless it is asked for.
283    pub fn thumb_border_color(mut self, color: Color) -> Self {
284        self.colors.thumb_border = Some(color);
285        self
286    }
287
288    /// The halo around the thumb, per state: hovered, focused, dragged. Unset, the
289    /// theme's, then the active colour at 12 % when one is given, then the scheme's
290    /// `primary` at 8 % hovered and 10 % focused or dragged.
291    pub fn overlay_color(mut self, color: WidgetStateProperty<Color>) -> Self {
292        self.overlay_color = Some(color);
293        self
294    }
295
296    /// How far the thumb sits off the track at rest. Unset, the theme's, then the
297    /// reference's 1; `0.0` casts no shadow.
298    pub fn thumb_elevation(mut self, elevation: f32) -> Self {
299        self.thumb_elevation = Some(elevation);
300        self
301    }
302
303    /// How far it rises while it is held. Unset, the theme's, then the reference's 6.
304    pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
305        self.pressed_thumb_elevation = Some(elevation);
306        self
307    }
308
309    /// The travel this slider covers; `0.0..=1.0` by default.
310    ///
311    /// Everything the application sees is in **these** units — the value it is given,
312    /// the value a step lands on, what a reader is told — rather than a fraction it
313    /// would have to convert back on both sides of every message. The two arguments are
314    /// sorted, so a range written backwards is not a silent empty one.
315    ///
316    /// The value is held inside the travel rather than rejected: a caller that lowers
317    /// the ceiling under a value it already had gets the ceiling, not a panic.
318    pub fn range(mut self, min: f32, max: f32) -> Self {
319        self.min = min.min(max);
320        self.max = min.max(max);
321        self
322    }
323
324    /// A **second value**, shown as a lighter part of the track beyond the value: how
325    /// much of a video is buffered behind where it is playing. It is not dragged.
326    pub fn secondary_track_value(mut self, value: f32) -> Self {
327        self.secondary = Some(value);
328        self
329    }
330
331    /// Splits the travel into `n` **steps**: the value snaps to `min + k·(max−min)/n`,
332    /// a tick marks each, and an arrow key moves by one of them. Without this call the
333    /// travel is continuous and an arrow moves by 5 %.
334    pub fn divisions(mut self, n: usize) -> Self {
335        self.divisions = Some(n.max(1));
336        self
337    }
338
339    /// Formats the value for the **value indicator**, the drop over the thumb — a
340    /// percentage, a price, a duration. When it shows is
341    /// [`show_value_indicator`](Self::show_value_indicator)'s to say. A screen reader
342    /// hears it too, unless [`semantic_formatter`](Self::semantic_formatter) says
343    /// otherwise.
344    pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
345        self.label = Some(Rc::new(label));
346        self
347    }
348
349    /// When the value indicator shows. Unset, the theme's, then
350    /// [`ShowValueIndicator::OnlyForDiscrete`], the reference's.
351    pub fn show_value_indicator(mut self, when: ShowValueIndicator) -> Self {
352        self.show_value_indicator = Some(when);
353        self
354    }
355
356    /// How a screen reader is told the value — the reference's `semanticFormatterCallback`.
357    pub fn semantic_formatter(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
358        self.semantic_label = Some(Rc::new(format));
359        self
360    }
361
362    /// The width, in logical pixels. Unset, the slider takes the width it is offered,
363    /// and 220 where none can be offered.
364    pub fn width(mut self, width: f32) -> Self {
365        self.width = width;
366        self.sized = true;
367        self
368    }
369
370    /// A closure producing a message from the new value, in the slider's units.
371    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
372        mut self,
373        on_change: impl Fn(f32) -> R + 'static,
374    ) -> Self {
375        self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
376        self
377    }
378
379    /// Sent **once**, when a drag begins — before the first
380    /// [`on_change`](Self::on_change), with the value the press landed on.
381    ///
382    /// With [`on_change_end`](Self::on_change_end) it brackets the stream.
383    /// `on_change` fires on every pixel of the movement, so an application that seeks a
384    /// video, writes a setting to disk or asks the network on each of them does it
385    /// sixty times a second; the bracket is what lets it show a preview while the
386    /// finger is down and commit when it lifts.
387    pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
388        mut self,
389        on_start: impl Fn(f32) -> R + 'static,
390    ) -> Self {
391        self.on_change_start = Some(Box::new(crate::callback::handler1(on_start)));
392        self
393    }
394
395    /// Sent **once**, when the drag ends — after the last
396    /// [`on_change`](Self::on_change), with the value it settled on.
397    ///
398    /// A press that never moved still gets one: it changed the value, and a caller
399    /// waiting for the release would otherwise never be told it happened.
400    pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
401        mut self,
402        on_end: impl Fn(f32) -> R + 'static,
403    ) -> Self {
404        self.on_change_end = Some(Box::new(crate::callback::handler1(on_end)));
405        self
406    }
407
408    /// Whether the slider can be moved. Disabled it is **inert** — it takes no drag and
409    /// answers no key — and it still shows where it is set.
410    ///
411    /// A slider is the control that makes the point of [`crate::disabled`]'s contract:
412    /// greying it out while it still answered a drag would leave it inert only to the
413    /// gesture nobody was using on it.
414    pub fn enabled(mut self, enabled: bool) -> Self {
415        self.enabled = enabled;
416        self
417    }
418
419    /// The value, held inside the travel.
420    fn held(&self) -> f32 {
421        self.value.clamp(self.min, self.max)
422    }
423
424    /// Where `value` sits along the travel, `0..=1`. An empty range pins it at the start
425    /// rather than dividing by nothing.
426    fn fraction_of(&self, value: f32) -> f32 {
427        let span = self.max - self.min;
428        if span <= 0.0 {
429            0.0
430        } else {
431            ((value.clamp(self.min, self.max) - self.min) / span).clamp(0.0, 1.0)
432        }
433    }
434
435    /// Where the thumb sits along the track, `0..=1`.
436    fn fraction(&self) -> f32 {
437        self.fraction_of(self.held())
438    }
439
440    /// Snaps a fraction to the nearest step, if there are steps.
441    fn snap(&self, fraction: f32) -> f32 {
442        match self.divisions {
443            Some(n) if n > 0 => (fraction * n as f32).round() / n as f32,
444            _ => fraction,
445        }
446    }
447
448    /// The value a fraction along the track stands for.
449    fn at(&self, fraction: f32) -> f32 {
450        self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
451    }
452
453    /// One arrow's step, as a fraction of the travel: a division if there are
454    /// divisions, otherwise [`KEY_STEP`].
455    fn key_step(&self) -> f32 {
456        match self.divisions {
457            Some(n) if n > 0 => 1.0 / n as f32,
458            _ => KEY_STEP,
459        }
460    }
461
462    fn discrete(&self) -> bool {
463        self.divisions.is_some_and(|n| n > 0)
464    }
465
466    /// Whether the indicator shows in this state, and how far it has come out.
467    fn indicator_reveal(&self, status: &Status, theme: &Theme) -> f32 {
468        if !self.enabled || self.label.is_none() {
469            return 0.0;
470        }
471        let when = self
472            .show_value_indicator
473            .or(theme.widgets.slider.show_value_indicator)
474            .unwrap_or_default();
475        let moved = status
476            .press_progress
477            .max(status.focus_progress)
478            .clamp(0.0, 1.0);
479        match when {
480            ShowValueIndicator::AlwaysVisible => 1.0,
481            ShowValueIndicator::Never => 0.0,
482            ShowValueIndicator::OnlyForDiscrete if !self.discrete() => 0.0,
483            ShowValueIndicator::OnlyForContinuous if self.discrete() => 0.0,
484            _ => moved,
485        }
486    }
487
488    /// The halo's colour in this state: the widget's, the active colour at 12 %, the
489    /// theme's, then the reference's.
490    fn overlay(&self, status: &Status, theme: &Theme) -> Color {
491        let mut states = status.states();
492        if status.interaction == crate::interaction::Interaction::Pressed {
493            states = states.with(WidgetState::Dragged);
494        }
495        let scheme = &theme.scheme;
496        self.overlay_color
497            .as_ref()
498            .and_then(|p| p.resolve(states).copied())
499            .or(self.colors.active_track.map(|c| c.fade(0.12)))
500            .or_else(|| {
501                theme
502                    .widgets
503                    .slider
504                    .overlay_color
505                    .as_ref()
506                    .and_then(|p| p.resolve(states).copied())
507            })
508            .unwrap_or_else(|| {
509                if states.contains(WidgetState::Dragged) {
510                    scheme.primary.fade(0.1)
511                } else if states.contains(WidgetState::Hovered) {
512                    scheme.primary.fade(0.08)
513                } else if states.contains(WidgetState::Focused) {
514                    scheme.primary.fade(0.1)
515                } else {
516                    Color::TRANSPARENT
517                }
518            })
519    }
520
521    fn track_height(&self, theme: &Theme) -> f32 {
522        theme.widgets.slider.track_height.unwrap_or(TRACK_HEIGHT)
523    }
524}
525
526/// Where the thumb's centre falls along a track running from `left` over `width`, for a
527/// value `t` of the travel: the reference's `_calcThumbCenter`. A slider with divisions
528/// keeps its thumb half a track's height inside each end, so the first and last ticks are
529/// under it.
530fn thumb_x(left: f32, width: f32, t: f32, discrete: bool, track_height: f32) -> f32 {
531    let padding = if discrete { track_height } else { 0.0 };
532    let x = left + t * (width - padding) + padding / 2.0;
533    let inner = if padding > THUMB_RADIUS {
534        padding / 2.0
535    } else {
536        0.0
537    };
538    x.clamp(left + inner, left + width - inner)
539}
540
541impl<Msg> Widget<Msg> for Slider<Msg> {
542    fn style(&self) -> Style {
543        Style {
544            width: Dimension::Length(self.width),
545            min_width: Dimension::Length(if self.sized {
546                0.0
547            } else {
548                MIN_TRACK + 2.0 * OVERLAY_RADIUS
549            }),
550            height: Dimension::Length(2.0 * OVERLAY_RADIUS),
551            ..Default::default()
552        }
553    }
554
555    /// **All the room on offer** across, as the reference's takes, unless a width was
556    /// given.
557    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
558        crate::widget::FillAxes {
559            horizontal: !self.sized,
560            vertical: false,
561        }
562    }
563
564    fn soft_extent(&self) -> crate::widget::FillAxes {
565        crate::widget::FillAxes {
566            horizontal: !self.sized,
567            vertical: false,
568        }
569    }
570
571    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
572        &[]
573    }
574
575    fn debug_name(&self) -> &'static str {
576        "Slider"
577    }
578
579    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
580        let o = status.opacity;
581        let rtl = theme.direction.is_rtl();
582        let palette = self.colors.resolve(theme, self.enabled);
583        let track_h = self.track_height(theme);
584        // The track, inset by the halo's radius at each end, centred in the box.
585        let left = bounds.x + OVERLAY_RADIUS;
586        let width = (bounds.width - 2.0 * OVERLAY_RADIUS).max(0.0);
587        let cy = bounds.y + bounds.height * 0.5;
588        let along = |t: f32| if rtl { 1.0 - t } else { t };
589        let discrete = self.discrete();
590        let thumb = thumb_x(left, width, along(self.fraction()), discrete, track_h);
591
592        if track_h > 0.0 {
593            let half = track_h * 0.5;
594            let active_half = (track_h + ACTIVE_EXTRA) * 0.5;
595            let right = left + width;
596            // The travelled part runs from the start, a little thicker; the rest from the
597            // thumb to the end. Each reaches half a track past the thumb, under it.
598            let (a0, a1) = if rtl {
599                (thumb - half, right)
600            } else {
601                (left, thumb + half)
602            };
603            let (i0, i1) = if rtl {
604                (left, thumb + half)
605            } else {
606                (thumb - half, right)
607            };
608            if i1 - i0 > track_h {
609                scene.draw_rect(
610                    Rect::new(i0, cy - half, i1 - i0, track_h),
611                    palette.inactive.fade(o),
612                    half,
613                    0.0,
614                    Color::TRANSPARENT,
615                );
616            }
617            if a1 - a0 > track_h {
618                scene.draw_rect(
619                    Rect::new(a0, cy - active_half, a1 - a0, 2.0 * active_half),
620                    palette.active.fade(o),
621                    active_half,
622                    0.0,
623                    Color::TRANSPARENT,
624                );
625            }
626            // The secondary value: from the thumb towards it, when it lies beyond.
627            if let Some(secondary) = self.secondary {
628                let sx = thumb_x(
629                    left,
630                    width,
631                    along(self.fraction_of(secondary)),
632                    discrete,
633                    track_h,
634                );
635                let beyond = if rtl { sx < thumb } else { sx > thumb };
636                if beyond {
637                    let (s0, s1) = if rtl { (sx, thumb) } else { (thumb, sx) };
638                    let radius = if rtl {
639                        frus_core::BorderRadius {
640                            top_left: half,
641                            bottom_left: half,
642                            ..frus_core::BorderRadius::ZERO
643                        }
644                    } else {
645                        frus_core::BorderRadius {
646                            top_right: half,
647                            bottom_right: half,
648                            ..frus_core::BorderRadius::ZERO
649                        }
650                    };
651                    scene.draw_rect(
652                        Rect::new(s0, cy - half, s1 - s0, track_h),
653                        palette.secondary.fade(o),
654                        radius,
655                        0.0,
656                        Color::TRANSPARENT,
657                    );
658                }
659            }
660        }
661
662        // The halo, grown by hover, focus or a press.
663        let activation = status
664            .hover_progress
665            .max(status.focus_progress)
666            .max(status.press_progress)
667            .clamp(0.0, 1.0);
668        if self.enabled && activation > 0.0 {
669            let r = OVERLAY_RADIUS * activation;
670            let halo = self.overlay(&status, theme);
671            if halo.a > 0.0 {
672                scene.draw_rect(
673                    Rect::new(thumb - r, cy - r, 2.0 * r, 2.0 * r),
674                    halo.fade(o),
675                    r,
676                    0.0,
677                    Color::TRANSPARENT,
678                );
679            }
680        }
681
682        // The ticks, one per division, unless they would crowd each other.
683        if let Some(n) = self.divisions.filter(|n| *n > 0) {
684            let tick_r = track_h / 4.0;
685            let adjusted = width - track_h;
686            if tick_r > 0.0 && adjusted / n as f32 >= 3.0 * 2.0 * tick_r {
687                for i in 0..=n {
688                    let x = left + i as f32 / n as f32 * adjusted + track_h / 2.0;
689                    let travelled = if rtl { x >= thumb } else { x <= thumb };
690                    let color = if travelled {
691                        palette.active_tick
692                    } else {
693                        palette.inactive_tick
694                    };
695                    scene.draw_rect(
696                        Rect::new(x - tick_r, cy - tick_r, 2.0 * tick_r, 2.0 * tick_r),
697                        color.fade(o),
698                        tick_r,
699                        0.0,
700                        Color::TRANSPARENT,
701                    );
702                }
703            }
704        }
705
706        // The thumb, over its shadow.
707        let thumb_box = Rect::new(
708            thumb - THUMB_RADIUS,
709            cy - THUMB_RADIUS,
710            2.0 * THUMB_RADIUS,
711            2.0 * THUMB_RADIUS,
712        );
713        paint_thumb_shadow(
714            thumb_box,
715            (self.thumb_elevation, self.pressed_thumb_elevation),
716            &status,
717            theme,
718            scene,
719        );
720        let (ring_width, ring) = match palette.ring {
721            Some(ring) => (2.0, ring.fade(o)),
722            None => (0.0, Color::TRANSPARENT),
723        };
724        scene.draw_rect(
725            thumb_box,
726            palette.thumb.fade(o),
727            THUMB_RADIUS,
728            ring_width,
729            ring,
730        );
731
732        // The value, in a drop over the thumb, while the slider is moved.
733        let reveal = self.indicator_reveal(&status, theme);
734        if let (Some(label), true) = (self.label.as_ref(), reveal > 0.0) {
735            paint_drop(
736                Point::new(thumb, cy),
737                label(self.held()),
738                theme,
739                reveal,
740                o,
741                scene,
742            );
743        }
744    }
745
746    fn on_click(&self) -> Option<Msg> {
747        None
748    }
749
750    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
751        // The value survives: a reader who cannot move the slider is still owed where it
752        // sits, which is the whole of what a slider says.
753        // In the caller's units, and said the caller's way when it gave a formatter:
754        // "42 €" is what is on screen, and a percentage of an unstated range is not an
755        // answer to "where is this set".
756        let held = self.held();
757        let spoken = match self.semantic_label.as_ref().or(self.label.as_ref()) {
758            Some(label) => label(held),
759            None if self.min == 0.0 && self.max == 1.0 => {
760                format!("{}%", (held * 100.0).round())
761            }
762            None => format!("{held}"),
763        };
764        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
765            .value(spoken)
766            .range(self.min, held, self.max);
767        Some(if self.enabled {
768            semantics
769        } else {
770            semantics.disabled(true)
771        })
772    }
773
774    fn draggable(&self) -> bool {
775        self.enabled
776    }
777
778    /// A drag lands on the track, inset by the halo's radius, and runs from the right in a
779    /// right-to-left reading direction.
780    fn drag_track(&self) -> crate::widget::DragTrack {
781        crate::widget::DragTrack {
782            inset: OVERLAY_RADIUS,
783            mirrored: true,
784        }
785    }
786
787    fn focusable(&self) -> bool {
788        self.enabled && self.on_change.is_some()
789    }
790
791    fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
792        // A disabled slider cannot be focused, so this should be unreachable — but a key
793        // arriving from a stale focus must not move a value the caller has frozen.
794        if !self.enabled {
795            return KeyResponse::Ignored;
796        }
797        // Arrows: one step. Home/End: the two ends, which the clamp in `at` reaches
798        // without either bound having to be named here.
799        let delta = match key {
800            Key::Left { .. } => -self.key_step(),
801            Key::Right { .. } => self.key_step(),
802            Key::Home { .. } => -2.0,
803            Key::End { .. } => 2.0,
804            _ => return KeyResponse::Ignored,
805        };
806        let moved = self.at(self.fraction() + delta);
807        KeyResponse::Handled(self.on_change.as_ref().map(|make| make(moved)))
808    }
809
810    fn on_drag(&self, fraction: f32) -> Option<Msg> {
811        // `draggable` already says no, but a drag in flight when the caller disables the
812        // slider must not land either.
813        if !self.enabled {
814            return None;
815        }
816        self.on_change.as_ref().map(|make| make(self.at(fraction)))
817    }
818
819    fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
820        if !self.enabled {
821            return None;
822        }
823        // The **value**, not the fraction: a caller who asked for a range of 0..=100 is
824        // owed a hundred here too, and a bracket in different units from the stream it
825        // brackets would be a trap inside a signature that looks symmetrical.
826        self.on_change_start
827            .as_ref()
828            .map(|make| make(self.at(fraction)))
829    }
830
831    fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
832        if !self.enabled {
833            return None;
834        }
835        self.on_change_end
836            .as_ref()
837            .map(|make| make(self.at(fraction)))
838    }
839}
840
841/// The reference's **drop value indicator** (`DropSliderValueIndicatorShape`): a pill
842/// 28 high, as wide as the text and a margin, over a small triangle pointing at the
843/// thumb's centre `at`, grown from the triangle's tip by `scale`.
844fn paint_drop(at: Point, text: String, theme: &Theme, scale: f32, o: f32, scene: &mut Scene) {
845    const TRIANGLE: f32 = 10.0;
846    const PADDING: f32 = 8.0;
847    const MIN_LABEL: f32 = 20.0;
848    const RECT_H: f32 = 28.0;
849    const RECT_Y: f32 = 6.0;
850    const TIP_Y: f32 = 16.0;
851    let style = drop_style(theme);
852    let text_w = frus_text::measure_resolved(&text, &style).width;
853    let rect_w = MIN_LABEL.max(text_w) + PADDING;
854    // Everything is drawn about the tip, scaled from it.
855    let tip = Point::new(at.x, at.y - TIP_Y);
856    let s = |dx: f32, dy: f32| Point::new(tip.x + dx * scale, tip.y + dy * scale);
857    let color = theme
858        .widgets
859        .slider
860        .value_indicator_color
861        .unwrap_or(theme.scheme.primary)
862        .fade(o);
863    let top_left = s(-rect_w / 2.0, -RECT_Y - RECT_H);
864    let pill = Rect::new(top_left.x, top_left.y, rect_w * scale, RECT_H * scale);
865    let path = frus_core::Path::new()
866        .move_to(s(0.0, 0.0))
867        .line_to(s(-TRIANGLE, -TRIANGLE))
868        .line_to(s(TRIANGLE, -TRIANGLE))
869        .close();
870    scene.fill_path(&path, color);
871    scene.draw_rect(pill, color, pill.height * 0.5, 0.0, Color::TRANSPARENT);
872    // The text at full size once the drop is, centred in the pill.
873    if scale >= 0.999 {
874        let text_color = style.color.unwrap_or(theme.scheme.on_primary);
875        scene.text(
876            Point::new(
877                pill.x + (pill.width - text_w) * 0.5,
878                pill.y + (pill.height - style.line_height()) * 0.5,
879            ),
880            text,
881            &style,
882            text_color.fade(o),
883        );
884    }
885}
886
887/// The drop's type: the theme's, else the reference's 2023 `labelMedium` in `on_primary`.
888fn drop_style(theme: &Theme) -> ResolvedTextStyle {
889    theme
890        .widgets
891        .slider
892        .value_indicator_text_style
893        .unwrap_or_else(|| {
894            crate::theme::type_scale(Some(theme))
895                .label_medium
896                .color(theme.scheme.on_primary)
897        })
898        .resolved()
899}
900
901/// A transparent, inert shim that positions the thumbs along the track.
902struct Spacer {
903    width: f32,
904    height: f32,
905}
906
907impl<Msg: Clone> Widget<Msg> for Spacer {
908    fn style(&self) -> Style {
909        Style {
910            width: Dimension::Length(self.width),
911            height: Dimension::Length(self.height),
912            ..Default::default()
913        }
914    }
915
916    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
917        &[]
918    }
919
920    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
921
922    fn on_click(&self) -> Option<Msg> {
923        None
924    }
925}
926
927/// The side of a [`RangeSlider`] thumb.
928#[derive(Copy, Clone)]
929enum Side {
930    Low,
931    High,
932}
933
934/// A **draggable** thumb of the range slider. Each thumb moves **its own** side
935/// (sticky: the thumb grabbed stays the thumb moved), bounded by the other.
936struct RangeThumb<Msg> {
937    side: Side,
938    low: f32,
939    high: f32,
940    /// The track's width, to convert a pixel delta into a fraction.
941    track: f32,
942    /// Total height (the track + any tooltip zone): the thumb is drawn in the
943    /// **lower** `H` band.
944    height: f32,
945    divisions: Option<usize>,
946    /// The tooltip formatter: the bubble only appears on **hover or focus** of the thumb.
947    label: Option<Rc<dyn Fn(f32) -> String>>,
948    /// The slider's availability, handed down to each thumb.
949    enabled: bool,
950    /// The slider's `(resting, pressed)` thumb heights, handed down likewise.
951    elevations: (Option<f32>, Option<f32>),
952    on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
953}
954
955impl<Msg> RangeThumb<Msg> {
956    /// Snaps `v` to the nearest step if `divisions` is set.
957    fn snap(&self, v: f32) -> f32 {
958        match self.divisions {
959            Some(n) if n > 0 => (v * n as f32).round() / n as f32,
960            _ => v,
961        }
962    }
963
964    /// The value this thumb carries, according to its side.
965    fn value(&self) -> f32 {
966        match self.side {
967            Side::Low => self.low,
968            Side::High => self.high,
969        }
970    }
971
972    /// The new interval after moving this thumb's side by `delta`, bounded by the
973    /// other (no crossing) and snapped.
974    fn moved(&self, delta: f32) -> (f32, f32) {
975        match self.side {
976            Side::Low => (
977                self.snap((self.low + delta).clamp(0.0, self.high)),
978                self.high,
979            ),
980            Side::High => (
981                self.low,
982                self.snap((self.high + delta).clamp(self.low, 1.0)),
983            ),
984        }
985    }
986
987    /// One arrow's step: a division if `divisions` is set, otherwise [`KEY_STEP`].
988    fn key_step(&self) -> f32 {
989        match self.divisions {
990            Some(n) if n > 0 => 1.0 / n as f32,
991            _ => KEY_STEP,
992        }
993    }
994}
995
996impl<Msg: Clone> Widget<Msg> for RangeThumb<Msg> {
997    fn style(&self) -> Style {
998        Style {
999            width: Dimension::Length(THUMB),
1000            height: Dimension::Length(self.height),
1001            ..Default::default()
1002        }
1003    }
1004
1005    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1006        &[]
1007    }
1008
1009    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1010        let o = status.opacity;
1011        // The thumb in the lower `H` band; an accented ring on keyboard focus.
1012        let y = bounds.y + bounds.height - H + (H - THUMB) * 0.5;
1013        // A disabled thumb never shows the focus ring, because it never takes focus.
1014        let border = if status.focused && self.enabled {
1015            3.0
1016        } else {
1017            2.0
1018        };
1019        let (fill, ring) = if self.enabled {
1020            (Color::WHITE, theme.primary)
1021        } else {
1022            let dead = disabled_content(theme);
1023            (dead, dead)
1024        };
1025        let thumb = Rect::new(bounds.x, y, THUMB, THUMB);
1026        paint_thumb_shadow(thumb, self.elevations, &status, theme, scene);
1027        scene.draw_rect(thumb, fill.fade(o), THUMB * 0.5, border, ring.fade(o));
1028        // The tooltip revealed on hover or focus (the upper zone the slider reserves).
1029        // A disabled thumb shows none: it is a hint about a value being changed.
1030        if let Some(label) = self.label.as_ref().filter(|_| self.enabled) {
1031            let active = status.focused || status.hover_progress > 0.01;
1032            if active {
1033                let reveal = if status.focused {
1034                    o
1035                } else {
1036                    status.hover_progress * o
1037                };
1038                paint_tip(
1039                    bounds.x + THUMB * 0.5,
1040                    bounds.y,
1041                    label(self.value()),
1042                    theme,
1043                    reveal,
1044                    scene,
1045                );
1046            }
1047        }
1048    }
1049
1050    fn on_click(&self) -> Option<Msg> {
1051        None
1052    }
1053
1054    fn focusable(&self) -> bool {
1055        self.enabled && self.on_change.is_some()
1056    }
1057
1058    fn draggable(&self) -> bool {
1059        self.enabled
1060    }
1061
1062    fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
1063        if !self.enabled || dx == 0.0 || self.track <= 0.0 {
1064            return None;
1065        }
1066        let (low, high) = self.moved(dx / self.track);
1067        self.on_change.as_ref().map(|make| make(low, high))
1068    }
1069
1070    fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
1071        // A disabled thumb cannot be focused, so this should be unreachable - but a key
1072        // arriving from a stale focus must not move a value the caller has frozen.
1073        if !self.enabled {
1074            return KeyResponse::Ignored;
1075        }
1076        // Arrows: one step; Home/End: this side's min/max bound (the shell offers
1077        // these keys to the focused widget before the default action).
1078        let delta = match key {
1079            Key::Left { .. } => -self.key_step(),
1080            Key::Right { .. } => self.key_step(),
1081            Key::Home { .. } => -2.0, // clamped at 0 or the low neighbour
1082            Key::End { .. } => 2.0,   // clamped at the high neighbour or 1
1083            _ => return KeyResponse::Ignored,
1084        };
1085        let (low, high) = self.moved(delta);
1086        KeyResponse::Handled(self.on_change.as_ref().map(|make| make(low, high)))
1087    }
1088}
1089
1090/// A **range** slider: two thumbs (low and high) bounding a `0.0..=1.0` interval,
1091/// **controlled** and **sticky** (each thumb moves its own side, with no crossing).
1092/// An optional discrete step ([`divisions`](RangeSlider::divisions)). The
1093/// application receives the new `(low, high)` interval.
1094pub struct RangeSlider<Msg = crate::callback::Callback> {
1095    low: f32,
1096    high: f32,
1097    width: f32,
1098    divisions: Option<usize>,
1099    /// The **value tooltip** formatter: when set, a bubble above each thumb shows
1100    /// `label(value)` (and the height reserves the room for it).
1101    label: Option<Rc<dyn Fn(f32) -> String>>,
1102    enabled: bool,
1103    thumb_elevation: Option<f32>,
1104    pressed_thumb_elevation: Option<f32>,
1105    on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1106    /// Sent once when a drag begins, before the first `on_change`.
1107    on_change_start: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1108    /// Sent once when it ends, after the last one.
1109    on_change_end: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
1110    children: Vec<Box<dyn Widget<Msg>>>,
1111}
1112
1113impl<Msg: Clone + 'static> RangeSlider<Msg> {
1114    /// Creates a range slider (values clamped to `0..=1` and ordered `low ≤ high`).
1115    pub fn new(low: f32, high: f32) -> Self {
1116        let low = low.clamp(0.0, 1.0);
1117        let high = high.clamp(0.0, 1.0);
1118        let mut slider = Self {
1119            low: low.min(high),
1120            high: low.max(high),
1121            width: 220.0,
1122            divisions: None,
1123            label: None,
1124            enabled: true,
1125            thumb_elevation: None,
1126            pressed_thumb_elevation: None,
1127            on_change: None,
1128            on_change_start: None,
1129            on_change_end: None,
1130            children: Vec::new(),
1131        };
1132        slider.rebuild();
1133        slider
1134    }
1135
1136    /// Width, in logical pixels.
1137    pub fn width(mut self, width: f32) -> Self {
1138        self.width = width;
1139        self.rebuild();
1140        self
1141    }
1142
1143    /// Splits the travel into `n` **steps**: dragged values snap to `k/n`. Without
1144    /// this call, the travel is continuous.
1145    pub fn divisions(mut self, n: usize) -> Self {
1146        self.divisions = Some(n.max(1));
1147        self.rebuild();
1148        self
1149    }
1150
1151    /// A closure producing a message from the new `(low, high)` interval.
1152    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
1153        mut self,
1154        on_change: impl Fn(f32, f32) -> R + 'static,
1155    ) -> Self {
1156        self.on_change = Some(Rc::new(crate::callback::handler2(on_change)));
1157        self.rebuild();
1158        self
1159    }
1160
1161    /// Whether the interval can be changed. Disabled the whole control is **inert** -
1162    /// neither thumb takes a drag, a key or the focus - and it still shows its interval.
1163    ///
1164    /// See [`crate::disabled`] for the whole contract.
1165    pub fn enabled(mut self, enabled: bool) -> Self {
1166        self.enabled = enabled;
1167        self.rebuild();
1168        self
1169    }
1170
1171    /// How far each thumb sits off the track at rest. Unset, the theme's, then the
1172    /// reference's 1; `0.0` casts no shadow.
1173    pub fn thumb_elevation(mut self, elevation: f32) -> Self {
1174        self.thumb_elevation = Some(elevation);
1175        self.rebuild();
1176        self
1177    }
1178
1179    /// How far a thumb rises while it is held. Unset, the theme's, then the reference's 6.
1180    pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
1181        self.pressed_thumb_elevation = Some(elevation);
1182        self.rebuild();
1183        self
1184    }
1185
1186    /// Shows a **value tooltip** above each thumb, formatted by `label(value)` (a
1187    /// percentage, a price…). Reserves the room above the track.
1188    pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
1189        self.label = Some(Rc::new(label));
1190        self.rebuild();
1191        self
1192    }
1193
1194    /// Sent **once**, when a drag begins — before the first
1195    /// [`on_change`](Self::on_change), with the interval as it stands.
1196    ///
1197    /// See [`Slider::on_change_start`] for why the bracket earns its place.
1198    pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
1199        mut self,
1200        on_start: impl Fn(f32, f32) -> R + 'static,
1201    ) -> Self {
1202        self.on_change_start = Some(Rc::new(crate::callback::handler2(on_start)));
1203        self
1204    }
1205
1206    /// Sent **once**, when the drag ends — after the last
1207    /// [`on_change`](Self::on_change), with the interval it settled on.
1208    pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
1209        mut self,
1210        on_end: impl Fn(f32, f32) -> R + 'static,
1211    ) -> Self {
1212        self.on_change_end = Some(Rc::new(crate::callback::handler2(on_end)));
1213        self
1214    }
1215
1216    /// The interval a drag reaching `fraction` would leave behind — the nearest thumb
1217    /// moved to it, the other one where it was.
1218    ///
1219    /// Shared by the drag and both its ends so that the three cannot disagree about
1220    /// which thumb the pointer was nearest.
1221    fn interval_at(&self, fraction: f32) -> (f32, f32) {
1222        let f = self.snap(fraction.clamp(0.0, 1.0));
1223        if f <= self.low {
1224            (f, self.high)
1225        } else if f >= self.high {
1226            (self.low, f)
1227        } else if f - self.low <= self.high - f {
1228            (f, self.high)
1229        } else {
1230            (self.low, f)
1231        }
1232    }
1233
1234    /// (Re)builds the row of thumbs set at the `low`/`high` positions.
1235    fn rebuild(&mut self) {
1236        let height = self.content_h();
1237        let thumb = |side: Side| RangeThumb {
1238            side,
1239            low: self.low,
1240            high: self.high,
1241            track: self.width,
1242            height,
1243            divisions: self.divisions,
1244            label: self.label.clone(),
1245            enabled: self.enabled,
1246            elevations: (self.thumb_elevation, self.pressed_thumb_elevation),
1247            on_change: self.on_change.clone(),
1248        };
1249        let lo_gap = (self.low * self.width - THUMB * 0.5).max(0.0);
1250        let mid_gap = ((self.high - self.low) * self.width - THUMB).max(0.0);
1251        let row = Flex::row()
1252            .child(Spacer {
1253                width: lo_gap,
1254                height,
1255            })
1256            .child(thumb(Side::Low))
1257            .child(Spacer {
1258                width: mid_gap,
1259                height,
1260            })
1261            .child(thumb(Side::High));
1262        self.children = vec![Box::new(row)];
1263    }
1264}
1265
1266/// Paints a value tooltip centred on `cx` (top edge `top`) showing `text`.
1267fn paint_tip(cx: f32, top: f32, text: String, theme: &Theme, o: f32, scene: &mut Scene) {
1268    let style = tip_style(Some(theme));
1269    let tw = frus_text::measure_resolved(&text, &style).width;
1270    let bw = tw + 12.0;
1271    let bx = cx - bw * 0.5;
1272    scene.draw_rect(
1273        Rect::new(bx, top, bw, TIP_H),
1274        theme.primary.fade(o),
1275        TIP_H * 0.5,
1276        0.0,
1277        Color::TRANSPARENT,
1278    );
1279    let ty = top + (TIP_H - style.line_height()) * 0.5;
1280    scene.text(
1281        Point::new(bx + 6.0, ty),
1282        text,
1283        &style,
1284        theme.on_primary.fade(o),
1285    );
1286}
1287
1288impl<Msg> RangeSlider<Msg> {
1289    /// Total height: the track alone, or the track + a tooltip zone if a `label` is set.
1290    fn content_h(&self) -> f32 {
1291        if self.label.is_some() {
1292            TIP_H + TIP_GAP + H
1293        } else {
1294            H
1295        }
1296    }
1297
1298    /// Snaps `v` to the nearest step if `divisions` is set.
1299    fn snap(&self, v: f32) -> f32 {
1300        match self.divisions {
1301            Some(n) if n > 0 => (v * n as f32).round() / n as f32,
1302            _ => v,
1303        }
1304    }
1305}
1306
1307impl<Msg: Clone + 'static> Widget<Msg> for RangeSlider<Msg> {
1308    fn style(&self) -> Style {
1309        Style {
1310            width: Dimension::Length(self.width),
1311            height: Dimension::Length(self.content_h()),
1312            ..Default::default()
1313        }
1314    }
1315
1316    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1317        &self.children
1318    }
1319
1320    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1321        let o = status.opacity;
1322        // Track + segment in the **lower** `H` band (the upper zone holds the bubbles).
1323        let base_y = bounds.y + bounds.height - H;
1324        let track_y = base_y + (H - TRACK_H) * 0.5;
1325        // The same split as the single slider, and the same secondary container.
1326        let (rail, span) = if self.enabled {
1327            (theme.scheme.secondary_container, theme.primary)
1328        } else {
1329            (disabled_container(theme), disabled_content(theme))
1330        };
1331        scene.draw_rect(
1332            Rect::new(bounds.x, track_y, bounds.width, TRACK_H),
1333            rail.fade(o),
1334            TRACK_H * 0.5,
1335            0.0,
1336            Color::TRANSPARENT,
1337        );
1338        let lo = bounds.x + bounds.width * self.low;
1339        let hi = bounds.x + bounds.width * self.high;
1340        scene.draw_rect(
1341            Rect::new(lo, track_y, (hi - lo).max(0.0), TRACK_H),
1342            span.fade(o),
1343            TRACK_H * 0.5,
1344            0.0,
1345            Color::TRANSPARENT,
1346        );
1347        // The tooltips are painted by the thumbs, revealed on hover or focus.
1348    }
1349
1350    fn on_click(&self) -> Option<Msg> {
1351        None
1352    }
1353
1354    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1355        // The interval survives, as the single slider's value does.
1356        let pct = |v: f32| (v * 100.0).round();
1357        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
1358            .value(format!("{}%–{}%", pct(self.low), pct(self.high)));
1359        Some(if self.enabled {
1360            semantics
1361        } else {
1362            semantics.disabled(true)
1363        })
1364    }
1365
1366    fn draggable(&self) -> bool {
1367        // The **track** (outside the thumbs, which sit above it) answers clicks and
1368        // drags: the nearest thumb moves to the position.
1369        self.enabled && self.on_change.is_some()
1370    }
1371
1372    fn on_drag(&self, fraction: f32) -> Option<Msg> {
1373        if !self.enabled {
1374            return None;
1375        }
1376        // The nearest thumb, bounded by the other, with no crossing.
1377        let (low, high) = self.interval_at(fraction);
1378        self.on_change.as_ref().map(|make| make(low, high))
1379    }
1380
1381    fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
1382        if !self.enabled {
1383            return None;
1384        }
1385        let (low, high) = self.interval_at(fraction);
1386        self.on_change_start.as_ref().map(|make| make(low, high))
1387    }
1388
1389    fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
1390        if !self.enabled {
1391            return None;
1392        }
1393        let (low, high) = self.interval_at(fraction);
1394        self.on_change_end.as_ref().map(|make| make(low, high))
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401
1402    #[derive(Clone, Debug, PartialEq)]
1403    enum Msg {
1404        Value(f32),
1405        Start(f32),
1406        End(f32),
1407        Range(f32, f32),
1408        RangeStart(f32, f32),
1409        RangeEnd(f32, f32),
1410    }
1411
1412    /// A slider that was never asked for the bracket sends nothing at either end.
1413    /// The stream is what it always was.
1414    #[test]
1415    fn a_slider_that_was_not_asked_sends_no_bracket() {
1416        let slider = Slider::new(0.0).on_change(Msg::Value);
1417        assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1418        assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1419    }
1420
1421    /// **The bracket is in the same units as the stream it brackets.** A caller who
1422    /// asked for `0..=100` gets a hundred from `on_change`, and would be owed one at
1423    /// each end too — a start in fractions inside a signature that looks symmetrical
1424    /// is a trap.
1425    #[test]
1426    fn the_bracket_speaks_in_values_not_fractions() {
1427        let slider = Slider::new(0.0)
1428            .range(0.0, 100.0)
1429            .on_change(Msg::Value)
1430            .on_change_start(Msg::Start)
1431            .on_change_end(Msg::End);
1432        assert_eq!(Widget::on_drag_start(&slider, 0.25), Some(Msg::Start(25.0)));
1433        assert_eq!(Widget::on_drag(&slider, 0.25), Some(Msg::Value(25.0)));
1434        assert_eq!(Widget::on_drag_end(&slider, 0.75), Some(Msg::End(75.0)));
1435    }
1436
1437    /// The divisions apply at the ends too: a start or an end landing between two
1438    /// stops would name a value the stream can never produce.
1439    #[test]
1440    fn the_bracket_snaps_like_the_stream() {
1441        let slider = Slider::new(0.0)
1442            .divisions(4)
1443            .on_change(Msg::Value)
1444            .on_change_start(Msg::Start)
1445            .on_change_end(Msg::End);
1446        // 0.3 lies between the 0.25 and 0.5 stops, nearer the first.
1447        assert_eq!(Widget::on_drag(&slider, 0.3), Some(Msg::Value(0.25)));
1448        assert_eq!(Widget::on_drag_start(&slider, 0.3), Some(Msg::Start(0.25)));
1449        assert_eq!(Widget::on_drag_end(&slider, 0.3), Some(Msg::End(0.25)));
1450    }
1451
1452    /// A disabled slider is inert at **every** end. A drag in flight when the caller
1453    /// freezes the value must not land its release either.
1454    #[test]
1455    fn a_disabled_slider_brackets_nothing() {
1456        let slider = Slider::new(0.0)
1457            .enabled(false)
1458            .on_change(Msg::Value)
1459            .on_change_start(Msg::Start)
1460            .on_change_end(Msg::End);
1461        assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1462        assert_eq!(Widget::on_drag(&slider, 0.5), None);
1463        assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1464    }
1465
1466    /// A range slider brackets the **interval**, and all three agree about which thumb
1467    /// the pointer was nearest — they ask the same function.
1468    #[test]
1469    fn a_range_slider_brackets_the_interval() {
1470        let range = RangeSlider::new(0.2, 0.8)
1471            .on_change(Msg::Range)
1472            .on_change_start(Msg::RangeStart)
1473            .on_change_end(Msg::RangeEnd);
1474        // 0.3 is nearer the low thumb, so the low one moves and the high one stays.
1475        assert_eq!(
1476            Widget::on_drag_start(&range, 0.3),
1477            Some(Msg::RangeStart(0.3, 0.8))
1478        );
1479        assert_eq!(Widget::on_drag(&range, 0.3), Some(Msg::Range(0.3, 0.8)));
1480        // 0.9 is past the high thumb, so that one moves instead.
1481        assert_eq!(
1482            Widget::on_drag_end(&range, 0.9),
1483            Some(Msg::RangeEnd(0.2, 0.9))
1484        );
1485    }
1486
1487    #[test]
1488    fn drag_maps_to_value() {
1489        let slider = Slider::new(0.0).on_change(Msg::Value);
1490        assert_eq!(Widget::on_drag(&slider, 0.5), Some(Msg::Value(0.5)));
1491        // Clamped.
1492        assert_eq!(Widget::on_drag(&slider, 1.5), Some(Msg::Value(1.0)));
1493    }
1494
1495    /// The row's two draggable thumbs (low, high).
1496    fn thumbs(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1497        let row = &Widget::<Msg>::children(rs)[0];
1498        row.children()
1499            .iter()
1500            .map(|c| c.as_ref())
1501            .filter(|c| c.draggable())
1502            .collect()
1503    }
1504
1505    fn range_of(msg: Option<Msg>) -> (f32, f32) {
1506        match msg {
1507            Some(Msg::Range(lo, hi)) => (lo, hi),
1508            other => panic!("expected Range, got {other:?}"),
1509        }
1510    }
1511
1512    fn range_of_key(resp: KeyResponse<Msg>) -> (f32, f32) {
1513        match resp {
1514            KeyResponse::Handled(msg) => range_of(msg),
1515            other => panic!("expected Handled, got {other:?}"),
1516        }
1517    }
1518
1519    #[test]
1520    fn each_thumb_moves_its_own_side_and_sticks() {
1521        let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range); // a 220 px track
1522        let t = thumbs(&rs);
1523        assert_eq!(t.len(), 2, "two draggable thumbs");
1524        // The low thumb: +22 px = +0.1 → low 0.3; the high one unchanged.
1525        let (lo, hi) = range_of(t[0].on_drag_delta(22.0));
1526        assert!(
1527            (lo - 0.3).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1528            "low moves ({lo}, {hi})"
1529        );
1530        // The high thumb: −22 px = −0.1 → high 0.7; the low one unchanged.
1531        let (lo, hi) = range_of(t[1].on_drag_delta(-22.0));
1532        assert!(
1533            (lo - 0.2).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1534            "high moves ({lo}, {hi})"
1535        );
1536        // Sticky: the low thumb pushed all the way stops at the high one (0.8), without moving it.
1537        let (lo, hi) = range_of(t[0].on_drag_delta(10_000.0));
1538        assert!(
1539            (lo - 0.8).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1540            "stuck to the top ({lo}, {hi})"
1541        );
1542        // A zero delta: no message.
1543        assert_eq!(t[0].on_drag_delta(0.0), None);
1544    }
1545
1546    #[test]
1547    fn arrow_keys_move_focused_thumb_by_a_step() {
1548        let rs = RangeSlider::new(0.4, 0.6)
1549            .divisions(10)
1550            .on_change(Msg::Range); // a step of 0.1
1551        let t = thumbs(&rs);
1552        // The low thumb focused: right arrow +0.1 → 0.5; left arrow −0.1 → 0.3.
1553        let (lo, hi) = range_of_key(t[0].on_key(&Key::Right {
1554            shift: false,
1555            word: false,
1556        }));
1557        assert!(
1558            (lo - 0.5).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1559            "→ ({lo}, {hi})"
1560        );
1561        let (lo, hi) = range_of_key(t[0].on_key(&Key::Left {
1562            shift: false,
1563            word: false,
1564        }));
1565        assert!(
1566            (lo - 0.3).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1567            "← ({lo}, {hi})"
1568        );
1569        // The thumbs are focusable, so reachable from the keyboard.
1570        assert!(t[0].focusable() && t[1].focusable());
1571    }
1572
1573    #[test]
1574    fn track_click_moves_nearest_thumb() {
1575        let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1576        // The track (the slider) is draggable and targets the nearest thumb.
1577        assert!(Widget::<Msg>::draggable(&rs), "the track answers clicks");
1578        let (lo, hi) = range_of(Widget::on_drag(&rs, 0.25));
1579        assert!(
1580            (lo - 0.25).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1581            "near the low end ({lo}, {hi})"
1582        );
1583        let (lo, hi) = range_of(Widget::on_drag(&rs, 0.9));
1584        assert!(
1585            (lo - 0.2).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1586            "near the high end ({lo}, {hi})"
1587        );
1588    }
1589
1590    #[test]
1591    fn home_end_snap_thumb_to_bounds() {
1592        let rs = RangeSlider::new(0.3, 0.7).on_change(Msg::Range);
1593        let t = thumbs(&rs);
1594        let home = Key::Home {
1595            shift: false,
1596            doc: false,
1597        };
1598        let end = Key::End {
1599            shift: false,
1600            doc: false,
1601        };
1602        // The low thumb: Home → 0; End → the high stop (0.7).
1603        let (lo, hi) = range_of_key(t[0].on_key(&home));
1604        assert!(
1605            (lo - 0.0).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1606            "low Home ({lo}, {hi})"
1607        );
1608        let (lo, hi) = range_of_key(t[0].on_key(&end));
1609        assert!(
1610            (lo - 0.7).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1611            "low End ({lo}, {hi})"
1612        );
1613        // The high thumb: End → 1.
1614        let (lo, hi) = range_of_key(t[1].on_key(&end));
1615        assert!(
1616            (lo - 0.3).abs() < 1e-4 && (hi - 1.0).abs() < 1e-4,
1617            "upper End ({lo}, {hi})"
1618        );
1619    }
1620
1621    #[test]
1622    fn value_label_reserves_height() {
1623        let plain = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1624        let tipped = RangeSlider::new(0.2, 0.8)
1625            .on_change(Msg::Range)
1626            .value_label(|v| format!("{}", v));
1627        let h = |rs: &RangeSlider<Msg>| match Widget::<Msg>::style(rs).height {
1628            Dimension::Length(v) => v,
1629            _ => 0.0,
1630        };
1631        assert!(h(&tipped) > h(&plain), "the tooltip reserves height");
1632    }
1633
1634    #[test]
1635    fn divisions_snap_to_steps() {
1636        // 10 steps, a 200 px track: +25 px = +0.125 from 0.0 → snapped to 0.1.
1637        let rs = RangeSlider::new(0.0, 1.0)
1638            .width(200.0)
1639            .divisions(10)
1640            .on_change(Msg::Range);
1641        let (lo, _) = range_of(thumbs(&rs)[0].on_drag_delta(25.0));
1642        assert!((lo - 0.1).abs() < 1e-4, "snapped to step 0.1, got {lo}");
1643    }
1644
1645    #[test]
1646    fn range_new_orders_and_clamps() {
1647        // Inverted bounds → reordered; outside [0,1] → clamped.
1648        let rs = RangeSlider::new(0.9, 0.1).on_change(Msg::Range);
1649        // From (0.1, 0.9), the low thumb +0.05 → 0.15.
1650        let (lo, hi) = range_of(thumbs(&rs)[0].on_drag_delta(0.05 * 220.0));
1651        assert!(
1652            (lo - 0.15).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1653            "reordered ({lo}, {hi})"
1654        );
1655    }
1656
1657    /// The thumbs **by position** (the row is spacer, thumb, spacer, thumb), which is the
1658    /// only way to reach them once they are disabled: the `thumbs` helper above finds them
1659    /// by `draggable`, and a disabled thumb is exactly the thing that is not.
1660    fn thumbs_by_position(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1661        let row = &Widget::<Msg>::children(rs)[0];
1662        let kids = row.children();
1663        vec![kids[1].as_ref(), kids[3].as_ref()]
1664    }
1665
1666    #[test]
1667    fn a_disabled_slider_is_inert_but_still_says_where_it_sits() {
1668        let dead = Slider::new(0.4).on_change(Msg::Value).enabled(false);
1669        assert!(!Widget::<Msg>::draggable(&dead), "it takes no drag");
1670        assert_eq!(
1671            Widget::on_drag(&dead, 0.9),
1672            None,
1673            "and a drag in flight does not land"
1674        );
1675        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1676        assert!(semantics.disabled, "announced as unavailable");
1677        assert_eq!(semantics.value.as_deref(), Some("40%"), "value survives");
1678    }
1679
1680    /// A slider is dragged and keyed rather than tapped, which is why milestone 322's
1681    /// guard covers those hooks: greying one out while it still answered a drag leaves it
1682    /// inert only to the gesture nobody was using on it.
1683    #[test]
1684    fn a_disabled_range_slider_takes_no_drag_no_key_and_no_focus() {
1685        let dead = RangeSlider::new(0.2, 0.8)
1686            .on_change(Msg::Range)
1687            .enabled(false);
1688        assert!(!Widget::<Msg>::draggable(&dead), "the track takes no drag");
1689        assert_eq!(Widget::on_drag(&dead, 0.5), None);
1690        for (i, thumb) in thumbs_by_position(&dead).into_iter().enumerate() {
1691            assert!(!thumb.draggable(), "thumb {i} still drags");
1692            assert!(!thumb.focusable(), "thumb {i} still takes focus");
1693            assert_eq!(thumb.on_drag_delta(20.0), None, "thumb {i} still moves");
1694            assert!(
1695                matches!(
1696                    thumb.on_key(&Key::Right {
1697                        shift: false,
1698                        word: false
1699                    }),
1700                    KeyResponse::Ignored
1701                ),
1702                "thumb {i} still answers an arrow"
1703            );
1704        }
1705        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1706        assert!(semantics.disabled);
1707        assert_eq!(
1708            semantics.value.as_deref(),
1709            Some("20%–80%"),
1710            "interval survives"
1711        );
1712    }
1713
1714    /// The track splits along the framework's one rule: the part still to travel is a
1715    /// container, the travelled part and the thumb are content on it.
1716    #[test]
1717    fn a_disabled_slider_takes_both_halves_of_the_rule() {
1718        for theme in [Theme::dark(), Theme::light()] {
1719            let mut scene = Scene::new();
1720            Widget::<Msg>::paint(
1721                &Slider::<Msg>::new(0.5).enabled(false),
1722                Rect::new(0.0, 0.0, 200.0, H),
1723                Status {
1724                    opacity: 1.0,
1725                    ..Default::default()
1726                },
1727                &theme,
1728                &mut scene,
1729            );
1730            // The crisp rectangles: the thumb's shadow is painted just before the thumb.
1731            let fills: Vec<frus_core::Color> = scene
1732                .primitives()
1733                .iter()
1734                .filter_map(|p| match p {
1735                    frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1736                    _ => None,
1737                })
1738                .collect();
1739            assert_eq!(fills[0], disabled_container(&theme), "the rail");
1740            assert_eq!(fills[1], disabled_content(&theme), "the travelled part");
1741            assert_eq!(fills[2], disabled_content(&theme), "the thumb");
1742            // Quieter means closer to the surface. Since milestone 329 both tokens are
1743            // opaque, so there is no alpha to compare — which is the point: a disabled
1744            // control flattens.
1745            let from_surface = |c: Color| {
1746                (c.r - theme.scheme.surface.r).abs()
1747                    + (c.g - theme.scheme.surface.g).abs()
1748                    + (c.b - theme.scheme.surface.b).abs()
1749            };
1750            assert!(
1751                from_surface(fills[0]) < from_surface(fills[1]),
1752                "the rail is the quieter of the two"
1753            );
1754        }
1755    }
1756    /// **A thumb stands off its track, and higher while it is held.** The reference's round
1757    /// thumb rests at 1 and rises to 6 as the press goes in, on a slider and on each of a
1758    /// range slider's two. Neither cast anything here.
1759    #[test]
1760    fn a_thumb_casts_a_shadow_that_rises_while_it_is_held() {
1761        // The heights the painted shadows stand for (milestone 606).
1762        fn heights(widget: &dyn Widget<Msg>, bounds: Rect, press: f32, theme: &Theme) -> Vec<f32> {
1763            let status = Status {
1764                opacity: 1.0,
1765                press_progress: press,
1766                ..Default::default()
1767            };
1768            let mut scene = Scene::new();
1769            widget.paint(bounds, status, theme, &mut scene);
1770            crate::shadowprobe::heights(scene.primitives())
1771        }
1772        let theme = Theme::default();
1773        let track = Rect::new(0.0, 0.0, 220.0, H);
1774        let slider = Slider::new(0.5).on_change(Msg::Value);
1775        assert_eq!(heights(&slider, track, 0.0, &theme), vec![1.0]);
1776        assert_eq!(heights(&slider, track, 1.0, &theme), vec![6.0]);
1777        assert_eq!(
1778            heights(&slider, track, 0.5, &theme),
1779            vec![3.5],
1780            "on its way up"
1781        );
1782        let grounded = Slider::new(0.5)
1783            .on_change(Msg::Value)
1784            .thumb_elevation(0.0)
1785            .pressed_thumb_elevation(0.0);
1786        assert!(heights(&grounded, track, 1.0, &theme).is_empty());
1787
1788        let mut flat = Theme::default();
1789        flat.widgets.slider.thumb_elevation = Some(0.0);
1790        flat.widgets.slider.pressed_thumb_elevation = Some(0.0);
1791        assert!(
1792            heights(&slider, track, 1.0, &flat).is_empty(),
1793            "the theme's word"
1794        );
1795        let told = Slider::new(0.5).on_change(Msg::Value).thumb_elevation(2.0);
1796        assert_eq!(
1797            heights(&told, track, 0.0, &flat),
1798            vec![2.0],
1799            "and the slider's over it"
1800        );
1801
1802        let cell = Rect::new(0.0, 0.0, THUMB, H);
1803        let range = RangeSlider::new(0.25, 0.75).on_change(Msg::Range);
1804        for thumb in thumbs(&range) {
1805            assert_eq!(heights(thumb, cell, 0.0, &theme), vec![1.0]);
1806            assert_eq!(heights(thumb, cell, 1.0, &theme), vec![6.0]);
1807        }
1808        let raised = RangeSlider::new(0.25, 0.75)
1809            .on_change(Msg::Range)
1810            .thumb_elevation(3.0);
1811        for thumb in thumbs(&raised) {
1812            assert_eq!(
1813                heights(thumb, cell, 0.0, &theme),
1814                vec![3.0],
1815                "told on the slider"
1816            );
1817        }
1818    }
1819}
1820
1821#[cfg(test)]
1822mod range_tests {
1823    use super::*;
1824    use crate::interaction::{Key, KeyResponse};
1825
1826    #[derive(Clone, Debug, PartialEq)]
1827    enum Msg {
1828        Value(f32),
1829    }
1830
1831    fn value_of(msg: Option<Msg>) -> f32 {
1832        match msg {
1833            Some(Msg::Value(v)) => v,
1834            other => panic!("expected a value, got {other:?}"),
1835        }
1836    }
1837
1838    /// The application is handed **its own** units, not a fraction it has to convert.
1839    #[test]
1840    fn a_drag_lands_in_the_caller_s_units() {
1841        let slider = Slider::new(20.0).range(20.0, 200.0).on_change(Msg::Value);
1842        assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1843        assert_eq!(value_of(Widget::on_drag(&slider, 0.5)), 110.0);
1844        assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1845        // Past the end of the track is the end of the range, not past it.
1846        assert_eq!(value_of(Widget::on_drag(&slider, 1.4)), 200.0);
1847    }
1848
1849    /// A range written backwards is sorted rather than left empty.
1850    #[test]
1851    fn a_backwards_range_is_taken_the_way_round_it_makes_sense() {
1852        let slider = Slider::new(0.0).range(200.0, 20.0).on_change(Msg::Value);
1853        assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1854        assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1855    }
1856
1857    /// A value outside the travel is held inside it, not rejected: a caller that lowers
1858    /// the ceiling under a value it already had gets the ceiling.
1859    #[test]
1860    fn a_value_outside_the_travel_is_held_inside_it() {
1861        let low = Slider::<Msg>::new(-5.0).range(0.0, 10.0);
1862        assert_eq!(low.fraction(), 0.0);
1863        let high = Slider::<Msg>::new(40.0).range(0.0, 10.0);
1864        assert_eq!(high.fraction(), 1.0);
1865    }
1866
1867    /// Steps land on the divisions, in the caller's units.
1868    #[test]
1869    fn divisions_snap_the_value() {
1870        let slider = Slider::new(0.0)
1871            .range(0.0, 100.0)
1872            .divisions(4)
1873            .on_change(Msg::Value);
1874        assert_eq!(value_of(Widget::on_drag(&slider, 0.30)), 25.0);
1875        assert_eq!(value_of(Widget::on_drag(&slider, 0.40)), 50.0);
1876        assert_eq!(value_of(Widget::on_drag(&slider, 0.99)), 100.0);
1877    }
1878
1879    /// An arrow moves one division when there are divisions, 5 % of the travel when
1880    /// there are not — and neither runs off either end.
1881    #[test]
1882    fn the_arrows_move_by_a_step() {
1883        let free = Slider::new(50.0).range(0.0, 100.0).on_change(Msg::Value);
1884        let step = |s: &Slider<Msg>, key: Key| match Widget::on_key(s, &key) {
1885            KeyResponse::Handled(msg) => value_of(msg),
1886            other => panic!("expected the key to be taken, got {other:?}"),
1887        };
1888        assert_eq!(
1889            step(
1890                &free,
1891                Key::Right {
1892                    shift: false,
1893                    word: false
1894                }
1895            ),
1896            55.0
1897        );
1898        assert_eq!(
1899            step(
1900                &free,
1901                Key::Left {
1902                    shift: false,
1903                    word: false
1904                }
1905            ),
1906            45.0
1907        );
1908
1909        let stepped = Slider::new(50.0)
1910            .range(0.0, 100.0)
1911            .divisions(4)
1912            .on_change(Msg::Value);
1913        assert_eq!(
1914            step(
1915                &stepped,
1916                Key::Right {
1917                    shift: false,
1918                    word: false
1919                }
1920            ),
1921            75.0
1922        );
1923
1924        let at_end = Slider::new(100.0).range(0.0, 100.0).on_change(Msg::Value);
1925        assert_eq!(
1926            step(
1927                &at_end,
1928                Key::Right {
1929                    shift: false,
1930                    word: false
1931                }
1932            ),
1933            100.0
1934        );
1935        assert_eq!(
1936            step(
1937                &at_end,
1938                Key::Home {
1939                    shift: false,
1940                    doc: false
1941                }
1942            ),
1943            0.0
1944        );
1945        assert_eq!(
1946            step(
1947                &at_end,
1948                Key::End {
1949                    shift: false,
1950                    doc: false
1951                }
1952            ),
1953            100.0
1954        );
1955    }
1956
1957    /// It is a keyboard control now — but only when there is somebody to tell.
1958    #[test]
1959    fn it_takes_the_focus_only_when_it_can_answer() {
1960        assert!(!Widget::<Msg>::focusable(&Slider::<Msg>::new(0.5)));
1961        assert!(Widget::focusable(&Slider::new(0.5).on_change(Msg::Value)));
1962        assert!(!Widget::focusable(
1963            &Slider::new(0.5).on_change(Msg::Value).enabled(false)
1964        ));
1965    }
1966
1967    /// A frozen slider answers no key, even one arriving from a stale focus.
1968    #[test]
1969    fn a_frozen_slider_answers_no_key() {
1970        let slider = Slider::new(0.5).on_change(Msg::Value).enabled(false);
1971        assert!(matches!(
1972            Widget::on_key(
1973                &slider,
1974                &Key::Right {
1975                    shift: false,
1976                    word: false
1977                }
1978            ),
1979            KeyResponse::Ignored
1980        ));
1981    }
1982
1983    /// A reader is told where it is set, in the units on screen.
1984    #[test]
1985    fn a_reader_is_told_the_real_value() {
1986        let plain = Slider::<Msg>::new(0.25);
1987        let s = Widget::<Msg>::semantics(&plain).expect("a slider says where it is");
1988        assert_eq!(
1989            s.value.as_deref(),
1990            Some("25%"),
1991            "a bare 0..1 reads as a share"
1992        );
1993
1994        let ranged = Slider::<Msg>::new(110.0).range(20.0, 200.0);
1995        let s = Widget::<Msg>::semantics(&ranged).expect("a slider says where it is");
1996        assert_eq!(s.range, Some((20.0, 110.0, 200.0)));
1997
1998        let priced = Slider::<Msg>::new(110.0)
1999            .range(20.0, 200.0)
2000            .value_label(|v| format!("{v} EUR"));
2001        let s = Widget::<Msg>::semantics(&priced).expect("a slider says where it is");
2002        assert_eq!(
2003            s.value.as_deref(),
2004            Some("110 EUR"),
2005            "said the caller's way when it gave a formatter"
2006        );
2007    }
2008
2009    /// The drop's type is the reference's 2023 one, `labelMedium` in `on_primary`, and
2010    /// comes from the theme when it names one (milestones 414 and 648).
2011    #[test]
2012    fn the_value_drop_wears_the_reference_s_type() {
2013        let slider = Slider::<Msg>::new(0.3)
2014            .divisions(10)
2015            .value_label(|v| format!("{v}"));
2016        let styles = |theme: &Theme| {
2017            let mut scene = Scene::new();
2018            Widget::<Msg>::paint(
2019                &slider,
2020                Rect::new(0.0, 0.0, 220.0, 48.0),
2021                Status {
2022                    focused: true,
2023                    focus_progress: 1.0,
2024                    ..Status::default()
2025                },
2026                theme,
2027                &mut scene,
2028            );
2029            scene
2030                .primitives()
2031                .iter()
2032                .filter_map(|p| match p {
2033                    frus_core::Primitive::Text {
2034                        size,
2035                        weight,
2036                        color,
2037                        ..
2038                    } => Some((*size, weight.to_u16(), *color)),
2039                    _ => None,
2040                })
2041                .collect::<Vec<_>>()
2042        };
2043        let plain = Theme::default();
2044        assert_eq!(
2045            styles(&plain),
2046            vec![(
2047                plain.text.label_medium.size.unwrap(),
2048                frus_core::FontWeight::Medium.to_u16(),
2049                plain.scheme.on_primary
2050            )]
2051        );
2052        // And a theme can move it.
2053        let mut themed = Theme::default();
2054        themed.widgets.slider.value_indicator_text_style = Some(frus_core::TextStyle::new(25.0));
2055        assert_eq!(styles(&themed).first().map(|s| s.0), Some(25.0));
2056    }
2057
2058    /// **As tall as its halo**, label or not: the drop is drawn over what is above, as
2059    /// the reference's is, and takes no room.
2060    #[test]
2061    fn the_slider_is_as_tall_as_its_halo() {
2062        let bare = Widget::<Msg>::style(&Slider::<Msg>::new(0.5)).height;
2063        let labelled =
2064            Widget::<Msg>::style(&Slider::<Msg>::new(0.5).value_label(|v| format!("{v}"))).height;
2065        assert_eq!(bare, frus_layout::Dimension::Length(48.0));
2066        assert_eq!(labelled, bare);
2067    }
2068}
2069
2070#[cfg(test)]
2071mod color_tests {
2072    use super::*;
2073    use frus_core::Primitive;
2074
2075    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
2076    const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
2077
2078    /// (rail, travelled, thumb, ring) as painted — the crisp rectangles, in order, and not
2079    /// the soft one under the thumb that is its shadow.
2080    fn painted(slider: &Slider<()>, theme: &Theme) -> (Color, Color, Color, Color) {
2081        let mut scene = Scene::new();
2082        Widget::<()>::paint(
2083            slider,
2084            Rect::new(0.0, 0.0, 220.0, H),
2085            Status {
2086                opacity: 1.0,
2087                ..Default::default()
2088            },
2089            theme,
2090            &mut scene,
2091        );
2092        let rects: Vec<(Color, Color)> = scene
2093            .primitives()
2094            .iter()
2095            .filter_map(|p| match p {
2096                Primitive::Rect {
2097                    color,
2098                    border_color,
2099                    blur,
2100                    ..
2101                } if *blur == 0.0 => Some((*color, *border_color)),
2102                _ => None,
2103            })
2104            .collect();
2105        (rects[0].0, rects[1].0, rects[2].0, rects[2].1)
2106    }
2107
2108    /// Nothing said: the reference's 2023 colours — `primary` travelled and for the
2109    /// thumb, `surface_container_highest` for the rest, and no ring.
2110    #[test]
2111    fn the_defaults_are_the_reference_s() {
2112        let theme = Theme::default();
2113        let (rail, filled, thumb, ring) = painted(&Slider::<()>::new(0.5), &theme);
2114        assert_eq!(rail, theme.scheme.surface_container_highest);
2115        assert_eq!(filled, theme.scheme.primary);
2116        assert_eq!(thumb, theme.scheme.primary);
2117        assert_eq!(ring, Color::TRANSPARENT);
2118    }
2119
2120    /// The thumb follows the travelled track unless it is named — the reference's
2121    /// `thumbColor ?? activeColor` — and there is a ring only when one is asked for.
2122    #[test]
2123    fn the_thumb_follows_the_track_it_was_not_told_about() {
2124        let theme = Theme::default();
2125        let (_, filled, thumb, ring) = painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme);
2126        assert_eq!((filled, thumb, ring), (BRAND, BRAND, Color::TRANSPARENT));
2127        let (_, _, _, named) = painted(
2128            &Slider::<()>::new(0.5)
2129                .active_color(BRAND)
2130                .thumb_border_color(RAIL),
2131            &theme,
2132        );
2133        assert_eq!(named, RAIL, "unless it is named");
2134    }
2135
2136    /// The two halves of the track are separate.
2137    #[test]
2138    fn each_half_of_the_track_takes_its_own_colour() {
2139        let theme = Theme::default();
2140        let (rail, filled, thumb, _) = painted(
2141            &Slider::<()>::new(0.5)
2142                .active_color(BRAND)
2143                .inactive_color(RAIL)
2144                .thumb_color(RAIL),
2145            &theme,
2146        );
2147        assert_eq!((rail, filled, thumb), (RAIL, BRAND, RAIL));
2148    }
2149
2150    /// The theme answers when the instance does not, and loses when it does.
2151    #[test]
2152    fn the_theme_answers_and_the_instance_overrules_it() {
2153        let mut theme = Theme::default();
2154        theme.widgets.slider.active_track_color = Some(RAIL);
2155        assert_eq!(painted(&Slider::<()>::new(0.5), &theme).1, RAIL);
2156        assert_eq!(
2157            painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme).1,
2158            BRAND
2159        );
2160    }
2161}
2162
2163/// The reference's 2023 slider, painted and measured (milestone 648).
2164#[cfg(test)]
2165mod reference_tests {
2166    use super::*;
2167    use frus_core::Primitive;
2168
2169    /// A box 248 wide: a track of 200 from 24, the halo's radius in.
2170    const BOX: Rect = Rect::new(0.0, 0.0, 248.0, 48.0);
2171
2172    fn rtl() -> Theme {
2173        Theme {
2174            direction: frus_core::TextDirection::Rtl,
2175            ..Theme::default()
2176        }
2177    }
2178
2179    fn scene_of(slider: &Slider<()>, status: Status, theme: &Theme) -> Vec<Primitive> {
2180        let mut scene = Scene::new();
2181        Widget::<()>::paint(slider, BOX, status, theme, &mut scene);
2182        scene.primitives().to_vec()
2183    }
2184
2185    fn rects(primitives: &[Primitive]) -> Vec<(Rect, Color)> {
2186        primitives
2187            .iter()
2188            .filter_map(|p| match p {
2189                Primitive::Rect {
2190                    rect, color, blur, ..
2191                } if *blur == 0.0 => Some((*rect, *color)),
2192                _ => None,
2193            })
2194            .collect()
2195    }
2196
2197    /// The thumb: the crisp 20-pixel square.
2198    fn thumb(primitives: &[Primitive]) -> Rect {
2199        rects(primitives)
2200            .into_iter()
2201            .map(|(r, _)| r)
2202            .rfind(|r| r.width == 20.0 && r.height == 20.0)
2203            .expect("a thumb")
2204    }
2205
2206    fn texts(primitives: &[Primitive]) -> usize {
2207        primitives
2208            .iter()
2209            .filter(|p| matches!(p, Primitive::Text { .. }))
2210            .count()
2211    }
2212
2213    fn rest() -> Status {
2214        Status::default()
2215    }
2216
2217    /// **The track is inset by the halo's radius**, so the thumb reaches both ends inside
2218    /// the box; the travelled part is two pixels thicker than the rest.
2219    #[test]
2220    fn the_track_is_inset_and_the_travelled_part_thicker() {
2221        let theme = Theme::default();
2222        let painted = scene_of(&Slider::new(0.5), rest(), &theme);
2223        assert_eq!(thumb(&painted), Rect::new(114.0, 14.0, 20.0, 20.0));
2224        let at = |v: f32| thumb(&scene_of(&Slider::new(v), rest(), &theme)).x + 10.0;
2225        assert_eq!((at(0.0), at(1.0)), (24.0, 224.0), "the ends, a radius in");
2226        let track = rects(&painted);
2227        let (inactive, active) = (track[0].0, track[1].0);
2228        assert_eq!(inactive.height, 4.0);
2229        assert_eq!(active.height, 6.0);
2230        assert_eq!(active.x, 24.0, "from the start");
2231        assert_eq!(inactive.x + inactive.width, 224.0, "to the end");
2232    }
2233
2234    /// **Right to left, it runs from the right**: a quarter is near the right end, and the
2235    /// travelled part is on the right.
2236    #[test]
2237    fn right_to_left_it_runs_from_the_right() {
2238        let painted = scene_of(&Slider::new(0.25), rest(), &rtl());
2239        assert_eq!(thumb(&painted).x + 10.0, 174.0);
2240        let track = rects(&painted);
2241        let (inactive, active) = (track[0].0, track[1].0);
2242        assert_eq!(
2243            active.x + active.width,
2244            224.0,
2245            "the travelled part ends at the right"
2246        );
2247        assert_eq!(inactive.x, 24.0, "the rest starts at the left");
2248    }
2249
2250    /// **A tick per division**, the first and last under the thumb at the ends, and none
2251    /// when they would crowd.
2252    #[test]
2253    fn a_tick_marks_each_division() {
2254        let theme = Theme::default();
2255        let ticks = |slider: Slider<()>| {
2256            rects(&scene_of(&slider, rest(), &theme))
2257                .into_iter()
2258                .filter(|(r, _)| r.width == 2.0 && r.height == 2.0)
2259                .collect::<Vec<_>>()
2260        };
2261        let four = ticks(Slider::new(0.5).divisions(4));
2262        assert_eq!(four.len(), 5);
2263        assert_eq!(four[0].0.x + 1.0, 26.0, "half a track in");
2264        assert_eq!(four[4].0.x + 1.0, 222.0);
2265        // The travelled ticks and the rest differ.
2266        assert_eq!(four[0].1, theme.scheme.on_primary.fade(0.38));
2267        assert_eq!(four[4].1, theme.scheme.on_surface_variant.fade(0.38));
2268        assert!(
2269            ticks(Slider::new(0.5).divisions(100)).is_empty(),
2270            "too dense"
2271        );
2272        assert!(ticks(Slider::new(0.5)).is_empty(), "continuous");
2273        // The discrete thumb sits over the first and last ticks.
2274        let end = thumb(&scene_of(&Slider::new(1.0).divisions(4), rest(), &theme));
2275        assert_eq!(end.x + 10.0, 222.0);
2276    }
2277
2278    /// **The secondary value** lightens the track from the thumb to it, when it is ahead.
2279    #[test]
2280    fn the_secondary_value_lightens_the_track_ahead() {
2281        let theme = Theme::default();
2282        let secondary = |slider: Slider<()>| {
2283            rects(&scene_of(&slider, rest(), &theme))
2284                .into_iter()
2285                .find(|(_, c)| *c == theme.scheme.primary.fade(0.54))
2286        };
2287        let (rect, _) =
2288            secondary(Slider::new(0.25).secondary_track_value(0.75)).expect("a secondary part");
2289        assert_eq!((rect.x, rect.x + rect.width), (74.0, 174.0));
2290        assert!(
2291            secondary(Slider::new(0.75).secondary_track_value(0.25)).is_none(),
2292            "behind"
2293        );
2294        let given = Color::rgb(0.2, 0.4, 0.6);
2295        let painted = scene_of(
2296            &Slider::new(0.25)
2297                .secondary_track_value(0.75)
2298                .secondary_active_color(given),
2299            rest(),
2300            &theme,
2301        );
2302        assert!(rects(&painted).iter().any(|(_, c)| *c == given));
2303    }
2304
2305    /// **When the drop shows**: on a slider with divisions while it is moved or focused,
2306    /// by default; the other policies as they say; never on a disabled one.
2307    #[test]
2308    fn the_drop_shows_as_its_policy_says() {
2309        let theme = Theme::default();
2310        let focused = Status {
2311            focused: true,
2312            focus_progress: 1.0,
2313            ..Status::default()
2314        };
2315        let pressed = Status {
2316            press_progress: 1.0,
2317            ..Status::default()
2318        };
2319        let labelled = || Slider::<()>::new(0.5).value_label(|v| format!("{v}"));
2320        let shows = |s: Slider<()>, status: Status| texts(&scene_of(&s, status, &theme)) == 1;
2321        assert!(shows(labelled().divisions(4), focused));
2322        assert!(shows(labelled().divisions(4), pressed));
2323        assert!(!shows(labelled().divisions(4), rest()), "at rest, hidden");
2324        assert!(
2325            !shows(labelled(), focused),
2326            "continuous: only for discrete by default"
2327        );
2328        assert!(shows(
2329            labelled().show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2330            focused
2331        ));
2332        assert!(!shows(
2333            labelled()
2334                .divisions(4)
2335                .show_value_indicator(ShowValueIndicator::OnlyForContinuous),
2336            focused
2337        ));
2338        assert!(shows(
2339            labelled().show_value_indicator(ShowValueIndicator::OnDrag),
2340            pressed
2341        ));
2342        assert!(shows(
2343            labelled().show_value_indicator(ShowValueIndicator::AlwaysVisible),
2344            rest()
2345        ));
2346        assert!(!shows(
2347            labelled()
2348                .divisions(4)
2349                .show_value_indicator(ShowValueIndicator::Never),
2350            focused
2351        ));
2352        assert!(
2353            !shows(labelled().divisions(4).enabled(false), focused),
2354            "disabled"
2355        );
2356        let mut themed = Theme::default();
2357        themed.widgets.slider.show_value_indicator = Some(ShowValueIndicator::AlwaysVisible);
2358        assert_eq!(
2359            texts(&scene_of(&labelled(), rest(), &themed)),
2360            1,
2361            "from the theme"
2362        );
2363    }
2364
2365    /// **The drop**: a pill 28 high whose bottom is 22 above the thumb's centre, over a
2366    /// triangle pointing at it, in `primary`.
2367    #[test]
2368    fn the_drop_sits_over_the_thumb() {
2369        let theme = Theme::default();
2370        let slider = Slider::<()>::new(0.5)
2371            .show_value_indicator(ShowValueIndicator::AlwaysVisible)
2372            .value_label(|_| "50".to_string());
2373        let painted = scene_of(&slider, rest(), &theme);
2374        let pill = rects(&painted)
2375            .into_iter()
2376            .find(|(r, _)| r.height == 28.0)
2377            .expect("the pill");
2378        assert_eq!(pill.0.y + pill.0.height, 24.0 - 22.0);
2379        assert!(
2380            (pill.0.x + pill.0.width / 2.0 - 124.0).abs() < 0.01,
2381            "centred on the thumb"
2382        );
2383        assert_eq!(pill.1, theme.scheme.primary);
2384        assert!(
2385            painted.iter().any(|p| matches!(p, Primitive::Path { .. })),
2386            "the point"
2387        );
2388    }
2389
2390    /// **The halo**: on hover, focus and press, in `primary` at 8 % hovered and 10 %
2391    /// pressed; none at rest, none disabled; a colour given at 12 %.
2392    #[test]
2393    fn the_halo_follows_the_state() {
2394        let theme = Theme::default();
2395        let halo = |slider: Slider<()>, status: Status| {
2396            rects(&scene_of(&slider, status, &theme))
2397                .into_iter()
2398                .find(|(r, _)| r.width == 48.0)
2399                .map(|(_, c)| c)
2400        };
2401        let hovered = Status {
2402            interaction: crate::interaction::Interaction::Hovered,
2403            hover_progress: 1.0,
2404            ..Status::default()
2405        };
2406        let pressed = Status {
2407            interaction: crate::interaction::Interaction::Pressed,
2408            press_progress: 1.0,
2409            ..Status::default()
2410        };
2411        assert_eq!(halo(Slider::new(0.5), rest()), None);
2412        assert_eq!(
2413            halo(Slider::new(0.5), hovered),
2414            Some(theme.scheme.primary.fade(0.08))
2415        );
2416        assert_eq!(
2417            halo(Slider::new(0.5), pressed),
2418            Some(theme.scheme.primary.fade(0.1))
2419        );
2420        assert_eq!(halo(Slider::new(0.5).enabled(false), hovered), None);
2421        let given = Color::rgb(0.0, 0.6, 0.3);
2422        assert_eq!(
2423            halo(Slider::new(0.5).active_color(given), hovered),
2424            Some(given.fade(0.12))
2425        );
2426        let property = WidgetStateProperty::new().otherwise(Color::rgb(1.0, 0.0, 0.0));
2427        assert_eq!(
2428            halo(Slider::new(0.5).overlay_color(property), hovered),
2429            Some(Color::rgb(1.0, 0.0, 0.0))
2430        );
2431    }
2432
2433    /// **Disabled**: the travelled part and the thumb as content, the rest as a
2434    /// container, the framework's disabled rule.
2435    #[test]
2436    fn a_disabled_slider_wears_the_disabled_rule() {
2437        let theme = Theme::default();
2438        let painted = scene_of(&Slider::new(0.5).enabled(false), rest(), &theme);
2439        let r = rects(&painted);
2440        assert_eq!(r[0].1, disabled_container(&theme));
2441        assert_eq!(r[1].1, disabled_content(&theme));
2442        assert_eq!(r.last().map(|x| x.1), Some(disabled_content(&theme)));
2443    }
2444
2445    /// **It takes the width it is offered**, at least three touch targets of track, unless
2446    /// a width is given.
2447    #[test]
2448    fn it_takes_the_width_it_is_offered() {
2449        let theme = Theme::default();
2450        let open = Slider::<()>::new(0.5);
2451        assert!(Widget::<()>::fill_axes(&open, &theme).horizontal);
2452        assert!(Widget::<()>::soft_extent(&open).horizontal);
2453        assert_eq!(
2454            Widget::<()>::style(&open).min_width,
2455            frus_layout::Dimension::Length(192.0)
2456        );
2457        let fixed = Slider::<()>::new(0.5).width(120.0);
2458        assert!(!Widget::<()>::fill_axes(&fixed, &theme).horizontal);
2459        assert_eq!(
2460            Widget::<()>::style(&fixed).width,
2461            frus_layout::Dimension::Length(120.0)
2462        );
2463    }
2464
2465    /// **A drag lands on the track**: inset by the halo's radius, mirrored right to left.
2466    #[test]
2467    fn a_drag_lands_on_the_inset_track() {
2468        let track = Widget::<()>::drag_track(&Slider::<()>::new(0.5));
2469        assert_eq!(track.inset, 24.0);
2470        assert!(track.mirrored);
2471        assert_eq!(track.fraction(BOX, 24.0, false), 0.0);
2472        assert_eq!(track.fraction(BOX, 124.0, false), 0.5);
2473        assert_eq!(track.fraction(BOX, 224.0, false), 1.0);
2474        assert_eq!(track.fraction(BOX, 74.0, true), 0.75, "from the right");
2475        assert_eq!(track.fraction(BOX, 0.0, false), 0.0, "held at the ends");
2476        assert_eq!(track.fraction(BOX, 248.0, false), 1.0);
2477        let whole = crate::widget::DragTrack::WHOLE;
2478        assert_eq!(
2479            whole.fraction(BOX, 62.0, true),
2480            0.25,
2481            "no opinion: left to right"
2482        );
2483        let empty = Rect::new(0.0, 0.0, 40.0, 48.0);
2484        assert_eq!(track.fraction(empty, 20.0, false), 0.0, "no travel");
2485    }
2486
2487    /// **A screen reader hears the semantic formatter** over the indicator's label.
2488    #[test]
2489    fn a_reader_hears_the_semantic_formatter() {
2490        let slider = Slider::<()>::new(0.5)
2491            .value_label(|v| format!("{v}"))
2492            .semantic_formatter(|v| format!("{} percent", (v * 100.0).round()));
2493        let said = Widget::<()>::semantics(&slider).and_then(|s| s.value);
2494        assert_eq!(said.as_deref(), Some("50 percent"));
2495    }
2496}