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;
13
14/// The height of the value tooltip (above the thumbs) and its gap from the track.
15const TIP_H: f32 = 20.0;
16const TIP_GAP: f32 = 6.0;
17/// The value bubble's style: what the theme says, else the reference's — it calls this the
18/// *value indicator* and sets it in `labelLarge`.
19///
20/// **Resolved once**, so that the number the bubble is measured with is the number the
21/// digits are drawn at.
22fn tip_style(theme: Option<&Theme>) -> ResolvedTextStyle {
23    theme
24        .and_then(|t| t.widgets.slider.value_indicator_text_style)
25        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
26        .resolved()
27}
28/// The default keyboard step (without `divisions`): an arrow moves by 5%.
29const KEY_STEP: f32 = 0.05;
30
31const H: f32 = 24.0;
32const TRACK_H: f32 = 6.0;
33const THUMB: f32 = 18.0;
34/// How far a thumb sits off the track at rest, and while it is held: the reference's round
35/// thumb on a slider (its slider parts, lines 680–681) and on a range slider (its range
36/// slider parts, lines 789–790).
37const THUMB_ELEVATION: f32 = 1.0;
38const THUMB_PRESSED_ELEVATION: f32 = 6.0;
39
40/// The shadow under a thumb whose box is `thumb`: its resting height, rising to the pressed
41/// one as the press goes in.
42///
43/// Drawn the way a search bar draws its height — a blur of two pixels a step from four, and a
44/// drop of half a pixel a step — and **not** the way a card does. That is not taste: the
45/// shadow primitive's soft edge stops at its own rectangle, and around a circle this small a
46/// card's wider blur leaves that rectangle's corners in the fade, so the halo comes out square.
47/// At rest this one is round; while held, at six, the corners begin to show.
48///
49/// `told` is the widget's `(resting, pressed)`, each over the theme's and the reference's.
50/// The reference's round thumb casts it enabled or not — its shape has no disabled height
51/// (its slider parts, lines 747–767) — and so does this one.
52fn paint_thumb_shadow(
53    thumb: Rect,
54    told: (Option<f32>, Option<f32>),
55    status: &Status,
56    theme: &Theme,
57    scene: &mut Scene,
58) {
59    let t = &theme.widgets.slider;
60    let rest = told.0.or(t.thumb_elevation).unwrap_or(THUMB_ELEVATION);
61    let pressed = told
62        .1
63        .or(t.pressed_thumb_elevation)
64        .unwrap_or(THUMB_PRESSED_ELEVATION);
65    let depth = rest + (pressed - rest) * status.press_progress.clamp(0.0, 1.0);
66    if depth <= 0.0 {
67        return;
68    }
69    let blur = depth * 2.0 + 4.0;
70    scene.shadow(
71        Rect::new(
72            thumb.x - blur,
73            thumb.y + depth * 0.5 - blur,
74            thumb.width + 2.0 * blur,
75            thumb.height + 2.0 * blur,
76        ),
77        theme.scheme.shadow.with_alpha(0.30).fade(status.opacity),
78        frus_core::BorderRadius::uniform(thumb.width * 0.5).inflate(blur),
79        blur,
80    );
81}
82
83/// A linear slider over `min..=max`, **controlled** and draggable.
84pub struct Slider<Msg = crate::callback::Callback> {
85    value: f32,
86    min: f32,
87    max: f32,
88    width: f32,
89    divisions: Option<usize>,
90    /// The **value tooltip** formatter: a bubble above the thumb, on hover or focus.
91    label: Option<Rc<dyn Fn(f32) -> String>>,
92    enabled: bool,
93    colors: SliderColors,
94    thumb_elevation: Option<f32>,
95    pressed_thumb_elevation: Option<f32>,
96    on_change: Option<Box<dyn Fn(f32) -> Msg>>,
97    /// Sent once when a drag begins, before the first `on_change`.
98    on_change_start: Option<Box<dyn Fn(f32) -> Msg>>,
99    /// Sent once when it ends, after the last one.
100    on_change_end: Option<Box<dyn Fn(f32) -> Msg>>,
101}
102
103/// What a slider was told about its own colours; unset entries fall through to the theme
104/// and then the scheme, resolved where they are painted rather than where they are built.
105#[derive(Clone, Copy, Debug, Default, PartialEq)]
106pub(crate) struct SliderColors {
107    pub active_track: Option<Color>,
108    pub inactive_track: Option<Color>,
109    pub thumb: Option<Color>,
110    pub thumb_border: Option<Color>,
111}
112
113impl SliderColors {
114    /// The four colours a live slider paints with: rail, travelled, thumb, ring.
115    fn resolve(&self, theme: &Theme) -> (Color, Color, Color, Color) {
116        // The rail is a filled track, not an edge, so its default is a **container**
117        // rather than an outline -- and the reference names which one: the secondary
118        // container, the role that carries a live-but-quiet fill and stays clear of the
119        // 12 % a disabled rail lands on.
120        let rail = self
121            .inactive_track
122            .or(theme.widgets.slider.inactive_track_color)
123            .unwrap_or(theme.scheme.secondary_container);
124        let filled = self
125            .active_track
126            .or(theme.widgets.slider.active_track_color)
127            .unwrap_or(theme.primary);
128        let thumb = self
129            .thumb
130            .or(theme.widgets.slider.thumb_color)
131            .unwrap_or(Color::WHITE);
132        // The ring follows the travelled track unless it is named: they are the same
133        // colour in the default scheme, and a caller who recolours the track and not the
134        // ring means the accent, not a hairline left behind in the old one.
135        let ring = self
136            .thumb_border
137            .or(theme.widgets.slider.thumb_border_color)
138            .unwrap_or(filled);
139        (rail, filled, thumb, ring)
140    }
141}
142
143impl<Msg> Slider<Msg> {
144    /// Creates a slider at the given value, over `0.0..=1.0` until
145    /// [`range`](Slider::range) says otherwise.
146    pub fn new(value: f32) -> Self {
147        Self {
148            value,
149            min: 0.0,
150            max: 1.0,
151            width: 220.0,
152            divisions: None,
153            label: None,
154            enabled: true,
155            colors: SliderColors::default(),
156            thumb_elevation: None,
157            pressed_thumb_elevation: None,
158            on_change: None,
159            on_change_start: None,
160            on_change_end: None,
161        }
162    }
163
164    /// The travelled part of the track; the theme's `primary` otherwise. The thumb's
165    /// ring follows it unless [`thumb_border_color`](Slider::thumb_border_color) says
166    /// otherwise.
167    pub fn active_color(mut self, color: Color) -> Self {
168        self.colors.active_track = Some(color);
169        self
170    }
171
172    /// The part of the track still to travel.
173    pub fn inactive_color(mut self, color: Color) -> Self {
174        self.colors.inactive_track = Some(color);
175        self
176    }
177
178    /// The thumb's fill; white otherwise.
179    pub fn thumb_color(mut self, color: Color) -> Self {
180        self.colors.thumb = Some(color);
181        self
182    }
183
184    /// The ring around the thumb; the travelled track's colour otherwise.
185    pub fn thumb_border_color(mut self, color: Color) -> Self {
186        self.colors.thumb_border = Some(color);
187        self
188    }
189
190    /// How far the thumb sits off the track at rest. Unset, the theme's, then the
191    /// reference's 1; `0.0` casts no shadow.
192    pub fn thumb_elevation(mut self, elevation: f32) -> Self {
193        self.thumb_elevation = Some(elevation);
194        self
195    }
196
197    /// How far it rises while it is held. Unset, the theme's, then the reference's 6.
198    pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
199        self.pressed_thumb_elevation = Some(elevation);
200        self
201    }
202
203    /// The travel this slider covers; `0.0..=1.0` by default.
204    ///
205    /// Everything the application sees is in **these** units — the value it is given,
206    /// the value a step lands on, what a reader is told — rather than a fraction it
207    /// would have to convert back on both sides of every message. The two arguments are
208    /// sorted, so a range written backwards is not a silent empty one.
209    ///
210    /// The value is held inside the travel rather than rejected: a caller that lowers
211    /// the ceiling under a value it already had gets the ceiling, not a panic.
212    pub fn range(mut self, min: f32, max: f32) -> Self {
213        self.min = min.min(max);
214        self.max = min.max(max);
215        self
216    }
217
218    /// Splits the travel into `n` **steps**: the value snaps to `min + k·(max−min)/n`,
219    /// and an arrow key moves by one of them. Without this call the travel is continuous
220    /// and an arrow moves by 5 %.
221    pub fn divisions(mut self, n: usize) -> Self {
222        self.divisions = Some(n.max(1));
223        self
224    }
225
226    /// Shows a **value tooltip** above the thumb, formatted by `label(value)` — a
227    /// percentage, a price, a duration. It appears on hover or focus and reserves the
228    /// room above the track, so a slider that has one is taller than one that does not.
229    pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
230        self.label = Some(Rc::new(label));
231        self
232    }
233
234    /// Width, in logical pixels.
235    pub fn width(mut self, width: f32) -> Self {
236        self.width = width;
237        self
238    }
239
240    /// A closure producing a message from the new value (`0..=1`).
241    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
242        mut self,
243        on_change: impl Fn(f32) -> R + 'static,
244    ) -> Self {
245        self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
246        self
247    }
248
249    /// Sent **once**, when a drag begins — before the first
250    /// [`on_change`](Self::on_change), with the value the press landed on.
251    ///
252    /// With [`on_change_end`](Self::on_change_end) it brackets the stream.
253    /// `on_change` fires on every pixel of the movement, so an application that seeks a
254    /// video, writes a setting to disk or asks the network on each of them does it
255    /// sixty times a second; the bracket is what lets it show a preview while the
256    /// finger is down and commit when it lifts.
257    pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
258        mut self,
259        on_start: impl Fn(f32) -> R + 'static,
260    ) -> Self {
261        self.on_change_start = Some(Box::new(crate::callback::handler1(on_start)));
262        self
263    }
264
265    /// Sent **once**, when the drag ends — after the last
266    /// [`on_change`](Self::on_change), with the value it settled on.
267    ///
268    /// A press that never moved still gets one: it changed the value, and a caller
269    /// waiting for the release would otherwise never be told it happened.
270    pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
271        mut self,
272        on_end: impl Fn(f32) -> R + 'static,
273    ) -> Self {
274        self.on_change_end = Some(Box::new(crate::callback::handler1(on_end)));
275        self
276    }
277
278    /// Whether the slider can be moved. Disabled it is **inert** — it takes no drag and
279    /// answers no key — and it still shows where it is set.
280    ///
281    /// A slider is the control that makes the point of [`crate::disabled`]'s contract:
282    /// greying it out while it still answered a drag would leave it inert only to the
283    /// gesture nobody was using on it.
284    pub fn enabled(mut self, enabled: bool) -> Self {
285        self.enabled = enabled;
286        self
287    }
288
289    /// The value, held inside the travel.
290    fn held(&self) -> f32 {
291        self.value.clamp(self.min, self.max)
292    }
293
294    /// Where the thumb sits along the track, `0..=1`. An empty range pins it at the
295    /// start rather than dividing by nothing.
296    fn fraction(&self) -> f32 {
297        let span = self.max - self.min;
298        if span <= 0.0 {
299            0.0
300        } else {
301            ((self.held() - self.min) / span).clamp(0.0, 1.0)
302        }
303    }
304
305    /// Snaps a fraction to the nearest step, if there are steps.
306    fn snap(&self, fraction: f32) -> f32 {
307        match self.divisions {
308            Some(n) if n > 0 => (fraction * n as f32).round() / n as f32,
309            _ => fraction,
310        }
311    }
312
313    /// The value a fraction along the track stands for.
314    fn at(&self, fraction: f32) -> f32 {
315        self.min + self.snap(fraction.clamp(0.0, 1.0)) * (self.max - self.min)
316    }
317
318    /// One arrow's step, as a fraction of the travel: a division if there are
319    /// divisions, otherwise [`KEY_STEP`].
320    fn key_step(&self) -> f32 {
321        match self.divisions {
322            Some(n) if n > 0 => 1.0 / n as f32,
323            _ => KEY_STEP,
324        }
325    }
326
327    /// The height the control asks for: the track, plus the tooltip's zone above it
328    /// when there is a tooltip to show.
329    fn content_h(&self) -> f32 {
330        if self.label.is_some() {
331            H + TIP_H + TIP_GAP
332        } else {
333            H
334        }
335    }
336}
337
338impl<Msg> Widget<Msg> for Slider<Msg> {
339    fn style(&self) -> Style {
340        Style {
341            width: Dimension::Length(self.width),
342            height: Dimension::Length(self.content_h()),
343            ..Default::default()
344        }
345    }
346
347    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
348        &[]
349    }
350
351    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
352        let o = status.opacity;
353        // The track lives in the **lower** `H` band: anything above it is the tooltip's
354        // reserved zone, which is empty unless there is a tooltip.
355        let track_top = bounds.y + bounds.height - H;
356        let track_y = track_top + (H - TRACK_H) * 0.5;
357        // A slider splits cleanly along the framework's one disabled rule: the part of the
358        // track still to travel is a **container** (12 %), the part already travelled and
359        // the thumb are **content** on it (38 %). That is the reference's own split too.
360        let (rail, filled_color, thumb, ring) = if self.enabled {
361            self.colors.resolve(theme)
362        } else {
363            let dead = disabled_content(theme);
364            (disabled_container(theme), dead, dead, dead)
365        };
366        // The rail, its whole length.
367        scene.draw_rect(
368            Rect::new(bounds.x, track_y, bounds.width, TRACK_H),
369            rail.fade(o),
370            TRACK_H * 0.5,
371            0.0,
372            Color::TRANSPARENT,
373        );
374        // The travelled part.
375        let filled = bounds.width * self.fraction();
376        scene.draw_rect(
377            Rect::new(bounds.x, track_y, filled, TRACK_H),
378            filled_color.fade(o),
379            TRACK_H * 0.5,
380            0.0,
381            Color::TRANSPARENT,
382        );
383        // The thumb, over its shadow.
384        let cx = bounds.x + filled;
385        let thumb_box = Rect::new(
386            cx - THUMB * 0.5,
387            track_top + (H - THUMB) * 0.5,
388            THUMB,
389            THUMB,
390        );
391        paint_thumb_shadow(
392            thumb_box,
393            (self.thumb_elevation, self.pressed_thumb_elevation),
394            &status,
395            theme,
396            scene,
397        );
398        scene.draw_rect(thumb_box, thumb.fade(o), THUMB * 0.5, 2.0, ring.fade(o));
399
400        // The tooltip, in the zone reserved above the track. A disabled slider shows
401        // none: it is a hint about a value being changed, and this one is not.
402        if let Some(label) = self.label.as_ref().filter(|_| self.enabled) {
403            let reveal = if status.focused {
404                o
405            } else {
406                status.hover_progress * o
407            };
408            if reveal > 0.01 {
409                paint_tip(cx, bounds.y, label(self.held()), theme, reveal, scene);
410            }
411        }
412    }
413
414    fn on_click(&self) -> Option<Msg> {
415        None
416    }
417
418    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
419        // The value survives: a reader who cannot move the slider is still owed where it
420        // sits, which is the whole of what a slider says.
421        // In the caller's units, and said the caller's way when it gave a formatter:
422        // "42 €" is what is on screen, and a percentage of an unstated range is not an
423        // answer to "where is this set".
424        let held = self.held();
425        let spoken = match self.label.as_ref() {
426            Some(label) => label(held),
427            None if self.min == 0.0 && self.max == 1.0 => {
428                format!("{}%", (held * 100.0).round())
429            }
430            None => format!("{held}"),
431        };
432        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
433            .value(spoken)
434            .range(self.min, held, self.max);
435        Some(if self.enabled {
436            semantics
437        } else {
438            semantics.disabled(true)
439        })
440    }
441
442    fn draggable(&self) -> bool {
443        self.enabled
444    }
445
446    fn focusable(&self) -> bool {
447        self.enabled && self.on_change.is_some()
448    }
449
450    fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
451        // A disabled slider cannot be focused, so this should be unreachable — but a key
452        // arriving from a stale focus must not move a value the caller has frozen.
453        if !self.enabled {
454            return KeyResponse::Ignored;
455        }
456        // Arrows: one step. Home/End: the two ends, which the clamp in `at` reaches
457        // without either bound having to be named here.
458        let delta = match key {
459            Key::Left { .. } => -self.key_step(),
460            Key::Right { .. } => self.key_step(),
461            Key::Home { .. } => -2.0,
462            Key::End { .. } => 2.0,
463            _ => return KeyResponse::Ignored,
464        };
465        let moved = self.at(self.fraction() + delta);
466        KeyResponse::Handled(self.on_change.as_ref().map(|make| make(moved)))
467    }
468
469    fn on_drag(&self, fraction: f32) -> Option<Msg> {
470        // `draggable` already says no, but a drag in flight when the caller disables the
471        // slider must not land either.
472        if !self.enabled {
473            return None;
474        }
475        self.on_change.as_ref().map(|make| make(self.at(fraction)))
476    }
477
478    fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
479        if !self.enabled {
480            return None;
481        }
482        // The **value**, not the fraction: a caller who asked for a range of 0..=100 is
483        // owed a hundred here too, and a bracket in different units from the stream it
484        // brackets would be a trap inside a signature that looks symmetrical.
485        self.on_change_start
486            .as_ref()
487            .map(|make| make(self.at(fraction)))
488    }
489
490    fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
491        if !self.enabled {
492            return None;
493        }
494        self.on_change_end
495            .as_ref()
496            .map(|make| make(self.at(fraction)))
497    }
498}
499
500/// A transparent, inert shim that positions the thumbs along the track.
501struct Spacer {
502    width: f32,
503    height: f32,
504}
505
506impl<Msg: Clone> Widget<Msg> for Spacer {
507    fn style(&self) -> Style {
508        Style {
509            width: Dimension::Length(self.width),
510            height: Dimension::Length(self.height),
511            ..Default::default()
512        }
513    }
514
515    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
516        &[]
517    }
518
519    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
520
521    fn on_click(&self) -> Option<Msg> {
522        None
523    }
524}
525
526/// The side of a [`RangeSlider`] thumb.
527#[derive(Copy, Clone)]
528enum Side {
529    Low,
530    High,
531}
532
533/// A **draggable** thumb of the range slider. Each thumb moves **its own** side
534/// (sticky: the thumb grabbed stays the thumb moved), bounded by the other.
535struct RangeThumb<Msg> {
536    side: Side,
537    low: f32,
538    high: f32,
539    /// The track's width, to convert a pixel delta into a fraction.
540    track: f32,
541    /// Total height (the track + any tooltip zone): the thumb is drawn in the
542    /// **lower** `H` band.
543    height: f32,
544    divisions: Option<usize>,
545    /// The tooltip formatter: the bubble only appears on **hover or focus** of the thumb.
546    label: Option<Rc<dyn Fn(f32) -> String>>,
547    /// The slider's availability, handed down to each thumb.
548    enabled: bool,
549    /// The slider's `(resting, pressed)` thumb heights, handed down likewise.
550    elevations: (Option<f32>, Option<f32>),
551    on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
552}
553
554impl<Msg> RangeThumb<Msg> {
555    /// Snaps `v` to the nearest step if `divisions` is set.
556    fn snap(&self, v: f32) -> f32 {
557        match self.divisions {
558            Some(n) if n > 0 => (v * n as f32).round() / n as f32,
559            _ => v,
560        }
561    }
562
563    /// The value this thumb carries, according to its side.
564    fn value(&self) -> f32 {
565        match self.side {
566            Side::Low => self.low,
567            Side::High => self.high,
568        }
569    }
570
571    /// The new interval after moving this thumb's side by `delta`, bounded by the
572    /// other (no crossing) and snapped.
573    fn moved(&self, delta: f32) -> (f32, f32) {
574        match self.side {
575            Side::Low => (
576                self.snap((self.low + delta).clamp(0.0, self.high)),
577                self.high,
578            ),
579            Side::High => (
580                self.low,
581                self.snap((self.high + delta).clamp(self.low, 1.0)),
582            ),
583        }
584    }
585
586    /// One arrow's step: a division if `divisions` is set, otherwise [`KEY_STEP`].
587    fn key_step(&self) -> f32 {
588        match self.divisions {
589            Some(n) if n > 0 => 1.0 / n as f32,
590            _ => KEY_STEP,
591        }
592    }
593}
594
595impl<Msg: Clone> Widget<Msg> for RangeThumb<Msg> {
596    fn style(&self) -> Style {
597        Style {
598            width: Dimension::Length(THUMB),
599            height: Dimension::Length(self.height),
600            ..Default::default()
601        }
602    }
603
604    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
605        &[]
606    }
607
608    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
609        let o = status.opacity;
610        // The thumb in the lower `H` band; an accented ring on keyboard focus.
611        let y = bounds.y + bounds.height - H + (H - THUMB) * 0.5;
612        // A disabled thumb never shows the focus ring, because it never takes focus.
613        let border = if status.focused && self.enabled {
614            3.0
615        } else {
616            2.0
617        };
618        let (fill, ring) = if self.enabled {
619            (Color::WHITE, theme.primary)
620        } else {
621            let dead = disabled_content(theme);
622            (dead, dead)
623        };
624        let thumb = Rect::new(bounds.x, y, THUMB, THUMB);
625        paint_thumb_shadow(thumb, self.elevations, &status, theme, scene);
626        scene.draw_rect(thumb, fill.fade(o), THUMB * 0.5, border, ring.fade(o));
627        // The tooltip revealed on hover or focus (the upper zone the slider reserves).
628        // A disabled thumb shows none: it is a hint about a value being changed.
629        if let Some(label) = self.label.as_ref().filter(|_| self.enabled) {
630            let active = status.focused || status.hover_progress > 0.01;
631            if active {
632                let reveal = if status.focused {
633                    o
634                } else {
635                    status.hover_progress * o
636                };
637                paint_tip(
638                    bounds.x + THUMB * 0.5,
639                    bounds.y,
640                    label(self.value()),
641                    theme,
642                    reveal,
643                    scene,
644                );
645            }
646        }
647    }
648
649    fn on_click(&self) -> Option<Msg> {
650        None
651    }
652
653    fn focusable(&self) -> bool {
654        self.enabled && self.on_change.is_some()
655    }
656
657    fn draggable(&self) -> bool {
658        self.enabled
659    }
660
661    fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
662        if !self.enabled || dx == 0.0 || self.track <= 0.0 {
663            return None;
664        }
665        let (low, high) = self.moved(dx / self.track);
666        self.on_change.as_ref().map(|make| make(low, high))
667    }
668
669    fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
670        // A disabled thumb cannot be focused, so this should be unreachable - but a key
671        // arriving from a stale focus must not move a value the caller has frozen.
672        if !self.enabled {
673            return KeyResponse::Ignored;
674        }
675        // Arrows: one step; Home/End: this side's min/max bound (the shell offers
676        // these keys to the focused widget before the default action).
677        let delta = match key {
678            Key::Left { .. } => -self.key_step(),
679            Key::Right { .. } => self.key_step(),
680            Key::Home { .. } => -2.0, // clamped at 0 or the low neighbour
681            Key::End { .. } => 2.0,   // clamped at the high neighbour or 1
682            _ => return KeyResponse::Ignored,
683        };
684        let (low, high) = self.moved(delta);
685        KeyResponse::Handled(self.on_change.as_ref().map(|make| make(low, high)))
686    }
687}
688
689/// A **range** slider: two thumbs (low and high) bounding a `0.0..=1.0` interval,
690/// **controlled** and **sticky** (each thumb moves its own side, with no crossing).
691/// An optional discrete step ([`divisions`](RangeSlider::divisions)). The
692/// application receives the new `(low, high)` interval.
693pub struct RangeSlider<Msg = crate::callback::Callback> {
694    low: f32,
695    high: f32,
696    width: f32,
697    divisions: Option<usize>,
698    /// The **value tooltip** formatter: when set, a bubble above each thumb shows
699    /// `label(value)` (and the height reserves the room for it).
700    label: Option<Rc<dyn Fn(f32) -> String>>,
701    enabled: bool,
702    thumb_elevation: Option<f32>,
703    pressed_thumb_elevation: Option<f32>,
704    on_change: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
705    /// Sent once when a drag begins, before the first `on_change`.
706    on_change_start: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
707    /// Sent once when it ends, after the last one.
708    on_change_end: Option<Rc<dyn Fn(f32, f32) -> Msg>>,
709    children: Vec<Box<dyn Widget<Msg>>>,
710}
711
712impl<Msg: Clone + 'static> RangeSlider<Msg> {
713    /// Creates a range slider (values clamped to `0..=1` and ordered `low ≤ high`).
714    pub fn new(low: f32, high: f32) -> Self {
715        let low = low.clamp(0.0, 1.0);
716        let high = high.clamp(0.0, 1.0);
717        let mut slider = Self {
718            low: low.min(high),
719            high: low.max(high),
720            width: 220.0,
721            divisions: None,
722            label: None,
723            enabled: true,
724            thumb_elevation: None,
725            pressed_thumb_elevation: None,
726            on_change: None,
727            on_change_start: None,
728            on_change_end: None,
729            children: Vec::new(),
730        };
731        slider.rebuild();
732        slider
733    }
734
735    /// Width, in logical pixels.
736    pub fn width(mut self, width: f32) -> Self {
737        self.width = width;
738        self.rebuild();
739        self
740    }
741
742    /// Splits the travel into `n` **steps**: dragged values snap to `k/n`. Without
743    /// this call, the travel is continuous.
744    pub fn divisions(mut self, n: usize) -> Self {
745        self.divisions = Some(n.max(1));
746        self.rebuild();
747        self
748    }
749
750    /// A closure producing a message from the new `(low, high)` interval.
751    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
752        mut self,
753        on_change: impl Fn(f32, f32) -> R + 'static,
754    ) -> Self {
755        self.on_change = Some(Rc::new(crate::callback::handler2(on_change)));
756        self.rebuild();
757        self
758    }
759
760    /// Whether the interval can be changed. Disabled the whole control is **inert** -
761    /// neither thumb takes a drag, a key or the focus - and it still shows its interval.
762    ///
763    /// See [`crate::disabled`] for the whole contract.
764    pub fn enabled(mut self, enabled: bool) -> Self {
765        self.enabled = enabled;
766        self.rebuild();
767        self
768    }
769
770    /// How far each thumb sits off the track at rest. Unset, the theme's, then the
771    /// reference's 1; `0.0` casts no shadow.
772    pub fn thumb_elevation(mut self, elevation: f32) -> Self {
773        self.thumb_elevation = Some(elevation);
774        self.rebuild();
775        self
776    }
777
778    /// How far a thumb rises while it is held. Unset, the theme's, then the reference's 6.
779    pub fn pressed_thumb_elevation(mut self, elevation: f32) -> Self {
780        self.pressed_thumb_elevation = Some(elevation);
781        self.rebuild();
782        self
783    }
784
785    /// Shows a **value tooltip** above each thumb, formatted by `label(value)` (a
786    /// percentage, a price…). Reserves the room above the track.
787    pub fn value_label(mut self, label: impl Fn(f32) -> String + 'static) -> Self {
788        self.label = Some(Rc::new(label));
789        self.rebuild();
790        self
791    }
792
793    /// Sent **once**, when a drag begins — before the first
794    /// [`on_change`](Self::on_change), with the interval as it stands.
795    ///
796    /// See [`Slider::on_change_start`] for why the bracket earns its place.
797    pub fn on_change_start<R: crate::callback::IntoMsg<Msg>>(
798        mut self,
799        on_start: impl Fn(f32, f32) -> R + 'static,
800    ) -> Self {
801        self.on_change_start = Some(Rc::new(crate::callback::handler2(on_start)));
802        self
803    }
804
805    /// Sent **once**, when the drag ends — after the last
806    /// [`on_change`](Self::on_change), with the interval it settled on.
807    pub fn on_change_end<R: crate::callback::IntoMsg<Msg>>(
808        mut self,
809        on_end: impl Fn(f32, f32) -> R + 'static,
810    ) -> Self {
811        self.on_change_end = Some(Rc::new(crate::callback::handler2(on_end)));
812        self
813    }
814
815    /// The interval a drag reaching `fraction` would leave behind — the nearest thumb
816    /// moved to it, the other one where it was.
817    ///
818    /// Shared by the drag and both its ends so that the three cannot disagree about
819    /// which thumb the pointer was nearest.
820    fn interval_at(&self, fraction: f32) -> (f32, f32) {
821        let f = self.snap(fraction.clamp(0.0, 1.0));
822        if f <= self.low {
823            (f, self.high)
824        } else if f >= self.high {
825            (self.low, f)
826        } else if f - self.low <= self.high - f {
827            (f, self.high)
828        } else {
829            (self.low, f)
830        }
831    }
832
833    /// (Re)builds the row of thumbs set at the `low`/`high` positions.
834    fn rebuild(&mut self) {
835        let height = self.content_h();
836        let thumb = |side: Side| RangeThumb {
837            side,
838            low: self.low,
839            high: self.high,
840            track: self.width,
841            height,
842            divisions: self.divisions,
843            label: self.label.clone(),
844            enabled: self.enabled,
845            elevations: (self.thumb_elevation, self.pressed_thumb_elevation),
846            on_change: self.on_change.clone(),
847        };
848        let lo_gap = (self.low * self.width - THUMB * 0.5).max(0.0);
849        let mid_gap = ((self.high - self.low) * self.width - THUMB).max(0.0);
850        let row = Flex::row()
851            .child(Spacer {
852                width: lo_gap,
853                height,
854            })
855            .child(thumb(Side::Low))
856            .child(Spacer {
857                width: mid_gap,
858                height,
859            })
860            .child(thumb(Side::High));
861        self.children = vec![Box::new(row)];
862    }
863}
864
865/// Paints a value tooltip centred on `cx` (top edge `top`) showing `text`.
866fn paint_tip(cx: f32, top: f32, text: String, theme: &Theme, o: f32, scene: &mut Scene) {
867    let style = tip_style(Some(theme));
868    let tw = frus_text::measure_resolved(&text, &style).width;
869    let bw = tw + 12.0;
870    let bx = cx - bw * 0.5;
871    scene.draw_rect(
872        Rect::new(bx, top, bw, TIP_H),
873        theme.primary.fade(o),
874        TIP_H * 0.5,
875        0.0,
876        Color::TRANSPARENT,
877    );
878    let ty = top + (TIP_H - style.line_height()) * 0.5;
879    scene.text(
880        Point::new(bx + 6.0, ty),
881        text,
882        &style,
883        theme.on_primary.fade(o),
884    );
885}
886
887impl<Msg> RangeSlider<Msg> {
888    /// Total height: the track alone, or the track + a tooltip zone if a `label` is set.
889    fn content_h(&self) -> f32 {
890        if self.label.is_some() {
891            TIP_H + TIP_GAP + H
892        } else {
893            H
894        }
895    }
896
897    /// Snaps `v` to the nearest step if `divisions` is set.
898    fn snap(&self, v: f32) -> f32 {
899        match self.divisions {
900            Some(n) if n > 0 => (v * n as f32).round() / n as f32,
901            _ => v,
902        }
903    }
904}
905
906impl<Msg: Clone + 'static> Widget<Msg> for RangeSlider<Msg> {
907    fn style(&self) -> Style {
908        Style {
909            width: Dimension::Length(self.width),
910            height: Dimension::Length(self.content_h()),
911            ..Default::default()
912        }
913    }
914
915    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
916        &self.children
917    }
918
919    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
920        let o = status.opacity;
921        // Track + segment in the **lower** `H` band (the upper zone holds the bubbles).
922        let base_y = bounds.y + bounds.height - H;
923        let track_y = base_y + (H - TRACK_H) * 0.5;
924        // The same split as the single slider, and the same secondary container.
925        let (rail, span) = if self.enabled {
926            (theme.scheme.secondary_container, theme.primary)
927        } else {
928            (disabled_container(theme), disabled_content(theme))
929        };
930        scene.draw_rect(
931            Rect::new(bounds.x, track_y, bounds.width, TRACK_H),
932            rail.fade(o),
933            TRACK_H * 0.5,
934            0.0,
935            Color::TRANSPARENT,
936        );
937        let lo = bounds.x + bounds.width * self.low;
938        let hi = bounds.x + bounds.width * self.high;
939        scene.draw_rect(
940            Rect::new(lo, track_y, (hi - lo).max(0.0), TRACK_H),
941            span.fade(o),
942            TRACK_H * 0.5,
943            0.0,
944            Color::TRANSPARENT,
945        );
946        // The tooltips are painted by the thumbs, revealed on hover or focus.
947    }
948
949    fn on_click(&self) -> Option<Msg> {
950        None
951    }
952
953    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
954        // The interval survives, as the single slider's value does.
955        let pct = |v: f32| (v * 100.0).round();
956        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
957            .value(format!("{}%–{}%", pct(self.low), pct(self.high)));
958        Some(if self.enabled {
959            semantics
960        } else {
961            semantics.disabled(true)
962        })
963    }
964
965    fn draggable(&self) -> bool {
966        // The **track** (outside the thumbs, which sit above it) answers clicks and
967        // drags: the nearest thumb moves to the position.
968        self.enabled && self.on_change.is_some()
969    }
970
971    fn on_drag(&self, fraction: f32) -> Option<Msg> {
972        if !self.enabled {
973            return None;
974        }
975        // The nearest thumb, bounded by the other, with no crossing.
976        let (low, high) = self.interval_at(fraction);
977        self.on_change.as_ref().map(|make| make(low, high))
978    }
979
980    fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
981        if !self.enabled {
982            return None;
983        }
984        let (low, high) = self.interval_at(fraction);
985        self.on_change_start.as_ref().map(|make| make(low, high))
986    }
987
988    fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
989        if !self.enabled {
990            return None;
991        }
992        let (low, high) = self.interval_at(fraction);
993        self.on_change_end.as_ref().map(|make| make(low, high))
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[derive(Clone, Debug, PartialEq)]
1002    enum Msg {
1003        Value(f32),
1004        Start(f32),
1005        End(f32),
1006        Range(f32, f32),
1007        RangeStart(f32, f32),
1008        RangeEnd(f32, f32),
1009    }
1010
1011    /// A slider that was never asked for the bracket sends nothing at either end.
1012    /// The stream is what it always was.
1013    #[test]
1014    fn a_slider_that_was_not_asked_sends_no_bracket() {
1015        let slider = Slider::new(0.0).on_change(Msg::Value);
1016        assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1017        assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1018    }
1019
1020    /// **The bracket is in the same units as the stream it brackets.** A caller who
1021    /// asked for `0..=100` gets a hundred from `on_change`, and would be owed one at
1022    /// each end too — a start in fractions inside a signature that looks symmetrical
1023    /// is a trap.
1024    #[test]
1025    fn the_bracket_speaks_in_values_not_fractions() {
1026        let slider = Slider::new(0.0)
1027            .range(0.0, 100.0)
1028            .on_change(Msg::Value)
1029            .on_change_start(Msg::Start)
1030            .on_change_end(Msg::End);
1031        assert_eq!(Widget::on_drag_start(&slider, 0.25), Some(Msg::Start(25.0)));
1032        assert_eq!(Widget::on_drag(&slider, 0.25), Some(Msg::Value(25.0)));
1033        assert_eq!(Widget::on_drag_end(&slider, 0.75), Some(Msg::End(75.0)));
1034    }
1035
1036    /// The divisions apply at the ends too: a start or an end landing between two
1037    /// stops would name a value the stream can never produce.
1038    #[test]
1039    fn the_bracket_snaps_like_the_stream() {
1040        let slider = Slider::new(0.0)
1041            .divisions(4)
1042            .on_change(Msg::Value)
1043            .on_change_start(Msg::Start)
1044            .on_change_end(Msg::End);
1045        // 0.3 lies between the 0.25 and 0.5 stops, nearer the first.
1046        assert_eq!(Widget::on_drag(&slider, 0.3), Some(Msg::Value(0.25)));
1047        assert_eq!(Widget::on_drag_start(&slider, 0.3), Some(Msg::Start(0.25)));
1048        assert_eq!(Widget::on_drag_end(&slider, 0.3), Some(Msg::End(0.25)));
1049    }
1050
1051    /// A disabled slider is inert at **every** end. A drag in flight when the caller
1052    /// freezes the value must not land its release either.
1053    #[test]
1054    fn a_disabled_slider_brackets_nothing() {
1055        let slider = Slider::new(0.0)
1056            .enabled(false)
1057            .on_change(Msg::Value)
1058            .on_change_start(Msg::Start)
1059            .on_change_end(Msg::End);
1060        assert_eq!(Widget::on_drag_start(&slider, 0.5), None);
1061        assert_eq!(Widget::on_drag(&slider, 0.5), None);
1062        assert_eq!(Widget::on_drag_end(&slider, 0.5), None);
1063    }
1064
1065    /// A range slider brackets the **interval**, and all three agree about which thumb
1066    /// the pointer was nearest — they ask the same function.
1067    #[test]
1068    fn a_range_slider_brackets_the_interval() {
1069        let range = RangeSlider::new(0.2, 0.8)
1070            .on_change(Msg::Range)
1071            .on_change_start(Msg::RangeStart)
1072            .on_change_end(Msg::RangeEnd);
1073        // 0.3 is nearer the low thumb, so the low one moves and the high one stays.
1074        assert_eq!(
1075            Widget::on_drag_start(&range, 0.3),
1076            Some(Msg::RangeStart(0.3, 0.8))
1077        );
1078        assert_eq!(Widget::on_drag(&range, 0.3), Some(Msg::Range(0.3, 0.8)));
1079        // 0.9 is past the high thumb, so that one moves instead.
1080        assert_eq!(
1081            Widget::on_drag_end(&range, 0.9),
1082            Some(Msg::RangeEnd(0.2, 0.9))
1083        );
1084    }
1085
1086    #[test]
1087    fn drag_maps_to_value() {
1088        let slider = Slider::new(0.0).on_change(Msg::Value);
1089        assert_eq!(Widget::on_drag(&slider, 0.5), Some(Msg::Value(0.5)));
1090        // Clamped.
1091        assert_eq!(Widget::on_drag(&slider, 1.5), Some(Msg::Value(1.0)));
1092    }
1093
1094    /// The row's two draggable thumbs (low, high).
1095    fn thumbs(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1096        let row = &Widget::<Msg>::children(rs)[0];
1097        row.children()
1098            .iter()
1099            .map(|c| c.as_ref())
1100            .filter(|c| c.draggable())
1101            .collect()
1102    }
1103
1104    fn range_of(msg: Option<Msg>) -> (f32, f32) {
1105        match msg {
1106            Some(Msg::Range(lo, hi)) => (lo, hi),
1107            other => panic!("expected Range, got {other:?}"),
1108        }
1109    }
1110
1111    fn range_of_key(resp: KeyResponse<Msg>) -> (f32, f32) {
1112        match resp {
1113            KeyResponse::Handled(msg) => range_of(msg),
1114            other => panic!("expected Handled, got {other:?}"),
1115        }
1116    }
1117
1118    #[test]
1119    fn each_thumb_moves_its_own_side_and_sticks() {
1120        let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range); // a 220 px track
1121        let t = thumbs(&rs);
1122        assert_eq!(t.len(), 2, "two draggable thumbs");
1123        // The low thumb: +22 px = +0.1 → low 0.3; the high one unchanged.
1124        let (lo, hi) = range_of(t[0].on_drag_delta(22.0));
1125        assert!(
1126            (lo - 0.3).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1127            "low moves ({lo}, {hi})"
1128        );
1129        // The high thumb: −22 px = −0.1 → high 0.7; the low one unchanged.
1130        let (lo, hi) = range_of(t[1].on_drag_delta(-22.0));
1131        assert!(
1132            (lo - 0.2).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1133            "high moves ({lo}, {hi})"
1134        );
1135        // Sticky: the low thumb pushed all the way stops at the high one (0.8), without moving it.
1136        let (lo, hi) = range_of(t[0].on_drag_delta(10_000.0));
1137        assert!(
1138            (lo - 0.8).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1139            "stuck to the top ({lo}, {hi})"
1140        );
1141        // A zero delta: no message.
1142        assert_eq!(t[0].on_drag_delta(0.0), None);
1143    }
1144
1145    #[test]
1146    fn arrow_keys_move_focused_thumb_by_a_step() {
1147        let rs = RangeSlider::new(0.4, 0.6)
1148            .divisions(10)
1149            .on_change(Msg::Range); // a step of 0.1
1150        let t = thumbs(&rs);
1151        // The low thumb focused: right arrow +0.1 → 0.5; left arrow −0.1 → 0.3.
1152        let (lo, hi) = range_of_key(t[0].on_key(&Key::Right {
1153            shift: false,
1154            word: false,
1155        }));
1156        assert!(
1157            (lo - 0.5).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1158            "→ ({lo}, {hi})"
1159        );
1160        let (lo, hi) = range_of_key(t[0].on_key(&Key::Left {
1161            shift: false,
1162            word: false,
1163        }));
1164        assert!(
1165            (lo - 0.3).abs() < 1e-4 && (hi - 0.6).abs() < 1e-4,
1166            "← ({lo}, {hi})"
1167        );
1168        // The thumbs are focusable, so reachable from the keyboard.
1169        assert!(t[0].focusable() && t[1].focusable());
1170    }
1171
1172    #[test]
1173    fn track_click_moves_nearest_thumb() {
1174        let rs = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1175        // The track (the slider) is draggable and targets the nearest thumb.
1176        assert!(Widget::<Msg>::draggable(&rs), "the track answers clicks");
1177        let (lo, hi) = range_of(Widget::on_drag(&rs, 0.25));
1178        assert!(
1179            (lo - 0.25).abs() < 1e-4 && (hi - 0.8).abs() < 1e-4,
1180            "near the low end ({lo}, {hi})"
1181        );
1182        let (lo, hi) = range_of(Widget::on_drag(&rs, 0.9));
1183        assert!(
1184            (lo - 0.2).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1185            "near the high end ({lo}, {hi})"
1186        );
1187    }
1188
1189    #[test]
1190    fn home_end_snap_thumb_to_bounds() {
1191        let rs = RangeSlider::new(0.3, 0.7).on_change(Msg::Range);
1192        let t = thumbs(&rs);
1193        let home = Key::Home {
1194            shift: false,
1195            doc: false,
1196        };
1197        let end = Key::End {
1198            shift: false,
1199            doc: false,
1200        };
1201        // The low thumb: Home → 0; End → the high stop (0.7).
1202        let (lo, hi) = range_of_key(t[0].on_key(&home));
1203        assert!(
1204            (lo - 0.0).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1205            "low Home ({lo}, {hi})"
1206        );
1207        let (lo, hi) = range_of_key(t[0].on_key(&end));
1208        assert!(
1209            (lo - 0.7).abs() < 1e-4 && (hi - 0.7).abs() < 1e-4,
1210            "low End ({lo}, {hi})"
1211        );
1212        // The high thumb: End → 1.
1213        let (lo, hi) = range_of_key(t[1].on_key(&end));
1214        assert!(
1215            (lo - 0.3).abs() < 1e-4 && (hi - 1.0).abs() < 1e-4,
1216            "upper End ({lo}, {hi})"
1217        );
1218    }
1219
1220    #[test]
1221    fn value_label_reserves_height() {
1222        let plain = RangeSlider::new(0.2, 0.8).on_change(Msg::Range);
1223        let tipped = RangeSlider::new(0.2, 0.8)
1224            .on_change(Msg::Range)
1225            .value_label(|v| format!("{}", v));
1226        let h = |rs: &RangeSlider<Msg>| match Widget::<Msg>::style(rs).height {
1227            Dimension::Length(v) => v,
1228            _ => 0.0,
1229        };
1230        assert!(h(&tipped) > h(&plain), "the tooltip reserves height");
1231    }
1232
1233    #[test]
1234    fn divisions_snap_to_steps() {
1235        // 10 steps, a 200 px track: +25 px = +0.125 from 0.0 → snapped to 0.1.
1236        let rs = RangeSlider::new(0.0, 1.0)
1237            .width(200.0)
1238            .divisions(10)
1239            .on_change(Msg::Range);
1240        let (lo, _) = range_of(thumbs(&rs)[0].on_drag_delta(25.0));
1241        assert!((lo - 0.1).abs() < 1e-4, "snapped to step 0.1, got {lo}");
1242    }
1243
1244    #[test]
1245    fn range_new_orders_and_clamps() {
1246        // Inverted bounds → reordered; outside [0,1] → clamped.
1247        let rs = RangeSlider::new(0.9, 0.1).on_change(Msg::Range);
1248        // From (0.1, 0.9), the low thumb +0.05 → 0.15.
1249        let (lo, hi) = range_of(thumbs(&rs)[0].on_drag_delta(0.05 * 220.0));
1250        assert!(
1251            (lo - 0.15).abs() < 1e-4 && (hi - 0.9).abs() < 1e-4,
1252            "reordered ({lo}, {hi})"
1253        );
1254    }
1255
1256    /// The thumbs **by position** (the row is spacer, thumb, spacer, thumb), which is the
1257    /// only way to reach them once they are disabled: the `thumbs` helper above finds them
1258    /// by `draggable`, and a disabled thumb is exactly the thing that is not.
1259    fn thumbs_by_position(rs: &RangeSlider<Msg>) -> Vec<&dyn Widget<Msg>> {
1260        let row = &Widget::<Msg>::children(rs)[0];
1261        let kids = row.children();
1262        vec![kids[1].as_ref(), kids[3].as_ref()]
1263    }
1264
1265    #[test]
1266    fn a_disabled_slider_is_inert_but_still_says_where_it_sits() {
1267        let dead = Slider::new(0.4).on_change(Msg::Value).enabled(false);
1268        assert!(!Widget::<Msg>::draggable(&dead), "it takes no drag");
1269        assert_eq!(
1270            Widget::on_drag(&dead, 0.9),
1271            None,
1272            "and a drag in flight does not land"
1273        );
1274        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1275        assert!(semantics.disabled, "announced as unavailable");
1276        assert_eq!(semantics.value.as_deref(), Some("40%"), "value survives");
1277    }
1278
1279    /// A slider is dragged and keyed rather than tapped, which is why milestone 322's
1280    /// guard covers those hooks: greying one out while it still answered a drag leaves it
1281    /// inert only to the gesture nobody was using on it.
1282    #[test]
1283    fn a_disabled_range_slider_takes_no_drag_no_key_and_no_focus() {
1284        let dead = RangeSlider::new(0.2, 0.8)
1285            .on_change(Msg::Range)
1286            .enabled(false);
1287        assert!(!Widget::<Msg>::draggable(&dead), "the track takes no drag");
1288        assert_eq!(Widget::on_drag(&dead, 0.5), None);
1289        for (i, thumb) in thumbs_by_position(&dead).into_iter().enumerate() {
1290            assert!(!thumb.draggable(), "thumb {i} still drags");
1291            assert!(!thumb.focusable(), "thumb {i} still takes focus");
1292            assert_eq!(thumb.on_drag_delta(20.0), None, "thumb {i} still moves");
1293            assert!(
1294                matches!(
1295                    thumb.on_key(&Key::Right {
1296                        shift: false,
1297                        word: false
1298                    }),
1299                    KeyResponse::Ignored
1300                ),
1301                "thumb {i} still answers an arrow"
1302            );
1303        }
1304        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1305        assert!(semantics.disabled);
1306        assert_eq!(
1307            semantics.value.as_deref(),
1308            Some("20%–80%"),
1309            "interval survives"
1310        );
1311    }
1312
1313    /// The track splits along the framework's one rule: the part still to travel is a
1314    /// container, the travelled part and the thumb are content on it.
1315    #[test]
1316    fn a_disabled_slider_takes_both_halves_of_the_rule() {
1317        for theme in [Theme::dark(), Theme::light()] {
1318            let mut scene = Scene::new();
1319            Widget::<Msg>::paint(
1320                &Slider::<Msg>::new(0.5).enabled(false),
1321                Rect::new(0.0, 0.0, 200.0, H),
1322                Status {
1323                    opacity: 1.0,
1324                    ..Default::default()
1325                },
1326                &theme,
1327                &mut scene,
1328            );
1329            // The crisp rectangles: the thumb's shadow is painted just before the thumb.
1330            let fills: Vec<frus_core::Color> = scene
1331                .primitives()
1332                .iter()
1333                .filter_map(|p| match p {
1334                    frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1335                    _ => None,
1336                })
1337                .collect();
1338            assert_eq!(fills[0], disabled_container(&theme), "the rail");
1339            assert_eq!(fills[1], disabled_content(&theme), "the travelled part");
1340            assert_eq!(fills[2], disabled_content(&theme), "the thumb");
1341            // Quieter means closer to the surface. Since milestone 329 both tokens are
1342            // opaque, so there is no alpha to compare — which is the point: a disabled
1343            // control flattens.
1344            let from_surface = |c: Color| {
1345                (c.r - theme.scheme.surface.r).abs()
1346                    + (c.g - theme.scheme.surface.g).abs()
1347                    + (c.b - theme.scheme.surface.b).abs()
1348            };
1349            assert!(
1350                from_surface(fills[0]) < from_surface(fills[1]),
1351                "the rail is the quieter of the two"
1352            );
1353        }
1354    }
1355    /// **A thumb stands off its track, and higher while it is held.** The reference's round
1356    /// thumb rests at 1 and rises to 6 as the press goes in, on a slider and on each of a
1357    /// range slider's two. Neither cast anything here.
1358    #[test]
1359    fn a_thumb_casts_a_shadow_that_rises_while_it_is_held() {
1360        // The heights the painted shadows stand for: a blur of two pixels a step, plus four.
1361        fn heights(widget: &dyn Widget<Msg>, bounds: Rect, press: f32, theme: &Theme) -> Vec<f32> {
1362            let status = Status {
1363                opacity: 1.0,
1364                press_progress: press,
1365                ..Default::default()
1366            };
1367            let mut scene = Scene::new();
1368            widget.paint(bounds, status, theme, &mut scene);
1369            scene
1370                .primitives()
1371                .iter()
1372                .filter_map(|p| match p {
1373                    frus_core::Primitive::Rect { blur, .. } if *blur > 0.0 => {
1374                        Some((blur - 4.0) / 2.0)
1375                    }
1376                    _ => None,
1377                })
1378                .collect()
1379        }
1380        let theme = Theme::default();
1381        let track = Rect::new(0.0, 0.0, 220.0, H);
1382        let slider = Slider::new(0.5).on_change(Msg::Value);
1383        assert_eq!(heights(&slider, track, 0.0, &theme), vec![1.0]);
1384        assert_eq!(heights(&slider, track, 1.0, &theme), vec![6.0]);
1385        assert_eq!(
1386            heights(&slider, track, 0.5, &theme),
1387            vec![3.5],
1388            "on its way up"
1389        );
1390        let grounded = Slider::new(0.5)
1391            .on_change(Msg::Value)
1392            .thumb_elevation(0.0)
1393            .pressed_thumb_elevation(0.0);
1394        assert!(heights(&grounded, track, 1.0, &theme).is_empty());
1395
1396        let mut flat = Theme::default();
1397        flat.widgets.slider.thumb_elevation = Some(0.0);
1398        flat.widgets.slider.pressed_thumb_elevation = Some(0.0);
1399        assert!(
1400            heights(&slider, track, 1.0, &flat).is_empty(),
1401            "the theme's word"
1402        );
1403        let told = Slider::new(0.5).on_change(Msg::Value).thumb_elevation(2.0);
1404        assert_eq!(
1405            heights(&told, track, 0.0, &flat),
1406            vec![2.0],
1407            "and the slider's over it"
1408        );
1409
1410        let cell = Rect::new(0.0, 0.0, THUMB, H);
1411        let range = RangeSlider::new(0.25, 0.75).on_change(Msg::Range);
1412        for thumb in thumbs(&range) {
1413            assert_eq!(heights(thumb, cell, 0.0, &theme), vec![1.0]);
1414            assert_eq!(heights(thumb, cell, 1.0, &theme), vec![6.0]);
1415        }
1416        let raised = RangeSlider::new(0.25, 0.75)
1417            .on_change(Msg::Range)
1418            .thumb_elevation(3.0);
1419        for thumb in thumbs(&raised) {
1420            assert_eq!(
1421                heights(thumb, cell, 0.0, &theme),
1422                vec![3.0],
1423                "told on the slider"
1424            );
1425        }
1426    }
1427}
1428
1429#[cfg(test)]
1430mod range_tests {
1431    use super::*;
1432    use crate::interaction::{Key, KeyResponse};
1433
1434    #[derive(Clone, Debug, PartialEq)]
1435    enum Msg {
1436        Value(f32),
1437    }
1438
1439    fn value_of(msg: Option<Msg>) -> f32 {
1440        match msg {
1441            Some(Msg::Value(v)) => v,
1442            other => panic!("expected a value, got {other:?}"),
1443        }
1444    }
1445
1446    /// The application is handed **its own** units, not a fraction it has to convert.
1447    #[test]
1448    fn a_drag_lands_in_the_caller_s_units() {
1449        let slider = Slider::new(20.0).range(20.0, 200.0).on_change(Msg::Value);
1450        assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1451        assert_eq!(value_of(Widget::on_drag(&slider, 0.5)), 110.0);
1452        assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1453        // Past the end of the track is the end of the range, not past it.
1454        assert_eq!(value_of(Widget::on_drag(&slider, 1.4)), 200.0);
1455    }
1456
1457    /// A range written backwards is sorted rather than left empty.
1458    #[test]
1459    fn a_backwards_range_is_taken_the_way_round_it_makes_sense() {
1460        let slider = Slider::new(0.0).range(200.0, 20.0).on_change(Msg::Value);
1461        assert_eq!(value_of(Widget::on_drag(&slider, 0.0)), 20.0);
1462        assert_eq!(value_of(Widget::on_drag(&slider, 1.0)), 200.0);
1463    }
1464
1465    /// A value outside the travel is held inside it, not rejected: a caller that lowers
1466    /// the ceiling under a value it already had gets the ceiling.
1467    #[test]
1468    fn a_value_outside_the_travel_is_held_inside_it() {
1469        let low = Slider::<Msg>::new(-5.0).range(0.0, 10.0);
1470        assert_eq!(low.fraction(), 0.0);
1471        let high = Slider::<Msg>::new(40.0).range(0.0, 10.0);
1472        assert_eq!(high.fraction(), 1.0);
1473    }
1474
1475    /// Steps land on the divisions, in the caller's units.
1476    #[test]
1477    fn divisions_snap_the_value() {
1478        let slider = Slider::new(0.0)
1479            .range(0.0, 100.0)
1480            .divisions(4)
1481            .on_change(Msg::Value);
1482        assert_eq!(value_of(Widget::on_drag(&slider, 0.30)), 25.0);
1483        assert_eq!(value_of(Widget::on_drag(&slider, 0.40)), 50.0);
1484        assert_eq!(value_of(Widget::on_drag(&slider, 0.99)), 100.0);
1485    }
1486
1487    /// An arrow moves one division when there are divisions, 5 % of the travel when
1488    /// there are not — and neither runs off either end.
1489    #[test]
1490    fn the_arrows_move_by_a_step() {
1491        let free = Slider::new(50.0).range(0.0, 100.0).on_change(Msg::Value);
1492        let step = |s: &Slider<Msg>, key: Key| match Widget::on_key(s, &key) {
1493            KeyResponse::Handled(msg) => value_of(msg),
1494            other => panic!("expected the key to be taken, got {other:?}"),
1495        };
1496        assert_eq!(
1497            step(
1498                &free,
1499                Key::Right {
1500                    shift: false,
1501                    word: false
1502                }
1503            ),
1504            55.0
1505        );
1506        assert_eq!(
1507            step(
1508                &free,
1509                Key::Left {
1510                    shift: false,
1511                    word: false
1512                }
1513            ),
1514            45.0
1515        );
1516
1517        let stepped = Slider::new(50.0)
1518            .range(0.0, 100.0)
1519            .divisions(4)
1520            .on_change(Msg::Value);
1521        assert_eq!(
1522            step(
1523                &stepped,
1524                Key::Right {
1525                    shift: false,
1526                    word: false
1527                }
1528            ),
1529            75.0
1530        );
1531
1532        let at_end = Slider::new(100.0).range(0.0, 100.0).on_change(Msg::Value);
1533        assert_eq!(
1534            step(
1535                &at_end,
1536                Key::Right {
1537                    shift: false,
1538                    word: false
1539                }
1540            ),
1541            100.0
1542        );
1543        assert_eq!(
1544            step(
1545                &at_end,
1546                Key::Home {
1547                    shift: false,
1548                    doc: false
1549                }
1550            ),
1551            0.0
1552        );
1553        assert_eq!(
1554            step(
1555                &at_end,
1556                Key::End {
1557                    shift: false,
1558                    doc: false
1559                }
1560            ),
1561            100.0
1562        );
1563    }
1564
1565    /// It is a keyboard control now — but only when there is somebody to tell.
1566    #[test]
1567    fn it_takes_the_focus_only_when_it_can_answer() {
1568        assert!(!Widget::<Msg>::focusable(&Slider::<Msg>::new(0.5)));
1569        assert!(Widget::focusable(&Slider::new(0.5).on_change(Msg::Value)));
1570        assert!(!Widget::focusable(
1571            &Slider::new(0.5).on_change(Msg::Value).enabled(false)
1572        ));
1573    }
1574
1575    /// A frozen slider answers no key, even one arriving from a stale focus.
1576    #[test]
1577    fn a_frozen_slider_answers_no_key() {
1578        let slider = Slider::new(0.5).on_change(Msg::Value).enabled(false);
1579        assert!(matches!(
1580            Widget::on_key(
1581                &slider,
1582                &Key::Right {
1583                    shift: false,
1584                    word: false
1585                }
1586            ),
1587            KeyResponse::Ignored
1588        ));
1589    }
1590
1591    /// A reader is told where it is set, in the units on screen.
1592    #[test]
1593    fn a_reader_is_told_the_real_value() {
1594        let plain = Slider::<Msg>::new(0.25);
1595        let s = Widget::<Msg>::semantics(&plain).expect("a slider says where it is");
1596        assert_eq!(
1597            s.value.as_deref(),
1598            Some("25%"),
1599            "a bare 0..1 reads as a share"
1600        );
1601
1602        let ranged = Slider::<Msg>::new(110.0).range(20.0, 200.0);
1603        let s = Widget::<Msg>::semantics(&ranged).expect("a slider says where it is");
1604        assert_eq!(s.range, Some((20.0, 110.0, 200.0)));
1605
1606        let priced = Slider::<Msg>::new(110.0)
1607            .range(20.0, 200.0)
1608            .value_label(|v| format!("{v} EUR"));
1609        let s = Widget::<Msg>::semantics(&priced).expect("a slider says where it is");
1610        assert_eq!(
1611            s.value.as_deref(),
1612            Some("110 EUR"),
1613            "said the caller's way when it gave a formatter"
1614        );
1615    }
1616
1617    /// The bubble's type is the reference's, and comes from the theme rather than from a
1618    /// private constant — milestone 414. The reference calls it the *value indicator* and
1619    /// sets it in `labelLarge`, which is 14 px **medium**: the weight is half the step, and
1620    /// the `TIP_SIZE: f32 = 12.0` this replaces could not have carried it at any value.
1621    #[test]
1622    fn the_value_bubble_wears_the_reference_s_step() {
1623        let slider = Slider::<Msg>::new(0.3).value_label(|v| format!("{v}"));
1624        let styles = |theme: &Theme| {
1625            let mut scene = Scene::new();
1626            Widget::<Msg>::paint(
1627                &slider,
1628                Rect::new(0.0, 0.0, 220.0, 50.0),
1629                Status {
1630                    focused: true,
1631                    ..Status::default()
1632                },
1633                theme,
1634                &mut scene,
1635            );
1636            scene
1637                .primitives()
1638                .iter()
1639                .filter_map(|p| match p {
1640                    frus_core::Primitive::Text { size, weight, .. } => {
1641                        Some((*size, weight.to_u16()))
1642                    }
1643                    _ => None,
1644                })
1645                .collect::<Vec<_>>()
1646        };
1647        let plain = Theme::default();
1648        assert_eq!(
1649            styles(&plain),
1650            vec![(
1651                plain.text.label_large.size.unwrap(),
1652                frus_core::FontWeight::Medium.to_u16()
1653            )]
1654        );
1655        // And a theme can move it, which nothing could before.
1656        let mut themed = Theme::default();
1657        themed.widgets.slider.value_indicator_text_style = Some(frus_core::TextStyle::new(25.0));
1658        assert_eq!(styles(&themed).first().map(|s| s.0), Some(25.0));
1659    }
1660
1661    /// A tooltip reserves the room above the track rather than sitting on it.
1662    #[test]
1663    fn a_tooltip_makes_the_control_taller() {
1664        let bare = Widget::<Msg>::style(&Slider::<Msg>::new(0.5)).height;
1665        let tipped =
1666            Widget::<Msg>::style(&Slider::<Msg>::new(0.5).value_label(|v| format!("{v}"))).height;
1667        assert_eq!(bare, frus_layout::Dimension::Length(H));
1668        assert_eq!(
1669            tipped,
1670            frus_layout::Dimension::Length(H + TIP_H + TIP_GAP),
1671            "the tooltip's zone is above the track, not over it"
1672        );
1673    }
1674}
1675
1676#[cfg(test)]
1677mod color_tests {
1678    use super::*;
1679    use frus_core::Primitive;
1680
1681    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
1682    const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
1683
1684    /// (rail, travelled, thumb, ring) as painted — the crisp rectangles, in order, and not
1685    /// the soft one under the thumb that is its shadow.
1686    fn painted(slider: &Slider<()>, theme: &Theme) -> (Color, Color, Color, Color) {
1687        let mut scene = Scene::new();
1688        Widget::<()>::paint(
1689            slider,
1690            Rect::new(0.0, 0.0, 220.0, H),
1691            Status {
1692                opacity: 1.0,
1693                ..Default::default()
1694            },
1695            theme,
1696            &mut scene,
1697        );
1698        let rects: Vec<(Color, Color)> = scene
1699            .primitives()
1700            .iter()
1701            .filter_map(|p| match p {
1702                Primitive::Rect {
1703                    color,
1704                    border_color,
1705                    blur,
1706                    ..
1707                } if *blur == 0.0 => Some((*color, *border_color)),
1708                _ => None,
1709            })
1710            .collect();
1711        (rects[0].0, rects[1].0, rects[2].0, rects[2].1)
1712    }
1713
1714    /// Nothing said: what it always painted.
1715    #[test]
1716    fn the_defaults_are_what_they_were() {
1717        let theme = Theme::default();
1718        let (rail, filled, thumb, ring) = painted(&Slider::<()>::new(0.5), &theme);
1719        assert_eq!(rail, theme.scheme.secondary_container);
1720        assert_eq!(filled, theme.primary);
1721        assert_eq!(thumb, Color::WHITE);
1722        assert_eq!(ring, theme.primary);
1723    }
1724
1725    /// The ring follows the travelled track unless it is named: they are one colour in
1726    /// the default scheme, and a caller who recolours the track means the accent.
1727    #[test]
1728    fn the_ring_follows_the_track_it_was_not_told_about() {
1729        let theme = Theme::default();
1730        let (_, filled, _, ring) = painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme);
1731        assert_eq!((filled, ring), (BRAND, BRAND));
1732        let (_, _, _, named) = painted(
1733            &Slider::<()>::new(0.5)
1734                .active_color(BRAND)
1735                .thumb_border_color(RAIL),
1736            &theme,
1737        );
1738        assert_eq!(named, RAIL, "unless it is named");
1739    }
1740
1741    /// The two halves of the track are separate.
1742    #[test]
1743    fn each_half_of_the_track_takes_its_own_colour() {
1744        let theme = Theme::default();
1745        let (rail, filled, thumb, _) = painted(
1746            &Slider::<()>::new(0.5)
1747                .active_color(BRAND)
1748                .inactive_color(RAIL)
1749                .thumb_color(RAIL),
1750            &theme,
1751        );
1752        assert_eq!((rail, filled, thumb), (RAIL, BRAND, RAIL));
1753    }
1754
1755    /// The theme answers when the instance does not, and loses when it does.
1756    #[test]
1757    fn the_theme_answers_and_the_instance_overrules_it() {
1758        let mut theme = Theme::default();
1759        theme.widgets.slider.active_track_color = Some(RAIL);
1760        assert_eq!(painted(&Slider::<()>::new(0.5), &theme).1, RAIL);
1761        assert_eq!(
1762            painted(&Slider::<()>::new(0.5).active_color(BRAND), &theme).1,
1763            BRAND
1764        );
1765    }
1766}