Skip to main content

frust_material/slider/
range.rs

1//! The Material 3 Expressive `RangeSlider`: two thumbs over one horizontal
2//! track, selecting a `start..=end` span. Flat or wavy, like its single-thumb
3//! sibling.
4//!
5//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
6//! `lib/components/sliders/` — `m3e_range_slider.dart`,
7//! `components/m3e_range_slider_build.dart`,
8//! `components/m3e_range_slider_track.dart`,
9//! `models/m3e_slider_range.dart`, `models/m3e_slider_range_labels.dart`
10//! (retrieved 2026-08-19).
11//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
12//!
13//! # Why a second widget
14//!
15//! Everything geometric is shared: the range track is
16//! [`SliderTrackKind::Range`] fed through the same
17//! [`core`](mod@super::core) formulas with the low thumb's fraction in
18//! `active_start_fraction`, and every paint call routes through the same
19//! helpers [`super`] uses. What cannot be shared is *state*: two thumbs need
20//! two [`Drag`] machines, an arbitration flag naming which one a drag owns, and
21//! a pair-shaped value and callback. Folding that into `SliderView` would put
22//! an `Option<f64>` second thumb on every plain slider, so the reference's own
23//! split into a second widget class is kept.
24//!
25//! # Thumb arbitration and the no-cross clamp
26//!
27//! A press picks the thumb whose *painted* position is nearer, ties going to
28//! the low thumb (`_selectThumb`'s `distStart <= distEnd`), and that choice
29//! holds for the whole drag — a pointer that runs past the other thumb keeps
30//! dragging the one it grabbed. Neither thumb can cross the other: the low
31//! thumb clamps to `min..=end`, the high one to `start..=max` before snapping
32//! (`_setThumbValue`), so they can meet but never swap. Both are pinned by this
33//! module's own tests.
34//!
35//! Two departures from the reference, both inherited from [`super`]'s core and
36//! documented there: a `Cancel` reports nothing (it may not reach app state),
37//! and a drag deduplicates against the last pair it reported rather than the
38//! app-confirmed one, since a rebuild is a frame away rather than synchronous.
39
40use std::rc::Rc;
41
42use frust::Theme;
43use frust::authoring::{
44    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
45    PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
46};
47use kurbo::Size;
48
49use super::core::{
50    DEFAULT_EXTENT, Drag, DragCtx, HANDLE_GAP, HANDLE_HEIGHT, HANDLE_WIDTH, HapticCadence,
51    HapticScheduler, PRESS_MORPH_DURATION, PRESSED_HANDLE_WIDTH, SliderGeometry, SliderTrackKind,
52    TrackMetrics, ValueSpec, accept_value,
53};
54use super::variants::{AxisMap, SliderAxis, WaveClock, WaveOverrides};
55use super::{
56    AmplitudeCurve, Overrides, Run, label_large, paint_dots, paint_thumb, paint_track,
57    paint_value_indicator, resolve_colors, wave_paint,
58};
59use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
60use crate::press::presses;
61
62/// A `start..=end` pair of slider values, `start <= end` (`M3ESliderRange`).
63///
64/// Named for the pair rather than for a Rust `Range` (which is half-open and
65/// carries iteration semantics this has none of), and kept a plain `Copy`
66/// struct so an app can hold it in state and compare it directly.
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct SliderRange {
69    /// The low thumb's value.
70    pub start: f64,
71    /// The high thumb's value.
72    pub end: f64,
73}
74
75impl SliderRange {
76    /// A range from `start` to `end`.
77    ///
78    /// Upstream asserts `start <= end` (`m3e_slider_range.dart:13`); an
79    /// inverted pair `debug_assert!`s here and otherwise orders itself, so a
80    /// shipped app degrades to the sorted pair rather than panicking or
81    /// painting an inside-out track.
82    pub fn new(start: f64, end: f64) -> Self {
83        debug_assert!(start <= end, "a slider range's start must be <= its end");
84        Self {
85            start: start.min(end),
86            end: start.max(end),
87        }
88    }
89}
90
91/// Clamp `value` into `spec`'s bounds — the identity on every in-bounds,
92/// finite pair, never quantizing onto the division grid. The adoption-step
93/// counterpart to [`ValueSpec::snap`]: `snap` resolves a *drag* onto a step,
94/// while this is what [`normalize_range`] uses to keep a controlled
95/// component's already-app-chosen value exactly as supplied.
96fn clamp_into_bounds(spec: &ValueSpec, value: f64) -> f64 {
97    if !(spec.min.is_finite() && spec.max.is_finite() && spec.max > spec.min) {
98        return if spec.min.is_finite() { spec.min } else { 0.0 };
99    }
100    if value.is_nan() {
101        return spec.min;
102    }
103    value.clamp(spec.min, spec.max)
104}
105
106/// Clamp `values` into `spec`'s current bounds and re-sort the pair, the
107/// build/rebuild adoption step that keeps `RangeSliderWidget::accept`'s
108/// `live.start`/`live.end` provably within `spec`'s own bounds.
109///
110/// **Clamps, never snaps.** [`clamp_into_bounds`] is the identity on every
111/// in-bounds, finite pair — an off-grid app value (`SliderRange::new(30.0,
112/// 70.0)` against `.range(0.0, 100.0).divisions(4)`) must round-trip through
113/// adoption unchanged, not quantize onto the division grid the way
114/// [`ValueSpec::snap`] would: this is a controlled component's *adoption*
115/// step, not a drag's *resolution* step, and snapping here silently
116/// desynced the thumbs from the raw-value indicator labels and never
117/// reported the correction through `on_changed`.
118///
119/// Without this clamp, an app-supplied pair the widget has not yet seen
120/// against a freshly widened/narrowed `range()` — `range_slider(SliderRange
121/// ::new(0.0, 0.0)).range(20.0, 80.0)`, or a persisted pair replayed against
122/// fresh bounds — would otherwise reach `accept`'s no-cross window with
123/// `live.end < spec.min`, an inverted bound `f64::clamp` panics on.
124/// [`clamp_into_bounds`] already degrades a degenerate `spec` safely, so
125/// this can never itself panic; see [`super::core`]'s Clamp Discipline
126/// section.
127fn normalize_range(spec: &ValueSpec, values: SliderRange) -> SliderRange {
128    let start = clamp_into_bounds(spec, values.start);
129    let end = clamp_into_bounds(spec, values.end);
130    SliderRange {
131        start: start.min(end),
132        end: start.max(end),
133    }
134}
135
136/// Which thumb a drag owns (`_M3ERangeThumb`).
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138enum Thumb {
139    Start,
140    End,
141}
142
143/// A view-held, typed range callback (erased on build).
144type OnRange<State> = Rc<dyn Fn(&mut State, SliderRange)>;
145
146/// A declarative M3E range slider. See the [module docs](self).
147pub struct RangeSliderView<State: 'static> {
148    values: SliderRange,
149    spec: ValueSpec,
150    wavy: bool,
151    wave: WaveOverrides,
152    amplitude_for_progress: Option<AmplitudeCurve>,
153    enabled: bool,
154    labels: Option<(String, String)>,
155    haptic: HapticSignal,
156    overrides: Overrides,
157    on_changed: OnRange<State>,
158    on_change_end: Option<OnRange<State>>,
159    /// See [`super::SliderView`]'s field of the same name — the counting seam
160    /// this module's tests use in place of `MaterialHaptics`' set-once hook.
161    haptic_sink: fn(HapticSignal),
162}
163
164/// Create a range slider at `values` that fires `on_changed(state, requested)`
165/// as either thumb is dragged (`M3ERangeSlider`).
166///
167/// The value range is `0.0..=1.0` until [`RangeSliderView::range`] widens it,
168/// and the slider is continuous until [`RangeSliderView::divisions`] steps it.
169pub fn range_slider<State: 'static, F: Fn(&mut State, SliderRange) + 'static>(
170    values: SliderRange,
171    on_changed: F,
172) -> RangeSliderView<State> {
173    RangeSliderView {
174        values,
175        spec: ValueSpec::default(),
176        wavy: false,
177        wave: WaveOverrides::default(),
178        amplitude_for_progress: None,
179        enabled: true,
180        labels: None,
181        haptic: HapticSignal::None,
182        overrides: Overrides::default(),
183        on_changed: Rc::new(on_changed),
184        on_change_end: None,
185        haptic_sink: MaterialHaptics::fire,
186    }
187}
188
189/// Create a range slider whose active span is a traveling sine wave
190/// (`M3ERangeSlider.wavy`). Its amplitude ramp reads the *span* between the
191/// thumbs as progress, not either thumb's own value
192/// (`_M3ERangeSliderState._amplitudeFactor`).
193pub fn wavy_range_slider<State: 'static, F: Fn(&mut State, SliderRange) + 'static>(
194    values: SliderRange,
195    on_changed: F,
196) -> RangeSliderView<State> {
197    RangeSliderView {
198        wavy: true,
199        ..range_slider(values, on_changed)
200    }
201}
202
203impl<State: 'static> RangeSliderView<State> {
204    /// Set the value bounds (`M3ERangeSlider.min`/`max`, defaulting to
205    /// `0..=1`). An inverted or empty range `debug_assert!`s and otherwise
206    /// degrades to a slider pinned at its minimum, as [`super::SliderView`].
207    pub fn range(mut self, min: f64, max: f64) -> Self {
208        debug_assert!(max > min, "a slider's max must be greater than its min");
209        self.spec.min = min;
210        self.spec.max = max;
211        self
212    }
213
214    /// Step the slider into `divisions` equal intervals
215    /// (`M3ERangeSlider.divisions`). `0` is treated as continuous.
216    pub fn divisions(mut self, divisions: u32) -> Self {
217        self.spec.divisions = Some(divisions);
218        self
219    }
220
221    /// Fire `on_change_end(state, values)` when an interaction settles
222    /// (`M3ERangeSlider.onChangeEnd`). A cancelled drag never fires it.
223    pub fn on_change_end<F: Fn(&mut State, SliderRange) + 'static>(
224        mut self,
225        on_change_end: F,
226    ) -> Self {
227        self.on_change_end = Some(Rc::new(on_change_end));
228        self
229    }
230
231    /// Override both value-indicator labels (`M3ERangeSlider.labels`); the one
232    /// shown is the dragged thumb's.
233    pub fn labels(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
234        self.labels = Some((start.into(), end.into()));
235        self
236    }
237
238    /// Gate interaction and switch to the disabled colour table
239    /// (`M3ERangeSlider.enabled`).
240    pub fn enabled(mut self, enabled: bool) -> Self {
241        self.enabled = enabled;
242        self
243    }
244
245    /// Opt into haptic feedback on value changes (`M3ERangeSlider.haptic`).
246    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
247        self.haptic = haptic;
248        self
249    }
250
251    /// Length of one full wave cycle, in logical px
252    /// (`M3ERangeSlider.wavelength`). Inert unless the slider is wavy.
253    pub fn wavelength(mut self, wavelength: f64) -> Self {
254        self.wave.wavelength = Some(wavelength);
255        self
256    }
257
258    /// How fast the wave travels, in logical px per second
259    /// (`M3ERangeSlider.waveSpeed`). Inert unless the slider is wavy.
260    pub fn wave_speed(mut self, wave_speed: f64) -> Self {
261        self.wave.wave_speed = Some(wave_speed);
262        self
263    }
264
265    /// Fix the wave's amplitude factor (`M3ERangeSlider.amplitude`). Inert
266    /// unless the slider is wavy; outranked by
267    /// [`Self::amplitude_for_progress`].
268    pub fn amplitude(mut self, amplitude: f64) -> Self {
269        self.wave.amplitude = Some(amplitude);
270        self
271    }
272
273    /// Drive the wave's amplitude factor from the span between the thumbs
274    /// (`M3ERangeSlider.amplitudeForProgress`). Inert unless wavy.
275    pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
276        self.amplitude_for_progress = Some(Rc::new(curve));
277        self
278    }
279
280    /// Thickness of both track segments, in logical px
281    /// (`M3ERangeSlider.trackThickness`).
282    pub fn track_thickness(mut self, thickness: f64) -> Self {
283        self.overrides.track_thickness = Some(thickness);
284        self
285    }
286
287    /// Outer corner radius of both track segments, in logical px
288    /// (`M3ERangeSlider.cornerRadius`).
289    pub fn corner_radius(mut self, radius: f64) -> Self {
290        self.overrides.corner_radius = Some(radius);
291        self
292    }
293
294    /// Length of each thumb along the cross axis, in logical px
295    /// (`M3ERangeSlider.thumbLength`).
296    pub fn thumb_length(mut self, length: f64) -> Self {
297        self.overrides.thumb_length = Some(length);
298        self
299    }
300
301    /// Diameter of the stop/tick markers, in logical px
302    /// (`M3ERangeSlider.dotSize`).
303    pub fn dot_size(mut self, size: f64) -> Self {
304        self.overrides.dot_size = Some(size);
305        self
306    }
307
308    /// Clear space between each track end and the outer edge of its marker, in
309    /// logical px (`M3ERangeSlider.dotSpacing`).
310    pub fn dot_spacing(mut self, spacing: f64) -> Self {
311        self.overrides.dot_spacing = Some(spacing);
312        self
313    }
314
315    /// Route fired haptics somewhere other than [`MaterialHaptics`] — the
316    /// counting seam this module's own tests use.
317    #[cfg(test)]
318    fn haptic_sink(mut self, sink: fn(HapticSignal)) -> Self {
319        self.haptic_sink = sink;
320        self
321    }
322
323    /// The value indicator's text for `thumb` (`_indicatorLabel`).
324    fn indicator_text(&self, thumb: Thumb) -> String {
325        if let Some((start, end)) = &self.labels {
326            return match thumb {
327                Thumb::Start => start.clone(),
328                Thumb::End => end.clone(),
329            };
330        }
331        let value = match thumb {
332            Thumb::Start => self.values.start,
333            Thumb::End => self.values.end,
334        };
335        if self.spec.divisions.is_some() {
336            format!("{}", value.round())
337        } else {
338            format!("{value:.2}")
339        }
340    }
341}
342
343/// The retained widget for a [`RangeSliderView`].
344pub struct RangeSliderWidget {
345    /// The app-confirmed pair (source of truth; adopted on `rebuild`).
346    values: SliderRange,
347    spec: ValueSpec,
348    ticks: Vec<f64>,
349    wavy: bool,
350    wave: WaveOverrides,
351    amplitude_for_progress: Option<AmplitudeCurve>,
352    wave_clock: WaveClock,
353    enabled: bool,
354    overrides: Overrides,
355    haptic: HapticSignal,
356    /// One machine per thumb, and which of them the live drag owns. Both
357    /// machines see every phase so a disarm is never missed; only the active
358    /// one resolves a value.
359    drag: Drag,
360    active: Option<Thumb>,
361    interaction: InteractionState,
362    /// The last pair reported this drag — what a change deduplicates and
363    /// no-cross-clamps against until `rebuild` confirms one.
364    last_reported: Option<SliderRange>,
365    /// `0.0` resting → `1.0` pressed: the handle width compression, shared by
366    /// both thumbs (only one is ever pressed).
367    press_anim: frust::AnimationController,
368    press_target: bool,
369    haptics: HapticScheduler,
370    haptic_sink: fn(HapticSignal),
371    /// The dragged thumb's shaped label, plus the thumb it was shaped for.
372    indicator: Run,
373    indicator_thumb: Thumb,
374    /// Both labels, kept so a press can shape the right one without waiting
375    /// for the app to hand one down.
376    labels: (String, String),
377    on_changed: frust::authoring::ErasedArgCallback<SliderRange>,
378    on_change_end: Option<frust::authoring::ErasedArgCallback<SliderRange>>,
379}
380
381impl<State: 'static> View<State> for RangeSliderView<State> {
382    type Element = RangeSliderWidget;
383
384    fn build(&self, _ctx: &mut BuildCtx<'_>) -> RangeSliderWidget {
385        let labels = (
386            self.indicator_text(Thumb::Start),
387            self.indicator_text(Thumb::End),
388        );
389        let mut indicator = Run::new();
390        indicator.set_content(&labels.1);
391        RangeSliderWidget {
392            values: normalize_range(&self.spec, self.values),
393            spec: self.spec,
394            ticks: self.spec.tick_fractions(),
395            wavy: self.wavy,
396            wave: self.wave,
397            amplitude_for_progress: self.amplitude_for_progress.clone(),
398            wave_clock: WaveClock::new(),
399            enabled: self.enabled,
400            overrides: self.overrides,
401            haptic: self.haptic,
402            drag: Drag::default(),
403            active: None,
404            interaction: InteractionState::new(),
405            last_reported: None,
406            press_anim: frust::AnimationController::new(PRESS_MORPH_DURATION)
407                .with_curve(frust::Curve::EaseOut),
408            press_target: false,
409            haptics: HapticScheduler::default(),
410            haptic_sink: self.haptic_sink,
411            indicator,
412            indicator_thumb: Thumb::End,
413            labels,
414            on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
415            on_change_end: self
416                .on_change_end
417                .as_ref()
418                .map(frust::authoring::erase_callback_arg),
419        }
420    }
421
422    fn rebuild(
423        &self,
424        prev: &Self,
425        element: &mut RangeSliderWidget,
426        _ctx: &mut BuildCtx<'_>,
427    ) -> ChangeFlags {
428        element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
429        element.on_change_end = self
430            .on_change_end
431            .as_ref()
432            .map(frust::authoring::erase_callback_arg);
433        element.haptic_sink = self.haptic_sink;
434        element.amplitude_for_progress = self.amplitude_for_progress.clone();
435        let mut flags = ChangeFlags::NONE;
436
437        let labels = (
438            self.indicator_text(Thumb::Start),
439            self.indicator_text(Thumb::End),
440        );
441        if element.labels != labels {
442            element.labels = labels;
443            element.sync_indicator();
444            // The shown label is shaped at layout time, so a changed one
445            // forces a relayout rather than a bare repaint.
446            flags |= ChangeFlags::LAYOUT;
447        }
448        if prev.spec != self.spec {
449            element.spec = self.spec;
450            element.ticks = self.spec.tick_fractions();
451            flags |= ChangeFlags::PAINT;
452        }
453        if prev.values != self.values || prev.spec != self.spec {
454            // Re-normalize on either edge, not just a `values` write: a
455            // widened/narrowed `range()` call can leave a previously-adopted
456            // pair outside the new bounds even when the app's own `values`
457            // field never moved (`normalize_range`'s doc comment has the
458            // full repro).
459            element.values = normalize_range(&self.spec, self.values);
460            element.last_reported = None;
461            flags |= ChangeFlags::PAINT;
462        }
463        if prev.enabled != self.enabled {
464            element.enabled = self.enabled;
465            if !self.enabled {
466                // Disabling mid-drag disarms the machine, so a still-captured
467                // pointer cannot keep reporting values.
468                element.drag = Drag::default();
469                element.active = None;
470                element.interaction.set_pressed(false);
471                element.interaction.set_dragged(false);
472                element.last_reported = None;
473            }
474            flags |= ChangeFlags::PAINT;
475        }
476        if prev.haptic != self.haptic {
477            element.haptic = self.haptic;
478        }
479        if prev.wavy != self.wavy || prev.wave != self.wave {
480            element.wavy = self.wavy;
481            element.wave = self.wave;
482            flags |= ChangeFlags::PAINT;
483        }
484        if prev.overrides != self.overrides {
485            element.overrides = self.overrides;
486            flags |= ChangeFlags::LAYOUT;
487        }
488        flags
489    }
490}
491
492impl RangeSliderWidget {
493    /// The pair a drag resolves against: the last one it reported, or the
494    /// app-confirmed one.
495    fn live(&self) -> SliderRange {
496        self.last_reported.unwrap_or(self.values)
497    }
498
499    /// Both thumbs' painter-space positions over a track of `extent`.
500    fn thumb_positions(&self, extent: f64) -> (f64, f64) {
501        let values = self.live();
502        (
503            self.spec.fraction(values.start) * extent,
504            self.spec.fraction(values.end) * extent,
505        )
506    }
507
508    /// The thumb a press at `primary` grabs: the nearer of the two, ties to
509    /// the low thumb (`_selectThumb`).
510    fn nearer_thumb(&self, primary: f64, extent: f64) -> Thumb {
511        let (start, end) = self.thumb_positions(extent);
512        if (primary - start).abs() <= (primary - end).abs() {
513            Thumb::Start
514        } else {
515            Thumb::End
516        }
517    }
518
519    /// Apply a raw value to `thumb`: no-cross clamp, snap, then the
520    /// deduplication test (`_setThumbValue`). Returns the pair to report, or
521    /// `None` when there is nothing new — including when the no-cross window
522    /// or `raw` itself cannot be trusted (see below).
523    ///
524    /// The no-cross window is ordered with `min()`/`max()` right before its
525    /// clamp rather than trusted to already be ordered — `live.end < spec.min`
526    /// (an unnormalized `live`) would otherwise hand `f64::clamp` an inverted
527    /// bound and panic. `build`/`rebuild`'s `normalize_range` keeps `live`
528    /// provably in-bounds against a *finite* `spec`, but `self.spec` here is
529    /// the raw, unnormalized builder value: a directly non-finite bound
530    /// (`.range(f64::NAN, 100.0)`) reaches this window as-is. `f64::min`/
531    /// `f64::max` already return the non-NaN side when only one operand is
532    /// NaN, so a single-NaN bound never panics the clamp below — it silently
533    /// collapses the window to a single point instead, which is its own
534    /// defect (a value clamped against a spec that was never well-formed
535    /// isn't one worth reporting), not the "both sides NaN at once" case a
536    /// total-by-construction argument would need to rule out. So the ordered
537    /// window is checked finite before it is ever clamped against, and a NaN
538    /// `raw` is rejected the same way — either makes the drag a no-op
539    /// (`None`), never a panic and never a NaN into app state.
540    fn accept(&self, thumb: Thumb, raw: f64) -> Option<SliderRange> {
541        let live = self.live();
542        let (clamped, current) = match thumb {
543            // The clamp is what keeps the thumbs from swapping: each stops at
544            // the other rather than passing it.
545            Thumb::Start => {
546                let (lo, hi) = (self.spec.min, live.end);
547                let (wlo, whi) = (lo.min(hi), lo.max(hi));
548                if !(wlo.is_finite() && whi.is_finite()) || raw.is_nan() {
549                    return None;
550                }
551                (raw.clamp(wlo, whi), live.start)
552            }
553            Thumb::End => {
554                let (lo, hi) = (live.start, self.spec.max);
555                let (wlo, whi) = (lo.min(hi), lo.max(hi));
556                if !(wlo.is_finite() && whi.is_finite()) || raw.is_nan() {
557                    return None;
558                }
559                (raw.clamp(wlo, whi), live.end)
560            }
561        };
562        let next = accept_value(&self.spec, current, clamped)?;
563        Some(match thumb {
564            Thumb::Start => SliderRange {
565                start: next,
566                end: live.end,
567            },
568            Thumb::End => SliderRange {
569                start: live.start,
570                end: next,
571            },
572        })
573    }
574
575    /// Feed one pointer primary to the active thumb, reporting through
576    /// `on_changed` if it moves.
577    fn update(&mut self, primary: f64, extent: f64, ctx: &mut EventCtx) {
578        let Some(thumb) = self.active else {
579            return;
580        };
581        let raw = self.spec.value_from_offset(primary, extent, false);
582        let Some(values) = self.accept(thumb, raw) else {
583            return;
584        };
585        if self.haptic != HapticSignal::None {
586            let cadence = if self.spec.divisions.is_some() {
587                HapticCadence::Immediate
588            } else {
589                HapticCadence::Throttled
590            };
591            self.haptics.queue(self.haptic, cadence);
592        }
593        self.last_reported = Some(values);
594        (self.on_changed)(ctx, values);
595    }
596
597    /// Point the cached indicator run at whichever thumb is being dragged.
598    fn sync_indicator(&mut self) {
599        let text = match self.indicator_thumb {
600            Thumb::Start => &self.labels.0,
601            Thumb::End => &self.labels.1,
602        };
603        let text = text.clone();
604        self.indicator.set_content(&text);
605    }
606
607    /// This frame's amplitude factor. Upstream reads the *span* between the
608    /// thumbs as progress, so a narrow selection flattens the wave exactly as
609    /// a nearly-empty single-thumb track does.
610    fn amplitude_factor(&self) -> f64 {
611        let values = self.live();
612        let progress =
613            (self.spec.fraction(values.end) - self.spec.fraction(values.start)).clamp(0.0, 1.0);
614        super::variants::amplitude_factor(
615            progress,
616            self.wave.amplitude,
617            self.amplitude_for_progress.as_deref(),
618        )
619    }
620
621    /// The drag machine's per-event inputs.
622    ///
623    /// This widget takes the machine for its *flags* (pressed/dragging/
624    /// capture) and resolves the value itself in [`Self::update`], because
625    /// which thumb a pointer moves — and what it clamps against — is the range
626    /// slider's own call. `current` is therefore only the baseline for a
627    /// `DragOutcome::value` this widget discards.
628    fn drag_ctx(&self, extent: f64) -> DragCtx {
629        DragCtx {
630            spec: self.spec,
631            current: self.live().end,
632            extent,
633            reverse: false,
634        }
635    }
636}
637
638impl Widget for RangeSliderWidget {
639    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
640        let style = label_large(Theme::from_layout_ctx(ctx));
641        self.indicator.layout(ctx, &style);
642        let width = if bc.max().width.is_finite() {
643            bc.max().width
644        } else {
645            DEFAULT_EXTENT
646        };
647        let height = HANDLE_HEIGHT.max(self.overrides.thumb_length());
648        bc.constrain(Size::new(width, height))
649    }
650
651    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
652        let theme = Theme::from_paint_ctx(ctx);
653        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
654        let colors = resolve_colors(theme, self.enabled);
655        let focused = ctx.has_focus();
656        let o = ctx.origin();
657        // Horizontal only: Compose has no vertical range slider
658        // (`m3e_range_slider.dart:33`), so the axis map is here purely to keep
659        // one placement path for every slider in this module tree.
660        let map = AxisMap::new(SliderAxis::Horizontal, ctx.size(), false);
661        let extent = map.extent();
662
663        let values = self.live();
664        let (start_primary, end_primary) = self.thumb_positions(extent);
665        let handle_thickness = if self.drag.pressed {
666            PRESSED_HANDLE_WIDTH
667        } else {
668            HANDLE_WIDTH
669        };
670        let track_thickness = self.overrides.track_thickness();
671        let geometry = SliderGeometry {
672            extent,
673            active_start_fraction: self.spec.fraction(values.start),
674            active_end_fraction: self.spec.fraction(values.end),
675            reverse: false,
676            kind: SliderTrackKind::Range,
677            handle_thickness,
678            handle_gap: HANDLE_GAP
679                + if focused {
680                    super::core::FOCUS_HANDLE_GAP_EXTRA
681                } else {
682                    0.0
683                },
684            track_thickness,
685            corner_radius: self.overrides.corner_radius(),
686        };
687
688        if self.drag.pressed != self.press_target {
689            self.press_anim
690                .animate_to(if self.drag.pressed { 1.0 } else { 0.0 });
691            self.press_target = self.drag.pressed;
692        }
693        if self.press_anim.advance(ctx.frame_time()) {
694            ctx.request_frame();
695        }
696        let wave = self.wavy.then(|| {
697            let factor = self.amplitude_factor();
698            wave_paint(&mut self.wave_clock, self.wave, factor, ctx, reduce_motion)
699        });
700        if let Some(signal) = self.haptics.take(ctx.frame_time()) {
701            (self.haptic_sink)(signal);
702        }
703
704        if let Some(metrics) = TrackMetrics::resolve(&geometry) {
705            paint_track(scene, o, &map, &metrics, track_thickness, wave, &colors);
706            let spec = super::core::DotSpec {
707                stop_size: self.overrides.dot_size(),
708                tick_size: self.overrides.tick_size(),
709                edge_inset: self.overrides.dot_spacing(),
710            };
711            paint_dots(scene, o, &map, &metrics, &self.ticks, &spec, &colors);
712        }
713
714        // Only the dragged thumb compresses; the idle one keeps its resting
715        // width (`m3e_range_slider_build.dart:249-268`'s per-thumb `pressed`).
716        let pressed_thickness =
717            HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * self.press_anim.value_clamped();
718        let length = self.overrides.thumb_length();
719        let overlay = self.interaction.resolve_opacity();
720        // The focus outline follows upstream's `_keyboardThumb` — the dragged
721        // thumb, or the high one when nothing is being dragged.
722        let focus_thumb = self.active.unwrap_or(Thumb::End);
723        for (thumb, primary) in [(Thumb::Start, start_primary), (Thumb::End, end_primary)] {
724            let dragged = self.active == Some(thumb);
725            let thickness = if dragged {
726                pressed_thickness
727            } else {
728                HANDLE_WIDTH
729            };
730            paint_thumb(
731                scene,
732                o,
733                map.place_thumb(primary, thickness, length),
734                focused && focus_thumb == thumb,
735                if dragged { overlay } else { 0.0 },
736                &colors,
737            );
738        }
739
740        if self.drag.pressed {
741            let primary = match self.indicator_thumb {
742                Thumb::Start => start_primary,
743                Thumb::End => end_primary,
744            };
745            paint_value_indicator(scene, o, &map, &self.indicator, primary, &colors);
746        }
747    }
748
749    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
750        let InputEvent::Pointer(p) = event else {
751            return EventResult::Ignored;
752        };
753        let extent = ctx.size().width;
754        let primary = p.position.x;
755        match p.phase {
756            PointerPhase::Down => {
757                if !self.enabled || !presses(p) {
758                    return EventResult::Ignored;
759                }
760                // Arbitrate first: the machine's own `down` reports nothing
761                // here, since which thumb it moves is this widget's call.
762                let thumb = self.nearer_thumb(primary, extent);
763                self.active = Some(thumb);
764                self.indicator_thumb = thumb;
765                self.sync_indicator();
766                let out = self.drag.down(primary, &self.drag_ctx(extent));
767                debug_assert!(out.capture, "a press always opens a capture");
768                ctx.capture_pointer();
769                self.update(primary, extent, ctx);
770                self.settle(ctx, false)
771            }
772            PointerPhase::Move => {
773                if !self.drag.moved(primary, &self.drag_ctx(extent)).handled {
774                    return EventResult::Ignored;
775                }
776                self.update(primary, extent, ctx);
777                self.settle(ctx, false)
778            }
779            PointerPhase::Up => {
780                if !self.drag.up().handled {
781                    return EventResult::Ignored;
782                }
783                self.active = None;
784                self.settle(ctx, true)
785            }
786            PointerPhase::Cancel => {
787                // No callback, no state reach: see `core::Drag::cancel`.
788                if !self.drag.cancel().handled {
789                    return EventResult::Ignored;
790                }
791                self.active = None;
792                self.settle(ctx, false)
793            }
794        }
795    }
796
797    fn semantics(&self, ctx: &mut SemanticsCtx) {
798        // Two thumbs, one node: no platform role carries a pair, so the node
799        // reports the track's own bounds with the high thumb as its numeric
800        // value, and announces the pair as one string — upstream's own
801        // fallback (`m3e_range_slider.dart:298-300`).
802        ctx.push_node(Role::Slider, |node| {
803            node.set_numeric_value(self.values.end);
804            node.set_min_numeric_value(self.spec.min);
805            node.set_max_numeric_value(self.spec.max);
806            node.set_value(format!("{} – {}", self.values.start, self.values.end));
807        });
808    }
809}
810
811impl RangeSliderWidget {
812    /// Mirror the drag flags into the interaction model, owe `on_change_end`
813    /// when the interaction settled, and request the follow-up frame.
814    fn settle(&mut self, ctx: &mut EventCtx, ended: bool) -> EventResult {
815        if ended && let Some(on_change_end) = &mut self.on_change_end {
816            let settled = self.last_reported.unwrap_or(self.values);
817            on_change_end(ctx, settled);
818        }
819        self.interaction.set_pressed(self.drag.pressed);
820        self.interaction.set_dragged(self.drag.dragging);
821        ctx.request_redraw();
822        EventResult::Handled
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use frust::FrameTime;
830    use frust::authoring::scene::GlyphRun;
831    use frust::authoring::text::TextContext;
832    use frust::authoring::{CornerRadii, PointerButton, PointerEvent};
833    use kurbo::{BezPath, Point};
834    use peniko::{Brush, Color};
835    use std::any::Any;
836    use std::sync::Mutex;
837
838    const WIDTH: f64 = 200.0;
839    const SIZE: Size = Size::new(WIDTH, HANDLE_HEIGHT);
840
841    #[derive(Default)]
842    struct Val {
843        changes: Vec<SliderRange>,
844        ends: Vec<SliderRange>,
845    }
846
847    fn view(start: f64, end: f64) -> RangeSliderView<Val> {
848        range_slider::<Val, _>(
849            SliderRange::new(start, end),
850            |s: &mut Val, v: SliderRange| {
851                s.changes.push(v);
852            },
853        )
854        .on_change_end(|s: &mut Val, v: SliderRange| s.ends.push(v))
855    }
856
857    fn build(view: &RangeSliderView<Val>) -> RangeSliderWidget {
858        let mut counter = 0u64;
859        View::<Val>::build(view, &mut BuildCtx::new(&mut counter))
860    }
861
862    fn widget(start: f64, end: f64) -> RangeSliderWidget {
863        build(&view(start, end))
864    }
865
866    fn ev(phase: PointerPhase, x: f64) -> InputEvent {
867        InputEvent::Pointer(PointerEvent {
868            phase,
869            position: Point::new(x, HANDLE_HEIGHT / 2.0),
870            button: PointerButton::Primary,
871        })
872    }
873
874    fn dispatch(w: &mut RangeSliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
875        let state_any: &mut dyn Any = state;
876        let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
877        w.event(&mut ctx, event)
878    }
879
880    #[derive(Default)]
881    struct Recorder {
882        rrects: Vec<(Point, Size, f64, Color)>,
883        radii: Vec<(Point, Size, CornerRadii, Color)>,
884        strokes: Vec<(f64, Color)>,
885        runs: Vec<Point>,
886    }
887
888    impl PaintScene for Recorder {
889        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
890        fn draw_text(&mut self, _o: Point, _t: &str) {}
891        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
892            self.rrects.push((o, s, radius, color));
893        }
894        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
895            self.radii.push((o, s, radii, color));
896        }
897        fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
898            let color = match brush {
899                Brush::Solid(c) => *c,
900                _ => Color::TRANSPARENT,
901            };
902            self.strokes.push((width, color));
903        }
904        fn draw_glyph_run(&mut self, run: GlyphRun) {
905            let t = run.transform.translation();
906            self.runs.push(Point::new(t.x, t.y));
907        }
908    }
909
910    fn layout(w: &mut RangeSliderWidget) -> Size {
911        let mut tcx = TextContext::new();
912        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
913        w.layout(&mut ctx, &BoxConstraints::loose(SIZE))
914    }
915
916    fn paint(w: &mut RangeSliderWidget) -> Recorder {
917        let mut rec = Recorder::default();
918        let mut ctx = PaintCtx::new(Point::ZERO, SIZE);
919        w.paint(&mut ctx, &mut rec);
920        rec
921    }
922
923    // -- arbitration --------------------------------------------------------
924
925    #[test]
926    fn a_press_grabs_the_nearer_thumb_and_keeps_it_for_the_whole_drag() {
927        let mut w = widget(0.25, 0.75);
928        let mut state = Val::default();
929
930        // 60px is nearer the low thumb (at 50) than the high one (at 150).
931        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
932        assert_eq!(w.active, Some(Thumb::Start));
933        assert_eq!(state.changes, vec![SliderRange::new(0.3, 0.75)]);
934
935        // Dragging past the *other* thumb's resting position keeps the grab:
936        // the low thumb simply stops where the high one is.
937        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 180.0));
938        assert_eq!(
939            w.active,
940            Some(Thumb::Start),
941            "the grab survives the crossing"
942        );
943        assert_eq!(
944            state.changes.last(),
945            Some(&SliderRange::new(0.75, 0.75)),
946            "clamped to the high thumb, never past it"
947        );
948
949        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 180.0));
950        assert_eq!(w.active, None);
951        assert_eq!(state.ends, vec![SliderRange::new(0.75, 0.75)]);
952    }
953
954    #[test]
955    fn a_value_pair_outside_a_freshly_widened_range_normalizes_instead_of_panicking() {
956        // The exact repro shape from the review finding: values sit outside
957        // a `range()` call that only widens the bounds afterward. Before the
958        // fix, `accept`'s no-cross window (`spec.min..=live.end` /
959        // `live.start..=spec.max`) would be handed an inverted bound
960        // (`live.end < spec.min`) on the very first press — an `f64::clamp`
961        // panic, not a catchable error.
962        let mut w = build(&view(0.0, 90.0).range(20.0, 80.0));
963        assert_eq!(
964            w.values,
965            SliderRange::new(20.0, 80.0),
966            "adoption clamps each side into the new bounds"
967        );
968        let mut state = Val::default();
969
970        // Press+drag the low thumb (nearer the left edge, position 0).
971        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0));
972        assert_eq!(w.active, Some(Thumb::Start));
973        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 60.0));
974        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 60.0));
975        assert!(
976            state
977                .changes
978                .iter()
979                .all(|v| v.start >= 20.0 && v.start <= v.end),
980            "{:?}",
981            state.changes
982        );
983
984        // Press+drag the high thumb (nearer the right edge, position 200)
985        // from a fresh widget over the same out-of-bounds pair.
986        let mut w = build(&view(0.0, 90.0).range(20.0, 80.0));
987        let mut state = Val::default();
988        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 195.0));
989        assert_eq!(w.active, Some(Thumb::End));
990        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 150.0));
991        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0));
992        assert!(
993            state
994                .changes
995                .iter()
996                .all(|v| v.end <= 80.0 && v.end >= v.start),
997            "{:?}",
998            state.changes
999        );
1000    }
1001
1002    #[test]
1003    fn a_rebuilds_narrowed_bounds_renormalize_a_stale_out_of_bounds_pair() {
1004        // "A persisted pair vs fresh bounds": the app's own `values` field
1005        // never changes across the rebuild, only `range()` does — the
1006        // narrower `if prev.values != self.values` check alone would miss
1007        // this edge and leave `element.values` stale and out of the new
1008        // bounds, an unnormalized `live` the next press's `accept` call
1009        // would panic on.
1010        let mut counter = 0u64;
1011        let prev = view(0.0, 90.0).range(0.0, 100.0);
1012        let mut w = build(&prev);
1013        assert_eq!(w.values, SliderRange::new(0.0, 90.0));
1014
1015        let next = view(0.0, 90.0).range(20.0, 80.0);
1016        let flags = View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1017        assert_eq!(w.values, SliderRange::new(20.0, 80.0));
1018        assert!(flags.needs_paint());
1019
1020        // The renormalized pair presses and drags without panicking.
1021        let mut state = Val::default();
1022        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0));
1023        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0));
1024    }
1025
1026    // -- normalize_range / clamp_into_bounds ---------------------------------
1027
1028    #[test]
1029    fn an_in_bounds_off_grid_pair_survives_adoption_verbatim() {
1030        // `normalize_range` must clamp into bounds, never snap onto the
1031        // division grid: 30/70 sit strictly inside `.range(0.0, 100.0)
1032        // .divisions(4)`'s bounds but off its 25-wide grid
1033        // (0/25/50/75/100) — snapping would silently turn this into 25/75,
1034        // desyncing the thumbs from the raw-value indicator labels with no
1035        // `on_changed` report.
1036        let mut w = build(&view(30.0, 70.0).range(0.0, 100.0).divisions(4));
1037        assert_eq!(
1038            w.values,
1039            SliderRange::new(30.0, 70.0),
1040            "an in-bounds off-grid pair must round-trip through adoption verbatim"
1041        );
1042
1043        layout(&mut w);
1044        let rec = paint(&mut w);
1045        let thumbs: Vec<&(Point, Size, f64, Color)> = rec
1046            .rrects
1047            .iter()
1048            .filter(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1049            .collect();
1050        assert_eq!(thumbs.len(), 2);
1051        // fraction(30) over 0..100 = 0.3 of the 200px track = 60.0;
1052        // fraction(70) = 0.7 = 140.0 — the un-snapped painted positions.
1053        assert_eq!(thumbs[0].0.x, 60.0 - HANDLE_WIDTH / 2.0, "low thumb at 30");
1054        assert_eq!(
1055            thumbs[1].0.x,
1056            140.0 - HANDLE_WIDTH / 2.0,
1057            "high thumb at 70"
1058        );
1059    }
1060
1061    #[test]
1062    fn normalize_range_clamps_an_out_of_bounds_pair_to_the_bounds() {
1063        let spec = ValueSpec {
1064            min: 20.0,
1065            max: 80.0,
1066            divisions: None,
1067        };
1068        assert_eq!(
1069            normalize_range(&spec, SliderRange::new(0.0, 90.0)),
1070            SliderRange::new(20.0, 80.0)
1071        );
1072    }
1073
1074    #[test]
1075    fn normalize_range_pins_a_nan_side_to_min() {
1076        let spec = ValueSpec {
1077            min: 20.0,
1078            max: 80.0,
1079            divisions: None,
1080        };
1081        assert_eq!(
1082            normalize_range(
1083                &spec,
1084                SliderRange {
1085                    start: f64::NAN,
1086                    end: 50.0,
1087                }
1088            ),
1089            SliderRange::new(20.0, 50.0)
1090        );
1091    }
1092
1093    // -- NaN robustness (accept/snap) ----------------------------------------
1094
1095    #[test]
1096    fn a_nan_min_bound_never_panics_or_reports_a_nan_value() {
1097        // The refuted-unreachable case: a single NaN bound reaching `accept`'s
1098        // no-cross window, not both `lo`/`hi` at once. `.range()`'s own
1099        // `debug_assert!` guards the public builder path in a debug/test
1100        // build, but that guard compiles out entirely in release — `spec.min`
1101        // is set directly here (bypassing the builder) to exercise the
1102        // widget-level contract `accept`/`snap` must hold regardless of how a
1103        // NaN bound arrives.
1104        let mut w = widget(0.3, 0.7);
1105        w.spec.min = f64::NAN;
1106        w.spec.max = 100.0;
1107        let mut state = Val::default();
1108
1109        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1110        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 90.0));
1111        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 90.0));
1112
1113        assert!(
1114            state
1115                .changes
1116                .iter()
1117                .all(|v| !v.start.is_nan() && !v.end.is_nan()),
1118            "no reported value may carry a NaN: {:?}",
1119            state.changes
1120        );
1121        assert!(
1122            state
1123                .ends
1124                .iter()
1125                .all(|v| !v.start.is_nan() && !v.end.is_nan()),
1126            "{:?}",
1127            state.ends
1128        );
1129    }
1130
1131    #[test]
1132    fn a_tie_goes_to_the_low_thumb_and_the_far_side_grabs_the_high_one() {
1133        let mut w = widget(0.25, 0.75);
1134        let mut state = Val::default();
1135        // Exactly between the two thumbs.
1136        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1137        assert_eq!(w.active, Some(Thumb::Start), "a tie goes to the low thumb");
1138
1139        let mut w = widget(0.25, 0.75);
1140        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 140.0));
1141        assert_eq!(w.active, Some(Thumb::End));
1142        assert_eq!(state.changes.last(), Some(&SliderRange::new(0.25, 0.7)));
1143    }
1144
1145    #[test]
1146    fn the_high_thumb_cannot_be_dragged_below_the_low_one() {
1147        let mut w = widget(0.25, 0.75);
1148        let mut state = Val::default();
1149        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0));
1150        assert_eq!(w.active, Some(Thumb::End));
1151        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 0.0));
1152        assert_eq!(
1153            state.changes.last(),
1154            Some(&SliderRange::new(0.25, 0.25)),
1155            "it meets the low thumb and stops"
1156        );
1157        // And a further move reports nothing new at all.
1158        let before = state.changes.len();
1159        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -40.0));
1160        assert_eq!(state.changes.len(), before);
1161    }
1162
1163    #[test]
1164    fn callbacks_fire_in_order_and_settle_once() {
1165        let mut w = widget(0.2, 0.8);
1166        let mut state = Val::default();
1167        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 160.0));
1168        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 170.0));
1169        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 260.0));
1170        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 260.0));
1171        assert_eq!(
1172            state.changes,
1173            vec![SliderRange::new(0.2, 0.85), SliderRange::new(0.2, 1.0)],
1174            "the press landed on the value already held, so it reported nothing"
1175        );
1176        assert_eq!(state.ends, vec![SliderRange::new(0.2, 1.0)]);
1177        assert_eq!(
1178            w.values,
1179            SliderRange::new(0.2, 0.8),
1180            "a controlled slider never writes its own values"
1181        );
1182    }
1183
1184    #[test]
1185    fn cancel_disarms_without_reporting_or_settling() {
1186        let mut w = widget(0.25, 0.75);
1187        let mut state = Val::default();
1188        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1189        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 60.0));
1190        assert_eq!(w.active, None);
1191        assert!(!w.drag.dragging && !w.drag.pressed);
1192        assert!(state.ends.is_empty(), "a Cancel never reaches app state");
1193    }
1194
1195    #[test]
1196    fn a_disabled_range_slider_ignores_every_phase() {
1197        let mut w = build(&view(0.25, 0.75).enabled(false));
1198        let mut state = Val::default();
1199        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 60.0));
1200        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 90.0));
1201        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 90.0));
1202        assert!(state.changes.is_empty() && state.ends.is_empty());
1203        assert_eq!(w.active, None);
1204    }
1205
1206    #[test]
1207    fn a_discrete_range_reports_only_when_a_thumb_crosses_a_step() {
1208        let mut w = build(&view(2.0, 8.0).range(0.0, 10.0).divisions(10));
1209        let mut state = Val::default();
1210        // 43px still snaps to the step the low thumb already sits on.
1211        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 43.0));
1212        assert!(state.changes.is_empty());
1213        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 63.0));
1214        assert_eq!(state.changes, vec![SliderRange::new(3.0, 8.0)]);
1215    }
1216
1217    // -- paint --------------------------------------------------------------
1218
1219    #[test]
1220    fn the_active_span_runs_between_the_two_thumbs() {
1221        let mut w = widget(0.25, 0.75);
1222        layout(&mut w);
1223        let rec = paint(&mut w);
1224        assert_eq!(rec.radii.len(), 3, "two inactive ends plus the active span");
1225        let active = rec.radii[2];
1226        assert_eq!(active.0.x, 58.0, "low thumb at 50 plus its 8dp gap");
1227        assert_eq!(active.1.width, 84.0, "up to the high thumb's own gap");
1228
1229        // Two thumbs, both at their resting width, plus the surviving stops.
1230        let thumbs: Vec<&(Point, Size, f64, Color)> = rec
1231            .rrects
1232            .iter()
1233            .filter(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1234            .collect();
1235        assert_eq!(thumbs.len(), 2);
1236        assert_eq!(thumbs[0].0.x, 50.0 - HANDLE_WIDTH / 2.0);
1237        assert_eq!(thumbs[1].0.x, 150.0 - HANDLE_WIDTH / 2.0);
1238    }
1239
1240    #[test]
1241    fn only_the_dragged_thumb_shows_its_halo_and_indicator() {
1242        let mut w = widget(0.25, 0.75);
1243        layout(&mut w);
1244        let mut state = Val::default();
1245        assert!(paint(&mut w).runs.is_empty(), "no indicator at rest");
1246
1247        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0));
1248        let rec = paint(&mut w);
1249        assert_eq!(rec.runs.len(), 1, "the dragged thumb's label");
1250        let halos = rec
1251            .rrects
1252            .iter()
1253            .filter(|(_, s, _, _)| s.width == HANDLE_WIDTH + super::super::core::FOCUS_INFLATE)
1254            .count();
1255        assert_eq!(halos, 1, "one halo, on the grabbed thumb only");
1256    }
1257
1258    #[test]
1259    fn a_wavy_range_slider_strokes_its_active_span_and_paces_its_frames() {
1260        let mut w = build(&view(0.2, 0.8).amplitude(1.0));
1261        let mut wavy = build(&wavy_range_slider::<Val, _>(
1262            SliderRange::new(0.2, 0.8),
1263            |_s, _v| {},
1264        ));
1265        layout(&mut w);
1266        layout(&mut wavy);
1267
1268        assert!(paint(&mut w).strokes.is_empty(), "a flat span fills");
1269        let mut rec = Recorder::default();
1270        let mut ctx = PaintCtx::new(Point::ZERO, SIZE);
1271        wavy.paint(&mut ctx, &mut rec);
1272        assert_eq!(rec.radii.len(), 2, "the active span is no longer a rect");
1273        assert_eq!(rec.strokes.len(), 1, "it is a stroked wave instead");
1274        assert_eq!(rec.strokes[0].0, super::super::core::TRACK_HEIGHT);
1275        assert!(ctx.needs_frame_paced_only(), "a wave is a cosmetic loop");
1276    }
1277
1278    #[test]
1279    fn the_wave_amplitude_reads_the_span_between_the_thumbs() {
1280        // A 5%-wide selection is inside the ramp's flat zone; a wide one is
1281        // not (`amplitude_ramp`'s own boundaries).
1282        let narrow = build(&wavy_range_slider::<Val, _>(
1283            SliderRange::new(0.5, 0.55),
1284            |_s, _v| {},
1285        ));
1286        assert_eq!(narrow.amplitude_factor(), 0.0);
1287        let wide = build(&wavy_range_slider::<Val, _>(
1288            SliderRange::new(0.1, 0.8),
1289            |_s, _v| {},
1290        ));
1291        assert_eq!(wide.amplitude_factor(), 1.0);
1292    }
1293
1294    // -- haptics ------------------------------------------------------------
1295
1296    static RECORDED: Mutex<Vec<HapticSignal>> = Mutex::new(Vec::new());
1297
1298    fn recording_sink(signal: HapticSignal) {
1299        RECORDED.lock().unwrap().push(signal);
1300    }
1301
1302    #[test]
1303    fn haptics_are_opt_in_and_pace_off_the_frame_clock() {
1304        RECORDED.lock().unwrap().clear();
1305        let mut w = build(&view(0.2, 0.8).haptic_sink(recording_sink));
1306        let mut state = Val::default();
1307        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 170.0));
1308        assert!(
1309            !w.haptics.has_pending(),
1310            "nothing queued at HapticSignal::None"
1311        );
1312
1313        let mut w = build(
1314            &view(0.2, 0.8)
1315                .haptic(HapticSignal::SliderTick)
1316                .haptic_sink(recording_sink),
1317        );
1318        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 170.0));
1319        assert!(w.haptics.has_pending(), "an opted-in change queues a tick");
1320        let fired = w.haptics.take(FrameTime::from_nanos(0)).expect("a tick");
1321        (w.haptic_sink)(fired);
1322        assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::SliderTick]);
1323        // Throttled: a second change inside 60ms of frame clock is dropped.
1324        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 172.0));
1325        assert!(
1326            w.haptics.take(FrameTime::from_nanos(10_000_000)).is_none(),
1327            "a continuous drag ticks at most once per 60ms"
1328        );
1329    }
1330}