Skip to main content

frust_material/slider/
core.rs

1//! The variant-agnostic slider core: token constants, value↔geometry mapping,
2//! track/thumb/dot/indicator geometry, the pointer drag state machine, and the
3//! frame-clock haptic scheduler. [`super`] owns the `View`/`Widget` wiring and
4//! every paint call; nothing in this module touches a `PaintScene`, a `Theme`,
5//! or a callback.
6//!
7//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
8//! `lib/components/sliders/` — `m3e_sliders.dart`, `components/
9//! m3e_slider_build.dart`, `m3e_slider_track_painter.dart`,
10//! `m3e_slider_thumb.dart`, `m3e_slider_value_indicator.dart`,
11//! `m3e_slider_track_icons_overlay.dart`, `utils/m3e_slider_math.dart`,
12//! `utils/m3e_slider_track_paint_metrics.dart`, `utils/m3e_slider_dot_layout.dart`,
13//! `utils/m3e_slider_dot_geometry.dart`, `res/m3e_slider_tokens.dart`,
14//! `styles/m3e_slider_theme.dart` (retrieved 2026-08-19).
15//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
16//!
17//! # Primary-axis geometry, mapped once at paint
18//!
19//! Every geometry function here works in **primary-axis** coordinates — a
20//! scalar offset along the track, `0.0` at the track's start and `extent` at
21//! its end — never in `(x, y)`. The horizontal mapping (primary → x, cross →
22//! y) lives entirely in [`super`]'s `paint`. This is the seam the vertical
23//! variant needs: it swaps that one mapping (and feeds a reversed fraction),
24//! with no change to any formula below.
25//!
26//! # Extension points the wavy/vertical/range variants hook into
27//!
28//! Wave-phase painting, vertical orientation, and the second thumb each hang
29//! off a named seam here, so none of them reopens the state machine
30//! ([`super::variants`] and [`super::range`] are the consumers):
31//!
32//! 1. **Wavy active segment** — [`TrackMetrics::segments`] emits the active
33//!    span as a [`TrackSegment`] with [`SegmentRole::Active`]; a wavy variant
34//!    paints *that one segment* as a stroked sine path instead of a rounded
35//!    rect (the reference's own split: `m3e_slider_track_painter.dart:226-244`
36//!    branches at exactly this point, leaving every other segment flat). The
37//!    span endpoints are already the ones the reference's `_drawWavyActive`
38//!    insets by half a stroke.
39//! 2. **Vertical orientation** — the primary/cross split above, plus
40//!    [`SliderGeometry::reverse`]: the reference's `reverse` flag
41//!    (`m3e_slider_build.dart:82`) is `!topToBottom` on a vertical slider and
42//!    RTL on a horizontal one. Every formula here stays value-oriented and the
43//!    variant's axis map applies the one flip at paint, mirroring the
44//!    reference's `Transform.flip` around its track layer;
45//!    [`SliderGeometry::reverse`] is what the *widget*-space consumers
46//!    ([`icon_dock`], [`ValueSpec::value_from_offset`]) take.
47//! 3. **Second thumb (range)** — [`SliderGeometry::active_start_fraction`] is
48//!    a real input to every formula (the reference's `activeStartFraction`),
49//!    fixed at `0.0` for the single-thumb slider. The range variant feeds the
50//!    low thumb's fraction there and selects [`SliderTrackKind::Range`], the
51//!    third arm of the same three `centered`-branching spots
52//!    ([`TrackMetrics::resolve`], [`TrackMetrics::segments`], [`dots`]) — the
53//!    same three the reference's `_range` getter branches in.
54//!
55//! The drag machine ([`Drag`]) and the haptic scheduler ([`HapticScheduler`])
56//! are thumb-agnostic: a range slider holds one [`Drag`] per thumb and one
57//! shared scheduler.
58//!
59//! # Clamp discipline
60//!
61//! `f64::clamp` panics if `min > max` or either bound is NaN — a hard abort
62//! under `panic = "abort"`, not a catchable error, so every `.clamp(..)` call
63//! in this module tree that clamps against a *computed* (not literal) pair of
64//! bounds must be able to show those bounds are always ordered and finite.
65//! Two shapes cover every site:
66//!
67//! - **Bounds are literal `0.0`/`1.0`** ([`ValueSpec::fraction`],
68//!   [`ValueSpec::value_from_fraction`], [`ValueSpec::value_from_offset`]'s
69//!   own `fraction` clamp, [`TrackMetrics::resolve`]'s two
70//!   `active_start_fraction`/`active_end_fraction` clamps,
71//!   [`super::range::RangeSliderWidget::amplitude_factor`]'s progress clamp,
72//!   [`super::variants::amplitude_factor`]'s ramp clamp) — trivially ordered,
73//!   never a hazard.
74//! - **Bounds are derived and explicitly ordered with `min()`/`max()`
75//!   immediately before the clamp** — [`ValueSpec::snap`] (guarded by an
76//!   early-return degenerate check, mirroring [`ValueSpec::fraction`]'s, for
77//!   `self.min`/`self.max` themselves), [`icon_dock`] (`min_center`/
78//!   `max_center` ordered before [`IconDock::center_at`]'s clamp, covering a
79//!   track laid out narrower than its icon), and
80//!   [`super::range::RangeSliderWidget::accept`] (the no-cross window per
81//!   thumb, ordered right before its clamp rather than trusting `live`/`spec`
82//!   to already be ordered — `super::range`'s `build`/`rebuild` normalizes
83//!   the adopted pair into bounds too, so this is a total-by-construction
84//!   backstop, not the only line of defense).
85//!
86//! A new clamp against a computed bound must fit one of these two shapes, not
87//! introduce a third.
88
89use std::time::Duration;
90
91use frust::{FrameTime, SpringDesc};
92use kurbo::{Point, Rect, Size};
93
94use crate::interaction::HapticSignal;
95
96// ---------------------------------------------------------------------------
97// Tokens (`res/m3e_slider_tokens.dart`, `styles/m3e_slider_theme.dart`)
98// ---------------------------------------------------------------------------
99//
100// No `Theme` token covers a fixed control dimension like a track thickness or
101// a handle width (`ShapeScale` publishes corner radii, not control metrics),
102// so these stay named module constants with their upstream source, exactly as
103// `crate::switch`'s track/thumb metrics do.
104
105/// Resting handle length along the cross axis, in logical px
106/// (`M3ESliderTokens.handleHeight`) — also the control's own layout height
107/// (`M3ESliderTheme.height`).
108pub(crate) const HANDLE_HEIGHT: f64 = 44.0;
109/// Resting handle thickness, in logical px (`M3ESliderTokens.handleWidth`).
110pub(crate) const HANDLE_WIDTH: f64 = 4.0;
111/// Handle thickness while pressed, in logical px
112/// (`M3ESliderTokens.pressedHandleWidth`) — the handle gets *thinner*, not
113/// wider, under a press.
114pub(crate) const PRESSED_HANDLE_WIDTH: f64 = 2.0;
115/// Track thickness, in logical px (`M3ESliderTokens.activeTrackHeight`, equal
116/// to `inactiveTrackHeight`).
117pub(crate) const TRACK_HEIGHT: f64 = 16.0;
118/// Clear space between the handle edge and each track segment, in logical px
119/// (`M3ESliderTokens.thumbTrackGapSize`).
120pub(crate) const HANDLE_GAP: f64 = 6.0;
121/// Corner radius on the track ends that face the handle gap, in logical px
122/// (`M3ESliderTokens.trackInsideCornerSize`) — the small half of the
123/// asymmetric per-segment radii.
124pub(crate) const TRACK_INSIDE_CORNER: f64 = 2.0;
125/// Corner radius on the track's outer ends, in logical px
126/// (`M3ESliderTokens.trackCornerRadius`). Fixed by the token table, *not*
127/// derived from the track thickness, though it is clamped to half of it.
128pub(crate) const TRACK_CORNER_RADIUS: f64 = 8.0;
129/// Diameter of a track-end stop indicator, in logical px
130/// (`M3ESliderTokens.stopIndicatorSize`).
131pub(crate) const STOP_INDICATOR_SIZE: f64 = 4.0;
132/// Diameter of a discrete tick marker, in logical px
133/// (`M3ESliderTokens.tickSize`).
134pub(crate) const TICK_SIZE: f64 = 4.0;
135/// Clear space between a track end and the outer edge of its stop indicator,
136/// in logical px (`M3ESliderTokens.stopIndicatorTrailingSpace`).
137pub(crate) const STOP_INDICATOR_TRAILING_SPACE: f64 = 6.0;
138/// Clear space between the track edge and the relocating icon's outer edge, in
139/// logical px (`M3ESliderTokens.iconEdgeInset`).
140pub(crate) const ICON_EDGE_INSET: f64 = 8.0;
141/// Default edge length of the relocating end icon, in logical px
142/// (`m3e_slider_build.dart:468`'s `widget.iconSize ?? 24`).
143pub(crate) const ICON_SIZE_DEFAULT: f64 = 24.0;
144/// Clear space between the value indicator's bottom edge and the track, in
145/// logical px (`M3ESliderTokens.valueIndicatorActiveBottomSpace`).
146pub(crate) const VALUE_INDICATOR_BOTTOM_SPACE: f64 = 12.0;
147/// Content opacity applied to every *active* role while disabled
148/// (`M3ESliderTokens.disabledActiveTrackOpacity`).
149pub(crate) const DISABLED_ACTIVE_OPACITY: f32 = 0.38;
150/// Container opacity applied to every *inactive* role while disabled
151/// (`M3ESliderTokens.disabledInactiveTrackOpacity`).
152pub(crate) const DISABLED_INACTIVE_OPACITY: f32 = 0.12;
153/// Edge length of an inset track icon, in logical px
154/// (`M3ESliderTrackIcons.size`'s default).
155pub(crate) const TRACK_ICON_SIZE: f64 = 16.0;
156/// Inset from a segment's own end to the icon it hosts, in logical px
157/// (`m3e_slider_track_icons_overlay.dart:60-74`'s literal `4`).
158pub(crate) const TRACK_ICON_INSET: f64 = 4.0;
159/// Extra segment length a hosted icon needs beyond its own size before it is
160/// painted at all (`m3e_slider_track_icons_overlay.dart:38-43`'s `icon + 8`).
161pub(crate) const TRACK_ICON_MIN_SLACK: f64 = 8.0;
162/// Extra handle gap while the control shows its focus outline, in logical px
163/// (`m3e_slider_build.dart:73-75`'s `handleGap + 4`) — the track backs off to
164/// clear the ring.
165pub(crate) const FOCUS_HANDLE_GAP_EXTRA: f64 = 4.0;
166/// Focus-outline stroke width, in logical px
167/// (`m3e_slider_thumb.dart:53`'s `_focusStroke`).
168pub(crate) const FOCUS_STROKE: f64 = 2.0;
169/// **Total** growth (both edges together) of the focus outline over the thumb
170/// box, in logical px (`m3e_slider_thumb.dart:54`'s `_focusInflate`, applied
171/// there as `w + _focusInflate`) — so each edge grows by half of it. Reused
172/// for the dragged/pressed halo; see [`focus_ring_rect`].
173pub(crate) const FOCUS_INFLATE: f64 = 6.0;
174/// Duration of the pressed-thumb width compression
175/// (`m3e_slider_thumb.dart:77`'s `AnimatedContainer(duration: 100ms)`).
176pub(crate) const PRESS_MORPH_DURATION: Duration = Duration::from_millis(100);
177/// Minimum spacing between two continuous-drag haptic ticks
178/// (`m3e_sliders.dart:613-621`'s `elapsedMilliseconds >= 60`).
179pub(crate) const HAPTIC_MIN_INTERVAL: Duration = Duration::from_millis(60);
180/// The relocating icon's dock spring: motor's
181/// `MaterialSpringMotion.expressiveSpatialFast`
182/// (`tmp/motor-1.1.0/lib/src/motion.dart:595-601`, stiffness `800`, damping
183/// ratio `0.6`), the spec `m3e_sliders.dart:480-483` builds `_dockController`
184/// on. Motor's `damping` is a damping *ratio*, the same ζ semantics
185/// [`SpringDesc::damping_ratio`] uses, so it carries over with no conversion.
186pub(crate) const DOCK_SPRING: SpringDesc = SpringDesc {
187    mass: 1.0,
188    stiffness: 800.0,
189    damping_ratio: 0.6,
190};
191/// Extra clearance beyond the two half-widths at which the relocating icon
192/// counts as docked (`m3e_slider_build.dart:481`'s `+ 8`).
193pub(crate) const ICON_DOCK_SLACK: f64 = 8.0;
194/// Extra clearance beyond the two half-widths the docked icon parks at
195/// (`m3e_slider_build.dart:486`'s `+ 12`).
196pub(crate) const ICON_DOCK_OFFSET_SLACK: f64 = 12.0;
197/// Offset from the thumb back to the value indicator's leading edge, in
198/// logical px (`m3e_slider_build.dart:441`'s `thumbPrimary - 24`).
199pub(crate) const VALUE_INDICATOR_LEAD: f64 = 24.0;
200/// Height the value indicator is lifted by above its bottom space, in logical
201/// px (`m3e_slider_build.dart:444`'s `-valueIndicatorBottomSpace - 24`).
202pub(crate) const VALUE_INDICATOR_LIFT: f64 = 24.0;
203/// Value-indicator container corner radius, in logical px
204/// (`m3e_slider_value_indicator.dart:34`).
205pub(crate) const VALUE_INDICATOR_RADIUS: f64 = 4.0;
206/// Value-indicator horizontal padding, in logical px
207/// (`m3e_slider_value_indicator.dart:37`).
208pub(crate) const VALUE_INDICATOR_PAD_X: f64 = 8.0;
209/// Value-indicator vertical padding, in logical px
210/// (`m3e_slider_value_indicator.dart:37`).
211pub(crate) const VALUE_INDICATOR_PAD_Y: f64 = 4.0;
212/// Track length used when the incoming constraints are horizontally unbounded.
213/// The reference has no equivalent (Flutter's `LayoutBuilder` would hand it an
214/// infinite width and throw); this mirrors the baseline `frust::Slider`'s own
215/// fallback so an unconstrained slider still measures something paintable.
216pub(crate) const DEFAULT_EXTENT: f64 = 200.0;
217
218/// Which track geometry a slider paints (`M3ESliderTrackKind`, with
219/// `M3ESliderPaintMode.range` folded in as its third arm).
220///
221/// Upstream splits this across two enums — a `trackKind` (standard/centered)
222/// and a paint `mode` (single/range) — but only three of the four combinations
223/// exist: a range track is always the standard kind
224/// (`m3e_range_slider_track.dart:100-101` hard-codes it, and Compose has no
225/// centered range slider). One enum keeps the impossible fourth
226/// unrepresentable.
227#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
228pub enum SliderTrackKind {
229    /// Active track runs from the start edge to the thumb.
230    #[default]
231    Standard,
232    /// Active track grows from the track's midpoint toward the thumb.
233    Centered,
234    /// Active track spans between two thumbs
235    /// ([`super::range`]'s `M3ERangeSlider`).
236    Range,
237}
238
239/// Axis-relative resting edge for the relocating end icon
240/// (`M3ESliderIconPosition`).
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
242pub enum SliderIconPosition {
243    /// The track's leading edge.
244    Start,
245    /// The track's trailing edge.
246    #[default]
247    End,
248}
249
250/// The value model: bounds plus an optional discrete step count
251/// (`M3ESlider.min`/`max`/`divisions`).
252#[derive(Clone, Copy, Debug, PartialEq)]
253pub(crate) struct ValueSpec {
254    pub(crate) min: f64,
255    pub(crate) max: f64,
256    pub(crate) divisions: Option<u32>,
257}
258
259impl Default for ValueSpec {
260    /// The reference's own defaults: `0..=1`, continuous
261    /// (`m3e_sliders.dart:62-65`).
262    fn default() -> Self {
263        Self {
264            min: 0.0,
265            max: 1.0,
266            divisions: None,
267        }
268    }
269}
270
271impl ValueSpec {
272    /// `0..=1` position of `value` within the bounds
273    /// (`M3ESliderMath.fraction`). A degenerate (`max <= min`) range reads
274    /// `0`, as upstream.
275    pub(crate) fn fraction(&self, value: f64) -> f64 {
276        if self.max <= self.min {
277            return 0.0;
278        }
279        ((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
280    }
281
282    /// A `0..=1` fraction mapped back into the bounds
283    /// (`M3ESliderMath.valueFromFraction`).
284    pub(crate) fn value_from_fraction(&self, fraction: f64) -> f64 {
285        self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min)
286    }
287
288    /// `value` clamped into the bounds and snapped to the nearest division
289    /// step, or merely clamped when continuous (`M3ESliderMath.snap`).
290    ///
291    /// Mirrors [`Self::fraction`]'s degenerate guard rather than reaching
292    /// `value.clamp(self.min, self.max)` unconditionally: `f64::clamp` panics
293    /// when `min > max` or either bound is NaN, and unlike `fraction` (which
294    /// only ever clamps against the literal `0.0..=1.0`), this method clamps
295    /// against the bounds themselves — an inverted `.range(20.0, 10.0)`
296    /// builder call or a NaN bound would otherwise crash a shipped app on the
297    /// very next drag/press (`snap` sits on both the live drag path and
298    /// `accept_value`). The degenerate case pins at `min` when it is itself
299    /// finite, else `0.0` — a NaN `min` must never propagate out, the
300    /// documented contract (see the [module docs](self)'s Clamp Discipline
301    /// section). A NaN `value` is guarded the same way, ahead of the clamp:
302    /// `f64::clamp` propagates a NaN input through rather than panicking on
303    /// it, and a NaN reaching the grid-snap division below would too.
304    pub(crate) fn snap(&self, value: f64) -> f64 {
305        if !(self.min.is_finite() && self.max.is_finite() && self.max > self.min) {
306            return if self.min.is_finite() { self.min } else { 0.0 };
307        }
308        if value.is_nan() {
309            return self.min;
310        }
311        let clamped = value.clamp(self.min, self.max);
312        let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
313            return clamped;
314        };
315        let step = (self.max - self.min) / f64::from(divisions);
316        self.min + ((clamped - self.min) / step).round() * step
317    }
318
319    /// The tick fractions a discrete slider marks — `divisions + 1` marks,
320    /// endpoints inclusive (`M3ESliderMath.tickFractions`). Empty when
321    /// continuous.
322    pub(crate) fn tick_fractions(&self) -> Vec<f64> {
323        let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
324            return Vec::new();
325        };
326        (0..=divisions)
327            .map(|i| f64::from(i) / f64::from(divisions))
328            .collect()
329    }
330
331    /// A primary-axis pointer offset mapped to a (snapped) value
332    /// (`M3ESliderMath.valueFromOffset`). `reverse` mirrors the track, the
333    /// flag a vertical or RTL variant sets.
334    pub(crate) fn value_from_offset(&self, primary: f64, extent: f64, reverse: bool) -> f64 {
335        if extent <= 0.0 {
336            return self.min;
337        }
338        let mut fraction = (primary / extent).clamp(0.0, 1.0);
339        if reverse {
340            fraction = 1.0 - fraction;
341        }
342        self.snap(self.value_from_fraction(fraction))
343    }
344}
345
346/// The reference's `_setValue` admission test (`m3e_sliders.dart:588-611`):
347/// clamp, snap, and drop a change that lands on the value already held.
348/// Returns the value to report, or `None` when there is nothing to report.
349pub(crate) fn accept_value(spec: &ValueSpec, current: f64, raw: f64) -> Option<f64> {
350    let next = spec.snap(raw);
351    if next == current { None } else { Some(next) }
352}
353
354/// The resolved per-pass slider geometry inputs — everything
355/// [`TrackMetrics::resolve`] needs, in primary-axis units.
356#[derive(Clone, Copy, Debug, PartialEq)]
357pub(crate) struct SliderGeometry {
358    /// Track length along the primary axis.
359    pub(crate) extent: f64,
360    /// Where the active span begins, `0..=1`. Always `0.0` for a single-thumb
361    /// slider; the range variant's low-thumb seam (see the [module docs](self)).
362    pub(crate) active_start_fraction: f64,
363    /// Where the active span ends (the thumb's own fraction), `0..=1`.
364    pub(crate) active_end_fraction: f64,
365    /// Whether the primary axis runs backwards (vertical/RTL seam).
366    pub(crate) reverse: bool,
367    pub(crate) kind: SliderTrackKind,
368    /// The handle thickness the *gap* is measured from — the instantaneous
369    /// pressed/resting value, never the animated one (see
370    /// [`TrackMetrics::resolve`]).
371    pub(crate) handle_thickness: f64,
372    pub(crate) handle_gap: f64,
373    pub(crate) track_thickness: f64,
374    pub(crate) corner_radius: f64,
375}
376
377/// Intermediate track geometry: `M3ESliderTrackPaintMetrics` and
378/// `M3ESliderDotGeometry` unified.
379///
380/// Upstream carries the same nine formulas twice, once per file
381/// (`m3e_slider_track_paint_metrics.dart:46-98` and
382/// `m3e_slider_dot_geometry.dart:63-129` are line-for-line identical apart
383/// from the three extra fields the dot side keeps). They are one struct here:
384/// two copies of a formula are two chances to diverge, and both consumers
385/// ([`TrackMetrics::segments`] and [`dots`]) live in this module.
386#[derive(Clone, Copy, Debug, PartialEq)]
387pub(crate) struct TrackMetrics {
388    pub(crate) slider_start: f64,
389    pub(crate) slider_end: f64,
390    pub(crate) centered: bool,
391    pub(crate) range: bool,
392    pub(crate) corner: f64,
393    pub(crate) inside_corner: f64,
394    pub(crate) start_gap: f64,
395    pub(crate) end_gap: f64,
396    pub(crate) value_start: f64,
397    pub(crate) value_end: f64,
398    pub(crate) center_axis: f64,
399    pub(crate) adjusted_value_start: f64,
400    pub(crate) adjusted_value_end: f64,
401    pub(crate) active_start: f64,
402    pub(crate) active_end: f64,
403}
404
405/// Which colour role a [`TrackSegment`] takes.
406#[derive(Clone, Copy, Debug, PartialEq, Eq)]
407pub(crate) enum SegmentRole {
408    Active,
409    Inactive,
410}
411
412/// One painted track span, in primary-axis units, with the asymmetric corner
413/// radii its two ends take (`start` is the low-coordinate end).
414#[derive(Clone, Copy, Debug, PartialEq)]
415pub(crate) struct TrackSegment {
416    pub(crate) start: f64,
417    pub(crate) end: f64,
418    pub(crate) start_corner: f64,
419    pub(crate) end_corner: f64,
420    pub(crate) role: SegmentRole,
421}
422
423impl TrackMetrics {
424    /// Resolve the metrics for one paint pass, or `None` for a degenerate
425    /// (zero-length) track — the reference's own `if (span <= 0) return`
426    /// (`m3e_slider_track_painter.dart:133-135`).
427    ///
428    /// The gap is measured from `geometry.handle_thickness`, which the
429    /// reference resolves *instantly* on press (`m3e_slider_build.dart:83-87`)
430    /// even though the handle's own width animates over 100ms — the gap snaps
431    /// while the handle glides. Transcribed as-is rather than "fixed": it is
432    /// the reference's own shape.
433    pub(crate) fn resolve(geometry: &SliderGeometry) -> Option<Self> {
434        let slider_start = 0.0;
435        let slider_end = geometry.extent;
436        let span = slider_end - slider_start;
437        if span <= 0.0 {
438            return None;
439        }
440        let centered = geometry.kind == SliderTrackKind::Centered;
441        let range = geometry.kind == SliderTrackKind::Range;
442        let corner = geometry.corner_radius.min(geometry.track_thickness / 2.0);
443        let start_gap = if centered || range {
444            geometry.handle_thickness / 2.0 + geometry.handle_gap
445        } else {
446            0.0
447        };
448        let end_gap = geometry.handle_thickness / 2.0 + geometry.handle_gap;
449
450        let value_start = slider_start + span * geometry.active_start_fraction.clamp(0.0, 1.0);
451        let value_end = slider_start + span * geometry.active_end_fraction.clamp(0.0, 1.0);
452        let center_axis = (slider_start + slider_end) / 2.0;
453
454        let adjusted_value_end = if centered {
455            value_end.min(center_axis)
456        } else {
457            value_start
458        };
459        let adjusted_value_start = if centered {
460            value_end.max(center_axis)
461        } else {
462            value_end
463        };
464        let active_start = if centered {
465            adjusted_value_end
466                + if adjusted_value_end < center_axis {
467                    start_gap
468                } else {
469                    0.0
470                }
471        } else if range {
472            // The low thumb carves its own gap out of the active span, the
473            // mirror of what the high thumb already does at `active_end`.
474            value_start + start_gap
475        } else {
476            slider_start
477        };
478        let active_end = if centered {
479            adjusted_value_start
480                - if adjusted_value_start > center_axis {
481                    end_gap
482                } else {
483                    0.0
484                }
485        } else {
486            value_end - end_gap
487        };
488
489        Some(Self {
490            slider_start,
491            slider_end,
492            centered,
493            range,
494            corner,
495            inside_corner: TRACK_INSIDE_CORNER,
496            start_gap,
497            end_gap,
498            value_start,
499            value_end,
500            center_axis,
501            adjusted_value_start,
502            adjusted_value_end,
503            active_start,
504            active_end,
505        })
506    }
507
508    /// The track's painted spans, in the reference's own paint order: inactive
509    /// leading (centered only), inactive trailing, then active
510    /// (`m3e_slider_track_painter.dart:151-153`).
511    ///
512    /// The `rtl` flag mirrors which end of a span takes the outer radius — the
513    /// reference's `_rtl` getter; a `reverse`d (vertical/RTL) variant passes
514    /// it through from [`SliderGeometry::reverse`].
515    pub(crate) fn segments(&self, rtl: bool) -> Vec<TrackSegment> {
516        let mut out = Vec::with_capacity(3);
517        let (outer, inner) = (self.corner, self.inside_corner);
518
519        // Inactive leading (`_paintInactiveLeading`): centered and range
520        // tracks only — a standard track's active span starts at the very
521        // edge, so there is nothing leading it.
522        if (self.centered || self.range)
523            && self.adjusted_value_end > self.slider_start + self.start_gap + outer
524        {
525            let start = self.slider_start;
526            let end = self.adjusted_value_end - self.start_gap;
527            if end > start {
528                out.push(TrackSegment {
529                    start,
530                    end,
531                    start_corner: if rtl { inner } else { outer },
532                    end_corner: if rtl { outer } else { inner },
533                    role: SegmentRole::Inactive,
534                });
535            }
536        }
537
538        // Inactive trailing (`_paintInactiveTrailing`).
539        if self.adjusted_value_start < self.slider_end - self.end_gap - outer {
540            let start = self.adjusted_value_start + self.end_gap;
541            let end = self.slider_end;
542            if end > start {
543                out.push(TrackSegment {
544                    start,
545                    end,
546                    start_corner: if rtl { outer } else { inner },
547                    end_corner: if rtl { inner } else { outer },
548                    role: SegmentRole::Inactive,
549                });
550            }
551        }
552
553        // Active (`_paintActive`).
554        let start_corner = if rtl || self.centered || self.range {
555            inner
556        } else {
557            outer
558        };
559        let end_corner = if rtl && !self.centered && !self.range {
560            outer
561        } else {
562            inner
563        };
564        if self.active_end - self.active_start > start_corner {
565            out.push(TrackSegment {
566                start: self.active_start,
567                end: self.active_end,
568                start_corner,
569                end_corner,
570                role: SegmentRole::Active,
571            });
572        }
573        out
574    }
575}
576
577/// One stop/tick marker along the track (`M3ESliderDotPlacement`), in
578/// primary-axis units. `active` selects the tick colour role and is always
579/// `false` for a track-end stop.
580#[derive(Clone, Copy, Debug, PartialEq)]
581pub(crate) struct DotPlacement {
582    pub(crate) primary: f64,
583    pub(crate) size: f64,
584    pub(crate) active: bool,
585}
586
587/// The dot sizing/inset inputs (`M3ESliderDotLayout.resolve`'s size trio).
588#[derive(Clone, Copy, Debug, PartialEq)]
589pub(crate) struct DotSpec {
590    pub(crate) stop_size: f64,
591    pub(crate) tick_size: f64,
592    /// Clear space between a track end and the outer edge of its marker.
593    pub(crate) edge_inset: f64,
594}
595
596/// Track-end stop indicators plus discrete ticks
597/// (`M3ESliderDotLayout.resolve`): a stop is dropped where the active span or
598/// a handle gap already covers it, and a tick is dropped inside a gap.
599pub(crate) fn dots(metrics: &TrackMetrics, ticks: &[f64], spec: &DotSpec) -> Vec<DotPlacement> {
600    let mut out = Vec::new();
601    // `edge_inset` is clear space to the marker's *outer* edge, so a centre
602    // is inset by the spacing plus half the marker.
603    let stop_radius = spec.stop_size / 2.0;
604    let stop_start = metrics.slider_start + spec.edge_inset + stop_radius;
605    let stop_end = metrics.slider_end - spec.edge_inset - stop_radius;
606    if stop_end <= stop_start {
607        return out;
608    }
609    for primary in [stop_start, stop_end] {
610        if !on_active_or_gap(primary, metrics) {
611            out.push(DotPlacement {
612                primary,
613                size: spec.stop_size,
614                active: false,
615            });
616        }
617    }
618
619    if ticks.is_empty() {
620        return out;
621    }
622    // Ticks share the stops' padded span.
623    let (tick_start, tick_end) = (stop_start, stop_end);
624    let center_gap_lo = metrics.center_axis - metrics.end_gap;
625    let center_gap_hi = metrics.center_axis + metrics.end_gap;
626    let start_gap_lo = metrics.value_start - metrics.start_gap;
627    let start_gap_hi = metrics.value_start + metrics.start_gap;
628    let end_gap_lo = metrics.value_end - metrics.end_gap;
629    let end_gap_hi = metrics.value_end + metrics.end_gap;
630    for (i, fraction) in ticks.iter().enumerate() {
631        // The two ends belong to the stop indicators.
632        if i == 0 || i == ticks.len() - 1 {
633            continue;
634        }
635        let center = tick_start + (tick_end - tick_start) * fraction;
636        if metrics.centered && center >= center_gap_lo && center <= center_gap_hi {
637            continue;
638        }
639        // A range track's low thumb owns a gap of its own.
640        if metrics.range && center >= start_gap_lo && center <= start_gap_hi {
641            continue;
642        }
643        if center >= end_gap_lo && center <= end_gap_hi {
644            continue;
645        }
646        out.push(DotPlacement {
647            primary: center,
648            size: spec.tick_size,
649            active: center >= metrics.active_start && center <= metrics.active_end,
650        });
651    }
652    out
653}
654
655/// Whether `primary` falls on the active span or inside a handle gap —
656/// `M3ESliderDotLayout._onActiveOrGap`.
657fn on_active_or_gap(primary: f64, metrics: &TrackMetrics) -> bool {
658    if primary >= metrics.active_start && primary <= metrics.active_end {
659        return true;
660    }
661    if metrics.range
662        && primary >= metrics.value_start - metrics.start_gap
663        && primary <= metrics.value_start + metrics.start_gap
664    {
665        return true;
666    }
667    if primary >= metrics.value_end - metrics.end_gap
668        && primary <= metrics.value_end + metrics.end_gap
669    {
670        return true;
671    }
672    metrics.centered
673        && metrics.start_gap > 0.0
674        && primary >= metrics.value_end - metrics.start_gap
675        && primary <= metrics.value_end + metrics.start_gap
676}
677
678/// The thumb's box, given its centre along the primary axis and the cross-axis
679/// centreline. `thickness` is the animated (pressed-morph) width, `length` the
680/// cross-axis extent (`m3e_slider_thumb.dart:72-74`).
681pub(crate) fn thumb_rect(primary: f64, cross_center: f64, thickness: f64, length: f64) -> Rect {
682    Rect::from_center_size(
683        Point::new(primary, cross_center),
684        Size::new(thickness, length),
685    )
686}
687
688/// A thumb box's corner radius — half its longer side
689/// (`m3e_slider_thumb.dart:74`).
690pub(crate) fn thumb_radius(thumb: Rect) -> f64 {
691    thumb.width().max(thumb.height()) / 2.0
692}
693
694/// The concentric focus outline (and, in this port, the dragged/pressed halo)
695/// around a thumb box: [`FOCUS_INFLATE`] of total growth, half on each edge
696/// (`m3e_slider_thumb.dart:95-104`).
697pub(crate) fn focus_ring_rect(thumb: Rect) -> Rect {
698    thumb.inflate(FOCUS_INFLATE / 2.0, FOCUS_INFLATE / 2.0)
699}
700
701/// The value indicator's top-left corner, in primary/cross-local units, for a
702/// thumb at `primary` (`m3e_slider_build.dart:439-449`). The indicator sits
703/// *above* the control's own box — a negative cross coordinate — the same way
704/// the reference's `Clip.none` stack lets it overflow.
705pub(crate) fn value_indicator_origin(primary: f64) -> Point {
706    Point::new(
707        primary - VALUE_INDICATOR_LEAD,
708        -VALUE_INDICATOR_BOTTOM_SPACE - VALUE_INDICATOR_LIFT,
709    )
710}
711
712/// The resolved relocating-icon dock geometry
713/// (`m3e_slider_build.dart:457-523`).
714#[derive(Clone, Copy, Debug, PartialEq)]
715pub(crate) struct IconDock {
716    /// Where the icon rests when the thumb is far away.
717    pub(crate) resting_center: f64,
718    /// Where it parks once the thumb is close enough to cover it.
719    pub(crate) docked_target: f64,
720    /// Whether the thumb is close enough to dock the icon right now.
721    pub(crate) docked: bool,
722    min_center: f64,
723    max_center: f64,
724}
725
726/// Inputs for [`icon_dock`].
727#[derive(Clone, Copy, Debug, PartialEq)]
728pub(crate) struct IconDockInput {
729    pub(crate) extent: f64,
730    pub(crate) thumb_primary: f64,
731    pub(crate) icon_size: f64,
732    pub(crate) edge_inset: f64,
733    pub(crate) handle_thickness: f64,
734    pub(crate) position: SliderIconPosition,
735    pub(crate) reverse: bool,
736}
737
738/// Resolve where the relocating end icon rests, where it docks, and whether it
739/// is docked at this thumb position.
740pub(crate) fn icon_dock(input: &IconDockInput) -> IconDock {
741    let icon_half = input.icon_size / 2.0;
742    let thumb_half = input.handle_thickness / 2.0;
743    let near_end = (input.position == SliderIconPosition::End) != input.reverse;
744    let resting_center = if near_end {
745        input.extent - input.edge_inset - icon_half
746    } else {
747        input.edge_inset + icon_half
748    };
749    let dock_distance_limit = thumb_half + icon_half + ICON_DOCK_SLACK;
750    let docked = (input.thumb_primary - resting_center).abs() <= dock_distance_limit;
751    let dock_offset = thumb_half + icon_half + ICON_DOCK_OFFSET_SLACK;
752    let docked_target = if input.reverse {
753        input.thumb_primary + dock_offset
754    } else {
755        input.thumb_primary - dock_offset
756    };
757    // `icon_half` and `extent - icon_half` invert whenever the track is laid
758    // out narrower than the icon (`extent < icon_size`, reachable since
759    // `layout` never lower-bounds itself by `icon_size`) — `min()`/`max()`
760    // order the pair so `IconDock::center_at`'s `f64::clamp` never sees an
761    // inverted bound (see the [module docs](self)'s Clamp Discipline
762    // section). The icon pins to whichever ordered clamp edge its resting/
763    // docked target lands nearest in that squeeze, rather than panicking —
764    // not literally re-centered (see the narrow-track test below).
765    IconDock {
766        resting_center,
767        docked_target,
768        docked,
769        min_center: icon_half.min(input.extent - icon_half),
770        max_center: icon_half.max(input.extent - icon_half),
771    }
772}
773
774impl IconDock {
775    /// The icon's centre at dock progress `t` (`0` resting, `1` docked),
776    /// clamped inside the track (`m3e_slider_build.dart:493-497`).
777    pub(crate) fn center_at(&self, t: f64) -> f64 {
778        let raw = self.resting_center + (self.docked_target - self.resting_center) * t;
779        raw.clamp(self.min_center, self.max_center)
780    }
781
782    /// Whether an icon at `center` sits over the active span, which selects
783    /// its ink (`m3e_slider_build.dart:499-501`).
784    pub(crate) fn over_active(&self, center: f64, thumb_primary: f64, reverse: bool) -> bool {
785        if reverse {
786            center >= thumb_primary
787        } else {
788            center <= thumb_primary
789        }
790    }
791}
792
793/// Which of the four inset track-icon slots a placement belongs to
794/// (`M3ESliderTrackIcons`' four fields). Crate-internal: an app names the
795/// slots by [`super::SliderTrackIcons`]' own fields instead.
796#[derive(Clone, Copy, Debug, PartialEq, Eq)]
797pub(crate) enum TrackIconSlot {
798    /// Leading end of the active segment.
799    ActiveStart,
800    /// Trailing end of the active segment.
801    ActiveEnd,
802    /// Leading end of the inactive segment.
803    InactiveStart,
804    /// Trailing end of the inactive segment.
805    InactiveEnd,
806}
807
808impl TrackIconSlot {
809    /// This slot's index into a caller's four-slot array, in
810    /// `M3ESliderTrackIcons` field order.
811    pub(crate) const fn index(self) -> usize {
812        match self {
813            Self::ActiveStart => 0,
814            Self::ActiveEnd => 1,
815            Self::InactiveStart => 2,
816            Self::InactiveEnd => 3,
817        }
818    }
819}
820
821/// A placed inset track icon: the slot it fills and its box's leading edge
822/// along the primary axis.
823#[derive(Clone, Copy, Debug, PartialEq)]
824pub(crate) struct TrackIconPlacement {
825    pub(crate) slot: TrackIconSlot,
826    pub(crate) primary: f64,
827    pub(crate) active: bool,
828}
829
830/// Where each inset track icon goes, dropping any whose segment is too short
831/// to host it (`m3e_slider_track_icons_overlay.dart:18-79`). `present` marks
832/// which of the four slots the caller actually holds an icon for, indexed by
833/// [`TrackIconSlot::index`].
834///
835/// A centered track offers only the two *active* slots upstream, so the other
836/// two are never candidates there whatever `present` says.
837pub(crate) fn track_icon_placements(
838    extent: f64,
839    fraction: f64,
840    kind: SliderTrackKind,
841    icon_size: f64,
842    present: [bool; 4],
843) -> Vec<TrackIconPlacement> {
844    let active_len = fraction * extent;
845    let inactive_len = (1.0 - fraction) * extent;
846    let candidates: Vec<(TrackIconSlot, f64, bool)> = match kind {
847        // Upstream reserves `M3ERangeSlider.trackIcons` "for parity" and never
848        // renders it (`m3e_range_slider_build.dart:149-211` wraps no
849        // `_TrackIconsOverlay`), so a range track hosts none either.
850        SliderTrackKind::Range => Vec::new(),
851        SliderTrackKind::Centered => vec![
852            (
853                TrackIconSlot::ActiveStart,
854                extent / 2.0 - icon_size - TRACK_ICON_INSET,
855                true,
856            ),
857            (
858                TrackIconSlot::ActiveEnd,
859                active_len + TRACK_ICON_INSET,
860                true,
861            ),
862        ],
863        SliderTrackKind::Standard => vec![
864            (TrackIconSlot::ActiveStart, TRACK_ICON_INSET, true),
865            (
866                TrackIconSlot::ActiveEnd,
867                active_len - icon_size - TRACK_ICON_INSET,
868                true,
869            ),
870            (
871                TrackIconSlot::InactiveStart,
872                active_len + TRACK_ICON_INSET,
873                false,
874            ),
875            (
876                TrackIconSlot::InactiveEnd,
877                extent - icon_size - TRACK_ICON_INSET,
878                false,
879            ),
880        ],
881    };
882
883    candidates
884        .into_iter()
885        .filter(|(slot, _, _)| present[slot.index()])
886        .filter(|(_, _, active)| {
887            let len = if *active { active_len } else { inactive_len };
888            len >= icon_size + TRACK_ICON_MIN_SLACK
889        })
890        .map(|(slot, primary, active)| TrackIconPlacement {
891            slot,
892            primary,
893            active,
894        })
895        .collect()
896}
897
898// ---------------------------------------------------------------------------
899// Drag state machine
900// ---------------------------------------------------------------------------
901
902/// The per-event inputs the drag machine resolves a value against.
903///
904/// [`ValueSpec`] is carried by value rather than borrowed: the widget builds
905/// this from its own fields in the same expression that mutates its [`Drag`],
906/// and a borrow of one field would lock the whole struct for that call.
907#[derive(Clone, Copy, Debug)]
908pub(crate) struct DragCtx {
909    pub(crate) spec: ValueSpec,
910    /// The value the machine deduplicates against — the app-confirmed value,
911    /// or the last one already reported this drag (see [`Drag::moved`]).
912    pub(crate) current: f64,
913    pub(crate) extent: f64,
914    pub(crate) reverse: bool,
915}
916
917/// What the widget must do after feeding one pointer phase to the machine.
918#[derive(Clone, Copy, Debug, Default, PartialEq)]
919pub(crate) struct DragOutcome {
920    /// Whether the event was consumed at all.
921    pub(crate) handled: bool,
922    /// Whether to open a pointer capture.
923    pub(crate) capture: bool,
924    /// A value to report through `on_changed`, already clamped and snapped.
925    pub(crate) value: Option<f64>,
926    /// Whether the interaction settled and `on_change_end` is owed.
927    pub(crate) end: bool,
928}
929
930/// The slider's pointer state machine: `Down` arms and jumps, `Move` tracks,
931/// `Up` settles, `Cancel` disarms (`m3e_slider_build.dart:362-405` plus
932/// `m3e_sliders.dart:667-673`).
933#[derive(Clone, Copy, Debug, Default, PartialEq)]
934pub(crate) struct Drag {
935    /// The pressed *visual* state — thumb compression, value indicator.
936    pub(crate) pressed: bool,
937    /// Armed by a `Down` alongside the pointer capture; gates `Move`.
938    pub(crate) dragging: bool,
939}
940
941impl Drag {
942    /// A `Down` inside the control: press, arm, capture, and immediately jump
943    /// to the tapped value (`_onTapDown`).
944    pub(crate) fn down(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
945        self.pressed = true;
946        self.dragging = true;
947        DragOutcome {
948            handled: true,
949            capture: true,
950            value: self.resolve(primary, ctx),
951            end: false,
952        }
953    }
954
955    /// A captured `Move`: track the pointer (`_horizontalDragUpdate`). An
956    /// uncaptured (hover) move is ignored outright.
957    pub(crate) fn moved(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
958        if !self.dragging {
959            return DragOutcome::default();
960        }
961        DragOutcome {
962            handled: true,
963            capture: false,
964            value: self.resolve(primary, ctx),
965            end: false,
966        }
967    }
968
969    /// An `Up`: settle and owe `on_change_end` (`_endInteraction`).
970    pub(crate) fn up(&mut self) -> DragOutcome {
971        if !self.dragging {
972            return DragOutcome::default();
973        }
974        self.pressed = false;
975        self.dragging = false;
976        DragOutcome {
977            handled: true,
978            capture: false,
979            value: None,
980            end: true,
981        }
982    }
983
984    /// A `Cancel` (platform gesture steal): disarm only.
985    ///
986    /// **A deliberate divergence.** The reference routes its drag-cancel to
987    /// the same `_endInteraction` an `Up` takes, firing `onChangeEnd`
988    /// (`m3e_slider_build.dart:344`/`357`). This framework forbids it: a
989    /// `Cancel` arm may never reach app state, because a synthesized cancel
990    /// can be delivered over a throwaway `()` state
991    /// (`docs/CODE_STANDARDS.md`'s Interaction Semantics). So a cancel clears
992    /// the flags and reports nothing.
993    pub(crate) fn cancel(&mut self) -> DragOutcome {
994        if !self.dragging {
995            return DragOutcome::default();
996        }
997        self.pressed = false;
998        self.dragging = false;
999        DragOutcome {
1000            handled: true,
1001            capture: false,
1002            value: None,
1003            end: false,
1004        }
1005    }
1006
1007    fn resolve(&self, primary: f64, ctx: &DragCtx) -> Option<f64> {
1008        let raw = ctx.spec.value_from_offset(primary, ctx.extent, ctx.reverse);
1009        accept_value(&ctx.spec, ctx.current, raw)
1010    }
1011}
1012
1013// ---------------------------------------------------------------------------
1014// Haptics
1015// ---------------------------------------------------------------------------
1016
1017/// How soon a queued haptic may fire.
1018#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1019pub(crate) enum HapticCadence {
1020    /// Every accepted change fires — a discrete slider's per-division tick
1021    /// (`m3e_sliders.dart:603-606`).
1022    Immediate,
1023    /// At most one tick per [`HAPTIC_MIN_INTERVAL`] — a continuous drag
1024    /// (`_maybeContinuousHaptic`).
1025    Throttled,
1026}
1027
1028/// The slider's haptic pacing, driven by the **frame clock**.
1029///
1030/// The reference paces continuous-drag ticks off a `Stopwatch` read inside its
1031/// gesture callback (`m3e_sliders.dart:450`, `613-621`). Widget code in this
1032/// framework may not read a wall clock at all — time enters only as the
1033/// `FrameTime` a shell hands `PaintCtx` (`docs/WIDGETS_CODE_STANDARDS.md`,
1034/// `docs/REVIEW_FOCUS.md`'s theme-resolution hot spot) — so a change *queues*
1035/// a signal from the event pass and the next paint decides whether it fires.
1036/// Two consequences, both accepted:
1037///
1038/// - A tick lands on the next frame rather than inside the event, at most one
1039///   frame (~8–16ms) later than upstream. Every value change requests a
1040///   redraw, so that frame is always imminent.
1041/// - The elapsed test measures queue-to-*frame*, so it is marginally more
1042///   permissive than upstream's queue-to-queue test. It can never fire faster
1043///   than one tick per [`HAPTIC_MIN_INTERVAL`], which is the property that
1044///   matters.
1045///
1046/// A throttled signal that arrives too early is **dropped**, not deferred —
1047/// upstream's own shape (`_maybeContinuousHaptic` simply does nothing when the
1048/// stopwatch is short).
1049#[derive(Clone, Copy, Debug, Default, PartialEq)]
1050pub(crate) struct HapticScheduler {
1051    last_fire: Option<FrameTime>,
1052    pending: Option<(HapticSignal, HapticCadence)>,
1053}
1054
1055impl HapticScheduler {
1056    /// Queue `signal` to fire on the next paint, subject to `cadence`. A
1057    /// second queue before that paint replaces the first: one frame emits at
1058    /// most one tick.
1059    pub(crate) fn queue(&mut self, signal: HapticSignal, cadence: HapticCadence) {
1060        self.pending = Some((signal, cadence));
1061    }
1062
1063    /// Whether a signal is waiting for the next paint to resolve it — a
1064    /// coverage seam (the widget itself only ever drains the queue), so it is
1065    /// compiled out of a real build.
1066    #[cfg(test)]
1067    pub(crate) fn has_pending(&self) -> bool {
1068        self.pending.is_some()
1069    }
1070
1071    /// Resolve any queued signal against the frame clock, returning the one to
1072    /// fire.
1073    pub(crate) fn take(&mut self, now: FrameTime) -> Option<HapticSignal> {
1074        let (signal, cadence) = self.pending.take()?;
1075        let eligible = match cadence {
1076            HapticCadence::Immediate => true,
1077            HapticCadence::Throttled => match self.last_fire {
1078                None => true,
1079                Some(last) => now.saturating_sub(last) >= HAPTIC_MIN_INTERVAL,
1080            },
1081        };
1082        if !eligible {
1083            return None;
1084        }
1085        self.last_fire = Some(now);
1086        Some(signal)
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093
1094    const SPEC_UNIT: ValueSpec = ValueSpec {
1095        min: 0.0,
1096        max: 1.0,
1097        divisions: None,
1098    };
1099
1100    fn spec(min: f64, max: f64, divisions: Option<u32>) -> ValueSpec {
1101        ValueSpec {
1102            min,
1103            max,
1104            divisions,
1105        }
1106    }
1107
1108    fn geometry(fraction: f64, kind: SliderTrackKind) -> SliderGeometry {
1109        SliderGeometry {
1110            extent: 200.0,
1111            active_start_fraction: 0.0,
1112            active_end_fraction: fraction,
1113            reverse: false,
1114            kind,
1115            handle_thickness: HANDLE_WIDTH,
1116            handle_gap: HANDLE_GAP,
1117            track_thickness: TRACK_HEIGHT,
1118            corner_radius: TRACK_CORNER_RADIUS,
1119        }
1120    }
1121
1122    // -- value model --------------------------------------------------------
1123
1124    #[test]
1125    fn fraction_maps_bounds_and_clamps_outside_them() {
1126        let s = spec(10.0, 20.0, None);
1127        assert_eq!(s.fraction(10.0), 0.0, "min edge");
1128        assert_eq!(s.fraction(15.0), 0.5);
1129        assert_eq!(s.fraction(20.0), 1.0, "max edge");
1130        assert_eq!(s.fraction(-5.0), 0.0, "below min clamps");
1131        assert_eq!(s.fraction(999.0), 1.0, "above max clamps");
1132        // A degenerate range reads 0 rather than dividing by zero.
1133        assert_eq!(spec(5.0, 5.0, None).fraction(5.0), 0.0);
1134    }
1135
1136    #[test]
1137    fn value_from_fraction_round_trips_through_fraction() {
1138        let s = spec(-40.0, 60.0, None);
1139        for f in [0.0, 0.25, 0.5, 0.75, 1.0] {
1140            let v = s.value_from_fraction(f);
1141            assert!((s.fraction(v) - f).abs() < 1e-12, "fraction {f} round trip");
1142        }
1143        assert_eq!(s.value_from_fraction(0.0), -40.0);
1144        assert_eq!(s.value_from_fraction(1.0), 60.0);
1145    }
1146
1147    #[test]
1148    fn discrete_snapping_lands_on_steps_including_both_edges() {
1149        let s = spec(0.0, 10.0, Some(5)); // steps of 2
1150        assert_eq!(s.snap(0.0), 0.0, "min edge stays put");
1151        assert_eq!(s.snap(10.0), 10.0, "max edge stays put");
1152        assert_eq!(s.snap(0.9), 0.0, "rounds down");
1153        assert_eq!(s.snap(1.1), 2.0, "rounds up");
1154        assert_eq!(s.snap(-3.0), 0.0, "clamps below min before snapping");
1155        assert_eq!(s.snap(99.0), 10.0, "clamps above max before snapping");
1156        // Continuous: clamp only, no snap.
1157        assert_eq!(spec(0.0, 10.0, None).snap(3.3), 3.3);
1158        // Zero divisions is treated as continuous, like upstream's `<= 0`.
1159        assert_eq!(spec(0.0, 10.0, Some(0)).snap(3.3), 3.3);
1160    }
1161
1162    #[test]
1163    fn snap_degrades_to_the_minimum_instead_of_panicking_on_a_bad_bound() {
1164        // `.range(20.0, 10.0)` (`max < min`) — `f64::clamp` would panic on
1165        // `min > max`; this is the exact repro from the review finding.
1166        let inverted = spec(20.0, 10.0, None);
1167        assert_eq!(inverted.snap(15.0), 20.0, "pins at min, never panics");
1168        assert_eq!(inverted.snap(-5.0), 20.0);
1169
1170        // Equal bounds: not itself panic-prone for `clamp`, but shares
1171        // `fraction`'s degenerate treatment (and sidesteps a `0/0` division
1172        // step when discrete).
1173        assert_eq!(spec(5.0, 5.0, Some(4)).snap(5.0), 5.0);
1174
1175        // NaN either bound — `f64::clamp` panics on a NaN min or max too.
1176        // A NaN `min` pins to `0.0` rather than propagating: `min` is the
1177        // degenerate case's own pin target, so a non-finite `min` cannot be
1178        // used as the pin itself.
1179        assert_eq!(spec(0.0, f64::NAN, None).snap(3.0), 0.0, "NaN max");
1180        assert_eq!(
1181            spec(f64::NAN, 10.0, None).snap(3.0),
1182            0.0,
1183            "NaN min pins to 0.0 instead of propagating"
1184        );
1185
1186        // NaN `value` against otherwise well-formed bounds pins to `min`.
1187        assert_eq!(
1188            spec(20.0, 30.0, None).snap(f64::NAN),
1189            20.0,
1190            "NaN value pins to min"
1191        );
1192
1193        // A well-formed range is unaffected by the guard.
1194        assert_eq!(spec(0.0, 10.0, None).snap(4.0), 4.0);
1195    }
1196
1197    #[test]
1198    fn tick_fractions_are_divisions_plus_one_marks_endpoints_included() {
1199        assert_eq!(
1200            spec(0.0, 1.0, Some(4)).tick_fractions(),
1201            vec![0.0, 0.25, 0.5, 0.75, 1.0]
1202        );
1203        assert!(SPEC_UNIT.tick_fractions().is_empty(), "continuous has none");
1204        assert!(spec(0.0, 1.0, Some(0)).tick_fractions().is_empty());
1205    }
1206
1207    #[test]
1208    fn value_from_offset_maps_position_snaps_and_honours_reverse() {
1209        let s = spec(0.0, 100.0, None);
1210        assert_eq!(s.value_from_offset(0.0, 200.0, false), 0.0);
1211        assert_eq!(s.value_from_offset(100.0, 200.0, false), 50.0);
1212        assert_eq!(s.value_from_offset(200.0, 200.0, false), 100.0);
1213        assert_eq!(s.value_from_offset(-40.0, 200.0, false), 0.0, "clamps");
1214        assert_eq!(s.value_from_offset(9000.0, 200.0, false), 100.0, "clamps");
1215        assert_eq!(s.value_from_offset(10.0, 0.0, false), 0.0, "zero extent");
1216        // The vertical/RTL seam mirrors the axis.
1217        assert_eq!(s.value_from_offset(0.0, 200.0, true), 100.0);
1218        assert_eq!(s.value_from_offset(150.0, 200.0, true), 25.0);
1219        // Discrete offsets land on a step.
1220        let d = spec(0.0, 10.0, Some(10));
1221        assert_eq!(d.value_from_offset(37.0, 200.0, false), 2.0);
1222    }
1223
1224    #[test]
1225    fn accept_value_drops_a_change_that_lands_on_the_held_value() {
1226        let s = spec(0.0, 10.0, Some(10));
1227        assert_eq!(accept_value(&s, 3.0, 3.2), None, "snaps back onto 3");
1228        assert_eq!(accept_value(&s, 3.0, 3.6), Some(4.0));
1229        assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.5), None);
1230        assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.6), Some(0.6));
1231        assert_eq!(accept_value(&SPEC_UNIT, 0.5, 9.0), Some(1.0), "clamped");
1232    }
1233
1234    // -- track anatomy ------------------------------------------------------
1235
1236    #[test]
1237    fn a_zero_length_track_resolves_no_metrics() {
1238        let mut g = geometry(0.5, SliderTrackKind::Standard);
1239        g.extent = 0.0;
1240        assert!(TrackMetrics::resolve(&g).is_none());
1241    }
1242
1243    #[test]
1244    fn a_standard_track_paints_the_inactive_remainder_then_the_active_span() {
1245        let g = geometry(0.5, SliderTrackKind::Standard);
1246        let m = TrackMetrics::resolve(&g).expect("metrics");
1247        let segments = m.segments(false);
1248        assert_eq!(segments.len(), 2, "inactive trailing + active");
1249
1250        let inactive = segments[0];
1251        let active = segments[1];
1252        assert_eq!(active.role, SegmentRole::Active);
1253        assert_eq!(inactive.role, SegmentRole::Inactive);
1254
1255        // gap = handle_thickness/2 + handle_gap = 2 + 6 = 8, on both sides of
1256        // the thumb at 100.
1257        assert_eq!(active.start, 0.0);
1258        assert_eq!(active.end, 92.0);
1259        assert_eq!(inactive.start, 108.0);
1260        assert_eq!(inactive.end, 200.0);
1261        assert_eq!(
1262            inactive.start - active.end,
1263            2.0 * (HANDLE_WIDTH / 2.0 + HANDLE_GAP)
1264        );
1265
1266        // Asymmetric radii: outer end full, inner (gap-facing) end small.
1267        assert_eq!(active.start_corner, TRACK_CORNER_RADIUS);
1268        assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1269        assert_eq!(inactive.start_corner, TRACK_INSIDE_CORNER);
1270        assert_eq!(inactive.end_corner, TRACK_CORNER_RADIUS);
1271    }
1272
1273    #[test]
1274    fn the_outer_corner_clamps_to_half_the_track_thickness() {
1275        let mut g = geometry(0.5, SliderTrackKind::Standard);
1276        g.track_thickness = 6.0;
1277        let m = TrackMetrics::resolve(&g).expect("metrics");
1278        assert_eq!(m.corner, 3.0, "clamped from the 8dp token");
1279    }
1280
1281    #[test]
1282    fn a_thumb_at_either_end_drops_the_segment_it_would_swallow() {
1283        // At the minimum the active span is shorter than its own corner, so
1284        // it is not painted at all; only the inactive remainder is.
1285        let m = TrackMetrics::resolve(&geometry(0.0, SliderTrackKind::Standard)).expect("metrics");
1286        let segments = m.segments(false);
1287        assert_eq!(segments.len(), 1);
1288        assert_eq!(segments[0].role, SegmentRole::Inactive);
1289
1290        // At the maximum the trailing inactive span is gone.
1291        let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
1292        let segments = m.segments(false);
1293        assert_eq!(segments.len(), 1);
1294        assert_eq!(segments[0].role, SegmentRole::Active);
1295    }
1296
1297    #[test]
1298    fn a_centered_track_grows_from_the_midpoint_and_keeps_both_inactive_ends() {
1299        let g = geometry(0.75, SliderTrackKind::Centered);
1300        let m = TrackMetrics::resolve(&g).expect("metrics");
1301        assert_eq!(m.center_axis, 100.0);
1302        let segments = m.segments(false);
1303        assert_eq!(
1304            segments.len(),
1305            3,
1306            "inactive leading, inactive trailing, active"
1307        );
1308        assert_eq!(segments[0].role, SegmentRole::Inactive);
1309        assert_eq!(segments[1].role, SegmentRole::Inactive);
1310        assert_eq!(segments[2].role, SegmentRole::Active);
1311
1312        // Active runs from the midpoint to the thumb less its gap; both its
1313        // ends face a gap, so both take the small radius. The gap on the
1314        // midpoint side is carved out of the *inactive* span, which stops 8
1315        // short of the centre — upstream's own asymmetry
1316        // (`m3e_slider_track_paint_metrics.dart:78-85`: the gap is added only
1317        // on the side the active span is *not* on).
1318        let active = segments[2];
1319        assert_eq!(active.start, 100.0, "the midpoint itself");
1320        assert_eq!(active.end, 142.0, "thumb at 150 less the 8dp gap");
1321        assert_eq!(active.start_corner, TRACK_INSIDE_CORNER);
1322        assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1323        assert_eq!(segments[0].start, 0.0);
1324        assert_eq!(segments[0].end, 92.0, "stops short of the midpoint gap");
1325        assert_eq!(segments[1].start, 158.0, "thumb at 150 plus the gap");
1326        assert_eq!(segments[1].end, 200.0);
1327    }
1328
1329    #[test]
1330    fn a_centered_track_below_the_midpoint_mirrors_the_active_span() {
1331        let m = TrackMetrics::resolve(&geometry(0.25, SliderTrackKind::Centered)).expect("metrics");
1332        let segments = m.segments(false);
1333        let active = segments
1334            .iter()
1335            .find(|s| s.role == SegmentRole::Active)
1336            .expect("an active span left of centre");
1337        assert_eq!(active.start, 58.0, "thumb at 50 plus the gap");
1338        assert_eq!(active.end, 100.0, "the midpoint itself");
1339    }
1340
1341    /// A range track over the same 200px extent, thumbs at `low`/`high`.
1342    fn range_geometry(low: f64, high: f64) -> SliderGeometry {
1343        SliderGeometry {
1344            active_start_fraction: low,
1345            active_end_fraction: high,
1346            ..geometry(high, SliderTrackKind::Range)
1347        }
1348    }
1349
1350    #[test]
1351    fn a_range_track_spans_between_two_thumbs_and_keeps_both_inactive_ends() {
1352        let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
1353        let segments = m.segments(false);
1354        assert_eq!(
1355            segments.len(),
1356            3,
1357            "inactive leading, inactive trailing, active"
1358        );
1359        // Both thumbs carve the same 8dp gap out of the active span.
1360        let active = segments[2];
1361        assert_eq!(active.role, SegmentRole::Active);
1362        assert_eq!(active.start, 58.0, "low thumb at 50 plus its gap");
1363        assert_eq!(active.end, 142.0, "high thumb at 150 less its gap");
1364        assert_eq!(
1365            active.start_corner, TRACK_INSIDE_CORNER,
1366            "both ends face a gap"
1367        );
1368        assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1369        assert_eq!((segments[0].start, segments[0].end), (0.0, 42.0));
1370        assert_eq!((segments[1].start, segments[1].end), (158.0, 200.0));
1371    }
1372
1373    #[test]
1374    fn a_range_tracks_dots_clear_both_thumb_gaps() {
1375        let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1376        let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
1377        let placed = dots(&m, &ticks, &dot_spec());
1378        // Both end stops survive (the active span is in the middle); the ticks
1379        // at 0.25 and 0.75 sit inside the two thumb gaps, leaving 0.5.
1380        let primaries: Vec<f64> = placed.iter().map(|d| d.primary).collect();
1381        assert_eq!(primaries, vec![8.0, 192.0, 100.0], "{placed:?}");
1382        assert!(
1383            placed[2].active,
1384            "the surviving tick lies on the active span"
1385        );
1386    }
1387
1388    #[test]
1389    fn a_range_track_hosts_no_inset_icons() {
1390        assert!(
1391            track_icon_placements(
1392                200.0,
1393                0.5,
1394                SliderTrackKind::Range,
1395                TRACK_ICON_SIZE,
1396                [true; 4]
1397            )
1398            .is_empty()
1399        );
1400    }
1401
1402    #[test]
1403    fn rtl_mirrors_which_end_of_each_segment_takes_the_outer_radius() {
1404        let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1405        let ltr = m.segments(false);
1406        let rtl = m.segments(true);
1407        assert_eq!(ltr.len(), rtl.len());
1408        for (l, r) in ltr.iter().zip(rtl.iter()) {
1409            assert_eq!((l.start, l.end), (r.start, r.end), "spans are unmirrored");
1410            assert_eq!(l.start_corner, r.end_corner);
1411            assert_eq!(l.end_corner, r.start_corner);
1412        }
1413    }
1414
1415    #[test]
1416    fn a_pressed_handle_narrows_the_gap_instantly() {
1417        let mut g = geometry(0.5, SliderTrackKind::Standard);
1418        g.handle_thickness = PRESSED_HANDLE_WIDTH;
1419        let m = TrackMetrics::resolve(&g).expect("metrics");
1420        assert_eq!(m.end_gap, PRESSED_HANDLE_WIDTH / 2.0 + HANDLE_GAP);
1421        assert_eq!(m.segments(false)[1].end, 93.0, "active reaches 1dp further");
1422    }
1423
1424    // -- dots ---------------------------------------------------------------
1425
1426    fn dot_spec() -> DotSpec {
1427        DotSpec {
1428            stop_size: STOP_INDICATOR_SIZE,
1429            tick_size: TICK_SIZE,
1430            edge_inset: STOP_INDICATOR_TRAILING_SPACE,
1431        }
1432    }
1433
1434    #[test]
1435    fn a_continuous_track_keeps_only_the_stop_the_active_span_does_not_cover() {
1436        let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1437        let placed = dots(&m, &[], &dot_spec());
1438        assert_eq!(placed.len(), 1, "the leading stop is under the active span");
1439        // inset 6 + half the 4dp dot = 8 from the trailing end.
1440        assert_eq!(placed[0].primary, 192.0);
1441        assert_eq!(placed[0].size, STOP_INDICATOR_SIZE);
1442        assert!(!placed[0].active, "an end stop always takes the stop role");
1443    }
1444
1445    #[test]
1446    fn a_thumb_at_the_maximum_leaves_no_stop_at_all() {
1447        let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
1448        assert!(
1449            dots(&m, &[], &dot_spec()).is_empty(),
1450            "both ends sit on the active span or in its gap"
1451        );
1452    }
1453
1454    #[test]
1455    fn discrete_ticks_skip_the_endpoints_and_the_handle_gap() {
1456        let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1457        let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1458        let placed = dots(&m, &ticks, &dot_spec());
1459        // Five marks over the padded 8..192 span: both endpoints belong to the
1460        // stop indicators (the leading one is under the active span), and the
1461        // middle tick at 100 sits inside the thumb gap.
1462        assert_eq!(placed.len(), 3, "{placed:?}");
1463        assert_eq!(placed[0].primary, 192.0, "the surviving end stop, first");
1464        assert_eq!(placed[1].primary, 54.0, "tick at 0.25");
1465        assert_eq!(placed[2].primary, 146.0, "tick at 0.75");
1466        assert!(placed[1].active, "0.25 lies on the active span");
1467        assert!(!placed[2].active, "0.75 lies past the thumb");
1468    }
1469
1470    #[test]
1471    fn a_longer_active_span_flips_more_ticks_to_the_active_role() {
1472        let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1473        let m = TrackMetrics::resolve(&geometry(0.9, SliderTrackKind::Standard)).expect("metrics");
1474        let placed = dots(&m, &ticks, &dot_spec());
1475        assert_eq!(
1476            placed.iter().filter(|d| d.active).count(),
1477            3,
1478            "every interior tick is inside a 90%-long active span: {placed:?}"
1479        );
1480    }
1481
1482    // -- thumb / indicator / dock -------------------------------------------
1483
1484    #[test]
1485    fn the_thumb_box_is_centred_on_its_value_and_pill_rounded() {
1486        let thumb = thumb_rect(100.0, 22.0, HANDLE_WIDTH, HANDLE_HEIGHT);
1487        assert_eq!(thumb.x0, 98.0);
1488        assert_eq!(thumb.x1, 102.0);
1489        assert_eq!(thumb.y0, 0.0);
1490        assert_eq!(thumb.y1, 44.0);
1491        assert_eq!(thumb_radius(thumb), HANDLE_HEIGHT / 2.0);
1492
1493        let ring = focus_ring_rect(thumb);
1494        assert_eq!(ring.width(), HANDLE_WIDTH + FOCUS_INFLATE);
1495        assert_eq!(ring.height(), HANDLE_HEIGHT + FOCUS_INFLATE);
1496    }
1497
1498    #[test]
1499    fn the_value_indicator_floats_above_the_control_box() {
1500        let origin = value_indicator_origin(100.0);
1501        assert_eq!(origin.x, 76.0);
1502        assert_eq!(
1503            origin.y,
1504            -(VALUE_INDICATOR_BOTTOM_SPACE + VALUE_INDICATOR_LIFT)
1505        );
1506        assert!(origin.y < 0.0, "it overflows the control's own box");
1507    }
1508
1509    fn dock_input(thumb_primary: f64) -> IconDockInput {
1510        IconDockInput {
1511            extent: 200.0,
1512            thumb_primary,
1513            icon_size: ICON_SIZE_DEFAULT,
1514            edge_inset: ICON_EDGE_INSET,
1515            handle_thickness: HANDLE_WIDTH,
1516            position: SliderIconPosition::End,
1517            reverse: false,
1518        }
1519    }
1520
1521    #[test]
1522    fn the_end_icon_rests_at_the_trailing_edge_until_the_thumb_nears_it() {
1523        let far = icon_dock(&dock_input(20.0));
1524        assert_eq!(far.resting_center, 180.0, "extent - inset - half the icon");
1525        assert!(!far.docked);
1526        assert_eq!(far.center_at(0.0), 180.0, "resting while undocked");
1527
1528        // dock limit = 2 + 12 + 8 = 22.
1529        let near = icon_dock(&dock_input(160.0));
1530        assert!(near.docked, "within the dock distance limit");
1531        assert_eq!(near.docked_target, 160.0 - (2.0 + 12.0 + 12.0));
1532        assert_eq!(near.center_at(1.0), 134.0);
1533    }
1534
1535    #[test]
1536    fn a_docked_icon_stays_inside_the_track_and_reports_which_segment_it_is_over() {
1537        let dock = icon_dock(&dock_input(4.0));
1538        assert_eq!(dock.center_at(1.0), 12.0, "clamped to half the icon");
1539        assert!(
1540            !dock.over_active(180.0, 4.0, false),
1541            "resting past the thumb is over the inactive segment"
1542        );
1543        assert!(dock.over_active(2.0, 4.0, false));
1544    }
1545
1546    #[test]
1547    fn a_track_narrower_than_its_icon_docks_without_panicking() {
1548        // `extent` (10px) is well under `ICON_SIZE_DEFAULT` (24px), the exact
1549        // shape a slider laid out inside a tight constraint produces —
1550        // `icon_half` (12) would otherwise exceed `extent - icon_half` (-2),
1551        // inverting `IconDock`'s clamp bounds.
1552        let mut input = dock_input(4.0);
1553        input.extent = 10.0;
1554        let dock = icon_dock(&input);
1555        assert_eq!(
1556            dock.min_center, -2.0,
1557            "ordered: extent - icon_half < icon_half"
1558        );
1559        assert_eq!(dock.max_center, 12.0);
1560        // Both the resting and docked targets fall outside the (now narrow
1561        // and inverted-were-it-not-ordered) window, so every dock progress
1562        // pins at the same clamped edge instead of panicking.
1563        assert_eq!(dock.center_at(0.0), dock.min_center);
1564        assert_eq!(dock.center_at(1.0), dock.min_center);
1565        assert!(dock.center_at(0.5).is_finite());
1566    }
1567
1568    #[test]
1569    fn a_start_positioned_icon_rests_at_the_leading_edge() {
1570        let mut input = dock_input(180.0);
1571        input.position = SliderIconPosition::Start;
1572        assert_eq!(icon_dock(&input).resting_center, 20.0);
1573    }
1574
1575    // -- inset track icons --------------------------------------------------
1576
1577    #[test]
1578    fn inset_track_icons_place_per_segment_and_drop_out_of_a_short_one() {
1579        let all = [true; 4];
1580        let placed =
1581            track_icon_placements(200.0, 0.5, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
1582        assert_eq!(placed.len(), 4);
1583        assert_eq!(placed[0].primary, TRACK_ICON_INSET);
1584        assert_eq!(
1585            placed[1].primary,
1586            100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1587        );
1588        assert_eq!(placed[2].primary, 104.0);
1589        assert_eq!(
1590            placed[3].primary,
1591            200.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1592        );
1593        assert!(placed[0].active && placed[1].active);
1594        assert!(!placed[2].active && !placed[3].active);
1595
1596        // A 10px-long active segment cannot host a 16px icon plus its slack.
1597        let squeezed =
1598            track_icon_placements(200.0, 0.05, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
1599        assert!(squeezed.iter().all(|p| !p.active), "{squeezed:?}");
1600    }
1601
1602    #[test]
1603    fn a_slot_with_no_icon_is_never_placed() {
1604        let only_active_start = [true, false, false, false];
1605        let placed = track_icon_placements(
1606            200.0,
1607            0.5,
1608            SliderTrackKind::Standard,
1609            TRACK_ICON_SIZE,
1610            only_active_start,
1611        );
1612        assert_eq!(placed.len(), 1);
1613        assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
1614    }
1615
1616    #[test]
1617    fn a_centered_track_hosts_active_slot_icons_only() {
1618        let placed = track_icon_placements(
1619            200.0,
1620            0.75,
1621            SliderTrackKind::Centered,
1622            TRACK_ICON_SIZE,
1623            [true; 4],
1624        );
1625        assert_eq!(placed.len(), 2);
1626        assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
1627        assert_eq!(
1628            placed[0].primary,
1629            100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1630        );
1631        assert_eq!(placed[1].slot, TrackIconSlot::ActiveEnd);
1632        assert_eq!(placed[1].primary, 150.0 + TRACK_ICON_INSET);
1633    }
1634
1635    // -- drag machine -------------------------------------------------------
1636
1637    fn drag_ctx(spec: ValueSpec, current: f64) -> DragCtx {
1638        DragCtx {
1639            spec,
1640            current,
1641            extent: 200.0,
1642            reverse: false,
1643        }
1644    }
1645
1646    #[test]
1647    fn down_move_up_reports_values_then_settles() {
1648        let spec = spec(0.0, 100.0, None);
1649        let mut drag = Drag::default();
1650
1651        let down = drag.down(50.0, &drag_ctx(spec, 0.0));
1652        assert!(down.handled && down.capture);
1653        assert_eq!(down.value, Some(25.0));
1654        assert!(!down.end);
1655        assert!(drag.pressed && drag.dragging);
1656
1657        let moved = drag.moved(150.0, &drag_ctx(spec, 25.0));
1658        assert_eq!(moved.value, Some(75.0));
1659        assert!(!moved.capture, "the capture is already open");
1660
1661        let up = drag.up();
1662        assert!(up.end, "settling owes on_change_end");
1663        assert_eq!(up.value, None, "and reports no new value");
1664        assert!(!drag.pressed && !drag.dragging);
1665    }
1666
1667    #[test]
1668    fn an_uncaptured_move_or_up_is_ignored_outright() {
1669        let spec = ValueSpec::default();
1670        let mut drag = Drag::default();
1671        assert_eq!(
1672            drag.moved(100.0, &drag_ctx(spec, 0.0)),
1673            DragOutcome::default()
1674        );
1675        assert_eq!(drag.up(), DragOutcome::default());
1676        assert_eq!(drag.cancel(), DragOutcome::default());
1677        assert!(!drag.pressed, "a hover move never presses");
1678    }
1679
1680    #[test]
1681    fn cancel_disarms_without_owing_a_change_end() {
1682        let spec = ValueSpec::default();
1683        let mut drag = Drag::default();
1684        drag.down(100.0, &drag_ctx(spec, 0.0));
1685        let cancelled = drag.cancel();
1686        assert!(cancelled.handled);
1687        assert!(!cancelled.end, "a Cancel arm may never reach app state");
1688        assert!(!drag.pressed && !drag.dragging);
1689    }
1690
1691    #[test]
1692    fn a_move_that_lands_on_the_held_value_reports_nothing() {
1693        let spec = spec(0.0, 10.0, Some(10));
1694        let mut drag = Drag::default();
1695        drag.down(100.0, &drag_ctx(spec, 0.0));
1696        // 101px still snaps to 5 on a 10-division, 200px track.
1697        assert_eq!(drag.moved(101.0, &drag_ctx(spec, 5.0)).value, None);
1698        assert_eq!(drag.moved(121.0, &drag_ctx(spec, 5.0)).value, Some(6.0));
1699    }
1700
1701    // -- haptic scheduler ---------------------------------------------------
1702
1703    fn ft_ms(ms: u64) -> FrameTime {
1704        FrameTime::from_nanos(ms * 1_000_000)
1705    }
1706
1707    #[test]
1708    fn the_haptic_scheduler_paces_continuous_ticks_and_lets_discrete_ones_through() {
1709        // Continuous: the first tick fires, then one per 60ms of frame clock.
1710        let mut sched = HapticScheduler::default();
1711        assert!(!sched.has_pending());
1712        sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
1713        assert!(sched.has_pending());
1714        assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::SliderTick));
1715        assert!(!sched.has_pending(), "taking drains the queue");
1716        assert_eq!(
1717            sched.take(ft_ms(100)),
1718            None,
1719            "nothing queued, nothing fires"
1720        );
1721
1722        // Step frames at 16ms; only the frames at/after each 60ms boundary
1723        // fire, and an ineligible signal is dropped rather than deferred.
1724        let mut fired = 0;
1725        for frame in 1..=12 {
1726            sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
1727            if sched.take(ft_ms(frame * 16)).is_some() {
1728                fired += 1;
1729            }
1730        }
1731        assert_eq!(
1732            fired, 3,
1733            "12 frames * 16ms = 192ms after the first tick: 64/128/192"
1734        );
1735
1736        // Discrete: every queued tick fires, whatever the clock says.
1737        let mut sched = HapticScheduler::default();
1738        let mut fired = 0;
1739        for frame in 0..12 {
1740            sched.queue(HapticSignal::SliderTick, HapticCadence::Immediate);
1741            if sched.take(ft_ms(frame)).is_some() {
1742                fired += 1;
1743            }
1744        }
1745        assert_eq!(fired, 12, "a per-division tick is never throttled");
1746    }
1747
1748    #[test]
1749    fn a_second_queue_before_the_next_paint_collapses_into_one_tick() {
1750        let mut sched = HapticScheduler::default();
1751        sched.queue(HapticSignal::Light, HapticCadence::Immediate);
1752        sched.queue(HapticSignal::Heavy, HapticCadence::Immediate);
1753        assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::Heavy));
1754        assert_eq!(sched.take(ft_ms(1)), None);
1755    }
1756}