Skip to main content

frust_material/slider/
mod.rs

1//! The Material 3 Expressive `Slider`: a controlled value picker painted as a
2//! segmented track with a bar handle, a floating value indicator, and
3//! (optionally) discrete stops or a relocating end icon — horizontal or
4//! vertical, flat or wavy.
5//!
6//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
7//! `lib/components/sliders/` (retrieved 2026-08-19).
8//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
9//!
10//! This module owns the `View`/`Widget` wiring, colour resolution, and every
11//! paint call; [`core`](mod@self::core) owns the tokens, the geometry, the drag
12//! state machine, and the haptic scheduler, and [`variants`](mod@self::variants)
13//! owns the axis map and the wave recipe the two variant families add.
14//! [`range`](mod@self::range) is a widget of its own — two thumbs need their own
15//! state — built on the same three.
16//!
17//! # Constructors: the reference's named-constructor split, kept
18//!
19//! `M3ESlider` ships six Dart named constructors over one widget class
20//! (`m3e_sliders.dart:58-306`). All six are sibling functions here rather than
21//! builder flags, so the mapping stays one-to-one:
22//!
23//! | Reference | Here |
24//! |---|---|
25//! | `M3ESlider(...)` | [`slider`] (alias [`Slider`]) |
26//! | `M3ESlider.centered(...)` | [`centered_slider`] |
27//! | `M3ESlider.wavy(...)` | [`wavy_slider`] |
28//! | `M3ESlider.wavyCentered(...)` | [`wavy_centered_slider`] |
29//! | `M3ESlider.vertical(...)` | [`vertical_slider`] |
30//! | `M3ESlider.verticalCentered(...)` | [`vertical_centered_slider`] |
31//! | `M3ERangeSlider(...)` | [`range_slider`] |
32//! | `M3ERangeSlider.wavy(...)` | [`wavy_range_slider`] |
33//!
34//! Every knob those constructors *only* accept when it is meaningful —
35//! `amplitude`/`amplitudeForProgress`/`wavelength`/`waveSpeed` on the two wavy
36//! ones, `topToBottom` on the two vertical ones — is a builder method here
37//! instead ([`SliderView::wavelength`], [`SliderView::top_to_bottom`], …),
38//! documented as inert on a constructor that has no use for it. A builder
39//! cannot be constructor-gated in Rust without splitting the view type per
40//! variant, which would multiply eight near-identical types (and their
41//! `Widget`s) to enforce what a doc line already says.
42//!
43//! All six build the same [`SliderView`], differing only in its
44//! [`SliderTrackKind`], its axis, and its wavy flag.
45//!
46//! # Controlled, like every other value-reporting widget here
47//!
48//! [`SliderView`] carries the app's `value` and reports a *requested* one
49//! through `on_changed`; it never writes its own `value` field (the
50//! controlled-component rule in `docs/CODE_STANDARDS.md`'s Interaction
51//! Semantics, shared with [`mod@crate::switch`]/[`mod@crate::checkbox`]). A `Down`
52//! arms the drag, captures the pointer, and jumps to the tapped value; each
53//! captured `Move` reports again; `Up` settles and fires `on_change_end`.
54//!
55//! Two departures from the reference, both forced by this framework's own
56//! contracts and documented at their call sites:
57//!
58//! - **`Cancel` fires nothing.** Upstream routes a cancelled drag through the
59//!   same `_endInteraction` an `Up` takes (`m3e_slider_build.dart:344`), which
60//!   would reach app state; a `Cancel` arm here may not
61//!   (`core::Drag::cancel`).
62//! - **A drag deduplicates against the last value it reported**, not against
63//!   the confirmed `value` field, since a rebuild is a frame away rather than
64//!   synchronous as Flutter's `setState` is. Without it a slow drag inside one
65//!   division would re-report (and re-tick) against a stale value. The
66//!   confirmed value wins again the moment `rebuild` adopts one.
67//!
68//! # Haptics: opt-in, and paced by the frame clock
69//!
70//! `M3ESlider.haptic` defaults to `M3EHapticFeedback.none`, and upstream skips
71//! the haptic call entirely at that value (`m3e_sliders.dart:603-609`) —
72//! transcribed here: [`SliderView::haptic`] defaults to
73//! [`HapticSignal::None`] and nothing is fired or queued until an app opts in.
74//! Once it has, a discrete slider ticks on every accepted step and a
75//! continuous drag ticks at most once per 60ms, resolved against the shell's
76//! frame clock rather than a wall clock — see `core::HapticScheduler` for
77//! why, and for the two accepted consequences.
78//!
79//! # Focus outline: geometry ported, reachability limited
80//!
81//! The concentric focus ring (`m3e_slider_thumb.dart:87-107`) and the +4dp
82//! handle gap it forces (`m3e_slider_build.dart:73-75`) are both ported and
83//! painted whenever [`PaintCtx::has_focus`] reads true. **Nothing focuses a
84//! slider today**: this framework has no focus-traversal seam (no Tab ring),
85//! and — unlike the reference — this widget does not grab focus on press. It
86//! deliberately does not: upstream grabs pointer focus to serve its keyboard
87//! stepping (`m3e_sliders.dart:623-665`, not ported in this core) and then
88//! *hides* the outline again for pointer-driven focus
89//! (`m3e_sliders.dart:456-465`), so not grabbing produces the same visuals
90//! today with none of the machinery. The ring's geometry is pinned by
91//! `core`'s own tests, so it is correct the day focus can reach here.
92//!
93//! # Not ported
94//!
95//! `thumbBuilder`/`trackBuilder`/`dotBuilder` (per-instance paint overrides),
96//! `semanticFormatterCallback`, `focusNode`/`autofocus`, keyboard stepping, and
97//! the desktop hover cursor (`MouseRegion`'s `SystemMouseCursors.click`,
98//! `m3e_slider_build.dart:318-322` — the baseline `frust::Slider` asks for no
99//! cursor either). The `label`, `trackIcons`, `icon`, the wave knobs,
100//! `topToBottom`, and every theme-override knob (`trackThickness`,
101//! `cornerRadius`, `thumbLength`, `dotSize`, `dotSpacing`, `iconSize`,
102//! `iconEdgeInset`) are.
103
104mod core;
105mod range;
106mod variants;
107
108use std::rc::Rc;
109
110use frust::authoring::text::{TextContext, TextLayout, TextStyle};
111use frust::authoring::{
112    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
113    PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
114};
115use frust::{AnimationController, Curve, IconData, IconSource, Theme};
116use kurbo::{Affine, Point, RoundedRect, Shape, Size, Vec2};
117use peniko::{Brush, Color};
118
119use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
120use crate::press::presses;
121
122use self::core::{
123    DEFAULT_EXTENT, DISABLED_ACTIVE_OPACITY, DISABLED_INACTIVE_OPACITY, Drag, DragCtx, DragOutcome,
124    FOCUS_STROKE, HANDLE_GAP, HANDLE_HEIGHT, HANDLE_WIDTH, HapticCadence, HapticScheduler,
125    ICON_EDGE_INSET, ICON_SIZE_DEFAULT, IconDockInput, PRESS_MORPH_DURATION, PRESSED_HANDLE_WIDTH,
126    STOP_INDICATOR_SIZE, STOP_INDICATOR_TRAILING_SPACE, SegmentRole, SliderGeometry, TICK_SIZE,
127    TRACK_CORNER_RADIUS, TRACK_HEIGHT, TRACK_ICON_SIZE, TrackMetrics, VALUE_INDICATOR_PAD_X,
128    VALUE_INDICATOR_PAD_Y, VALUE_INDICATOR_RADIUS, ValueSpec,
129};
130use self::variants::{AxisMap, SliderAxis, WAVE_AMPLITUDE, WaveClock, WaveOverrides};
131
132pub use self::core::{SliderIconPosition, SliderTrackKind};
133pub use self::range::{
134    RangeSliderView, RangeSliderWidget, SliderRange, range_slider, wavy_range_slider,
135};
136
137/// Tessellation tolerance for the stroked focus ring (the same value
138/// [`mod@crate::switch`]/[`crate::card`] use for their own stroked paths).
139const PATH_TOLERANCE: f64 = 0.1;
140
141/// Label-large size, in logical px — the value indicator's type role when no
142/// theme is threaded (M3 `labelLarge`, 14/20).
143const LABEL_LARGE_SIZE: f32 = 14.0;
144/// The ink the indicator label is *shaped* with; re-brushed with its resolved
145/// colour at paint time, so a recolour never misses the shape cache (the same
146/// contract [`crate::text_field`]'s runs hold).
147const SHAPING_INK: Color = Color::BLACK;
148
149/// Unthemed fallback for the `primary` role (M3 baseline light `#6750A4`).
150const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
151/// Unthemed fallback for the `on_primary` role (M3 baseline light `#FFFFFF`).
152const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
153/// Unthemed fallback for the `secondary_container` role (M3 baseline light
154/// `#E8DEF8`).
155const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
156/// Unthemed fallback for the `on_surface` role (M3 baseline light `#1D1B20`).
157const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
158/// Unthemed fallback for the `on_surface_variant` role (M3 baseline light
159/// `#49454F`).
160const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
161/// Unthemed fallback for the `inverse_surface` role (M3 baseline light
162/// `#322F35`).
163const INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
164/// Unthemed fallback for the `inverse_on_surface` role (M3 baseline light
165/// `#F5EFF7`).
166const INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
167
168/// The inks one slider paint pass resolves (`M3ESliderColors`, plus the two
169/// icon roles this port folds in — see [`resolve_colors`]).
170#[derive(Clone, Copy, Debug, PartialEq)]
171struct SliderColors {
172    thumb: Color,
173    active_track: Color,
174    inactive_track: Color,
175    active_tick: Color,
176    inactive_tick: Color,
177    value_indicator: Color,
178    value_indicator_label: Color,
179    /// Ink for an icon sitting over the *inactive* track (the relocating
180    /// icon's undocked state, and every inactive-slot inset icon).
181    on_inactive: Color,
182    /// Ink for an icon sitting over the *active* track.
183    on_active: Color,
184}
185
186/// `M3ESliderTheme.colors` (`m3e_slider_theme.dart:147-169`), including its
187/// deliberate tick reversal: a tick painted **on** the active track takes the
188/// *inactive* track's colour and vice versa, so a marker always contrasts with
189/// what it sits on. The stop indicator uses the active-track colour, which is
190/// `inactive_tick` here — the same value, not a second role.
191fn resolve_colors(theme: Option<&Theme>, enabled: bool) -> SliderColors {
192    let (
193        primary,
194        on_primary,
195        secondary_container,
196        on_surface,
197        on_surface_variant,
198        inverse,
199        on_inverse,
200    ) = match theme {
201        Some(theme) => {
202            let s = theme.scheme();
203            (
204                s.primary,
205                s.on_primary,
206                s.secondary_container,
207                s.on_surface,
208                s.on_surface_variant,
209                s.inverse_surface,
210                s.inverse_on_surface,
211            )
212        }
213        None => (
214            PRIMARY,
215            ON_PRIMARY,
216            SECONDARY_CONTAINER,
217            ON_SURFACE,
218            ON_SURFACE_VARIANT,
219            INVERSE_SURFACE,
220            INVERSE_ON_SURFACE,
221        ),
222    };
223    let active = |c: Color| {
224        if enabled {
225            c
226        } else {
227            on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
228        }
229    };
230    let inactive = |c: Color| {
231        if enabled {
232            c
233        } else {
234            on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
235        }
236    };
237    let active_track = active(primary);
238    let inactive_track = inactive(secondary_container);
239    SliderColors {
240        thumb: active(primary),
241        active_track,
242        inactive_track,
243        active_tick: inactive_track,
244        inactive_tick: active_track,
245        value_indicator: inverse,
246        value_indicator_label: on_inverse,
247        // `m3e_slider_build.dart:504-511`'s icon-colour ladder, reused for the
248        // inset track icons: upstream leaves those to an ambient `IconTheme`
249        // this framework has no equivalent of, so they take the same
250        // over-active/over-inactive pair the relocating icon does.
251        on_inactive: active(on_surface_variant),
252        on_active: active(on_primary),
253    }
254}
255
256/// Optional icons inset into the track's own segments
257/// (`M3ESliderTrackIcons`). A slot left `None` paints nothing, and any icon
258/// whose segment is shorter than `size + 8` is dropped for that frame.
259#[derive(Clone, Copy, Debug)]
260pub struct SliderTrackIcons {
261    /// Leading end of the active segment.
262    pub active_start: Option<IconSource>,
263    /// Trailing end of the active segment.
264    pub active_end: Option<IconSource>,
265    /// Leading end of the inactive segment (standard track only).
266    pub inactive_start: Option<IconSource>,
267    /// Trailing end of the inactive segment (standard track only).
268    pub inactive_end: Option<IconSource>,
269    /// Edge length of every slot's icon, in logical px.
270    pub size: f64,
271}
272
273/// Slot-wise geometry equality, hand-written because [`IconSource`] itself is
274/// not `PartialEq` — the same `d`/`design` identity test `same_icon_source`
275/// applies to a single slot, which is all a `rebuild` diff needs.
276impl PartialEq for SliderTrackIcons {
277    fn eq(&self, other: &Self) -> bool {
278        self.size == other.size
279            && same_icon_source(self.active_start, other.active_start)
280            && same_icon_source(self.active_end, other.active_end)
281            && same_icon_source(self.inactive_start, other.inactive_start)
282            && same_icon_source(self.inactive_end, other.inactive_end)
283    }
284}
285
286impl Default for SliderTrackIcons {
287    /// [`SliderTrackIcons::new`] — an empty set at the token icon size, never
288    /// a zero-sized one.
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl SliderTrackIcons {
295    /// An empty set at the reference's default icon size (16dp).
296    pub fn new() -> Self {
297        Self {
298            active_start: None,
299            active_end: None,
300            inactive_start: None,
301            inactive_end: None,
302            size: TRACK_ICON_SIZE,
303        }
304    }
305
306    /// Which slots hold an icon, in [`TrackIconSlot::index`] order — the same
307    /// order [`SliderWidget::track_icon_geom`] caches their geometry in.
308    fn present(&self) -> [bool; 4] {
309        [
310            self.active_start.is_some(),
311            self.active_end.is_some(),
312            self.inactive_start.is_some(),
313            self.inactive_end.is_some(),
314        ]
315    }
316
317    /// The resolved edge length — the struct's own `size`, or the token
318    /// default if a caller zeroed it.
319    fn size(&self) -> f64 {
320        if self.size > 0.0 {
321            self.size
322        } else {
323            TRACK_ICON_SIZE
324        }
325    }
326}
327
328/// Per-instance overrides for the values a `M3ESliderTheme` would otherwise
329/// supply. One struct so `rebuild` diffs them in a single comparison, and so
330/// the **explicit value > token default** precedence lives in one place.
331#[derive(Clone, Copy, Debug, Default, PartialEq)]
332struct Overrides {
333    track_thickness: Option<f64>,
334    corner_radius: Option<f64>,
335    thumb_length: Option<f64>,
336    dot_size: Option<f64>,
337    dot_spacing: Option<f64>,
338    icon_size: Option<f64>,
339    icon_edge_inset: Option<f64>,
340}
341
342impl Overrides {
343    fn track_thickness(&self) -> f64 {
344        self.track_thickness.unwrap_or(TRACK_HEIGHT)
345    }
346    fn corner_radius(&self) -> f64 {
347        self.corner_radius.unwrap_or(TRACK_CORNER_RADIUS)
348    }
349    fn thumb_length(&self) -> f64 {
350        self.thumb_length.unwrap_or(HANDLE_HEIGHT)
351    }
352    fn dot_size(&self) -> f64 {
353        self.dot_size.unwrap_or(STOP_INDICATOR_SIZE)
354    }
355    fn tick_size(&self) -> f64 {
356        self.dot_size.unwrap_or(TICK_SIZE)
357    }
358    fn dot_spacing(&self) -> f64 {
359        self.dot_spacing.unwrap_or(STOP_INDICATOR_TRAILING_SPACE)
360    }
361    fn icon_size(&self) -> f64 {
362        self.icon_size.unwrap_or(ICON_SIZE_DEFAULT)
363    }
364    fn icon_edge_inset(&self) -> f64 {
365        self.icon_edge_inset.unwrap_or(ICON_EDGE_INSET)
366    }
367}
368
369/// A cached, lazily-shaped run for the value indicator's label — the same
370/// catalog-owns-its-text pattern [`crate::text_field`] uses, for the same
371/// reason: the ink is a paint-time value the shaped layout must not bake.
372struct Run {
373    content: String,
374    layout: Option<TextLayout>,
375    shaped_style: Option<TextStyle>,
376}
377
378impl Run {
379    fn new() -> Self {
380        Self {
381            content: String::new(),
382            layout: None,
383            shaped_style: None,
384        }
385    }
386
387    /// Replace the text, dropping the cached layout only if it changed.
388    fn set_content(&mut self, content: &str) {
389        if self.content != content {
390            self.content = content.to_string();
391            self.layout = None;
392        }
393    }
394
395    fn size(&self) -> Size {
396        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
397    }
398
399    /// Shape (or reuse) the run in `style`.
400    fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) {
401        if self.layout.is_some() && self.shaped_style.as_ref() == Some(style) {
402            return;
403        }
404        let text_ctx = ctx.text_context::<TextContext>();
405        self.layout = Some(text_ctx.layout(&self.content, style, None));
406        self.shaped_style = Some(style.clone());
407    }
408
409    /// Paint the run at `origin` in `color`. A run never laid out paints
410    /// nothing.
411    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
412        let Some(layout) = &self.layout else {
413            return;
414        };
415        for mut run in layout.to_scene_runs(origin) {
416            run.brush = Brush::Solid(color);
417            scene.draw_glyph_run(run);
418        }
419    }
420}
421
422/// The label-large type role the value indicator shapes in. Themed:
423/// `Theme::type_scale.label_large`. Unthemed: [`LABEL_LARGE_SIZE`] at the
424/// default family.
425fn label_large(theme: Option<&Theme>) -> TextStyle {
426    let mut style = match theme {
427        Some(theme) => theme.type_scale.label_large.clone(),
428        None => TextStyle::new(LABEL_LARGE_SIZE, SHAPING_INK),
429    };
430    style.color = SHAPING_INK;
431    style
432}
433
434/// Resolve an optional [`IconSource`] into its cached `(design-space path,
435/// design box)` pair (the same helper [`mod@crate::switch`] carries).
436fn resolve_icon(source: Option<IconSource>) -> Option<(kurbo::BezPath, f64)> {
437    source.map(|s| IconData::from(s).resolve())
438}
439
440/// Whether two optional icon sources name the same geometry.
441fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
442    match (a, b) {
443        (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
444        (None, None) => true,
445        _ => false,
446    }
447}
448
449/// A view-held, typed value callback (erased on build).
450type OnValue<State> = Rc<dyn Fn(&mut State, f64)>;
451
452/// An app-supplied amplitude-vs-progress curve for a wavy track
453/// (`M3ESlider.amplitudeForProgress`). Shared rather than boxed so `rebuild`
454/// can adopt the new view's copy for the cost of a refcount bump, the same way
455/// the value callbacks do.
456pub(crate) type AmplitudeCurve = Rc<dyn Fn(f64) -> f64>;
457
458/// A declarative M3E slider. See the [module docs](self).
459pub struct SliderView<State: 'static> {
460    value: f64,
461    spec: ValueSpec,
462    kind: SliderTrackKind,
463    axis: SliderAxis,
464    /// `M3ESlider.topToBottom` — vertical only; the horizontal constructors
465    /// hold it at its `true` (leading-edge-is-minimum) default.
466    top_to_bottom: bool,
467    wavy: bool,
468    wave: WaveOverrides,
469    amplitude_for_progress: Option<AmplitudeCurve>,
470    enabled: bool,
471    label: Option<String>,
472    haptic: HapticSignal,
473    icon: Option<IconSource>,
474    icon_position: SliderIconPosition,
475    track_icons: Option<SliderTrackIcons>,
476    overrides: Overrides,
477    on_changed: OnValue<State>,
478    on_change_end: Option<OnValue<State>>,
479    /// The sink every fired [`HapticSignal`] goes through — always
480    /// [`MaterialHaptics::fire`] in a real build. It is a field rather than a
481    /// direct call so a test can count ticks without touching
482    /// [`MaterialHaptics`]' process-global, set-once hook (the injectable-hook
483    /// seam in `docs/CODE_STANDARDS.md`'s Testing Patterns); a global install
484    /// here would race [`crate::interaction`]'s own first-set-wins coverage.
485    haptic_sink: fn(HapticSignal),
486}
487
488/// Create a standard horizontal slider at `value` that fires
489/// `on_changed(state, requested)` as it is dragged (`M3ESlider`).
490///
491/// The value range is `0.0..=1.0` until [`SliderView::range`] widens it, and
492/// the slider is continuous until [`SliderView::divisions`] steps it.
493pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
494    value: f64,
495    on_changed: F,
496) -> SliderView<State> {
497    SliderView {
498        value,
499        spec: ValueSpec::default(),
500        kind: SliderTrackKind::Standard,
501        axis: SliderAxis::Horizontal,
502        top_to_bottom: true,
503        wavy: false,
504        wave: WaveOverrides::default(),
505        amplitude_for_progress: None,
506        enabled: true,
507        label: None,
508        haptic: HapticSignal::None,
509        icon: None,
510        icon_position: SliderIconPosition::End,
511        track_icons: None,
512        overrides: Overrides::default(),
513        on_changed: Rc::new(on_changed),
514        on_change_end: None,
515        haptic_sink: MaterialHaptics::fire,
516    }
517}
518
519/// PascalCase alias for [`slider`].
520#[allow(non_snake_case)]
521pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
522    value: f64,
523    on_changed: F,
524) -> SliderView<State> {
525    slider(value, on_changed)
526}
527
528/// Create a horizontal slider whose active track grows from the track's
529/// midpoint toward the thumb (`M3ESlider.centered`).
530pub fn centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
531    value: f64,
532    on_changed: F,
533) -> SliderView<State> {
534    SliderView {
535        kind: SliderTrackKind::Centered,
536        ..slider(value, on_changed)
537    }
538}
539
540/// Create a horizontal slider whose active track is a traveling sine wave
541/// (`M3ESlider.wavy`). Everything else — inactive track, thumb, gaps, ticks,
542/// interaction — matches [`slider`].
543///
544/// The wave's shape and travel are tunable through [`SliderView::wavelength`],
545/// [`SliderView::wave_speed`], [`SliderView::amplitude`], and
546/// [`SliderView::amplitude_for_progress`].
547pub fn wavy_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
548    value: f64,
549    on_changed: F,
550) -> SliderView<State> {
551    SliderView {
552        wavy: true,
553        ..slider(value, on_changed)
554    }
555}
556
557/// Create a horizontal centered slider with a wavy active segment
558/// (`M3ESlider.wavyCentered`).
559pub fn wavy_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
560    value: f64,
561    on_changed: F,
562) -> SliderView<State> {
563    SliderView {
564        kind: SliderTrackKind::Centered,
565        wavy: true,
566        ..slider(value, on_changed)
567    }
568}
569
570/// Create a vertical slider (`M3ESlider.vertical`, Compose's
571/// `VerticalSlider`).
572///
573/// As upstream, `topToBottom` defaults to `false`: the minimum sits at the
574/// bottom and dragging up increases the value. [`SliderView::top_to_bottom`]
575/// flips it.
576pub fn vertical_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
577    value: f64,
578    on_changed: F,
579) -> SliderView<State> {
580    SliderView {
581        axis: SliderAxis::Vertical,
582        top_to_bottom: false,
583        ..slider(value, on_changed)
584    }
585}
586
587/// Create a vertical slider whose active track grows from the track's midpoint
588/// toward the thumb (`M3ESlider.verticalCentered`), minimum at the bottom.
589pub fn vertical_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
590    value: f64,
591    on_changed: F,
592) -> SliderView<State> {
593    SliderView {
594        kind: SliderTrackKind::Centered,
595        ..vertical_slider(value, on_changed)
596    }
597}
598
599impl<State: 'static> SliderView<State> {
600    /// Set the value bounds (`M3ESlider.min`/`max`, defaulting to `0..=1`).
601    ///
602    /// Upstream asserts `max > min` (`m3e_sliders.dart:95`); an inverted or
603    /// empty range `debug_assert!`s here and otherwise degrades to a slider
604    /// pinned at its minimum — never a panic in a shipped app.
605    pub fn range(mut self, min: f64, max: f64) -> Self {
606        debug_assert!(max > min, "a slider's max must be greater than its min");
607        self.spec.min = min;
608        self.spec.max = max;
609        self
610    }
611
612    /// Step the slider into `divisions` equal intervals
613    /// (`M3ESlider.divisions`): `divisions + 1` stops, endpoints included.
614    /// `0` is treated as continuous, matching upstream's `<= 0` guard.
615    pub fn divisions(mut self, divisions: u32) -> Self {
616        self.spec.divisions = Some(divisions);
617        self
618    }
619
620    /// Fire `on_change_end(state, value)` when an interaction settles
621    /// (`M3ESlider.onChangeEnd`). A cancelled drag never fires it — see the
622    /// [module docs](self).
623    pub fn on_change_end<F: Fn(&mut State, f64) + 'static>(mut self, on_change_end: F) -> Self {
624        self.on_change_end = Some(Rc::new(on_change_end));
625        self
626    }
627
628    /// Override the value indicator's text (`M3ESlider.label`). Without one it
629    /// is the rounded value on a discrete slider and a two-decimal one
630    /// otherwise (`m3e_slider_build.dart:88-92`).
631    pub fn label(mut self, label: impl Into<String>) -> Self {
632        self.label = Some(label.into());
633        self
634    }
635
636    /// Gate interaction and switch to the disabled colour table
637    /// (`M3ESlider.enabled`). Upstream also treats a null `onChanged` as
638    /// disabled; this crate's controlled-component contract always takes a
639    /// concrete callback, so the flag is the only switch.
640    pub fn enabled(mut self, enabled: bool) -> Self {
641        self.enabled = enabled;
642        self
643    }
644
645    /// Opt into haptic feedback on value changes (`M3ESlider.haptic`,
646    /// defaulting to none). See the [module docs](self)' Haptics section.
647    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
648        self.haptic = haptic;
649        self
650    }
651
652    /// Map the vertical track's *top* edge to the minimum
653    /// (`M3ESlider.topToBottom`), instead of the default bottom-up
654    /// orientation. Inert on a horizontal slider, which has no such choice
655    /// here (RTL is not wired — see the [module docs](self)).
656    pub fn top_to_bottom(mut self, top_to_bottom: bool) -> Self {
657        self.top_to_bottom = top_to_bottom;
658        self
659    }
660
661    /// Length of one full wave cycle, in logical px (`M3ESlider.wavelength`,
662    /// defaulting to 40). Inert unless the slider is wavy.
663    pub fn wavelength(mut self, wavelength: f64) -> Self {
664        self.wave.wavelength = Some(wavelength);
665        self
666    }
667
668    /// How fast the wave travels, in logical px per second
669    /// (`M3ESlider.waveSpeed`, defaulting to the wavelength — one cycle per
670    /// second). Inert unless the slider is wavy.
671    pub fn wave_speed(mut self, wave_speed: f64) -> Self {
672        self.wave.wave_speed = Some(wave_speed);
673        self
674    }
675
676    /// Fix the wave's amplitude factor (`0..=1`, `M3ESlider.amplitude`),
677    /// replacing the default end-of-track ramp. Inert unless the slider is
678    /// wavy; outranked by [`Self::amplitude_for_progress`].
679    pub fn amplitude(mut self, amplitude: f64) -> Self {
680        self.wave.amplitude = Some(amplitude);
681        self
682    }
683
684    /// Drive the wave's amplitude factor from the current progress
685    /// (`M3ESlider.amplitudeForProgress`), the highest-precedence of the three
686    /// amplitude sources. The return is clamped to `0..=1`. Inert unless the
687    /// slider is wavy.
688    pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
689        self.amplitude_for_progress = Some(Rc::new(curve));
690        self
691    }
692
693    /// Show a relocating icon at one track end (`M3ESlider.icon`), which docks
694    /// beside the thumb rather than letting it cover the icon.
695    ///
696    /// Upstream asserts this is mutually exclusive with `divisions`
697    /// (`m3e_sliders.dart:96-99`); here the pair `debug_assert!`s at build and
698    /// the icon is simply not painted on a discrete slider. Either way the
699    /// track's stop indicators give up their place to the icon.
700    pub fn icon(mut self, icon: IconSource) -> Self {
701        self.icon = Some(icon);
702        self
703    }
704
705    /// Which end the relocating [`icon`](Self::icon) rests at
706    /// (`M3ESlider.iconPosition`).
707    pub fn icon_position(mut self, position: SliderIconPosition) -> Self {
708        self.icon_position = position;
709        self
710    }
711
712    /// Edge length of the relocating [`icon`](Self::icon), in logical px
713    /// (`M3ESlider.iconSize`).
714    pub fn icon_size(mut self, size: f64) -> Self {
715        self.overrides.icon_size = Some(size);
716        self
717    }
718
719    /// Clear space between the track edge and the relocating
720    /// [`icon`](Self::icon)'s outer edge (`M3ESlider.iconEdgeInset`).
721    pub fn icon_edge_inset(mut self, inset: f64) -> Self {
722        self.overrides.icon_edge_inset = Some(inset);
723        self
724    }
725
726    /// Inset icons for the track's own segments (`M3ESlider.trackIcons`).
727    pub fn track_icons(mut self, icons: SliderTrackIcons) -> Self {
728        self.track_icons = Some(icons);
729        self
730    }
731
732    /// Thickness of both track segments, in logical px
733    /// (`M3ESlider.trackThickness`).
734    pub fn track_thickness(mut self, thickness: f64) -> Self {
735        self.overrides.track_thickness = Some(thickness);
736        self
737    }
738
739    /// Outer corner radius of both track segments, in logical px
740    /// (`M3ESlider.cornerRadius`); clamped to half the track thickness.
741    pub fn corner_radius(mut self, radius: f64) -> Self {
742        self.overrides.corner_radius = Some(radius);
743        self
744    }
745
746    /// Length of the thumb along the cross axis, in logical px
747    /// (`M3ESlider.thumbLength`).
748    pub fn thumb_length(mut self, length: f64) -> Self {
749        self.overrides.thumb_length = Some(length);
750        self
751    }
752
753    /// Diameter of the stop/tick markers, in logical px
754    /// (`M3ESlider.dotSize`).
755    pub fn dot_size(mut self, size: f64) -> Self {
756        self.overrides.dot_size = Some(size);
757        self
758    }
759
760    /// Clear space between each track end and the outer edge of its marker, in
761    /// logical px (`M3ESlider.dotSpacing`).
762    pub fn dot_spacing(mut self, spacing: f64) -> Self {
763        self.overrides.dot_spacing = Some(spacing);
764        self
765    }
766
767    /// Route fired haptics somewhere other than [`MaterialHaptics`] — the
768    /// counting seam this module's own tests use. See
769    /// [`SliderView::haptic_sink`](struct.SliderView.html#structfield.haptic_sink).
770    #[cfg(test)]
771    fn haptic_sink(mut self, sink: fn(HapticSignal)) -> Self {
772        self.haptic_sink = sink;
773        self
774    }
775
776    /// Whether the primary axis runs backwards in widget space
777    /// (`m3e_slider_build.dart:82`). Vertical only in this port: the
778    /// horizontal arm of upstream's expression is RTL, which is not wired.
779    fn reverse(&self) -> bool {
780        self.axis == SliderAxis::Vertical && !self.top_to_bottom
781    }
782
783    /// The value indicator's text for the current value
784    /// (`m3e_slider_build.dart:88-92`).
785    fn indicator_text(&self) -> String {
786        match &self.label {
787            Some(label) => label.clone(),
788            None if self.spec.divisions.is_some() => format!("{}", self.value.round()),
789            None => format!("{:.2}", self.value),
790        }
791    }
792}
793
794/// The retained widget for a [`SliderView`].
795pub struct SliderWidget {
796    /// The app-confirmed value (source of truth; adopted on `rebuild`).
797    value: f64,
798    spec: ValueSpec,
799    ticks: Vec<f64>,
800    kind: SliderTrackKind,
801    axis: SliderAxis,
802    /// Whether the primary axis runs backwards in widget space — a bottom-up
803    /// vertical slider (`m3e_slider_build.dart:82`'s `reverse`).
804    reverse: bool,
805    wavy: bool,
806    wave: WaveOverrides,
807    amplitude_for_progress: Option<AmplitudeCurve>,
808    /// The wave's repeating phase clock; idle (never stepped) unless wavy.
809    wave_clock: WaveClock,
810    enabled: bool,
811    overrides: Overrides,
812    haptic: HapticSignal,
813    icon: Option<IconSource>,
814    icon_geom: Option<(kurbo::BezPath, f64)>,
815    icon_position: SliderIconPosition,
816    track_icons: Option<SliderTrackIcons>,
817    track_icon_geom: [Option<(kurbo::BezPath, f64)>; 4],
818    /// The pointer state machine (pressed/dragging).
819    drag: Drag,
820    /// Feeds the handle halo's overlay opacity through the crate's precedence
821    /// resolver; `dragged` is set for the length of a drag.
822    interaction: InteractionState,
823    /// The last value reported this drag — what a change deduplicates against
824    /// until `rebuild` confirms one (see the [module docs](self)).
825    last_reported: Option<f64>,
826    /// `0.0` resting → `1.0` pressed, over 100ms of ease-out: the handle's
827    /// width compression (`m3e_slider_thumb.dart:76-85`).
828    press_anim: AnimationController,
829    press_target: bool,
830    /// `0.0` resting → `1.0` docked for the relocating icon, on
831    /// [`core::DOCK_SPRING`].
832    dock_anim: AnimationController,
833    dock_target: bool,
834    haptics: HapticScheduler,
835    haptic_sink: fn(HapticSignal),
836    /// The value indicator's shaped label.
837    indicator: Run,
838    on_changed: frust::authoring::ErasedArgCallback<f64>,
839    on_change_end: Option<frust::authoring::ErasedArgCallback<f64>>,
840}
841
842impl<State: 'static> View<State> for SliderView<State> {
843    type Element = SliderWidget;
844
845    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
846        debug_assert!(
847            self.icon.is_none() || self.spec.divisions.is_none(),
848            "a relocating slider icon and discrete divisions are mutually exclusive"
849        );
850        let mut indicator = Run::new();
851        indicator.set_content(&self.indicator_text());
852        SliderWidget {
853            value: self.value,
854            spec: self.spec,
855            ticks: self.spec.tick_fractions(),
856            kind: self.kind,
857            axis: self.axis,
858            reverse: self.reverse(),
859            wavy: self.wavy,
860            wave: self.wave,
861            amplitude_for_progress: self.amplitude_for_progress.clone(),
862            wave_clock: WaveClock::new(),
863            enabled: self.enabled,
864            overrides: self.overrides,
865            haptic: self.haptic,
866            icon: self.icon,
867            icon_geom: resolve_icon(self.icon),
868            icon_position: self.icon_position,
869            track_icons: self.track_icons,
870            track_icon_geom: resolve_track_icons(self.track_icons.as_ref()),
871            drag: Drag::default(),
872            interaction: InteractionState::new(),
873            last_reported: None,
874            press_anim: AnimationController::new(PRESS_MORPH_DURATION).with_curve(Curve::EaseOut),
875            press_target: false,
876            dock_anim: AnimationController::new(std::time::Duration::ZERO),
877            dock_target: false,
878            haptics: HapticScheduler::default(),
879            haptic_sink: self.haptic_sink,
880            indicator,
881            on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
882            on_change_end: self
883                .on_change_end
884                .as_ref()
885                .map(frust::authoring::erase_callback_arg),
886        }
887    }
888
889    fn rebuild(
890        &self,
891        prev: &Self,
892        element: &mut SliderWidget,
893        _ctx: &mut BuildCtx<'_>,
894    ) -> ChangeFlags {
895        element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
896        element.on_change_end = self
897            .on_change_end
898            .as_ref()
899            .map(frust::authoring::erase_callback_arg);
900        element.haptic_sink = self.haptic_sink;
901        // Two closures a diff cannot compare: adopted every pass like the
902        // value callbacks above. A wavy slider repaints every frame anyway, so
903        // an adopted curve is on screen by the next one.
904        element.amplitude_for_progress = self.amplitude_for_progress.clone();
905        let mut flags = ChangeFlags::NONE;
906
907        // The value indicator's label is shaped at layout time, so anything
908        // that changes its text forces a relayout rather than a bare repaint
909        // (`docs/WIDGETS_CODE_STANDARDS.md`'s layout-time-baked rule).
910        let text = self.indicator_text();
911        if element.indicator.content != text {
912            element.indicator.set_content(&text);
913            flags |= ChangeFlags::LAYOUT;
914        }
915        if prev.value != self.value {
916            element.value = self.value;
917            // The app confirmed a value: stop deduplicating against the drag's
918            // own last report.
919            element.last_reported = None;
920            flags |= ChangeFlags::PAINT;
921        }
922        if prev.spec != self.spec {
923            element.spec = self.spec;
924            element.ticks = self.spec.tick_fractions();
925            flags |= ChangeFlags::PAINT;
926        }
927        if prev.kind != self.kind {
928            element.kind = self.kind;
929            flags |= ChangeFlags::PAINT;
930        }
931        if prev.axis != self.axis || prev.top_to_bottom != self.top_to_bottom {
932            element.axis = self.axis;
933            element.reverse = self.reverse();
934            // The axis decides which constraint the control measures against,
935            // so a swapped axis is a relayout, not a repaint.
936            flags |= ChangeFlags::LAYOUT;
937        }
938        if prev.wavy != self.wavy || prev.wave != self.wave {
939            element.wavy = self.wavy;
940            element.wave = self.wave;
941            flags |= ChangeFlags::PAINT;
942        }
943        if prev.enabled != self.enabled {
944            element.enabled = self.enabled;
945            if !self.enabled {
946                // Disabling mid-drag disarms the machine, so a still-captured
947                // pointer cannot keep reporting values: upstream simply wires
948                // no gesture at all while disabled
949                // (`m3e_slider_build.dart:329-331`).
950                element.drag = Drag::default();
951                element.interaction.set_pressed(false);
952                element.interaction.set_dragged(false);
953                element.last_reported = None;
954            }
955            flags |= ChangeFlags::PAINT;
956        }
957        if prev.haptic != self.haptic {
958            element.haptic = self.haptic;
959        }
960        if prev.overrides != self.overrides {
961            element.overrides = self.overrides;
962            flags |= ChangeFlags::LAYOUT;
963        }
964        if !same_icon_source(prev.icon, self.icon) {
965            element.icon = self.icon;
966            element.icon_geom = resolve_icon(self.icon);
967            flags |= ChangeFlags::PAINT;
968        }
969        if prev.icon_position != self.icon_position {
970            element.icon_position = self.icon_position;
971            flags |= ChangeFlags::PAINT;
972        }
973        if prev.track_icons != self.track_icons {
974            element.track_icons = self.track_icons;
975            element.track_icon_geom = resolve_track_icons(self.track_icons.as_ref());
976            flags |= ChangeFlags::PAINT;
977        }
978        flags
979    }
980}
981
982/// Resolve all four inset track-icon slots into cached geometry.
983fn resolve_track_icons(icons: Option<&SliderTrackIcons>) -> [Option<(kurbo::BezPath, f64)>; 4] {
984    let Some(icons) = icons else {
985        return [None, None, None, None];
986    };
987    [
988        resolve_icon(icons.active_start),
989        resolve_icon(icons.active_end),
990        resolve_icon(icons.inactive_start),
991        resolve_icon(icons.inactive_end),
992    ]
993}
994
995impl SliderWidget {
996    /// The drag machine's per-event inputs. The dedup baseline is the last
997    /// value already reported this drag, falling back to the confirmed one.
998    fn drag_ctx(&self, extent: f64) -> DragCtx {
999        DragCtx {
1000            spec: self.spec,
1001            current: self.last_reported.unwrap_or(self.value),
1002            extent,
1003            reverse: self.reverse,
1004        }
1005    }
1006
1007    /// The axis map for a paint or event pass over `size`.
1008    fn axis_map(&self, size: Size) -> AxisMap {
1009        AxisMap::new(self.axis, size, self.reverse)
1010    }
1011
1012    /// Apply one drag outcome: queue the haptic, report the value, settle.
1013    ///
1014    /// The haptic is queued *before* the callback runs, the same order the
1015    /// reference fires it in (`m3e_sliders.dart:600-610`); the frame clock
1016    /// decides when it actually reaches the hook.
1017    fn apply(&mut self, out: DragOutcome, ctx: &mut EventCtx) -> EventResult {
1018        if !out.handled {
1019            return EventResult::Ignored;
1020        }
1021        if out.capture {
1022            ctx.capture_pointer();
1023        }
1024        if let Some(value) = out.value {
1025            if self.haptic != HapticSignal::None {
1026                let cadence = if self.spec.divisions.is_some() {
1027                    HapticCadence::Immediate
1028                } else {
1029                    HapticCadence::Throttled
1030                };
1031                self.haptics.queue(self.haptic, cadence);
1032            }
1033            self.last_reported = Some(value);
1034            (self.on_changed)(ctx, value);
1035        }
1036        if out.end {
1037            let settled = self.last_reported.unwrap_or(self.value);
1038            if let Some(on_change_end) = &mut self.on_change_end {
1039                on_change_end(ctx, settled);
1040            }
1041        }
1042        self.interaction.set_pressed(self.drag.pressed);
1043        self.interaction.set_dragged(self.drag.dragging);
1044        ctx.request_redraw();
1045        EventResult::Handled
1046    }
1047}
1048
1049impl Widget for SliderWidget {
1050    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1051        // The value indicator's label is shaped every pass, whether or not it
1052        // is currently shown: a press must not have to wait for a relayout to
1053        // put text on screen.
1054        let style = label_large(Theme::from_layout_ctx(ctx));
1055        self.indicator.layout(ctx, &style);
1056
1057        // The track fills the primary axis and takes its natural thickness
1058        // across it. A vertical slider transposes exactly that rule; upstream
1059        // instead lets a vertical slider's *cross* axis fill the incoming
1060        // width too (`m3e_slider_build.dart:122-126`), which is a
1061        // `LayoutBuilder`/`Stack` artifact — it would make an unconstrained
1062        // vertical slider as wide as its parent.
1063        let cross = HANDLE_HEIGHT.max(self.overrides.thumb_length());
1064        let available = match self.axis {
1065            SliderAxis::Horizontal => bc.max().width,
1066            SliderAxis::Vertical => bc.max().height,
1067        };
1068        let extent = if available.is_finite() {
1069            available
1070        } else {
1071            DEFAULT_EXTENT
1072        };
1073        let size = match self.axis {
1074            SliderAxis::Horizontal => Size::new(extent, cross),
1075            SliderAxis::Vertical => Size::new(cross, extent),
1076        };
1077        bc.constrain(size)
1078    }
1079
1080    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1081        let theme = Theme::from_paint_ctx(ctx);
1082        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
1083        let colors = resolve_colors(theme, self.enabled);
1084        let focused = ctx.has_focus();
1085        let o = ctx.origin();
1086        let map = self.axis_map(ctx.size());
1087        let extent = map.extent();
1088
1089        let fraction = self.spec.fraction(self.value);
1090        let thumb_primary = fraction * extent;
1091        // The gap is measured from the *instant* handle thickness even while
1092        // the handle's own width is still gliding — the reference's own split
1093        // (see `TrackMetrics::resolve`). A vertical slider's own token
1094        // (`verticalHandleHeight`) resolves to the same 4/2 pair, so the axis
1095        // makes no difference here.
1096        let handle_thickness = if self.drag.pressed {
1097            PRESSED_HANDLE_WIDTH
1098        } else {
1099            HANDLE_WIDTH
1100        };
1101        let track_thickness = self.overrides.track_thickness();
1102        let geometry = SliderGeometry {
1103            extent,
1104            active_start_fraction: 0.0,
1105            active_end_fraction: fraction,
1106            reverse: self.reverse,
1107            kind: self.kind,
1108            handle_thickness,
1109            handle_gap: HANDLE_GAP
1110                + if focused {
1111                    core::FOCUS_HANDLE_GAP_EXTRA
1112                } else {
1113                    0.0
1114                },
1115            track_thickness,
1116            corner_radius: self.overrides.corner_radius(),
1117        };
1118
1119        // The relocating icon's dock target is geometry-derived, so it is
1120        // resolved before the animation channels advance (the crate's
1121        // lazily-started fling idiom — see `crate::switch`).
1122        let dock = self.icon_dock(&geometry, map.flip(thumb_primary));
1123        if let Some(dock) = &dock
1124            && dock.docked != self.dock_target
1125        {
1126            // Only the sign matters: the displacement drives the spring.
1127            let velocity = if dock.docked { 1e-3 } else { -1e-3 };
1128            self.dock_anim.fling(velocity, core::DOCK_SPRING);
1129            self.dock_target = dock.docked;
1130        }
1131        if self.drag.pressed != self.press_target {
1132            self.press_anim
1133                .animate_to(if self.drag.pressed { 1.0 } else { 0.0 });
1134            self.press_target = self.drag.pressed;
1135        }
1136        let press_animating = self.press_anim.advance(ctx.frame_time());
1137        let dock_animating = self.dock_anim.advance(ctx.frame_time());
1138        if press_animating || dock_animating {
1139            ctx.request_frame();
1140        }
1141        let wave = self.wavy.then(|| {
1142            let factor = self.amplitude_factor(fraction);
1143            wave_paint(&mut self.wave_clock, self.wave, factor, ctx, reduce_motion)
1144        });
1145        // The haptic queued by the last event pass resolves against this
1146        // frame's clock (see `core::HapticScheduler`).
1147        if let Some(signal) = self.haptics.take(ctx.frame_time()) {
1148            (self.haptic_sink)(signal);
1149        }
1150
1151        if let Some(metrics) = TrackMetrics::resolve(&geometry) {
1152            paint_track(scene, o, &map, &metrics, track_thickness, wave, &colors);
1153            // A relocating icon takes the stop indicators' place
1154            // (`m3e_slider_build.dart:227`'s `drawDots`).
1155            if self.icon.is_none() {
1156                let spec = core::DotSpec {
1157                    stop_size: self.overrides.dot_size(),
1158                    tick_size: self.overrides.tick_size(),
1159                    edge_inset: self.overrides.dot_spacing(),
1160                };
1161                paint_dots(scene, o, &map, &metrics, &self.ticks, &spec, &colors);
1162            }
1163        }
1164
1165        self.paint_track_icons(scene, o, &map, fraction, &colors);
1166
1167        let thumb_thickness =
1168            HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * self.press_anim.value_clamped();
1169        let thumb = map.place_thumb(
1170            thumb_primary,
1171            thumb_thickness,
1172            self.overrides.thumb_length(),
1173        );
1174        paint_thumb(
1175            scene,
1176            o,
1177            thumb,
1178            focused,
1179            self.interaction.resolve_opacity(),
1180            &colors,
1181        );
1182
1183        if let (Some(dock), Some((path, design))) = (dock, &self.icon_geom) {
1184            // Widget space, like the reference's own icon layer — see
1185            // `variants`' module docs.
1186            let center = dock.center_at(self.dock_anim.value_clamped());
1187            let icon_size = self.overrides.icon_size();
1188            let color = if dock.over_active(center, map.flip(thumb_primary), self.reverse) {
1189                colors.on_active
1190            } else {
1191                colors.on_inactive
1192            };
1193            paint_icon(
1194                scene,
1195                o,
1196                path,
1197                *design,
1198                map.widget_square(center, icon_size),
1199                icon_size,
1200                color,
1201            );
1202        }
1203
1204        // The value indicator floats outside the control while it is pressed
1205        // (`m3e_slider_build.dart:439-449`).
1206        if self.drag.pressed {
1207            paint_value_indicator(scene, o, &map, &self.indicator, thumb_primary, &colors);
1208        }
1209    }
1210
1211    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1212        let InputEvent::Pointer(p) = event else {
1213            return EventResult::Ignored;
1214        };
1215        let map = self.axis_map(ctx.size());
1216        let extent = map.extent();
1217        let primary = map.event_primary(p.position);
1218        match p.phase {
1219            PointerPhase::Down => {
1220                if !self.enabled || !presses(p) {
1221                    return EventResult::Ignored;
1222                }
1223                let out = self.drag.down(primary, &self.drag_ctx(extent));
1224                self.apply(out, ctx)
1225            }
1226            PointerPhase::Move => {
1227                let out = self.drag.moved(primary, &self.drag_ctx(extent));
1228                self.apply(out, ctx)
1229            }
1230            PointerPhase::Up => {
1231                let out = self.drag.up();
1232                self.apply(out, ctx)
1233            }
1234            PointerPhase::Cancel => {
1235                // No callback, no state reach: see `core::Drag::cancel`.
1236                let out = self.drag.cancel();
1237                if !out.handled {
1238                    return EventResult::Ignored;
1239                }
1240                self.interaction.set_pressed(false);
1241                self.interaction.set_dragged(false);
1242                ctx.request_redraw();
1243                EventResult::Handled
1244            }
1245        }
1246    }
1247
1248    fn semantics(&self, ctx: &mut SemanticsCtx) {
1249        ctx.push_node(Role::Slider, |node| {
1250            node.set_numeric_value(self.value);
1251            node.set_min_numeric_value(self.spec.min);
1252            node.set_max_numeric_value(self.spec.max);
1253        });
1254    }
1255}
1256
1257impl SliderWidget {
1258    /// The relocating icon's dock geometry, or `None` when this slider shows
1259    /// no icon. Standard tracks only, as upstream
1260    /// (`m3e_slider_build.dart:464-466`), and never on a discrete slider (the
1261    /// pair upstream asserts against).
1262    fn icon_dock(&self, geometry: &SliderGeometry, thumb_primary: f64) -> Option<core::IconDock> {
1263        if self.icon.is_none()
1264            || self.kind != SliderTrackKind::Standard
1265            || self.spec.divisions.is_some()
1266        {
1267            return None;
1268        }
1269        Some(core::icon_dock(&IconDockInput {
1270            extent: geometry.extent,
1271            thumb_primary,
1272            icon_size: self.overrides.icon_size(),
1273            edge_inset: self.overrides.icon_edge_inset(),
1274            handle_thickness: geometry.handle_thickness,
1275            position: self.icon_position,
1276            reverse: geometry.reverse,
1277        }))
1278    }
1279
1280    /// Paint whichever inset track icons fit their segment this frame.
1281    fn paint_track_icons(
1282        &self,
1283        scene: &mut dyn PaintScene,
1284        origin: Point,
1285        map: &AxisMap,
1286        fraction: f64,
1287        colors: &SliderColors,
1288    ) {
1289        let Some(icons) = &self.track_icons else {
1290            return;
1291        };
1292        let size = icons.size();
1293        for placement in
1294            core::track_icon_placements(map.extent(), fraction, self.kind, size, icons.present())
1295        {
1296            let Some((path, design)) = &self.track_icon_geom[placement.slot.index()] else {
1297                continue;
1298            };
1299            let color = if placement.active {
1300                colors.on_active
1301            } else {
1302                colors.on_inactive
1303            };
1304            paint_icon(
1305                scene,
1306                origin,
1307                path,
1308                *design,
1309                map.place_leading_square(placement.primary, size),
1310                size,
1311                color,
1312            );
1313        }
1314    }
1315
1316    /// This frame's amplitude factor, in the reference's precedence order.
1317    fn amplitude_factor(&self, progress: f64) -> f64 {
1318        variants::amplitude_factor(
1319            progress,
1320            self.wave.amplitude,
1321            self.amplitude_for_progress.as_deref(),
1322        )
1323    }
1324}
1325
1326/// The resolved wave inputs for one paint pass.
1327#[derive(Clone, Copy, Debug, PartialEq)]
1328struct WavePaint {
1329    /// Peak cross-axis offset, in logical px (the token amplitude already
1330    /// scaled by this frame's amplitude factor).
1331    amplitude: f64,
1332    wavelength: f64,
1333    /// Travel phase, in radians.
1334    phase: f64,
1335}
1336
1337/// Advance a wavy track's phase clock for this frame and resolve its paint
1338/// inputs.
1339///
1340/// The wave is a perpetual decorative loop: it asks for a *paced* frame, and
1341/// under `reduce_motion` it freezes wherever its phase sits and asks for
1342/// nothing at all (`docs/WIDGETS_CODE_STANDARDS.md`, the same shape
1343/// [`crate::progress`]/[`crate::loading_indicator`] take). It is still drawn
1344/// while frozen — only its travel stops.
1345fn wave_paint(
1346    clock: &mut WaveClock,
1347    wave: WaveOverrides,
1348    amplitude_factor: f64,
1349    ctx: &mut PaintCtx,
1350    reduce_motion: bool,
1351) -> WavePaint {
1352    if !reduce_motion {
1353        clock.step(ctx.frame_time());
1354        ctx.request_frame_paced();
1355    }
1356    WavePaint {
1357        amplitude: WAVE_AMPLITUDE * amplitude_factor,
1358        wavelength: wave.wavelength(),
1359        phase: clock.radians(&wave),
1360    }
1361}
1362
1363/// Paint a resolved track: every segment in the reference's own order, with
1364/// the active one stroked as a traveling wave when `wave` is set.
1365fn paint_track(
1366    scene: &mut dyn PaintScene,
1367    origin: Point,
1368    map: &AxisMap,
1369    metrics: &TrackMetrics,
1370    track_thickness: f64,
1371    wave: Option<WavePaint>,
1372    colors: &SliderColors,
1373) {
1374    // `segments`' flag is RTL, which stays false in this port; a vertical
1375    // slider's mirroring is the axis map's job instead (see `variants`).
1376    for segment in metrics.segments(false) {
1377        let color = match segment.role {
1378            SegmentRole::Active => colors.active_track,
1379            SegmentRole::Inactive => colors.inactive_track,
1380        };
1381        // The reference's own split: only the active segment waves, every
1382        // other one stays flat (`m3e_slider_track_painter.dart:226-235`).
1383        if let Some(wave) = wave.filter(|_| segment.role == SegmentRole::Active)
1384            && let Some(path) = variants::wave_path(
1385                map,
1386                segment.start,
1387                segment.end,
1388                track_thickness,
1389                wave.amplitude,
1390                wave.wavelength,
1391                wave.phase,
1392            )
1393        {
1394            // Stroked at the track's own thickness with round caps, so the
1395            // wave's tips land exactly where the flat segment's ends would.
1396            scene.stroke_path(origin, &path, track_thickness, &Brush::Solid(color));
1397            continue;
1398        }
1399        let placed = map.place_span(
1400            segment.start,
1401            segment.end,
1402            track_thickness,
1403            segment.start_corner,
1404            segment.end_corner,
1405        );
1406        scene.fill_rounded_rect_radii(
1407            Point::new(origin.x + placed.origin.x, origin.y + placed.origin.y),
1408            placed.size,
1409            placed.radii,
1410            color,
1411        );
1412    }
1413}
1414
1415/// Paint a track's stop indicators and discrete ticks.
1416fn paint_dots(
1417    scene: &mut dyn PaintScene,
1418    origin: Point,
1419    map: &AxisMap,
1420    metrics: &TrackMetrics,
1421    ticks: &[f64],
1422    spec: &core::DotSpec,
1423    colors: &SliderColors,
1424) {
1425    for dot in core::dots(metrics, ticks, spec) {
1426        let color = if dot.active {
1427            colors.active_tick
1428        } else {
1429            colors.inactive_tick
1430        };
1431        let at = map.place_square(dot.primary, dot.size);
1432        scene.fill_rounded_rect(
1433            Point::new(origin.x + at.x, origin.y + at.y),
1434            Size::new(dot.size, dot.size),
1435            dot.size / 2.0,
1436            color,
1437        );
1438    }
1439}
1440
1441/// Paint one thumb: its interaction halo, the handle itself, and the focus
1442/// outline when the control holds focus.
1443///
1444/// The reference paints no halo at all (Compose dropped the slider's state
1445/// layer); this crate keeps every interactive component on one interaction
1446/// substrate, so a dragged/pressed handle carries the same overlay every other
1447/// control does, at the focus ring's own footprint rather than a second
1448/// invented size.
1449fn paint_thumb(
1450    scene: &mut dyn PaintScene,
1451    origin: Point,
1452    thumb: kurbo::Rect,
1453    focused: bool,
1454    overlay: f32,
1455    colors: &SliderColors,
1456) {
1457    if overlay > 0.0 {
1458        let halo = core::focus_ring_rect(thumb);
1459        scene.fill_rounded_rect(
1460            Point::new(origin.x + halo.x0, origin.y + halo.y0),
1461            halo.size(),
1462            core::thumb_radius(halo),
1463            colors.thumb.multiply_alpha(overlay),
1464        );
1465    }
1466    scene.fill_rounded_rect(
1467        Point::new(origin.x + thumb.x0, origin.y + thumb.y0),
1468        thumb.size(),
1469        core::thumb_radius(thumb),
1470        colors.thumb,
1471    );
1472    if focused {
1473        let ring = core::focus_ring_rect(thumb);
1474        let inset = FOCUS_STROKE / 2.0;
1475        let path = RoundedRect::from_rect(
1476            ring.inset(-inset),
1477            (core::thumb_radius(ring) - inset).max(0.0),
1478        )
1479        .to_path(PATH_TOLERANCE);
1480        scene.stroke_path(origin, &path, FOCUS_STROKE, &Brush::Solid(colors.thumb));
1481    }
1482}
1483
1484/// Paint the floating value indicator for a thumb at `thumb_primary`.
1485fn paint_value_indicator(
1486    scene: &mut dyn PaintScene,
1487    origin: Point,
1488    map: &AxisMap,
1489    indicator: &Run,
1490    thumb_primary: f64,
1491    colors: &SliderColors,
1492) {
1493    let label = indicator.size();
1494    let at = map.value_indicator_origin(thumb_primary);
1495    let box_size = Size::new(
1496        label.width + 2.0 * VALUE_INDICATOR_PAD_X,
1497        label.height + 2.0 * VALUE_INDICATOR_PAD_Y,
1498    );
1499    let box_origin = Point::new(origin.x + at.x, origin.y + at.y);
1500    scene.fill_rounded_rect(
1501        box_origin,
1502        box_size,
1503        VALUE_INDICATOR_RADIUS,
1504        colors.value_indicator,
1505    );
1506    indicator.paint(
1507        Point::new(
1508            box_origin.x + VALUE_INDICATOR_PAD_X,
1509            box_origin.y + VALUE_INDICATOR_PAD_Y,
1510        ),
1511        colors.value_indicator_label,
1512        scene,
1513    );
1514}
1515
1516/// Paint one resolved icon path scaled into a `size`-square box at `local`
1517/// (widget-local coordinates), in `color`.
1518fn paint_icon(
1519    scene: &mut dyn PaintScene,
1520    origin: Point,
1521    path: &kurbo::BezPath,
1522    design: f64,
1523    local: Point,
1524    size: f64,
1525    color: Color,
1526) {
1527    let scale = if design > 0.0 { size / design } else { 1.0 };
1528    let transform = Affine::translate(Vec2::new(local.x, local.y)) * Affine::scale(scale);
1529    scene.fill_path(origin, &(transform * path.clone()), &Brush::Solid(color));
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535    use frust::FrameTime;
1536    use frust::authoring::scene::GlyphRun;
1537    use frust::authoring::{CornerRadii, PointerButton, PointerEvent};
1538    use kurbo::BezPath;
1539    use std::any::Any;
1540    use std::sync::Mutex;
1541
1542    /// The track every harness lays out and paints in.
1543    const WIDTH: f64 = 200.0;
1544    const SIZE: Size = Size::new(WIDTH, HANDLE_HEIGHT);
1545
1546    #[derive(Default)]
1547    struct Val {
1548        value: f64,
1549        changes: Vec<f64>,
1550        ends: Vec<f64>,
1551    }
1552
1553    fn view(value: f64) -> SliderView<Val> {
1554        slider::<Val, _>(value, |s: &mut Val, v: f64| {
1555            s.value = v;
1556            s.changes.push(v);
1557        })
1558        .on_change_end(|s: &mut Val, v: f64| s.ends.push(v))
1559    }
1560
1561    fn build(view: &SliderView<Val>) -> SliderWidget {
1562        let mut counter = 0u64;
1563        View::<Val>::build(view, &mut BuildCtx::new(&mut counter))
1564    }
1565
1566    fn widget(value: f64) -> SliderWidget {
1567        build(&view(value))
1568    }
1569
1570    fn ev(phase: PointerPhase, x: f64) -> InputEvent {
1571        InputEvent::Pointer(PointerEvent {
1572            phase,
1573            position: Point::new(x, HANDLE_HEIGHT / 2.0),
1574            button: PointerButton::Primary,
1575        })
1576    }
1577
1578    fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
1579        InputEvent::Pointer(PointerEvent {
1580            phase,
1581            position: Point::new(x, HANDLE_HEIGHT / 2.0),
1582            button: PointerButton::Secondary,
1583        })
1584    }
1585
1586    fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
1587        let state_any: &mut dyn Any = state;
1588        let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
1589        w.event(&mut ctx, event)
1590    }
1591
1592    /// Records every primitive these tests assert on.
1593    #[derive(Default)]
1594    struct Recorder {
1595        rrects: Vec<(Point, Size, f64, Color)>,
1596        radii: Vec<(Point, Size, CornerRadii, Color)>,
1597        strokes: Vec<(f64, Color)>,
1598        /// Every stroked path, in call order — the wave assertions read it.
1599        stroke_paths: Vec<BezPath>,
1600        paths: Vec<Color>,
1601        runs: Vec<(Point, Color)>,
1602    }
1603
1604    impl PaintScene for Recorder {
1605        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1606        fn draw_text(&mut self, _o: Point, _t: &str) {}
1607        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1608            self.rrects.push((o, s, radius, color));
1609        }
1610        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1611            self.radii.push((o, s, radii, color));
1612        }
1613        fn stroke_path(&mut self, _o: Point, p: &BezPath, width: f64, brush: &Brush) {
1614            let color = match brush {
1615                Brush::Solid(c) => *c,
1616                _ => Color::TRANSPARENT,
1617            };
1618            self.strokes.push((width, color));
1619            self.stroke_paths.push(p.clone());
1620        }
1621        fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
1622            if let Brush::Solid(color) = brush {
1623                self.paths.push(*color);
1624            }
1625        }
1626        fn draw_glyph_run(&mut self, run: GlyphRun) {
1627            let color = match run.brush {
1628                Brush::Solid(c) => c,
1629                _ => Color::TRANSPARENT,
1630            };
1631            let t = run.transform.translation();
1632            self.runs.push((Point::new(t.x, t.y), color));
1633        }
1634    }
1635
1636    impl Recorder {
1637        /// The thumb: the one uniform rounded rect exactly as tall as the
1638        /// handle (the halo is taller, the dots and the indicator shorter).
1639        fn thumb(&self) -> (Point, Size, f64, Color) {
1640            *self
1641                .rrects
1642                .iter()
1643                .find(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
1644                .expect("every pass paints a thumb")
1645        }
1646    }
1647
1648    fn layout(w: &mut SliderWidget) -> Size {
1649        let mut tcx = TextContext::new();
1650        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1651        w.layout(&mut ctx, &BoxConstraints::loose(SIZE))
1652    }
1653
1654    fn paint(w: &mut SliderWidget, theme: Option<&Theme>) -> Recorder {
1655        paint_frame(w, theme, SIZE).rec
1656    }
1657
1658    /// One paint pass plus the frame requests it made — what the wave's
1659    /// pacing and reduce-motion assertions read.
1660    struct Frame {
1661        rec: Recorder,
1662        needs_frame: bool,
1663        paced_only: bool,
1664    }
1665
1666    fn paint_frame(w: &mut SliderWidget, theme: Option<&Theme>, size: Size) -> Frame {
1667        let mut rec = Recorder::default();
1668        let mut ctx = match theme {
1669            Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
1670            None => PaintCtx::new(Point::ZERO, size),
1671        };
1672        w.paint(&mut ctx, &mut rec);
1673        Frame {
1674            needs_frame: ctx.needs_frame(),
1675            paced_only: ctx.needs_frame_paced_only(),
1676            rec,
1677        }
1678    }
1679
1680    // -- value reporting ----------------------------------------------------
1681
1682    #[test]
1683    fn down_move_up_reports_in_order_and_settles_once() {
1684        let mut w = widget(0.0);
1685        let mut state = Val::default();
1686        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
1687        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
1688        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 260.0));
1689        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 260.0));
1690        assert_eq!(state.changes, vec![0.25, 0.5, 1.0], "clamped at the end");
1691        assert_eq!(state.ends, vec![1.0], "settles on the last reported value");
1692        assert_eq!(
1693            w.value, 0.0,
1694            "a controlled slider never writes its own value"
1695        );
1696    }
1697
1698    #[test]
1699    fn a_secondary_press_never_captures_or_reports() {
1700        let mut w = widget(0.0);
1701        let mut state = Val::default();
1702        let result = dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
1703        assert!(matches!(result, EventResult::Ignored));
1704        assert!(!w.drag.dragging);
1705        assert!(state.changes.is_empty());
1706        // A move after it is an ordinary uncaptured pass, not a drag.
1707        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
1708        assert!(state.changes.is_empty());
1709    }
1710
1711    #[test]
1712    fn a_hover_move_is_ignored_and_requests_nothing() {
1713        let mut w = widget(0.3);
1714        let mut state = Val::default();
1715        let state_any: &mut dyn Any = &mut state;
1716        let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
1717        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
1718        assert!(matches!(result, EventResult::Ignored));
1719        assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
1720        assert!(state.changes.is_empty());
1721    }
1722
1723    #[test]
1724    fn cancel_disarms_without_reporting_or_settling() {
1725        let mut w = widget(0.0);
1726        let mut state = Val::default();
1727        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1728        assert!(w.drag.dragging);
1729        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
1730        assert!(!w.drag.dragging && !w.drag.pressed);
1731        assert!(state.ends.is_empty(), "a Cancel never reaches app state");
1732        // And a follow-up move is a plain hover again.
1733        let before = state.changes.len();
1734        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
1735        assert_eq!(state.changes.len(), before);
1736    }
1737
1738    #[test]
1739    fn disabled_ignores_every_pointer_phase() {
1740        let mut w = build(&view(0.0).enabled(false));
1741        let mut state = Val::default();
1742        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1743        assert!(!w.drag.dragging);
1744        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
1745        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 120.0));
1746        assert!(state.changes.is_empty() && state.ends.is_empty());
1747    }
1748
1749    #[test]
1750    fn disabling_mid_drag_disarms_the_machine() {
1751        let mut w = widget(0.0);
1752        let mut state = Val::default();
1753        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1754        assert!(w.drag.dragging);
1755
1756        let mut counter = 0u64;
1757        let prev = view(0.0);
1758        let next = view(0.0).enabled(false);
1759        View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1760        assert!(!w.drag.dragging && !w.drag.pressed);
1761
1762        let before = state.changes.len();
1763        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 160.0));
1764        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 160.0));
1765        assert_eq!(
1766            state.changes.len(),
1767            before,
1768            "a captured drag stops reporting"
1769        );
1770        assert!(state.ends.is_empty());
1771    }
1772
1773    #[test]
1774    fn a_discrete_drag_reports_only_when_it_crosses_a_step() {
1775        let mut w = build(&view(0.0).range(0.0, 10.0).divisions(10));
1776        let mut state = Val::default();
1777        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1778        // Still inside step 5's own catchment: nothing new to report.
1779        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 103.0));
1780        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 121.0));
1781        assert_eq!(state.changes, vec![5.0, 6.0]);
1782    }
1783
1784    #[test]
1785    fn a_drag_deduplicates_against_its_own_last_report_until_a_rebuild_confirms_one() {
1786        let mut w = widget(0.0);
1787        let mut state = Val::default();
1788        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1789        assert_eq!(w.last_reported, Some(0.5));
1790        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
1791        assert_eq!(state.changes, vec![0.5], "the repeat is dropped");
1792
1793        let mut counter = 0u64;
1794        let prev = view(0.0);
1795        let next = view(0.5);
1796        View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1797        assert_eq!(w.value, 0.5);
1798        assert_eq!(w.last_reported, None, "the confirmed value takes over");
1799    }
1800
1801    #[test]
1802    fn rebuild_adopts_value_and_bounds_without_self_mutation() {
1803        let mut counter = 0u64;
1804        let prev = view(0.2);
1805        let mut w = build(&prev);
1806        assert_eq!(w.value, 0.2);
1807        let next = view(0.8).range(0.0, 10.0).divisions(5);
1808        let flags = View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1809        assert_eq!(w.value, 0.8);
1810        assert_eq!(w.spec.max, 10.0);
1811        assert_eq!(w.ticks.len(), 6, "divisions + 1 marks");
1812        assert!(flags.needs_paint());
1813        assert!(
1814            flags.needs_layout(),
1815            "the indicator label changed, so it must be re-shaped"
1816        );
1817    }
1818
1819    // -- geometry / paint ---------------------------------------------------
1820
1821    #[test]
1822    fn layout_fills_the_width_and_takes_the_handle_height() {
1823        let mut w = widget(0.5);
1824        assert_eq!(layout(&mut w), SIZE);
1825
1826        // An unbounded width falls back to the default extent.
1827        let mut tcx = TextContext::new();
1828        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1829        let unbounded = w.layout(
1830            &mut ctx,
1831            &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1832        );
1833        assert_eq!(unbounded.width, DEFAULT_EXTENT);
1834    }
1835
1836    #[test]
1837    fn the_track_paints_two_segments_with_asymmetric_radii_and_a_stop_dot() {
1838        let mut w = widget(0.5);
1839        layout(&mut w);
1840        let rec = paint(&mut w, None);
1841
1842        assert_eq!(rec.radii.len(), 2, "inactive remainder, then active span");
1843        let (inactive_origin, inactive_size, inactive_radii, inactive_color) = rec.radii[0];
1844        let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
1845        assert_eq!(active_color, PRIMARY);
1846        assert_eq!(inactive_color, SECONDARY_CONTAINER);
1847        assert_eq!(active_origin, Point::new(0.0, 14.0), "vertically centred");
1848        assert_eq!(active_size, Size::new(92.0, TRACK_HEIGHT));
1849        assert_eq!(inactive_origin.x, 108.0);
1850        assert_eq!(inactive_size.width, 92.0);
1851        // Outer ends full, gap-facing ends small.
1852        assert_eq!(active_radii.top_left, TRACK_CORNER_RADIUS);
1853        assert_eq!(active_radii.top_right, core::TRACK_INSIDE_CORNER);
1854        assert_eq!(inactive_radii.top_left, core::TRACK_INSIDE_CORNER);
1855        assert_eq!(inactive_radii.top_right, TRACK_CORNER_RADIUS);
1856
1857        // One trailing stop dot plus the thumb, both uniform rounded rects.
1858        assert_eq!(rec.rrects.len(), 2, "stop dot, then thumb");
1859        let (dot_origin, dot_size, dot_radius, dot_color) = rec.rrects[0];
1860        assert_eq!(
1861            dot_size,
1862            Size::new(STOP_INDICATOR_SIZE, STOP_INDICATOR_SIZE)
1863        );
1864        assert_eq!(dot_radius, STOP_INDICATOR_SIZE / 2.0);
1865        assert_eq!(dot_origin, Point::new(190.0, 20.0), "centred at 192");
1866        assert_eq!(dot_color, PRIMARY, "a stop takes the active-track ink");
1867
1868        let (thumb_origin, thumb_size, thumb_radius, thumb_color) = rec.rrects[1];
1869        assert_eq!(thumb_size, Size::new(HANDLE_WIDTH, HANDLE_HEIGHT));
1870        assert_eq!(thumb_origin, Point::new(98.0, 0.0));
1871        assert_eq!(thumb_radius, HANDLE_HEIGHT / 2.0);
1872        assert_eq!(thumb_color, PRIMARY);
1873    }
1874
1875    #[test]
1876    fn a_centered_track_paints_three_segments() {
1877        let mut w = build(&centered_slider::<Val, _>(0.75, |_s, _v| {}));
1878        layout(&mut w);
1879        let rec = paint(&mut w, None);
1880        assert_eq!(rec.radii.len(), 3);
1881        assert_eq!(rec.radii[2].3, PRIMARY, "the active span paints last");
1882        assert_eq!(rec.radii[2].0.x, 100.0, "growing from the midpoint");
1883    }
1884
1885    #[test]
1886    fn themed_paint_resolves_the_slider_roles() {
1887        let theme = crate::baseline();
1888        let scheme = theme.scheme();
1889        let mut w = widget(0.5);
1890        layout(&mut w);
1891        let rec = paint(&mut w, Some(&theme));
1892        assert_eq!(rec.radii[0].3, scheme.secondary_container);
1893        assert_eq!(rec.radii[1].3, scheme.primary);
1894        assert_eq!(rec.rrects[1].3, scheme.primary, "thumb");
1895    }
1896
1897    #[test]
1898    fn a_disabled_slider_dims_active_and_inactive_by_their_own_opacities() {
1899        let theme = crate::baseline();
1900        let scheme = theme.scheme();
1901        let mut w = build(&view(0.5).enabled(false));
1902        layout(&mut w);
1903        let rec = paint(&mut w, Some(&theme));
1904        assert_eq!(
1905            rec.radii[0].3,
1906            scheme.on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
1907        );
1908        assert_eq!(
1909            rec.radii[1].3,
1910            scheme.on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
1911        );
1912    }
1913
1914    #[test]
1915    fn discrete_ticks_paint_alongside_the_stops() {
1916        let mut w = build(&view(0.5).divisions(4));
1917        layout(&mut w);
1918        let rec = paint(&mut w, None);
1919        // One surviving stop + two interior ticks + the thumb.
1920        assert_eq!(rec.rrects.len(), 4);
1921        assert_eq!(rec.rrects[1].0.x, 52.0, "tick at 0.25, centred on 54");
1922        assert_eq!(
1923            rec.rrects[1].3, SECONDARY_CONTAINER,
1924            "a tick on the active track takes the inactive-track ink"
1925        );
1926        assert_eq!(rec.rrects[2].3, PRIMARY, "and vice versa past the thumb");
1927    }
1928
1929    #[test]
1930    fn the_pressed_thumb_compresses_over_the_morph_and_shows_the_value_indicator() {
1931        let mut w = widget(0.5);
1932        layout(&mut w);
1933        let mut state = Val::default();
1934
1935        // At rest: full-width thumb, no indicator container, no halo.
1936        let rec = paint(&mut w, None);
1937        assert_eq!(rec.rrects.len(), 2, "stop dot + thumb only");
1938        assert_eq!(rec.rrects[1].1.width, HANDLE_WIDTH);
1939        assert!(rec.runs.is_empty(), "no indicator label at rest");
1940
1941        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
1942        // The morph starts on the first paint after the press and seeds its
1943        // clock there, so the thumb is still at its resting width.
1944        let rec = paint(&mut w, None);
1945        assert!(w.press_anim.is_animating(), "a 100ms ease-out started");
1946        assert_eq!(rec.thumb().1.width, HANDLE_WIDTH);
1947        // Halo + indicator container are already there.
1948        let halo = rec
1949            .rrects
1950            .iter()
1951            .find(|(_, s, _, _)| s.width == HANDLE_WIDTH + core::FOCUS_INFLATE)
1952            .expect("a dragged handle paints its halo");
1953        assert!(
1954            (halo.3.components[3] - crate::interaction::DRAGGED_OPACITY).abs() < 1e-6,
1955            "the halo takes the dragged precedence opacity"
1956        );
1957        assert_eq!(rec.runs.len(), 1, "the value indicator's label");
1958
1959        // Halfway through the morph the width is between the two ends; at the
1960        // end it is exactly the pressed width.
1961        w.press_anim.advance(secs(0.05));
1962        let mid =
1963            HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * w.press_anim.value_clamped();
1964        assert!(mid < HANDLE_WIDTH && mid > PRESSED_HANDLE_WIDTH, "{mid}");
1965        w.press_anim.advance(secs(0.2));
1966        assert!(!w.press_anim.is_animating(), "settled inside 100ms");
1967        let rec = paint(&mut w, None);
1968        assert_eq!(rec.thumb().1.width, PRESSED_HANDLE_WIDTH);
1969
1970        // Releasing hides the indicator and reverses the morph.
1971        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0));
1972        let rec = paint(&mut w, None);
1973        assert!(rec.runs.is_empty(), "the indicator hides on release");
1974        assert!(w.press_anim.is_animating(), "and the morph reverses");
1975    }
1976
1977    #[test]
1978    fn the_focus_ring_strokes_only_while_the_control_holds_focus() {
1979        // Nothing focuses a slider today (see the module docs), so the ring is
1980        // exercised through the paint-time focus read directly.
1981        let mut w = widget(0.5);
1982        layout(&mut w);
1983        assert!(paint(&mut w, None).strokes.is_empty());
1984
1985        let ring = core::focus_ring_rect(core::thumb_rect(
1986            100.0,
1987            HANDLE_HEIGHT / 2.0,
1988            HANDLE_WIDTH,
1989            HANDLE_HEIGHT,
1990        ));
1991        assert_eq!(ring.width(), HANDLE_WIDTH + core::FOCUS_INFLATE);
1992        assert_eq!(ring.height(), HANDLE_HEIGHT + core::FOCUS_INFLATE);
1993    }
1994
1995    #[test]
1996    fn a_relocating_icon_replaces_the_stop_dots_and_docks_beside_the_thumb() {
1997        let mut w = build(&view(0.9).icon(crate::icons::CHECK));
1998        layout(&mut w);
1999        let rec = paint(&mut w, None);
2000        assert_eq!(rec.rrects.len(), 1, "the thumb only — no stop dots");
2001        assert_eq!(rec.paths.len(), 1, "the icon paints as a path");
2002        assert!(w.dock_target, "a thumb at 180 is inside the dock limit");
2003        assert!(w.dock_anim.is_animating(), "the dock spring started");
2004
2005        // Far from the icon it undocks again.
2006        let mut counter = 0u64;
2007        let prev = view(0.9).icon(crate::icons::CHECK);
2008        let next = view(0.1).icon(crate::icons::CHECK);
2009        View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
2010        paint(&mut w, None);
2011        assert!(!w.dock_target);
2012    }
2013
2014    #[test]
2015    fn inset_track_icons_paint_per_segment() {
2016        let icons = SliderTrackIcons {
2017            active_start: Some(crate::icons::CHECK),
2018            inactive_end: Some(crate::icons::CLOSE),
2019            ..SliderTrackIcons::new()
2020        };
2021        let mut w = build(&view(0.5).track_icons(icons));
2022        layout(&mut w);
2023        let rec = paint(&mut w, None);
2024        assert_eq!(rec.paths.len(), 2, "one per filled slot");
2025        assert_eq!(rec.paths[0], ON_PRIMARY, "over the active track");
2026        assert_eq!(rec.paths[1], ON_SURFACE_VARIANT, "over the inactive track");
2027    }
2028
2029    // -- vertical -----------------------------------------------------------
2030    //
2031    // The vertical harness transposes the horizontal one: the same 200px
2032    // track, now 200 tall and `HANDLE_HEIGHT` wide.
2033
2034    const V_SIZE: Size = Size::new(HANDLE_HEIGHT, WIDTH);
2035
2036    fn v_view(value: f64) -> SliderView<Val> {
2037        vertical_slider::<Val, _>(value, |s: &mut Val, v: f64| {
2038            s.value = v;
2039            s.changes.push(v);
2040        })
2041    }
2042
2043    fn v_layout(w: &mut SliderWidget) -> Size {
2044        let mut tcx = TextContext::new();
2045        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2046        w.layout(&mut ctx, &BoxConstraints::loose(V_SIZE))
2047    }
2048
2049    fn v_ev(phase: PointerPhase, y: f64) -> InputEvent {
2050        InputEvent::Pointer(PointerEvent {
2051            phase,
2052            position: Point::new(HANDLE_HEIGHT / 2.0, y),
2053            button: PointerButton::Primary,
2054        })
2055    }
2056
2057    fn v_dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
2058        let state_any: &mut dyn Any = state;
2059        let mut ctx = EventCtx::new(state_any, Point::ZERO, V_SIZE);
2060        w.event(&mut ctx, event)
2061    }
2062
2063    #[test]
2064    fn a_vertical_slider_fills_the_height_and_takes_the_handle_width_across_it() {
2065        let mut w = build(&v_view(0.5));
2066        assert_eq!(v_layout(&mut w), V_SIZE);
2067
2068        let mut tcx = TextContext::new();
2069        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2070        let unbounded = w.layout(
2071            &mut ctx,
2072            &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
2073        );
2074        assert_eq!(
2075            unbounded.height, DEFAULT_EXTENT,
2076            "the primary axis is y now"
2077        );
2078        assert_eq!(unbounded.width, HANDLE_HEIGHT);
2079    }
2080
2081    #[test]
2082    fn a_vertical_slider_maps_value_to_y_bottom_up() {
2083        let mut w = build(&v_view(0.25));
2084        v_layout(&mut w);
2085        let rec = paint_frame(&mut w, None, V_SIZE).rec;
2086
2087        // Painter space is value-oriented, so the active span still runs
2088        // `0..thumb`; the axis map mirrors it onto the *bottom* of the box.
2089        assert_eq!(rec.radii.len(), 2);
2090        let (inactive_origin, inactive_size, inactive_radii, _) = rec.radii[0];
2091        let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
2092        assert_eq!(active_color, PRIMARY);
2093        assert_eq!(
2094            active_origin,
2095            Point::new(14.0, 158.0),
2096            "42 tall, at the foot"
2097        );
2098        assert_eq!(active_size, Size::new(TRACK_HEIGHT, 42.0));
2099        assert_eq!(inactive_origin, Point::new(14.0, 0.0));
2100        assert_eq!(inactive_size, Size::new(TRACK_HEIGHT, 142.0));
2101        // The track's outer (bottom) end keeps the full radius, the
2102        // gap-facing one the small radius — mirrored with the span.
2103        assert_eq!(active_radii.bottom_left, TRACK_CORNER_RADIUS);
2104        assert_eq!(active_radii.top_left, core::TRACK_INSIDE_CORNER);
2105        assert_eq!(inactive_radii.top_left, TRACK_CORNER_RADIUS);
2106        assert_eq!(inactive_radii.bottom_left, core::TRACK_INSIDE_CORNER);
2107
2108        // The thumb is transposed too: handle *width* along y.
2109        let thumb = rec
2110            .rrects
2111            .iter()
2112            .find(|(_, s, _, _)| s.width == HANDLE_HEIGHT)
2113            .expect("a vertical thumb spans the cross axis");
2114        assert_eq!(thumb.0, Point::new(0.0, 148.0), "centred on y = 150");
2115        assert_eq!(thumb.1, Size::new(HANDLE_HEIGHT, HANDLE_WIDTH));
2116
2117        // And a press reads y, inverted: the top of the track is the maximum.
2118        let mut state = Val::default();
2119        v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 50.0));
2120        assert_eq!(state.changes, vec![0.75]);
2121        v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
2122        assert_eq!(state.changes.last(), Some(&1.0), "the top edge is the max");
2123    }
2124
2125    #[test]
2126    fn top_to_bottom_puts_the_minimum_at_the_top_instead() {
2127        let mut w = build(&v_view(0.25).top_to_bottom(true));
2128        v_layout(&mut w);
2129        let rec = paint_frame(&mut w, None, V_SIZE).rec;
2130        let (active_origin, active_size, _, _) = rec.radii[1];
2131        assert_eq!(active_origin, Point::new(14.0, 0.0), "grown from the top");
2132        assert_eq!(active_size.height, 42.0);
2133
2134        let mut state = Val::default();
2135        v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 100.0));
2136        assert_eq!(state.changes, vec![0.5], "y now runs with the value");
2137        v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
2138        assert_eq!(state.changes.last(), Some(&0.0), "the top edge is the min");
2139    }
2140
2141    #[test]
2142    fn a_vertical_centered_slider_grows_from_the_midpoint() {
2143        let mut w = build(&vertical_centered_slider::<Val, _>(0.75, |_s, _v| {}));
2144        v_layout(&mut w);
2145        let rec = paint_frame(&mut w, None, V_SIZE).rec;
2146        assert_eq!(
2147            rec.radii.len(),
2148            3,
2149            "both inactive ends plus the active span"
2150        );
2151        let (active_origin, active_size, _, _) = rec.radii[2];
2152        // Painter space [100, 142] mirrors onto [58, 100] — the midpoint is
2153        // its own mirror, so the span still touches the centre.
2154        assert_eq!(active_origin, Point::new(14.0, 58.0));
2155        assert_eq!(active_size.height, 42.0);
2156        assert_eq!(active_origin.y + active_size.height, 100.0, "the midpoint");
2157    }
2158
2159    // -- wavy ---------------------------------------------------------------
2160
2161    /// The points of a sampled wave path, which is moves and lines only.
2162    fn path_points(path: &BezPath) -> Vec<Point> {
2163        path.elements()
2164            .iter()
2165            .map(|el| match el {
2166                kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => *p,
2167                other => panic!("a sampled wave is moves and lines only: {other:?}"),
2168            })
2169            .collect()
2170    }
2171
2172    #[test]
2173    fn a_wavy_slider_strokes_its_active_span_instead_of_filling_it() {
2174        let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
2175        layout(&mut w);
2176        let frame = paint_frame(&mut w, None, SIZE);
2177
2178        assert_eq!(
2179            frame.rec.radii.len(),
2180            1,
2181            "only the inactive remainder fills"
2182        );
2183        assert_eq!(frame.rec.strokes.len(), 1, "the active span is a wave");
2184        assert_eq!(
2185            frame.rec.strokes[0],
2186            (TRACK_HEIGHT, PRIMARY),
2187            "stroked at the track's own thickness, in the active ink"
2188        );
2189        // The wave spans the flat segment's own ends, inset half a stroke so
2190        // its round caps land exactly where the rect's edges would.
2191        let points = path_points(&frame.rec.stroke_paths[0]);
2192        assert_eq!(points[0].x, TRACK_HEIGHT / 2.0);
2193        assert_eq!(points.last().unwrap().x, 92.0 - TRACK_HEIGHT / 2.0);
2194        assert!(
2195            points
2196                .iter()
2197                .any(|p| (p.y - HANDLE_HEIGHT / 2.0).abs() > 1.0),
2198            "and it actually leaves the centreline: {points:?}"
2199        );
2200        assert!(frame.paced_only, "a traveling wave is a cosmetic loop");
2201    }
2202
2203    #[test]
2204    fn the_wave_moves_as_its_phase_advances_and_freezes_under_reduce_motion() {
2205        let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
2206        layout(&mut w);
2207        let first = path_points(&paint(&mut w, None).stroke_paths[0]);
2208
2209        // Advancing the phase clock moves the whole wave (the widget's own
2210        // clock is stepped directly here: a test `PaintCtx` carries no shell
2211        // frame time — the same reason the haptic tests drive the scheduler).
2212        w.wave_clock.step(secs(0.05));
2213        let second = path_points(&paint(&mut w, None).stroke_paths[0]);
2214        assert_eq!(first.len(), second.len(), "the same span, re-sampled");
2215        assert!(
2216            first.iter().zip(second.iter()).all(|(a, b)| a.x == b.x),
2217            "the span itself does not move, only the wave on it"
2218        );
2219        // Two sine curves a phase apart still cross, so a *sample* may match;
2220        // the curve as a whole must have moved.
2221        let travel = first
2222            .iter()
2223            .zip(second.iter())
2224            .map(|(a, b)| (a.y - b.y).abs())
2225            .fold(0.0_f64, f64::max);
2226        assert!(
2227            travel > 0.1,
2228            "the wave must travel: {:?} vs {:?}",
2229            &first[..3],
2230            &second[..3]
2231        );
2232
2233        // Reduce-motion still draws the wave, but neither advances it nor
2234        // asks for another frame.
2235        let mut theme = crate::baseline();
2236        theme.motion.reduce_motion = true;
2237        let before = w.wave_clock.seconds();
2238        let frame = paint_frame(&mut w, Some(&theme), SIZE);
2239        assert_eq!(frame.rec.strokes.len(), 1, "the frozen wave is still drawn");
2240        assert_eq!(w.wave_clock.seconds(), before, "and its phase stands still");
2241        assert!(!frame.needs_frame, "a frozen loop requests no frames");
2242    }
2243
2244    #[test]
2245    fn the_amplitude_ramp_flattens_a_nearly_full_track_unless_overridden() {
2246        // 0.98 is past the ramp's upper boundary, so the wave collapses onto
2247        // the centreline rather than fighting the thumb gap.
2248        let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}));
2249        layout(&mut w);
2250        let flat = path_points(&paint(&mut w, None).stroke_paths[0]);
2251        assert!(flat.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0), "{flat:?}");
2252
2253        // An explicit amplitude outranks the ramp...
2254        let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}).amplitude(1.0));
2255        layout(&mut w);
2256        let waved = path_points(&paint(&mut w, None).stroke_paths[0]);
2257        assert!(waved.iter().any(|p| p.y != HANDLE_HEIGHT / 2.0));
2258
2259        // ...and a curve outranks both, including down to nothing.
2260        let mut w = build(
2261            &wavy_slider::<Val, _>(0.5, |_s, _v| {})
2262                .amplitude(1.0)
2263                .amplitude_for_progress(|_| 0.0),
2264        );
2265        layout(&mut w);
2266        let curved = path_points(&paint(&mut w, None).stroke_paths[0]);
2267        assert!(
2268            curved.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0),
2269            "{curved:?}"
2270        );
2271    }
2272
2273    #[test]
2274    fn a_wavy_centered_slider_waves_only_its_active_span() {
2275        let mut w = build(&wavy_centered_slider::<Val, _>(0.75, |_s, _v| {}));
2276        layout(&mut w);
2277        let rec = paint(&mut w, None);
2278        assert_eq!(rec.radii.len(), 2, "both inactive spans stay flat rects");
2279        assert_eq!(rec.strokes.len(), 1, "the centred active span waves");
2280        let points = path_points(&rec.stroke_paths[0]);
2281        assert_eq!(points[0].x, 100.0 + TRACK_HEIGHT / 2.0, "from the midpoint");
2282    }
2283
2284    #[test]
2285    fn a_flat_slider_never_asks_for_a_frame_it_does_not_need() {
2286        let mut w = widget(0.5);
2287        layout(&mut w);
2288        let frame = paint_frame(&mut w, None, SIZE);
2289        assert!(frame.rec.strokes.is_empty());
2290        assert!(!frame.needs_frame, "a resting flat slider is static");
2291    }
2292
2293    #[test]
2294    fn semantics_reports_the_value_and_its_bounds() {
2295        fn logic(_state: &mut Val) -> SliderView<Val> {
2296            slider::<Val, _>(4.0, |_s, _v| {}).range(0.0, 10.0)
2297        }
2298        let mut root: frust_core::RenderRoot<Val, SliderView<Val>> = frust_core::RenderRoot::new();
2299        let mut state = Val::default();
2300        let mut tcx = TextContext::new();
2301        root.rebuild(&mut logic, &mut state);
2302        // `layout_with_text`, not the bare `layout`: the value indicator's
2303        // label is shaped every layout pass, so this widget needs the text
2304        // context a real shell always threads.
2305        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
2306        let update = root.semantics();
2307        let (_, node) = update
2308            .nodes
2309            .iter()
2310            .find(|(_, n)| n.role() == Role::Slider)
2311            .expect("a slider contributes a Role::Slider node");
2312        assert_eq!(node.numeric_value(), Some(4.0));
2313        assert_eq!(node.min_numeric_value(), Some(0.0));
2314        assert_eq!(node.max_numeric_value(), Some(10.0));
2315    }
2316
2317    // -- haptics ------------------------------------------------------------
2318    //
2319    // One test fn, not several: the recorder below is a process-global
2320    // `Mutex`, and `MaterialHaptics`' own hook is a set-once `OnceLock` whose
2321    // install/rejection ordering `crate::interaction`'s tests already pin.
2322    // Counting through the widget's own sink keeps both concerns out of each
2323    // other's way (see `SliderView::haptic_sink`), and keeping every
2324    // assertion in one fn keeps the recorder's contents from depending on
2325    // which test the harness happens to run first.
2326
2327    static RECORDED: Mutex<Vec<HapticSignal>> = Mutex::new(Vec::new());
2328
2329    fn recording_sink(signal: HapticSignal) {
2330        RECORDED.lock().unwrap().push(signal);
2331    }
2332
2333    fn secs(s: f64) -> FrameTime {
2334        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2335    }
2336
2337    /// Paint at an explicit frame time by advancing the widget's own clock —
2338    /// `PaintCtx::for_test` lives behind `frust-core`'s `test-support`
2339    /// feature, which this crate's dev-dependency does not enable, so the
2340    /// haptic scheduler is stepped directly and the paint pass then drains it.
2341    fn paint_at(w: &mut SliderWidget, now: FrameTime) -> usize {
2342        let fired = w.haptics.take(now);
2343        if let Some(signal) = fired {
2344            (w.haptic_sink)(signal);
2345        }
2346        usize::from(fired.is_some())
2347    }
2348
2349    #[test]
2350    fn haptics_are_opt_in_then_paced_per_division_or_per_60ms() {
2351        RECORDED.lock().unwrap().clear();
2352
2353        // Opt-out by default: upstream skips the call entirely at
2354        // `M3EHapticFeedback.none`, so nothing is even queued.
2355        let mut w = build(&view(0.0).haptic_sink(recording_sink));
2356        let mut state = Val::default();
2357        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
2358        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
2359        assert!(
2360            !w.haptics.has_pending(),
2361            "no haptic queued at HapticSignal::None"
2362        );
2363        assert!(RECORDED.lock().unwrap().is_empty());
2364
2365        // Continuous, opted in: one tick per 60ms of frame clock. Frames step
2366        // at 16ms and the pointer moves on every one of them.
2367        let mut w = build(
2368            &view(0.0)
2369                .haptic(HapticSignal::SliderTick)
2370                .haptic_sink(recording_sink),
2371        );
2372        let mut state = Val::default();
2373        let mut fired = 0;
2374        for frame in 0..13 {
2375            dispatch(
2376                &mut w,
2377                &mut state,
2378                &ev(
2379                    if frame == 0 {
2380                        PointerPhase::Down
2381                    } else {
2382                        PointerPhase::Move
2383                    },
2384                    10.0 + f64::from(frame) * 10.0,
2385                ),
2386            );
2387            fired += paint_at(&mut w, secs(f64::from(frame) * 0.016));
2388        }
2389        assert_eq!(
2390            fired, 4,
2391            "192ms of drag: the first tick plus one per 60ms boundary"
2392        );
2393        assert_eq!(state.changes.len(), 13, "every frame still reports a value");
2394        assert_eq!(
2395            *RECORDED.lock().unwrap(),
2396            vec![HapticSignal::SliderTick; 4],
2397            "and each fired tick reached the sink"
2398        );
2399
2400        // Discrete: every accepted step ticks, however fast they arrive.
2401        RECORDED.lock().unwrap().clear();
2402        let mut w = build(
2403            &view(0.0)
2404                .range(0.0, 10.0)
2405                .divisions(10)
2406                .haptic(HapticSignal::Medium)
2407                .haptic_sink(recording_sink),
2408        );
2409        let mut state = Val::default();
2410        let mut fired = 0;
2411        for frame in 0..5 {
2412            dispatch(
2413                &mut w,
2414                &mut state,
2415                &ev(
2416                    if frame == 0 {
2417                        PointerPhase::Down
2418                    } else {
2419                        PointerPhase::Move
2420                    },
2421                    20.0 * f64::from(frame + 1),
2422                ),
2423            );
2424            fired += paint_at(&mut w, secs(f64::from(frame) * 0.001));
2425        }
2426        assert_eq!(fired, 5, "one tick per crossed division, unthrottled");
2427        assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::Medium; 5]);
2428    }
2429}