Skip to main content

azul_layout/widgets/
slider.rs

1//! Slider / range widget — a horizontal track with a draggable circular thumb
2//! that maps a position along the track to a numeric value in `[min, max]`.
3//! Combines the value/min/max state + `on_value_change` callback shape of
4//! [`crate::widgets::number_input::NumberInput`] with the pointer-drag handling
5//! of [`crate::widgets::map`] (cursor-relative-to-node → value), and the
6//! switch's "track + knob slid via `margin-left`" rendering.
7//!
8//! Behaviour: pressing or dragging anywhere on the track sets the value from the
9//! cursor's X position (relative to the track, in logical px), slides the thumb
10//! live via `set_css_property`, and invokes the user's `on_value_change`.
11//!
12//! Key types: [`Slider`], [`SliderState`], [`SliderOnValueChange`].
13
14use azul_core::{
15    callbacks::{CoreCallbackData, Update},
16    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
17    refany::RefAny,
18};
19use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
20use azul_css::{
21    props::{
22        basic::{color::ColorU, *},
23        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutMarginLeft},
24        property::{CssProperty, *},
25        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleCursor},
26    },
27    impl_option_inner, AzString,
28};
29
30use crate::callbacks::{Callback, CallbackInfo};
31
32static SLIDER_TRACK_CLASS: &[IdOrClass] =
33    &[Class(AzString::from_const_str("__azul-native-slider"))];
34static SLIDER_THUMB_CLASS: &[IdOrClass] =
35    &[Class(AzString::from_const_str("__azul-native-slider-thumb"))];
36
37/// Callback function type invoked when the slider value changes.
38pub type SliderOnValueChangeCallbackType =
39    extern "C" fn(RefAny, CallbackInfo, SliderState) -> Update;
40impl_widget_callback!(
41    SliderOnValueChange,
42    OptionSliderOnValueChange,
43    SliderOnValueChangeCallback,
44    SliderOnValueChangeCallbackType
45);
46
47azul_core::impl_managed_callback! {
48    wrapper:        SliderOnValueChangeCallback,
49    info_ty:        CallbackInfo,
50    return_ty:      Update,
51    default_ret:    Update::DoNothing,
52    invoker_static: SLIDER_ON_VALUE_CHANGE_INVOKER,
53    invoker_ty:     AzSliderOnValueChangeCallbackInvoker,
54    thunk_fn:       az_slider_on_value_change_callback_thunk,
55    setter_fn:      AzApp_setSliderOnValueChangeCallbackInvoker,
56    from_handle_fn: AzSliderOnValueChangeCallback_createFromHostHandle,
57    extra_args:     [ state: SliderState ],
58}
59
60/// A horizontal slider with a draggable thumb and a value-change callback.
61#[derive(Debug, Clone, PartialEq)]
62#[repr(C)]
63pub struct Slider {
64    pub slider_state: SliderStateWrapper,
65    /// Style for the slider track (the horizontal rail).
66    pub track_style: CssPropertyWithConditionsVec,
67    /// Style for the draggable thumb.
68    pub thumb_style: CssPropertyWithConditionsVec,
69}
70
71#[derive(Debug, Default, Clone, PartialEq)]
72#[repr(C)]
73pub struct SliderStateWrapper {
74    /// Optional: function to call when the value changes.
75    pub on_value_change: OptionSliderOnValueChange,
76    /// The value/range of this Slider.
77    pub inner: SliderState,
78    /// `true` while a pointer-drag is in flight (mirrors `map::MapTileCache::drag_anchor`).
79    /// Transient; not part of the user-visible [`SliderState`].
80    pub dragging: bool,
81}
82
83/// State of a [`Slider`]: the current value and the allowed `[min, max]` range.
84#[derive(Debug, Copy, Clone, PartialEq)]
85#[repr(C)]
86pub struct SliderState {
87    /// The current value (always within `[min, max]`).
88    pub value: f32,
89    /// Minimum allowed value (inclusive) — thumb at the far left.
90    pub min: f32,
91    /// Maximum allowed value (inclusive) — thumb at the far right.
92    pub max: f32,
93}
94
95impl Default for SliderState {
96    fn default() -> Self {
97        Self {
98            value: 0.0,
99            min: 0.0,
100            max: 100.0,
101        }
102    }
103}
104
105// ---- dimensions (logical px) ----
106const TRACK_WIDTH: isize = 200;
107const TRACK_HEIGHT: isize = 16;
108const TRACK_RADIUS: isize = 8;
109const THUMB_SIZE: isize = 16;
110const THUMB_RADIUS: isize = 8;
111
112// ---- colours ----
113/// Rail colour (#cccccc).
114const RAIL_COLOR: ColorU = ColorU {
115    r: 204,
116    g: 204,
117    b: 204,
118    a: 255,
119};
120/// Thumb colour (#0d6efd, accent blue).
121const THUMB_COLOR: ColorU = ColorU {
122    r: 13,
123    g: 110,
124    b: 253,
125    a: 255,
126};
127
128const RAIL_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(RAIL_COLOR)];
129const RAIL_BG: StyleBackgroundContentVec =
130    StyleBackgroundContentVec::from_const_slice(RAIL_BG_ITEMS);
131const THUMB_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(THUMB_COLOR)];
132const THUMB_BG: StyleBackgroundContentVec =
133    StyleBackgroundContentVec::from_const_slice(THUMB_BG_ITEMS);
134
135/// The track (rail) style is parameter-independent, so it lives in a const slice.
136static SLIDER_TRACK_STYLE: &[CssPropertyWithConditions] = &[
137    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
138    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
139    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
140    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Center)),
141    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
142    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(TRACK_WIDTH))),
143    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
144        TRACK_HEIGHT,
145    ))),
146    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
147        StyleBorderTopLeftRadius::const_px(TRACK_RADIUS),
148    )),
149    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
150        StyleBorderTopRightRadius::const_px(TRACK_RADIUS),
151    )),
152    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
153        StyleBorderBottomLeftRadius::const_px(TRACK_RADIUS),
154    )),
155    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
156        StyleBorderBottomRightRadius::const_px(TRACK_RADIUS),
157    )),
158    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
159    CssPropertyWithConditions::simple(CssProperty::const_background_content(RAIL_BG)),
160];
161
162/// Maps a value to a `[0, 1]` fraction along the track.
163fn value_to_fraction(value: f32, min: f32, max: f32) -> f32 {
164    if max <= min {
165        0.0
166    } else {
167        ((value - min) / (max - min)).clamp(0.0, 1.0)
168    }
169}
170
171/// Builds the thumb style; the `margin-left` is the only position-dependent
172/// property and slides the thumb between the left (`min`) and right (`max`) ends.
173#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
174fn build_thumb_style(fraction: f32) -> CssPropertyWithConditionsVec {
175    // `fraction` is a bare `f32` with no type-level guard. Its only caller feeds
176    // it `value_to_fraction`'s already-clamped output, but the helper must be
177    // safe on its own terms: `const_px` encodes the margin as `isize * 1000`
178    // (`FloatValue::const_new`), so any |margin| above `isize::MAX / 1000`
179    // overflows that multiply — a panic in an overflow-checked build, a wrapped
180    // and wildly-wrong margin in release. `as isize` already saturates NaN to 0
181    // and ±inf to isize::MIN/MAX, so the only thing missing is the clamp into
182    // what the fixed-point encoding can actually hold. Clamping the ENCODED px
183    // rather than the fraction keeps every in-contract and out-of-contract-but-
184    // representable result (including negative fractions) bit-for-bit unchanged.
185    const MAX_ENCODABLE_PX: isize = isize::MAX / 1000;
186    let margin = ((fraction * (TRACK_WIDTH - THUMB_SIZE) as f32).round() as isize)
187        .clamp(-MAX_ENCODABLE_PX, MAX_ENCODABLE_PX);
188    CssPropertyWithConditionsVec::from_vec(alloc::vec![
189        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
190            THUMB_SIZE,
191        ))),
192        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
193            THUMB_SIZE,
194        ))),
195        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
196            0,
197        ))),
198        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
199            StyleBorderTopLeftRadius::const_px(THUMB_RADIUS),
200        )),
201        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
202            StyleBorderTopRightRadius::const_px(THUMB_RADIUS),
203        )),
204        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
205            StyleBorderBottomLeftRadius::const_px(THUMB_RADIUS),
206        )),
207        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
208            StyleBorderBottomRightRadius::const_px(THUMB_RADIUS),
209        )),
210        CssPropertyWithConditions::simple(CssProperty::const_background_content(THUMB_BG)),
211        CssPropertyWithConditions::simple(CssProperty::const_margin_left(
212            LayoutMarginLeft::const_px(margin),
213        )),
214    ])
215}
216
217/// Clamps `value` into `[min, max]`, tolerating the degenerate bounds that
218/// `f32::clamp` panics on: an inverted range (`min > max`) is swapped, and a NaN
219/// bound is dropped (if both are NaN the value is returned untouched). `min`/`max`
220/// are `pub` fields on a `#[repr(C)]` `SliderState` that crosses the C/FFI
221/// boundary, so a caller can supply either — and unwinding across FFI is UB.
222fn clamp_to_range(value: f32, min: f32, max: f32) -> f32 {
223    let (lo, hi) = match (min.is_nan(), max.is_nan()) {
224        (true, true) => return value,
225        (true, false) => (max, max),
226        (false, true) => (min, min),
227        (false, false) if min <= max => (min, max),
228        (false, false) => (max, min),
229    };
230    value.clamp(lo, hi)
231}
232
233impl Slider {
234    /// Creates a slider with the given current value and `[min, max]` range.
235    #[must_use] pub fn create(value: f32, min: f32, max: f32) -> Self {
236        let value = clamp_to_range(value, min, max);
237        Self {
238            slider_state: SliderStateWrapper {
239                inner: SliderState { value, min, max },
240                ..Default::default()
241            },
242            track_style: CssPropertyWithConditionsVec::from_const_slice(SLIDER_TRACK_STYLE),
243            thumb_style: build_thumb_style(value_to_fraction(value, min, max)),
244        }
245    }
246
247    /// Sets the current value (clamped to the range), recomputing the thumb position.
248    #[inline]
249    pub fn set_value(&mut self, value: f32) {
250        let min = self.slider_state.inner.min;
251        let max = self.slider_state.inner.max;
252        let value = clamp_to_range(value, min, max);
253        self.slider_state.inner.value = value;
254        self.thumb_style = build_thumb_style(value_to_fraction(value, min, max));
255    }
256
257    /// Builder-style setter for the current value.
258    #[inline]
259    #[must_use] pub fn with_value(mut self, value: f32) -> Self {
260        self.set_value(value);
261        self
262    }
263
264    #[inline]
265    #[must_use] pub fn swap_with_default(&mut self) -> Self {
266        let mut s = Self::create(0.0, 0.0, 100.0);
267        core::mem::swap(&mut s, self);
268        s
269    }
270
271    #[inline]
272    pub fn set_on_value_change<C: Into<SliderOnValueChangeCallback>>(
273        &mut self,
274        data: RefAny,
275        on_value_change: C,
276    ) {
277        self.slider_state.on_value_change = Some(SliderOnValueChange {
278            callback: on_value_change.into(),
279            refany: data,
280        })
281        .into();
282    }
283
284    #[inline]
285    #[must_use] pub fn with_on_value_change<C: Into<SliderOnValueChangeCallback>>(
286        mut self,
287        data: RefAny,
288        on_value_change: C,
289    ) -> Self {
290        self.set_on_value_change(data, on_value_change);
291        self
292    }
293
294    #[inline]
295    #[must_use] pub fn dom(self) -> Dom {
296        use azul_core::{
297            callbacks::CoreCallback,
298            dom::{EventFilter, HoverEventFilter},
299            refany::OptionRefAny,
300        };
301
302        // One shared RefAny across all pointer callbacks so the transient
303        // `dragging` flag set on press is visible to the move/release handlers
304        // (RefAny::clone shares the underlying data — same pattern as map.rs).
305        let state = RefAny::new(self.slider_state);
306        let mk = |event: EventFilter, cb: usize| CoreCallbackData {
307            event,
308            callback: CoreCallback {
309                cb,
310                ctx: OptionRefAny::None,
311            },
312            refany: state.clone(),
313        };
314        let callbacks = vec![
315            mk(
316                EventFilter::Hover(HoverEventFilter::MouseDown),
317                on_slider_pointer_down as usize,
318            ),
319            mk(
320                EventFilter::Hover(HoverEventFilter::MouseOver),
321                on_slider_pointer_move as usize,
322            ),
323            mk(
324                EventFilter::Hover(HoverEventFilter::MouseUp),
325                on_slider_pointer_up as usize,
326            ),
327            mk(
328                EventFilter::Hover(HoverEventFilter::MouseLeave),
329                on_slider_pointer_up as usize,
330            ),
331            mk(
332                EventFilter::Hover(HoverEventFilter::TouchStart),
333                on_slider_pointer_down as usize,
334            ),
335            mk(
336                EventFilter::Hover(HoverEventFilter::TouchMove),
337                on_slider_pointer_move as usize,
338            ),
339            mk(
340                EventFilter::Hover(HoverEventFilter::TouchEnd),
341                on_slider_pointer_up as usize,
342            ),
343        ];
344
345        Dom::create_div()
346            .with_ids_and_classes(IdOrClassVec::from_const_slice(SLIDER_TRACK_CLASS))
347            .with_css_props(self.track_style)
348            .with_callbacks(callbacks.into())
349            .with_tab_index(TabIndex::Auto)
350            .with_children(
351                vec![Dom::create_div()
352                    .with_ids_and_classes(IdOrClassVec::from_const_slice(SLIDER_THUMB_CLASS))
353                    .with_css_props(self.thumb_style)]
354                .into(),
355            )
356    }
357}
358
359impl Default for Slider {
360    fn default() -> Self {
361        Self::create(0.0, 0.0, 100.0)
362    }
363}
364
365/// Shared logic for press + drag: compute the value from the cursor's X position
366/// relative to the track, slide the thumb live, and invoke the user callback.
367#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
368fn apply_cursor_value(slider: &mut SliderStateWrapper, info: &mut CallbackInfo) -> Update {
369    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
370        return Update::DoNothing;
371    };
372    // Track width in LOGICAL px (falls back to the design width before first layout).
373    let width = info
374        .get_hit_node_rect()
375        .map(|r| r.size.width)
376        .filter(|w| *w > 0.0)
377        .unwrap_or(TRACK_WIDTH as f32);
378
379    let fraction = (pos.x / width).clamp(0.0, 1.0);
380    let min = slider.inner.min;
381    let max = slider.inner.max;
382    slider.inner.value = fraction.mul_add(max - min, min);
383
384    // Slide the thumb (first child of the track) to the new position.
385    let track_id = info.get_hit_node();
386    if let Some(thumb_id) = info.get_first_child(track_id) {
387        let margin = (fraction * (width - THUMB_SIZE as f32)).round() as isize;
388        info.set_css_property(
389            thumb_id,
390            CssProperty::const_margin_left(LayoutMarginLeft::const_px(margin)),
391        );
392    }
393
394    let inner = slider.inner;
395    match slider.on_value_change.as_mut() {
396        Some(SliderOnValueChange { callback, refany }) => (callback.cb)(refany.clone(), *info, inner),
397        None => Update::DoNothing,
398    }
399}
400
401/// Pointer down → begin a drag and set the value from the press position.
402extern "C" fn on_slider_pointer_down(mut data: RefAny, mut info: CallbackInfo) -> Update {
403    let Some(mut slider) = data.downcast_mut::<SliderStateWrapper>() else {
404        return Update::DoNothing;
405    };
406    slider.dragging = true;
407    apply_cursor_value(&mut slider, &mut info)
408}
409
410/// Pointer move → if a drag is active, track the value to the cursor.
411extern "C" fn on_slider_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
412    let Some(mut slider) = data.downcast_mut::<SliderStateWrapper>() else {
413        return Update::DoNothing;
414    };
415    if !slider.dragging {
416        return Update::DoNothing;
417    }
418    apply_cursor_value(&mut slider, &mut info)
419}
420
421/// Pointer up / leave → end the drag.
422extern "C" fn on_slider_pointer_up(mut data: RefAny, _info: CallbackInfo) -> Update {
423    if let Some(mut slider) = data.downcast_mut::<SliderStateWrapper>() {
424        slider.dragging = false;
425    }
426    Update::DoNothing
427}
428
429impl From<Slider> for Dom {
430    fn from(s: Slider) -> Self {
431        s.dom()
432    }
433}
434
435// ────────── Adversarial autotest coverage ────────────────────────────
436//
437// The slider is a pure `f32 -> fraction -> whole-pixel margin` pipeline wrapped
438// in three pointer callbacks. Everything below feeds it values a real app (or an
439// FFI caller writing the `#[repr(C)]` `pub` fields directly) can produce — an
440// inverted `[min, max]`, a NaN bound, an infinite range, a cursor outside the
441// track, a zero-width track, a foreign payload — and asserts the widget
442// *contains* them instead of panicking or sliding the thumb off the rail.
443#[cfg(all(test, feature = "std"))]
444#[allow(
445    clippy::float_cmp,
446    clippy::cast_precision_loss,
447    clippy::cast_possible_truncation,
448    clippy::unreadable_literal,
449    clippy::too_many_lines
450)]
451mod autotest_generated {
452    use std::{
453        collections::{BTreeMap, HashMap},
454        mem::discriminant,
455        panic::{catch_unwind, AssertUnwindSafe},
456        sync::{Arc, Mutex},
457    };
458
459    use azul_core::{
460        dom::{
461            DomId, DomNodeId, EventFilter, FormattingContext, HoverEventFilter, NodeId, NodeType,
462        },
463        geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition},
464        gl::OptionGlContextPtr,
465        hit_test::ScrollPosition,
466        refany::OptionRefAny,
467        resources::RendererResources,
468        styled_dom::{NodeHierarchyItemId, StyledDom},
469        window::{MonitorVec, RawWindowHandle},
470    };
471    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
472    use rust_fontconfig::FcFontCache;
473
474    use super::*;
475    #[cfg(feature = "icu")]
476    use crate::icu::IcuLocalizerHandle;
477    use crate::{
478        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
479        solver3::{
480            display_list::DisplayList,
481            geometry::PackedBoxProps,
482            layout_tree::{LayoutNodeHot, LayoutTree},
483        },
484        window::{DomLayoutResult, LayoutWindow},
485        window_state::FullWindowState,
486    };
487
488    // ------------------------------------------------------------------
489    // Fixtures
490    // ------------------------------------------------------------------
491
492    /// The travel the thumb has along the design-width track: a thumb at
493    /// `fraction == 1.0` must sit flush against the right end, not past it.
494    const TRAVEL: f32 = (TRACK_WIDTH - THUMB_SIZE) as f32; // 184.0
495
496    /// The whole design width of the track, as an `f32` — the value
497    /// `apply_cursor_value` falls back to before the first layout.
498    const DESIGN_WIDTH: f32 = TRACK_WIDTH as f32; // 200.0
499
500    /// Fractions `value_to_fraction` can actually hand to `build_thumb_style`:
501    /// the closed unit interval (plus both signed zeroes) and NaN, which is what
502    /// a NaN value/bound collapses to. Nothing else is reachable through the
503    /// public API — the out-of-contract extremes get their own probe.
504    const REACHABLE_FRACTIONS: [f32; 10] = [
505        0.0,
506        -0.0,
507        f32::MIN_POSITIVE,
508        f32::EPSILON,
509        0.001,
510        0.25,
511        0.5,
512        0.75,
513        1.0,
514        f32::NAN,
515    ];
516
517    /// `[min, max]` ranges a caller can legally build (`min <= max`, both finite).
518    const SANE_RANGES: [(f32, f32); 8] = [
519        (0.0, 100.0),
520        (0.0, 1.0),
521        (-100.0, -50.0),
522        (-50.0, 50.0),
523        (0.0, 0.0),
524        (-7.5, 7.5),
525        (1.0, 1.0e9),
526        (-1.0e9, 1.0e9),
527    ];
528
529    /// Ranges that break `f32::clamp`'s `min <= max` precondition. Every one of
530    /// them is expressible: `SliderState`'s `min`/`max` are `pub` fields on a
531    /// `#[repr(C)]` struct that crosses the C/FFI boundary.
532    const DEGENERATE_RANGES: [(f32, f32); 6] = [
533        (100.0, 0.0),
534        (1.0, -1.0),
535        (f32::NAN, 100.0),
536        (0.0, f32::NAN),
537        (f32::NAN, f32::NAN),
538        (f32::INFINITY, f32::NEG_INFINITY),
539    ];
540
541    // ------------------------------------------------------------------
542    // Style-vec probes
543    // ------------------------------------------------------------------
544
545    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
546        v.as_ref().iter().map(|p| p.property.clone()).collect()
547    }
548
549    /// The `f32` behind a `PixelValue`, asserting it is an absolute `px` length.
550    /// An `em`/`%` slipping into the thumb offset would resolve against the
551    /// parent font/box, so "92px along the rail" could land anywhere.
552    fn px(pv: PixelValue) -> f32 {
553        assert_eq!(
554            pv.metric,
555            SizeMetric::Px,
556            "slider geometry must be absolute px, got {:?}",
557            pv.metric,
558        );
559        pv.number.get()
560    }
561
562    /// The declared `margin-left` of a style vec — the thumb's position.
563    fn margin_left(v: &CssPropertyWithConditionsVec) -> Option<f32> {
564        v.as_ref().iter().find_map(|p| match &p.property {
565            CssProperty::MarginLeft(m) => m.get_property().map(|m| px(m.inner)),
566            _ => None,
567        })
568    }
569
570    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
571        v.as_ref().iter().find_map(|p| match &p.property {
572            CssProperty::Width(w) => match w.get_property() {
573                Some(LayoutWidth::Px(pv)) => Some(px(*pv)),
574                _ => None,
575            },
576            _ => None,
577        })
578    }
579
580    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
581        v.as_ref().iter().find_map(|p| match &p.property {
582            CssProperty::Height(h) => match h.get_property() {
583                Some(LayoutHeight::Px(pv)) => Some(px(*pv)),
584                _ => None,
585            },
586            _ => None,
587        })
588    }
589
590    fn background(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
591        v.as_ref().iter().find_map(|p| match &p.property {
592            CssProperty::BackgroundContent(b) => match b.get_property()?.as_ref().first()? {
593                StyleBackgroundContent::Color(c) => Some(*c),
594                _ => None,
595            },
596            _ => None,
597        })
598    }
599
600    /// The thumb offset a freshly built widget declares.
601    fn thumb_margin(s: &Slider) -> f32 {
602        margin_left(&s.thumb_style).expect("the thumb style must declare a margin-left")
603    }
604
605    fn classes(dom: &Dom) -> Vec<String> {
606        dom.root
607            .get_ids_and_classes()
608            .as_ref()
609            .iter()
610            .filter_map(|c| match c {
611                IdOrClass::Class(s) => Some(s.as_str().to_string()),
612                IdOrClass::Id(_) => None,
613            })
614            .collect()
615    }
616
617    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
618        dom.root
619            .style
620            .iter_inline_properties()
621            .map(|(p, _)| p.clone())
622            .collect()
623    }
624
625    // ------------------------------------------------------------------
626    // Callback harness
627    // ------------------------------------------------------------------
628
629    fn node(idx: usize) -> DomNodeId {
630        DomNodeId {
631            dom: DomId::ROOT_ID,
632            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
633        }
634    }
635
636    fn node_none() -> DomNodeId {
637        DomNodeId {
638            dom: DomId::ROOT_ID,
639            node: NodeHierarchyItemId::NONE,
640        }
641    }
642
643    /// A layout result carrying only the styled DOM: the hierarchy is real (so
644    /// `get_first_child` resolves the thumb) but nothing is laid out, so
645    /// `get_hit_node_rect` returns `None` — the pre-first-layout state a widget
646    /// is in when the very first `MouseDown` arrives.
647    fn unlaid(styled_dom: StyledDom) -> DomLayoutResult {
648        DomLayoutResult {
649            styled_dom,
650            layout_tree: LayoutTree {
651                nodes: Vec::new(),
652                warm: Vec::new(),
653                cold: Vec::new(),
654                root: 0,
655                dom_to_layout: BTreeMap::new(),
656                children_arena: Vec::new(),
657                children_offsets: Vec::new(),
658                subtree_needs_intrinsic: Vec::new(),
659            },
660            calculated_positions: Vec::new(),
661            viewport: LogicalRect::zero(),
662            display_list: DisplayList::default(),
663            scroll_ids: HashMap::new(),
664            scroll_id_to_node_id: HashMap::new(),
665        }
666    }
667
668    /// The same DOM, but laid out: node 0 (the track) reports `track` as its
669    /// used size, so `get_hit_node_rect` yields a real width.
670    fn laid_out_at(styled_dom: StyledDom, track: LogicalSize) -> DomLayoutResult {
671        let hot = |dom_node: usize, size: LogicalSize, parent: Option<usize>| LayoutNodeHot {
672            box_props: PackedBoxProps::default(),
673            dom_node_id: Some(NodeId::new(dom_node)),
674            used_size: Some(size),
675            formatting_context: FormattingContext::Flex,
676            parent,
677        };
678        let mut dom_to_layout = BTreeMap::new();
679        dom_to_layout.insert(NodeId::new(0), vec![0usize]);
680        dom_to_layout.insert(NodeId::new(1), vec![1usize]);
681
682        let mut result = unlaid(styled_dom);
683        result.layout_tree.nodes = vec![
684            hot(0, track, None),
685            hot(1, LogicalSize::new(THUMB_SIZE as f32, THUMB_SIZE as f32), Some(0)),
686        ];
687        result.layout_tree.dom_to_layout = dom_to_layout;
688        result.calculated_positions = vec![LogicalPosition::zero(), LogicalPosition::zero()];
689        result
690    }
691
692    /// Runs `f` with a real `CallbackInfo` over `layout`, hitting `hit`, with
693    /// `cursor` reported as the cursor position relative to the hit node.
694    /// Returns `f`'s value plus everything the callback pushed onto the log.
695    fn with_info<R>(
696        layout: DomLayoutResult,
697        hit: DomNodeId,
698        cursor: OptionLogicalPosition,
699        f: impl FnOnce(&mut CallbackInfo) -> R,
700    ) -> (R, Vec<CallbackChange>) {
701        let mut layout_window =
702            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
703        layout_window.layout_results.insert(DomId::ROOT_ID, layout);
704
705        let renderer_resources = RendererResources::default();
706        let previous_window_state: Option<FullWindowState> = None;
707        let current_window_state = FullWindowState::default();
708        let gl_context = OptionGlContextPtr::None;
709        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
710            BTreeMap::new();
711        let window_handle = RawWindowHandle::Unsupported;
712        let system_callbacks = ExternalSystemCallbacks::rust_internal();
713
714        let ref_data = CallbackInfoRefData {
715            layout_window: &layout_window,
716            renderer_resources: &renderer_resources,
717            previous_window_state: &previous_window_state,
718            current_window_state: &current_window_state,
719            gl_context: &gl_context,
720            current_scroll_manager: &scroll_states,
721            current_window_handle: &window_handle,
722            system_callbacks: &system_callbacks,
723            system_style: Arc::new(azul_css::system::SystemStyle::default()),
724            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
725            #[cfg(feature = "icu")]
726            icu_localizer: IcuLocalizerHandle::default(),
727            ctx: OptionRefAny::None,
728        };
729
730        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
731        let mut info = CallbackInfo::new(
732            &ref_data,
733            &changes,
734            hit,
735            cursor,
736            OptionLogicalPosition::None,
737        );
738
739        let out = f(&mut info);
740        let pushed = info.take_changes();
741        (out, pushed)
742    }
743
744    /// Renders `slider` and hands back the styled DOM *plus the very `RefAny`
745    /// the widget registered on its own handlers* — driving the callbacks with
746    /// these two is the real wiring, so a mismatch between what `dom()` stores
747    /// and what the handlers expect cannot hide behind the fixture.
748    fn wired(slider: Slider) -> (StyledDom, RefAny) {
749        let dom = slider.dom();
750        let state = dom.root.callbacks.as_ref()[0].refany.clone();
751        (StyledDom::create_from_dom(dom), state)
752    }
753
754    fn cursor(x: f32, y: f32) -> OptionLogicalPosition {
755        OptionLogicalPosition::Some(LogicalPosition::new(x, y))
756    }
757
758    /// One pointer event of `kind` delivered to the widget's own handler.
759    fn deliver(
760        slider: Slider,
761        state: &RefAny,
762        hit: DomNodeId,
763        at: OptionLogicalPosition,
764        track: Option<LogicalSize>,
765        kind: extern "C" fn(RefAny, CallbackInfo) -> Update,
766    ) -> (Update, Vec<CallbackChange>) {
767        let styled = StyledDom::create_from_dom(slider.dom());
768        let layout = match track {
769            Some(t) => laid_out_at(styled, t),
770            None => unlaid(styled),
771        };
772        with_info(layout, hit, at, |info| kind(state.clone(), *info))
773    }
774
775    /// A press at `x` on a never-laid-out slider — the common path.
776    fn press_at(slider: Slider, x: f32) -> (Update, Vec<CallbackChange>, SliderStateWrapper) {
777        let (styled, state) = wired(slider);
778        let (update, changes) = with_info(unlaid(styled), node(0), cursor(x, 8.0), |info| {
779            on_slider_pointer_down(state.clone(), *info)
780        });
781        (update, changes, read_state(&state))
782    }
783
784    fn read_state(state: &RefAny) -> SliderStateWrapper {
785        let mut state = state.clone();
786        let wrapper = state
787            .downcast_ref::<SliderStateWrapper>()
788            .expect("the widget state changed type");
789        wrapper.clone()
790    }
791
792    /// Every `(node, margin-left)` pair a callback pushed onto the change log.
793    fn pushed_margins(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
794        changes
795            .iter()
796            .filter_map(|c| match c {
797                CallbackChange::ChangeNodeCssProperties {
798                    node_id, properties, ..
799                } => properties
800                    .as_ref()
801                    .iter()
802                    .find_map(|p| match p {
803                        CssProperty::MarginLeft(m) => m.get_property().map(|m| px(m.inner)),
804                        _ => None,
805                    })
806                    .map(|m| (*node_id, m)),
807                _ => None,
808            })
809            .collect()
810    }
811
812    /// What `apply_cursor_value` is documented to compute: the cursor fraction
813    /// of the track, mapped onto `[min, max]`. `mul_add` (not `a * b + c`) so
814    /// the expectation is bit-identical to the implementation.
815    fn expected_value(x: f32, width: f32, min: f32, max: f32) -> f32 {
816        (x / width).clamp(0.0, 1.0).mul_add(max - min, min)
817    }
818
819    // ------------------------------------------------------------------
820    // User-hook probes
821    // ------------------------------------------------------------------
822
823    /// A payload the value-change hook writes into. It arrives as the `data`
824    /// argument — a *shared* clone of what the test still holds — so the test
825    /// reads back exactly what the widget passed, with no global state.
826    #[derive(Debug, Clone, Default, PartialEq)]
827    struct ValueLog {
828        seen: Vec<SliderState>,
829    }
830
831    extern "C" fn record_value(mut data: RefAny, _: CallbackInfo, state: SliderState) -> Update {
832        if let Some(mut log) = data.downcast_mut::<ValueLog>() {
833            log.seen.push(state);
834        }
835        Update::RefreshDom
836    }
837
838    extern "C" fn value_do_nothing(_: RefAny, _: CallbackInfo, _: SliderState) -> Update {
839        Update::DoNothing
840    }
841
842    extern "C" fn value_refresh_all(_: RefAny, _: CallbackInfo, _: SliderState) -> Update {
843        Update::RefreshDomAllWindows
844    }
845
846    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in,
847    /// which the `From<Callback>` arm *transmutes* into the 3-arg slider slot.
848    extern "C" fn generic_shaped(_: RefAny, _: CallbackInfo) -> Update {
849        Update::DoNothing
850    }
851
852    fn log_refany() -> RefAny {
853        RefAny::new(ValueLog::default())
854    }
855
856    fn read_log(probe: &RefAny) -> ValueLog {
857        let mut probe = probe.clone();
858        let log = probe
859            .downcast_ref::<ValueLog>()
860            .expect("the user payload changed type");
861        log.clone()
862    }
863
864    fn hook_ptr(s: &Slider) -> Option<usize> {
865        s.slider_state
866            .on_value_change
867            .as_ref()
868            .map(|h| h.callback.cb as *const () as usize)
869    }
870
871    // ==================================================================
872    // value_to_fraction  (numeric)
873    // ==================================================================
874
875    #[test]
876    fn value_to_fraction_maps_the_endpoints_and_the_midpoint_exactly() {
877        assert_eq!(value_to_fraction(0.0, 0.0, 100.0), 0.0);
878        assert_eq!(value_to_fraction(100.0, 0.0, 100.0), 1.0);
879        assert_eq!(value_to_fraction(50.0, 0.0, 100.0), 0.5);
880        assert_eq!(value_to_fraction(25.0, 0.0, 100.0), 0.25);
881        // A range that does not start at zero catches a `value / max` shortcut.
882        assert_eq!(value_to_fraction(150.0, 100.0, 200.0), 0.5);
883        assert_eq!(value_to_fraction(-75.0, -100.0, -50.0), 0.5);
884    }
885
886    #[test]
887    fn value_to_fraction_clamps_instead_of_letting_the_thumb_leave_the_rail() {
888        for (value, min, max) in [
889            (200.0_f32, 0.0_f32, 100.0_f32),
890            (-1.0, 0.0, 100.0),
891            (1.0e30, 0.0, 1.0),
892            (-1.0e30, 0.0, 1.0),
893            (f32::MAX, -1.0, 1.0),
894            (f32::MIN, -1.0, 1.0),
895        ] {
896            let f = value_to_fraction(value, min, max);
897            assert!(
898                (0.0..=1.0).contains(&f),
899                "value_to_fraction({value}, {min}, {max}) = {f} is outside [0, 1]",
900            );
901        }
902        assert_eq!(value_to_fraction(200.0, 0.0, 100.0), 1.0);
903        assert_eq!(value_to_fraction(-1.0, 0.0, 100.0), 0.0);
904    }
905
906    #[test]
907    fn value_to_fraction_guards_the_zero_width_and_inverted_range() {
908        // `max <= min` is the documented guard: a zero-width range would be a
909        // division by zero (0/0 = NaN, x/0 = ±inf) and an inverted one would
910        // run the fraction backwards.
911        for (value, min, max) in [
912            (0.0_f32, 0.0_f32, 0.0_f32),
913            (5.0, 5.0, 5.0),
914            (-5.0, -5.0, -5.0),
915            (1.0e9, 7.0, 7.0),
916            (50.0, 100.0, 0.0),
917            (50.0, 1.0, -1.0),
918        ] {
919            assert_eq!(
920                value_to_fraction(value, min, max),
921                0.0,
922                "a degenerate range [{min}, {max}] must pin the thumb left",
923            );
924        }
925    }
926
927    #[test]
928    fn value_to_fraction_is_monotonic_across_the_whole_range() {
929        // A sign slip in `(value - min) / (max - min)` still returns values in
930        // [0, 1] — only the ordering catches a reversed rail.
931        let mut previous = f32::NEG_INFINITY;
932        for i in -50..=150 {
933            let f = value_to_fraction(i as f32, 0.0, 100.0);
934            assert!(
935                f >= previous,
936                "the fraction went backwards at value = {i} ({f} < {previous})",
937            );
938            previous = f;
939        }
940        assert_eq!(previous, 1.0);
941    }
942
943    #[test]
944    fn value_to_fraction_nan_inputs_do_not_panic_and_stay_nan() {
945        // `f32::clamp` only asserts on its *bounds* (0.0/1.0 here), so a NaN
946        // `self` propagates rather than panicking. Downstream,
947        // `build_thumb_style` turns that NaN into a zero margin.
948        assert!(value_to_fraction(f32::NAN, 0.0, 100.0).is_nan());
949        // A NaN bound makes `max <= min` false, so the guard does *not* fire and
950        // the arithmetic produces NaN — still no panic.
951        assert!(value_to_fraction(50.0, f32::NAN, 100.0).is_nan());
952        assert!(value_to_fraction(50.0, 0.0, f32::NAN).is_nan());
953        assert!(value_to_fraction(f32::NAN, f32::NAN, f32::NAN).is_nan());
954    }
955
956    #[test]
957    fn value_to_fraction_never_escapes_the_unit_interval_for_any_input() {
958        // The only contract that matters downstream: the result is either NaN
959        // (which `build_thumb_style` casts to a 0 margin) or a real fraction.
960        // Anything else slides the thumb off the track.
961        let interesting = [
962            0.0_f32,
963            -0.0,
964            1.0,
965            -1.0,
966            50.0,
967            f32::MIN_POSITIVE,
968            f32::EPSILON,
969            f32::MAX,
970            f32::MIN,
971            f32::INFINITY,
972            f32::NEG_INFINITY,
973            f32::NAN,
974        ];
975        for value in interesting {
976            for min in interesting {
977                for max in interesting {
978                    let f = value_to_fraction(value, min, max);
979                    assert!(
980                        f.is_nan() || (0.0..=1.0).contains(&f),
981                        "value_to_fraction({value}, {min}, {max}) = {f} escaped [0, 1]",
982                    );
983                }
984            }
985        }
986    }
987
988    #[test]
989    fn value_to_fraction_infinite_bounds_produce_a_defined_result() {
990        // An unbounded range has no meaningful fraction: `inf / inf` is NaN, and
991        // the widget must treat that as "pin the thumb", not as a panic.
992        assert!(value_to_fraction(0.0, f32::NEG_INFINITY, f32::INFINITY).is_nan());
993        assert!(value_to_fraction(-50.0, f32::NEG_INFINITY, 0.0).is_nan());
994        // A half-open range collapses every finite value onto the closed end.
995        assert_eq!(value_to_fraction(50.0, 0.0, f32::INFINITY), 0.0);
996        // An infinite *value* inside a finite range saturates instead of wrapping.
997        assert_eq!(value_to_fraction(f32::INFINITY, 0.0, 100.0), 1.0);
998        assert_eq!(value_to_fraction(f32::NEG_INFINITY, 0.0, 100.0), 0.0);
999    }
1000
1001    #[test]
1002    fn value_to_fraction_survives_a_range_whose_width_overflows_f32() {
1003        // `f32::MAX - f32::MIN` overflows to +inf, so the fraction degenerates:
1004        // every finite value divides down to 0.0 (the thumb pins left instead of
1005        // tracking the value) and the very top of the range becomes inf/inf =
1006        // NaN. Neither escapes [0, 1] and neither panics — downstream both park
1007        // the thumb at the left end, because a NaN margin casts to 0.
1008        for value in [f32::MIN, -1.0e30, 0.0, 1.0e30] {
1009            assert_eq!(
1010                value_to_fraction(value, f32::MIN, f32::MAX),
1011                0.0,
1012                "an f32-wide range should collapse {value} onto the left end",
1013            );
1014        }
1015        assert!(value_to_fraction(f32::MAX, f32::MIN, f32::MAX).is_nan());
1016        assert_eq!(margin_left(&build_thumb_style(f32::NAN)), Some(0.0));
1017    }
1018
1019    #[test]
1020    fn value_to_fraction_is_pure() {
1021        for (min, max) in SANE_RANGES {
1022            for value in [min, max, 0.0, -1.0, 1.0e9] {
1023                let a = value_to_fraction(value, min, max);
1024                let b = value_to_fraction(value, min, max);
1025                assert!(
1026                    a == b || (a.is_nan() && b.is_nan()),
1027                    "value_to_fraction({value}, {min}, {max}) is not deterministic",
1028                );
1029            }
1030        }
1031    }
1032
1033    // ==================================================================
1034    // build_thumb_style  (numeric)
1035    // ==================================================================
1036
1037    #[test]
1038    fn build_thumb_style_endpoints_keep_the_thumb_inside_the_track() {
1039        assert_eq!(margin_left(&build_thumb_style(0.0)), Some(0.0));
1040        assert_eq!(margin_left(&build_thumb_style(1.0)), Some(TRAVEL));
1041        // The right end must leave exactly one thumb-width of room, otherwise
1042        // the thumb overhangs the rail it is supposed to sit on.
1043        assert_eq!(TRAVEL + THUMB_SIZE as f32, DESIGN_WIDTH);
1044    }
1045
1046    #[test]
1047    fn build_thumb_style_rounds_to_whole_pixels() {
1048        // The margin is an `isize` of logical px: fractional positions must round
1049        // (not truncate), else the thumb drifts left by up to a pixel.
1050        for (fraction, expected) in [
1051            (0.5_f32, 92.0_f32),
1052            (0.25, 46.0),
1053            (0.75, 138.0),
1054            (0.1, 18.0),  // 18.4 -> 18
1055            (0.9, 166.0), // 165.6 -> 166
1056            (0.001, 0.0), // 0.184 -> 0
1057        ] {
1058            assert_eq!(
1059                margin_left(&build_thumb_style(fraction)),
1060                Some(expected),
1061                "fraction {fraction} landed on the wrong pixel",
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn build_thumb_style_is_monotonic_and_bounded_over_the_unit_interval() {
1068        let mut previous = f32::NEG_INFINITY;
1069        for i in 0..=1000 {
1070            let m = margin_left(&build_thumb_style(i as f32 / 1000.0))
1071                .expect("every thumb style declares a margin-left");
1072            assert!(m >= previous, "the thumb moved backwards at {i}/1000");
1073            assert!(
1074                (0.0..=TRAVEL).contains(&m),
1075                "the thumb left the rail at {i}/1000 (margin {m})",
1076            );
1077            previous = m;
1078        }
1079    }
1080
1081    #[test]
1082    fn build_thumb_style_nan_fraction_pins_the_thumb_left_instead_of_panicking() {
1083        // `NaN as isize` saturates to 0 in Rust (it has been a defined saturating
1084        // cast since 1.45, not UB), so a NaN value/bound leaves the thumb parked
1085        // at the left end rather than at a garbage offset.
1086        assert_eq!(margin_left(&build_thumb_style(f32::NAN)), Some(0.0));
1087    }
1088
1089    #[test]
1090    fn build_thumb_style_negative_and_signed_zero_fractions_are_deterministic() {
1091        assert_eq!(margin_left(&build_thumb_style(-0.0)), Some(0.0));
1092        assert_eq!(margin_left(&build_thumb_style(-1.0)), Some(-TRAVEL));
1093        assert_eq!(margin_left(&build_thumb_style(-0.5)), Some(-92.0));
1094    }
1095
1096    #[test]
1097    fn build_thumb_style_declares_the_full_thumb_geometry_exactly_once() {
1098        let props = properties(&build_thumb_style(0.5));
1099        assert_eq!(props.len(), 9, "the thumb style changed shape: {props:?}");
1100        let mut seen = Vec::new();
1101        for p in &props {
1102            let d = discriminant(p);
1103            assert!(!seen.contains(&d), "the thumb style declares {p:?} twice");
1104            seen.push(d);
1105        }
1106        // A duplicate declaration would silently let the later one win; a missing
1107        // margin-left would freeze the thumb at the left end forever.
1108        assert!(
1109            matches!(props.last(), Some(CssProperty::MarginLeft(_))),
1110            "margin-left must be the last (position-dependent) declaration",
1111        );
1112    }
1113
1114    #[test]
1115    fn build_thumb_style_geometry_is_absolute_px_and_independent_of_the_fraction() {
1116        for fraction in REACHABLE_FRACTIONS {
1117            let v = build_thumb_style(fraction);
1118            assert_eq!(width_px(&v), Some(THUMB_SIZE as f32), "fraction {fraction}");
1119            assert_eq!(height_px(&v), Some(THUMB_SIZE as f32), "fraction {fraction}");
1120            assert_eq!(background(&v), Some(THUMB_COLOR), "fraction {fraction}");
1121        }
1122    }
1123
1124    #[test]
1125    fn build_thumb_style_only_the_margin_depends_on_the_fraction() {
1126        // Everything but `margin-left` must be byte-identical across fractions —
1127        // otherwise dragging the thumb would restyle the whole knob every frame.
1128        let strip = |f: f32| -> Vec<CssProperty> {
1129            properties(&build_thumb_style(f))
1130                .into_iter()
1131                .filter(|p| !matches!(p, CssProperty::MarginLeft(_)))
1132                .collect()
1133        };
1134        let reference = strip(0.0);
1135        for fraction in REACHABLE_FRACTIONS {
1136            assert_eq!(strip(fraction), reference, "fraction {fraction} restyled the thumb");
1137        }
1138    }
1139
1140    #[test]
1141    fn build_thumb_style_is_pure_for_every_reachable_fraction() {
1142        for fraction in REACHABLE_FRACTIONS {
1143            assert_eq!(
1144                build_thumb_style(fraction),
1145                build_thumb_style(fraction),
1146                "build_thumb_style({fraction}) is not deterministic",
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn build_thumb_style_handles_every_fraction_value_to_fraction_can_produce() {
1153        // The full reachable domain, end to end: nothing here may panic and the
1154        // thumb may never leave the rail.
1155        for fraction in REACHABLE_FRACTIONS {
1156            let m = margin_left(&build_thumb_style(fraction))
1157                .expect("every thumb style declares a margin-left");
1158            assert!(
1159                (0.0..=TRAVEL).contains(&m),
1160                "fraction {fraction} put the thumb at {m}, off a {TRAVEL}px rail",
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn build_thumb_style_out_of_contract_fractions_must_not_panic() {
1167        // `build_thumb_style` takes a bare `f32` with no guard. Its only caller
1168        // feeds it `value_to_fraction`'s clamped output, so these are currently
1169        // unreachable — but the margin is encoded as `isize * 1000`
1170        // (`FloatValue::const_new`), so any |fraction| above ~5e13 overflows that
1171        // multiply and *panics* in an overflow-checked build instead of
1172        // saturating. A clamp inside `build_thumb_style` (or a saturating
1173        // `const_px`) would make the helper safe on its own terms.
1174        let hostile: [f32; 6] = [
1175            1.0e14,
1176            -1.0e14,
1177            1.0e30,
1178            f32::MAX,
1179            f32::INFINITY,
1180            f32::NEG_INFINITY,
1181        ];
1182        let panicked: Vec<f32> = hostile
1183            .iter()
1184            .copied()
1185            .filter(|&f| catch_unwind(AssertUnwindSafe(|| build_thumb_style(f))).is_err())
1186            .collect();
1187        assert!(
1188            panicked.is_empty(),
1189            "build_thumb_style overflows the isize fixed-point encoding and panics \
1190             instead of saturating for: {panicked:?}",
1191        );
1192    }
1193
1194    // ==================================================================
1195    // Slider::create  (numeric)
1196    // ==================================================================
1197
1198    #[test]
1199    fn create_clamps_the_value_into_the_range() {
1200        assert_eq!(Slider::create(150.0, 0.0, 100.0).slider_state.inner.value, 100.0);
1201        assert_eq!(Slider::create(-50.0, 0.0, 100.0).slider_state.inner.value, 0.0);
1202        assert_eq!(Slider::create(50.0, 0.0, 100.0).slider_state.inner.value, 50.0);
1203        assert_eq!(Slider::create(f32::MAX, -1.0, 1.0).slider_state.inner.value, 1.0);
1204        assert_eq!(Slider::create(f32::MIN, -1.0, 1.0).slider_state.inner.value, -1.0);
1205    }
1206
1207    #[test]
1208    fn create_stores_the_bounds_verbatim() {
1209        for (min, max) in SANE_RANGES {
1210            let s = Slider::create(min, min, max);
1211            assert_eq!(s.slider_state.inner.min, min, "min was rewritten");
1212            assert_eq!(s.slider_state.inner.max, max, "max was rewritten");
1213        }
1214    }
1215
1216    #[test]
1217    fn create_places_the_thumb_where_the_pure_helpers_say_it_belongs() {
1218        // The composition `create -> value_to_fraction -> build_thumb_style` is
1219        // the whole widget: a mismatch means the rendered thumb and the stored
1220        // value disagree.
1221        for (min, max) in SANE_RANGES {
1222            for value in [min, max, (min + max) / 2.0, min - 1.0, max + 1.0, 0.0] {
1223                let s = Slider::create(value, min, max);
1224                let expected =
1225                    margin_left(&build_thumb_style(value_to_fraction(value.clamp(min, max), min, max)));
1226                assert_eq!(
1227                    margin_left(&s.thumb_style),
1228                    expected,
1229                    "create({value}, {min}, {max}) put the thumb in the wrong place",
1230                );
1231            }
1232        }
1233    }
1234
1235    #[test]
1236    fn create_leaves_the_thumb_on_the_rail_for_every_sane_range() {
1237        for (min, max) in SANE_RANGES {
1238            for value in [min, max, (min + max) / 2.0, min - 1.0e9, max + 1.0e9] {
1239                let m = thumb_margin(&Slider::create(value, min, max));
1240                assert!(
1241                    (0.0..=TRAVEL).contains(&m),
1242                    "create({value}, {min}, {max}) parked the thumb at {m}",
1243                );
1244            }
1245        }
1246    }
1247
1248    #[test]
1249    fn create_with_a_zero_width_range_pins_the_thumb_left() {
1250        for bound in [0.0_f32, 5.0, -5.0, 1.0e9] {
1251            let s = Slider::create(bound, bound, bound);
1252            assert_eq!(s.slider_state.inner.value, bound);
1253            assert_eq!(thumb_margin(&s), 0.0, "a [{bound}, {bound}] range must pin left");
1254        }
1255    }
1256
1257    #[test]
1258    fn create_with_a_nan_value_keeps_the_nan_but_still_parks_the_thumb() {
1259        // `f32::clamp` only asserts on its bounds, so a NaN *value* passes
1260        // straight through — and the NaN fraction has to degrade to a 0 margin
1261        // rather than an arbitrary offset.
1262        let s = Slider::create(f32::NAN, 0.0, 100.0);
1263        assert!(s.slider_state.inner.value.is_nan(), "the NaN value was silently rewritten");
1264        assert_eq!(thumb_margin(&s), 0.0);
1265    }
1266
1267    #[test]
1268    fn create_with_infinite_bounds_does_not_panic() {
1269        // `min <= max` holds for these, so `clamp` is happy; the fraction is NaN
1270        // (inf / inf) and must degrade to a parked thumb.
1271        for (value, min, max) in [
1272            (0.0_f32, f32::NEG_INFINITY, f32::INFINITY),
1273            (f32::INFINITY, 0.0, f32::INFINITY),
1274            (f32::NEG_INFINITY, f32::NEG_INFINITY, 0.0),
1275            (50.0, 0.0, f32::INFINITY),
1276        ] {
1277            let s = Slider::create(value, min, max);
1278            let m = thumb_margin(&s);
1279            assert!(
1280                m.is_finite() && (0.0..=TRAVEL).contains(&m),
1281                "create({value}, {min}, {max}) put the thumb at {m}",
1282            );
1283        }
1284    }
1285
1286    #[test]
1287    fn create_with_a_degenerate_range_must_not_panic() {
1288        // `create` runs `value.clamp(min, max)`, and `f32::clamp` **panics**
1289        // unless `min <= max` — a NaN bound fails that too. `min`/`max` are `pub`
1290        // fields on a `#[repr(C)]` struct that crosses the C/FFI boundary, so
1291        // nothing stops a caller from asking for an inverted or NaN-bounded
1292        // slider, and a panic here takes the whole app with it. Normalising the
1293        // range (swap, or fall back to the default) would be safe; unwinding is
1294        // not. Note `apply_cursor_value` already tolerates these ranges — only
1295        // the constructor and `set_value` do not.
1296        let panicked: Vec<(f32, f32)> = DEGENERATE_RANGES
1297            .iter()
1298            .copied()
1299            .filter(|&(min, max)| {
1300                catch_unwind(AssertUnwindSafe(|| Slider::create(0.0, min, max))).is_err()
1301            })
1302            .collect();
1303        assert!(
1304            panicked.is_empty(),
1305            "Slider::create panics (f32::clamp asserts min <= max) instead of \
1306             normalising these ranges: {panicked:?}",
1307        );
1308    }
1309
1310    #[test]
1311    fn create_is_deterministic_and_distinguishes_distinct_values() {
1312        for (min, max) in SANE_RANGES {
1313            assert_eq!(Slider::create(min, min, max), Slider::create(min, min, max));
1314        }
1315        assert_ne!(Slider::create(0.0, 0.0, 100.0), Slider::create(100.0, 0.0, 100.0));
1316        // Same value, different range: the states differ even though the thumb
1317        // ends up in the same place.
1318        assert_ne!(Slider::create(0.0, 0.0, 100.0), Slider::create(0.0, 0.0, 200.0));
1319    }
1320
1321    #[test]
1322    fn create_installs_no_hook_and_starts_undragged() {
1323        let s = Slider::create(50.0, 0.0, 100.0);
1324        assert!(
1325            s.slider_state.on_value_change.as_ref().is_none(),
1326            "create invented a value-change hook out of nowhere",
1327        );
1328        assert!(!s.slider_state.dragging, "a fresh slider must not be mid-drag");
1329    }
1330
1331    #[test]
1332    fn create_track_style_is_shared_and_value_independent() {
1333        // The rail is parameter-free, so every slider must hand out the very same
1334        // const table — a per-instance copy would allocate on every rebuild.
1335        let reference = properties(&Slider::create(0.0, 0.0, 100.0).track_style);
1336        for (min, max) in SANE_RANGES {
1337            assert_eq!(
1338                properties(&Slider::create(max, min, max).track_style),
1339                reference,
1340                "the track style leaked a dependency on [{min}, {max}]",
1341            );
1342        }
1343        assert_eq!(reference.len(), 13, "the track style changed shape");
1344    }
1345
1346    #[test]
1347    fn create_track_geometry_is_absolute_px_and_declared_once() {
1348        let s = Slider::create(50.0, 0.0, 100.0);
1349        assert_eq!(width_px(&s.track_style), Some(DESIGN_WIDTH));
1350        assert_eq!(height_px(&s.track_style), Some(TRACK_HEIGHT as f32));
1351        assert_eq!(background(&s.track_style), Some(RAIL_COLOR));
1352
1353        let props = properties(&s.track_style);
1354        let mut seen = Vec::new();
1355        for p in &props {
1356            let d = discriminant(p);
1357            assert!(!seen.contains(&d), "the track style declares {p:?} twice");
1358            seen.push(d);
1359        }
1360        // Without `cursor: pointer` the rail looks inert even though it carries
1361        // every pointer handler; with `flex-grow != 0` it would stretch and stop
1362        // being the 200px box the fallback width assumes.
1363        assert!(props.contains(&CssProperty::const_cursor(StyleCursor::Pointer)));
1364        assert!(props.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
1365    }
1366
1367    #[test]
1368    fn default_is_exactly_create_0_0_100() {
1369        assert_eq!(Slider::default(), Slider::create(0.0, 0.0, 100.0));
1370        assert_eq!(Slider::default().slider_state.inner, SliderState::default());
1371        assert_eq!(SliderState::default().min, 0.0);
1372        assert_eq!(SliderState::default().max, 100.0);
1373        assert_eq!(SliderState::default().value, 0.0);
1374    }
1375
1376    // ==================================================================
1377    // Slider::set_value / with_value
1378    // ==================================================================
1379
1380    #[test]
1381    fn set_value_clamps_and_moves_the_thumb_together() {
1382        let mut s = Slider::create(0.0, 0.0, 100.0);
1383        for (input, expected_value) in [
1384            (50.0_f32, 50.0_f32),
1385            (100.0, 100.0),
1386            (150.0, 100.0),
1387            (-1.0, 0.0),
1388            (f32::INFINITY, 100.0),
1389            (f32::NEG_INFINITY, 0.0),
1390            (0.0, 0.0),
1391        ] {
1392            s.set_value(input);
1393            assert_eq!(s.slider_state.inner.value, expected_value, "set_value({input})");
1394            assert_eq!(
1395                thumb_margin(&s),
1396                margin_left(&build_thumb_style(value_to_fraction(expected_value, 0.0, 100.0)))
1397                    .expect("margin"),
1398                "set_value({input}) did not move the thumb with the value",
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn set_value_never_touches_the_bounds() {
1405        for (min, max) in SANE_RANGES {
1406            let mut s = Slider::create(min, min, max);
1407            s.set_value(1.0e9);
1408            s.set_value(-1.0e9);
1409            assert_eq!(s.slider_state.inner.min, min);
1410            assert_eq!(s.slider_state.inner.max, max);
1411        }
1412    }
1413
1414    #[test]
1415    fn set_value_round_trips_every_value_inside_the_range() {
1416        // value -> (clamp, fraction, margin) -> value: the stored value must come
1417        // back bit-identical for anything already inside the range.
1418        let mut s = Slider::create(0.0, 0.0, 100.0);
1419        for i in 0..=100 {
1420            let v = i as f32;
1421            s.set_value(v);
1422            assert_eq!(s.slider_state.inner.value, v, "{v} did not survive set_value");
1423            assert_eq!(
1424                thumb_margin(&s),
1425                (v / 100.0 * TRAVEL).round(),
1426                "{v} landed on the wrong pixel",
1427            );
1428        }
1429    }
1430
1431    #[test]
1432    fn set_value_is_idempotent() {
1433        let mut s = Slider::create(0.0, 0.0, 100.0);
1434        s.set_value(37.5);
1435        let once = s.clone();
1436        s.set_value(37.5);
1437        assert_eq!(s, once, "re-setting the same value changed the widget");
1438    }
1439
1440    #[test]
1441    fn set_value_with_nan_parks_the_thumb_without_panicking() {
1442        let mut s = Slider::create(50.0, 0.0, 100.0);
1443        s.set_value(f32::NAN);
1444        assert!(s.slider_state.inner.value.is_nan());
1445        assert_eq!(thumb_margin(&s), 0.0);
1446        // ...and the widget still recovers on the next sane write.
1447        s.set_value(25.0);
1448        assert_eq!(s.slider_state.inner.value, 25.0);
1449        assert_eq!(thumb_margin(&s), 46.0);
1450    }
1451
1452    #[test]
1453    fn set_value_on_a_degenerate_range_must_not_panic() {
1454        // Same `f32::clamp` precondition as `create`, but reachable *without*
1455        // ever calling `create` with a bad range: the bounds are `pub`, so an FFI
1456        // caller (or a Rust caller doing `s.slider_state.inner.max = ...`) can
1457        // invert them between construction and the next `set_value`.
1458        let panicked: Vec<(f32, f32)> = DEGENERATE_RANGES
1459            .iter()
1460            .copied()
1461            .filter(|&(min, max)| {
1462                let mut s = Slider::create(0.0, 0.0, 100.0);
1463                s.slider_state.inner.min = min;
1464                s.slider_state.inner.max = max;
1465                catch_unwind(AssertUnwindSafe(move || s.set_value(1.0))).is_err()
1466            })
1467            .collect();
1468        assert!(
1469            panicked.is_empty(),
1470            "Slider::set_value panics (f32::clamp asserts min <= max) on these \
1471             externally-set ranges: {panicked:?}",
1472        );
1473    }
1474
1475    #[test]
1476    fn with_value_is_exactly_set_value() {
1477        for v in [0.0_f32, 50.0, 100.0, 150.0, -1.0, f32::INFINITY] {
1478            let mut expected = Slider::create(0.0, 0.0, 100.0);
1479            expected.set_value(v);
1480            assert_eq!(
1481                Slider::create(0.0, 0.0, 100.0).with_value(v),
1482                expected,
1483                "with_value({v}) diverged from set_value({v})",
1484            );
1485        }
1486    }
1487
1488    #[test]
1489    fn with_value_keeps_the_hook_it_was_handed() {
1490        // The builder moves `self`; dropping the callback on the way through
1491        // would silently disconnect a slider that still looks correct.
1492        let s = Slider::create(0.0, 0.0, 100.0)
1493            .with_on_value_change(log_refany(), record_value as SliderOnValueChangeCallbackType)
1494            .with_value(80.0);
1495        assert_eq!(hook_ptr(&s), Some(record_value as *const () as usize));
1496        assert_eq!(s.slider_state.inner.value, 80.0);
1497    }
1498
1499    #[test]
1500    fn with_value_chains_to_the_last_write() {
1501        let s = Slider::create(0.0, 0.0, 100.0)
1502            .with_value(10.0)
1503            .with_value(90.0)
1504            .with_value(45.0);
1505        assert_eq!(s.slider_state.inner.value, 45.0);
1506        assert_eq!(thumb_margin(&s), (0.45 * TRAVEL).round());
1507    }
1508
1509    // ==================================================================
1510    // Slider::swap_with_default
1511    // ==================================================================
1512
1513    #[test]
1514    fn swap_with_default_returns_the_old_widget_and_leaves_a_default_behind() {
1515        let mut s = Slider::create(75.0, 50.0, 200.0);
1516        let old = s.swap_with_default();
1517        assert_eq!(old.slider_state.inner.value, 75.0);
1518        assert_eq!(old.slider_state.inner.min, 50.0);
1519        assert_eq!(old.slider_state.inner.max, 200.0);
1520        assert_eq!(s, Slider::default(), "the hole was not filled with a default");
1521    }
1522
1523    #[test]
1524    fn swap_with_default_moves_the_hook_out_with_the_old_widget() {
1525        let mut s = Slider::create(10.0, 0.0, 100.0)
1526            .with_on_value_change(log_refany(), record_value as SliderOnValueChangeCallbackType);
1527        let old = s.swap_with_default();
1528        assert_eq!(hook_ptr(&old), Some(record_value as *const () as usize));
1529        assert_eq!(hook_ptr(&s), None, "the hook survived the swap");
1530    }
1531
1532    #[test]
1533    fn swap_with_default_is_an_involution_when_chained() {
1534        let original = Slider::create(33.0, 0.0, 100.0);
1535        let mut s = original.clone();
1536        let first = s.swap_with_default();
1537        s = first;
1538        assert_eq!(s, original, "swap-then-restore lost state");
1539    }
1540
1541    #[test]
1542    fn swap_with_default_does_not_panic_for_hostile_widgets() {
1543        // The replacement is always `create(0, 0, 100)`, so the swap itself is
1544        // safe even when the outgoing widget holds NaN/infinite state.
1545        for (value, min, max) in [
1546            (f32::NAN, 0.0_f32, 100.0_f32),
1547            (0.0, f32::NEG_INFINITY, f32::INFINITY),
1548            (5.0, 5.0, 5.0),
1549        ] {
1550            let mut s = Slider::create(value, min, max);
1551            let old = s.swap_with_default();
1552            assert_eq!(s, Slider::default());
1553            assert_eq!(old.slider_state.inner.min, min);
1554        }
1555    }
1556
1557    // ==================================================================
1558    // Slider::set_on_value_change / with_on_value_change
1559    // ==================================================================
1560
1561    #[test]
1562    fn set_on_value_change_stores_the_pointer_and_the_payload() {
1563        let probe = RefAny::new(0xDEAD_BEEF_u32);
1564        let mut s = Slider::create(0.0, 0.0, 100.0);
1565        s.set_on_value_change(probe.clone(), record_value as SliderOnValueChangeCallbackType);
1566
1567        let hook = s
1568            .slider_state
1569            .on_value_change
1570            .as_ref()
1571            .expect("the hook was dropped");
1572        assert_eq!(hook.callback.cb as *const () as usize, record_value as *const () as usize);
1573        let mut stored = hook.refany.clone();
1574        assert_eq!(
1575            *stored.downcast_ref::<u32>().expect("the payload changed type"),
1576            0xDEAD_BEEF,
1577        );
1578    }
1579
1580    #[test]
1581    fn set_on_value_change_replaces_a_previous_hook() {
1582        // The setter writes `Some(..)` unconditionally; a caller re-registering
1583        // must end up with exactly one (the newest) hook, not the first one.
1584        let mut s = Slider::create(0.0, 0.0, 100.0);
1585        s.set_on_value_change(RefAny::new(1u8), record_value as SliderOnValueChangeCallbackType);
1586        s.set_on_value_change(RefAny::new(2u8), value_refresh_all as SliderOnValueChangeCallbackType);
1587        assert_eq!(hook_ptr(&s), Some(value_refresh_all as *const () as usize));
1588        let mut stored = s
1589            .slider_state
1590            .on_value_change
1591            .as_ref()
1592            .expect("hook")
1593            .refany
1594            .clone();
1595        assert_eq!(*stored.downcast_ref::<u8>().expect("payload"), 2);
1596    }
1597
1598    #[test]
1599    fn set_on_value_change_leaves_the_value_and_both_styles_untouched() {
1600        let before = Slider::create(60.0, 0.0, 100.0);
1601        let mut after = before.clone();
1602        after.set_on_value_change(log_refany(), record_value as SliderOnValueChangeCallbackType);
1603        assert_eq!(after.slider_state.inner, before.slider_state.inner);
1604        assert_eq!(after.track_style, before.track_style);
1605        assert_eq!(after.thumb_style, before.thumb_style);
1606    }
1607
1608    #[test]
1609    fn with_on_value_change_matches_the_setter() {
1610        let a = Slider::create(60.0, 0.0, 100.0)
1611            .with_on_value_change(RefAny::new(9u8), record_value as SliderOnValueChangeCallbackType);
1612        let mut b = Slider::create(60.0, 0.0, 100.0);
1613        b.set_on_value_change(RefAny::new(9u8), record_value as SliderOnValueChangeCallbackType);
1614        assert_eq!(hook_ptr(&a), hook_ptr(&b));
1615        assert_eq!(a.slider_state.inner, b.slider_state.inner);
1616    }
1617
1618    #[test]
1619    fn with_on_value_change_accepts_a_generic_callback_without_mangling_the_pointer() {
1620        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg
1621        // slider slot — this is the FFI (Python/C) path. The pointer must come
1622        // out bit-identical; a mangled one is a wild jump on the first drag.
1623        let generic = Callback {
1624            cb: generic_shaped,
1625            ctx: OptionRefAny::None,
1626        };
1627        let s = Slider::create(0.0, 0.0, 100.0).with_on_value_change(RefAny::new(0u8), generic);
1628        assert_eq!(
1629            hook_ptr(&s),
1630            Some(generic_shaped as *const () as usize),
1631            "the Callback -> SliderOnValueChangeCallback transmute mangled the pointer",
1632        );
1633    }
1634
1635    #[test]
1636    fn with_on_value_change_does_not_panic_for_hostile_widgets() {
1637        for (value, min, max) in [
1638            (f32::NAN, 0.0_f32, 100.0_f32),
1639            (0.0, f32::NEG_INFINITY, f32::INFINITY),
1640            (7.0, 7.0, 7.0),
1641        ] {
1642            let s = Slider::create(value, min, max).with_on_value_change(
1643                log_refany(),
1644                record_value as SliderOnValueChangeCallbackType,
1645            );
1646            assert!(s.slider_state.on_value_change.as_ref().is_some());
1647        }
1648    }
1649
1650    // ==================================================================
1651    // Slider::dom
1652    // ==================================================================
1653
1654    #[test]
1655    fn dom_renders_a_classed_track_with_exactly_one_thumb_child() {
1656        let dom = Slider::create(50.0, 0.0, 100.0).dom();
1657        assert_eq!(classes(&dom), vec!["__azul-native-slider".to_string()]);
1658        assert!(matches!(dom.root.get_node_type(), NodeType::Div));
1659        let kids = dom.children.as_ref();
1660        assert_eq!(kids.len(), 1, "the track must have exactly one child (the thumb)");
1661        assert_eq!(classes(&kids[0]), vec!["__azul-native-slider-thumb".to_string()]);
1662        assert!(kids[0].children.as_ref().is_empty(), "the thumb must be a leaf");
1663    }
1664
1665    #[test]
1666    fn dom_is_keyboard_reachable() {
1667        // A slider that cannot take focus is unusable without a mouse.
1668        let dom = Slider::create(0.0, 0.0, 100.0).dom();
1669        assert_eq!(dom.root.get_tab_index(), Some(TabIndex::Auto));
1670    }
1671
1672    #[test]
1673    fn dom_registers_every_pointer_filter_exactly_once_in_order() {
1674        let dom = Slider::create(0.0, 0.0, 100.0).dom();
1675        let expected: [(EventFilter, usize); 7] = [
1676            (EventFilter::Hover(HoverEventFilter::MouseDown), on_slider_pointer_down as usize),
1677            (EventFilter::Hover(HoverEventFilter::MouseOver), on_slider_pointer_move as usize),
1678            (EventFilter::Hover(HoverEventFilter::MouseUp), on_slider_pointer_up as usize),
1679            (EventFilter::Hover(HoverEventFilter::MouseLeave), on_slider_pointer_up as usize),
1680            (EventFilter::Hover(HoverEventFilter::TouchStart), on_slider_pointer_down as usize),
1681            (EventFilter::Hover(HoverEventFilter::TouchMove), on_slider_pointer_move as usize),
1682            (EventFilter::Hover(HoverEventFilter::TouchEnd), on_slider_pointer_up as usize),
1683        ];
1684        let got: Vec<(EventFilter, usize)> = dom
1685            .root
1686            .callbacks
1687            .as_ref()
1688            .iter()
1689            .map(|c| (c.event, c.callback.cb))
1690            .collect();
1691        assert_eq!(got, expected.to_vec(), "the pointer wiring changed");
1692        // Touch must not be dropped: without TouchStart/Move/End the slider is
1693        // dead on a touchscreen even though it looks fine under a mouse.
1694        assert_eq!(got.len(), 7);
1695    }
1696
1697    #[test]
1698    fn dom_shares_one_state_refany_across_all_seven_handlers() {
1699        // The transient `dragging` flag set by MouseDown must be visible to the
1700        // MouseOver/MouseUp handlers — a per-callback `RefAny::new` would give
1701        // each handler its own copy and the slider would never drag.
1702        let dom = Slider::create(0.0, 0.0, 100.0).dom();
1703        let cbs = dom.root.callbacks.as_ref();
1704        {
1705            let mut first = cbs[0].refany.clone();
1706            let mut guard = first
1707                .downcast_mut::<SliderStateWrapper>()
1708                .expect("the state changed type");
1709            guard.dragging = true;
1710        }
1711        for (i, cb) in cbs.iter().enumerate() {
1712            let mut other = cb.refany.clone();
1713            let guard = other
1714                .downcast_ref::<SliderStateWrapper>()
1715                .expect("the state changed type");
1716            assert!(guard.dragging, "handler {i} does not share the drag state");
1717        }
1718    }
1719
1720    #[test]
1721    fn dom_carries_the_widgets_own_state_not_a_default() {
1722        let mut state = Slider::create(42.0, -10.0, 90.0).dom().root.callbacks.as_ref()[0]
1723            .refany
1724            .clone();
1725        let wrapper = state
1726            .downcast_ref::<SliderStateWrapper>()
1727            .expect("the state changed type");
1728        assert_eq!(wrapper.inner.value, 42.0);
1729        assert_eq!(wrapper.inner.min, -10.0);
1730        assert_eq!(wrapper.inner.max, 90.0);
1731        assert!(!wrapper.dragging, "a freshly rendered slider must not be mid-drag");
1732    }
1733
1734    #[test]
1735    fn dom_inlines_the_track_and_thumb_styles_verbatim() {
1736        let s = Slider::create(75.0, 0.0, 100.0);
1737        let (track_props, thumb_props) = (properties(&s.track_style), properties(&s.thumb_style));
1738        let dom = s.dom();
1739        assert_eq!(inline_properties(&dom), track_props);
1740        assert_eq!(inline_properties(&dom.children.as_ref()[0]), thumb_props);
1741    }
1742
1743    #[test]
1744    fn dom_does_not_panic_for_hostile_widgets() {
1745        for (value, min, max) in [
1746            (f32::NAN, 0.0_f32, 100.0_f32),
1747            (0.0, f32::NEG_INFINITY, f32::INFINITY),
1748            (3.0, 3.0, 3.0),
1749            (f32::MAX, f32::MIN, f32::MAX),
1750        ] {
1751            let dom = Slider::create(value, min, max).dom();
1752            assert_eq!(dom.children.as_ref().len(), 1, "({value}, {min}, {max})");
1753        }
1754    }
1755
1756    #[test]
1757    fn from_slider_for_dom_is_exactly_dom() {
1758        let s = Slider::create(25.0, 0.0, 100.0);
1759        let via_impl: Dom = s.clone().into();
1760        assert_eq!(inline_properties(&via_impl), inline_properties(&s.dom()));
1761    }
1762
1763    // ==================================================================
1764    // apply_cursor_value + the pointer handlers
1765    // ==================================================================
1766
1767    #[test]
1768    fn a_press_maps_the_cursor_x_onto_the_range_using_the_design_width() {
1769        // Before the first layout there is no node rect, so the track falls back
1770        // to its 200px design width. Anything else silently rescales the value.
1771        for (x, expected) in [
1772            (0.0_f32, 0.0_f32),
1773            (50.0, 25.0),
1774            (100.0, 50.0),
1775            (200.0, 100.0),
1776        ] {
1777            let (_, _, state) = press_at(Slider::create(0.0, 0.0, 100.0), x);
1778            assert_eq!(
1779                state.inner.value,
1780                expected,
1781                "a press at x = {x} produced the wrong value",
1782            );
1783            assert_eq!(state.inner.value, expected_value(x, DESIGN_WIDTH, 0.0, 100.0));
1784        }
1785    }
1786
1787    #[test]
1788    fn a_press_outside_the_track_clamps_instead_of_overshooting() {
1789        for (x, expected) in [
1790            (-1.0_f32, 0.0_f32),
1791            (-1.0e9, 0.0),
1792            (201.0, 100.0),
1793            (1.0e9, 100.0),
1794            (f32::INFINITY, 100.0),
1795            (f32::NEG_INFINITY, 0.0),
1796        ] {
1797            let (_, _, state) = press_at(Slider::create(50.0, 0.0, 100.0), x);
1798            assert_eq!(state.inner.value, expected, "a press at x = {x} escaped the range");
1799        }
1800    }
1801
1802    #[test]
1803    fn a_press_maps_onto_a_negative_range_too() {
1804        let (_, _, state) = press_at(Slider::create(-100.0, -100.0, -50.0), 100.0);
1805        assert_eq!(state.inner.value, -75.0, "the midpoint of [-100, -50] is -75");
1806    }
1807
1808    #[test]
1809    fn a_press_slides_the_thumb_by_writing_margin_left_on_the_first_child() {
1810        let (update, changes, _) = press_at(Slider::create(0.0, 0.0, 100.0), 100.0);
1811        assert_eq!(update, Update::DoNothing, "no hook is installed, so nothing to redraw");
1812        let margins = pushed_margins(&changes);
1813        assert_eq!(
1814            margins.len(),
1815            1,
1816            "exactly one thumb move per press, got {changes:?}",
1817        );
1818        let (node_id, margin) = margins[0];
1819        assert_eq!(node_id, NodeId::new(1), "the margin landed on the track, not the thumb");
1820        assert_eq!(margin, (0.5 * TRAVEL).round(), "the thumb went to the wrong pixel");
1821    }
1822
1823    #[test]
1824    fn a_press_never_slides_the_thumb_off_the_rail() {
1825        for x in [-1.0e9_f32, -1.0, 0.0, 37.0, 199.0, 200.0, 1.0e9, f32::INFINITY] {
1826            let (_, changes, _) = press_at(Slider::create(0.0, 0.0, 100.0), x);
1827            for (_, margin) in pushed_margins(&changes) {
1828                assert!(
1829                    (0.0..=TRAVEL).contains(&margin),
1830                    "a press at x = {x} put the thumb at {margin}, off a {TRAVEL}px rail",
1831                );
1832            }
1833        }
1834    }
1835
1836    #[test]
1837    fn a_press_uses_the_real_track_width_once_the_node_is_laid_out() {
1838        // A slider stretched (or shrunk) by its container must map the cursor
1839        // against the *laid-out* width, not the 200px design width — otherwise
1840        // the value jumps as soon as the layout differs from the design.
1841        for (width, x, expected) in [
1842            (400.0_f32, 100.0_f32, 25.0_f32),
1843            (400.0, 400.0, 100.0),
1844            (100.0, 50.0, 50.0),
1845            (50.0, 200.0, 100.0), // clamped: cursor past the (short) track
1846        ] {
1847            let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0));
1848            let layout = laid_out_at(styled, LogicalSize::new(width, TRACK_HEIGHT as f32));
1849            let (_, _) = with_info(layout, node(0), cursor(x, 8.0), |info| {
1850                on_slider_pointer_down(state.clone(), *info)
1851            });
1852            assert_eq!(
1853                read_state(&state).inner.value,
1854                expected,
1855                "a {width}px-wide track mapped x = {x} wrongly",
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn a_zero_width_track_falls_back_to_the_design_width_instead_of_dividing_by_zero() {
1862        // A collapsed track would make `pos.x / 0.0` = ±inf (or NaN at x = 0);
1863        // the `> 0.0` filter is what keeps the value finite.
1864        let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0));
1865        let layout = laid_out_at(styled, LogicalSize::new(0.0, TRACK_HEIGHT as f32));
1866        with_info(layout, node(0), cursor(100.0, 8.0), |info| {
1867            on_slider_pointer_down(state.clone(), *info)
1868        });
1869        let value = read_state(&state).inner.value;
1870        assert!(value.is_finite(), "a zero-width track produced {value}");
1871        assert_eq!(value, 50.0, "the fallback must be the 200px design width");
1872    }
1873
1874    #[test]
1875    fn a_press_without_a_cursor_latches_the_drag_but_changes_nothing() {
1876        // Touch/synthetic events can arrive with no cursor position at all.
1877        let (styled, state) = wired(Slider::create(60.0, 0.0, 100.0));
1878        let (update, changes) = with_info(
1879            unlaid(styled),
1880            node(0),
1881            OptionLogicalPosition::None,
1882            |info| on_slider_pointer_down(state.clone(), *info),
1883        );
1884        assert_eq!(update, Update::DoNothing);
1885        assert!(changes.is_empty(), "the thumb moved without a cursor: {changes:?}");
1886        let s = read_state(&state);
1887        assert_eq!(s.inner.value, 60.0, "the value changed without a cursor");
1888        assert!(s.dragging, "the press must still arm the drag");
1889    }
1890
1891    #[test]
1892    fn a_nan_cursor_does_not_panic_and_leaves_the_thumb_parked() {
1893        let (styled, state) = wired(Slider::create(10.0, 0.0, 100.0));
1894        let (update, changes) = with_info(
1895            unlaid(styled),
1896            node(0),
1897            cursor(f32::NAN, f32::NAN),
1898            |info| on_slider_pointer_down(state.clone(), *info),
1899        );
1900        assert_eq!(update, Update::DoNothing);
1901        // NaN survives the clamp, so the value goes NaN — but the *pixel* margin
1902        // saturates to 0 rather than becoming a garbage offset.
1903        assert!(read_state(&state).inner.value.is_nan());
1904        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(1), 0.0)]);
1905    }
1906
1907    #[test]
1908    fn a_press_on_an_unknown_node_still_updates_the_value_but_moves_no_thumb() {
1909        // Hit nodes come from hit-testing, which can name a node this DOM does
1910        // not have (stale frame) or no node at all.
1911        for hit in [node_none(), node(99), node(usize::MAX - 1)] {
1912            let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0));
1913            let (update, changes) = with_info(unlaid(styled), hit, cursor(100.0, 8.0), |info| {
1914                on_slider_pointer_down(state.clone(), *info)
1915            });
1916            assert_eq!(update, Update::DoNothing);
1917            assert_eq!(read_state(&state).inner.value, 50.0, "hit {hit:?}");
1918            assert!(
1919                pushed_margins(&changes).is_empty(),
1920                "a thumb was moved for a node that does not exist: {changes:?}",
1921            );
1922        }
1923    }
1924
1925    #[test]
1926    fn a_move_is_ignored_until_a_press_starts_the_drag() {
1927        // Hover fires constantly; without the `dragging` latch the slider would
1928        // follow the cursor across a hover with no button held.
1929        let (styled, state) = wired(Slider::create(10.0, 0.0, 100.0));
1930        let (update, changes) = with_info(unlaid(styled), node(0), cursor(200.0, 8.0), |info| {
1931            on_slider_pointer_move(state.clone(), *info)
1932        });
1933        assert_eq!(update, Update::DoNothing);
1934        assert!(changes.is_empty(), "a hover moved the thumb: {changes:?}");
1935        assert_eq!(read_state(&state).inner.value, 10.0, "a hover changed the value");
1936    }
1937
1938    #[test]
1939    fn a_move_tracks_the_cursor_once_the_press_armed_the_drag() {
1940        let slider = Slider::create(0.0, 0.0, 100.0);
1941        let (styled, state) = wired(slider.clone());
1942        with_info(unlaid(styled), node(0), cursor(0.0, 8.0), |info| {
1943            on_slider_pointer_down(state.clone(), *info)
1944        });
1945        assert!(read_state(&state).dragging, "the press did not arm the drag");
1946
1947        let styled2 = StyledDom::create_from_dom(slider.dom());
1948        let (update, changes) = with_info(unlaid(styled2), node(0), cursor(150.0, 8.0), |info| {
1949            on_slider_pointer_move(state.clone(), *info)
1950        });
1951        assert_eq!(update, Update::DoNothing);
1952        assert_eq!(read_state(&state).inner.value, 75.0, "the drag did not track the cursor");
1953        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(1), (0.75 * TRAVEL).round())]);
1954    }
1955
1956    #[test]
1957    fn a_release_ends_the_drag_and_records_nothing() {
1958        let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0));
1959        with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
1960            on_slider_pointer_down(state.clone(), *info)
1961        });
1962        assert!(read_state(&state).dragging);
1963
1964        let styled2 = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
1965        let (update, changes) = with_info(unlaid(styled2), node(0), cursor(0.0, 8.0), |info| {
1966            on_slider_pointer_up(state.clone(), *info)
1967        });
1968        assert_eq!(update, Update::DoNothing);
1969        assert!(changes.is_empty(), "the release moved the thumb: {changes:?}");
1970        let s = read_state(&state);
1971        assert!(!s.dragging, "the drag outlived the release");
1972        assert_eq!(s.inner.value, 50.0, "the release rewrote the value");
1973    }
1974
1975    #[test]
1976    fn a_release_is_idempotent_and_safe_before_any_press() {
1977        let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0));
1978        with_info(unlaid(styled), node(0), OptionLogicalPosition::None, |info| {
1979            on_slider_pointer_up(state.clone(), *info)
1980        });
1981        assert!(!read_state(&state).dragging);
1982        let styled2 = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
1983        with_info(unlaid(styled2), node(0), OptionLogicalPosition::None, |info| {
1984            on_slider_pointer_up(state.clone(), *info)
1985        });
1986        assert!(!read_state(&state).dragging);
1987    }
1988
1989    #[test]
1990    fn every_pointer_handler_ignores_a_foreign_payload() {
1991        // A mis-wired DOM (or an FFI caller passing the wrong `RefAny`) must be
1992        // a no-op, not a downcast panic.
1993        for handler in [
1994            on_slider_pointer_down as extern "C" fn(RefAny, CallbackInfo) -> Update,
1995            on_slider_pointer_move,
1996            on_slider_pointer_up,
1997        ] {
1998            let foreign = RefAny::new(0xABCD_u32);
1999            let styled = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
2000            let (update, changes) =
2001                with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
2002                    handler(foreign.clone(), *info)
2003                });
2004            assert_eq!(update, Update::DoNothing);
2005            assert!(changes.is_empty(), "a foreign payload still mutated the DOM: {changes:?}");
2006            let mut foreign = foreign;
2007            assert_eq!(
2008                *foreign.downcast_ref::<u32>().expect("the payload was replaced"),
2009                0xABCD,
2010            );
2011        }
2012    }
2013
2014    #[test]
2015    fn a_press_forwards_the_hooks_update_verbatim() {
2016        for (cb, expected) in [
2017            (value_do_nothing as SliderOnValueChangeCallbackType, Update::DoNothing),
2018            (record_value as SliderOnValueChangeCallbackType, Update::RefreshDom),
2019            (value_refresh_all as SliderOnValueChangeCallbackType, Update::RefreshDomAllWindows),
2020        ] {
2021            let (styled, state) =
2022                wired(Slider::create(0.0, 0.0, 100.0).with_on_value_change(log_refany(), cb));
2023            let (update, _) = with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
2024                on_slider_pointer_down(state.clone(), *info)
2025            });
2026            assert_eq!(update, expected, "the handler swallowed {expected:?}");
2027        }
2028    }
2029
2030    #[test]
2031    fn the_hook_is_told_the_new_value_not_the_old_one() {
2032        // Passing `SliderState::default()` (or the pre-press value) would
2033        // type-check and would look right for exactly one press position.
2034        let probe = log_refany();
2035        let (styled, state) = wired(Slider::create(0.0, -10.0, 90.0).with_on_value_change(
2036            probe.clone(),
2037            record_value as SliderOnValueChangeCallbackType,
2038        ));
2039        let (update, _) = with_info(unlaid(styled), node(0), cursor(50.0, 8.0), |info| {
2040            on_slider_pointer_down(state.clone(), *info)
2041        });
2042        assert_eq!(update, Update::RefreshDom);
2043        assert_eq!(
2044            read_log(&probe).seen,
2045            vec![SliderState {
2046                value: expected_value(50.0, DESIGN_WIDTH, -10.0, 90.0),
2047                min: -10.0,
2048                max: 90.0,
2049            }],
2050        );
2051    }
2052
2053    #[test]
2054    fn the_hook_is_not_called_when_there_is_no_cursor() {
2055        let probe = log_refany();
2056        let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0).with_on_value_change(
2057            probe.clone(),
2058            record_value as SliderOnValueChangeCallbackType,
2059        ));
2060        with_info(unlaid(styled), node(0), OptionLogicalPosition::None, |info| {
2061            on_slider_pointer_down(state.clone(), *info)
2062        });
2063        assert!(read_log(&probe).seen.is_empty(), "the hook fired without a cursor");
2064    }
2065
2066    #[test]
2067    fn the_hook_is_not_called_on_release() {
2068        let probe = log_refany();
2069        let (styled, state) = wired(Slider::create(0.0, 0.0, 100.0).with_on_value_change(
2070            probe.clone(),
2071            record_value as SliderOnValueChangeCallbackType,
2072        ));
2073        with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
2074            on_slider_pointer_up(state.clone(), *info)
2075        });
2076        assert!(read_log(&probe).seen.is_empty(), "the release reported a value change");
2077    }
2078
2079    #[test]
2080    fn apply_cursor_value_tolerates_a_degenerate_range_without_panicking() {
2081        // Unlike `create`/`set_value`, this path never calls `f32::clamp` on the
2082        // bounds — it interpolates. That means an inverted range is survivable
2083        // here (the value just runs backwards), which is exactly why the panic in
2084        // the constructor is worth fixing rather than accepting.
2085        for (min, max) in DEGENERATE_RANGES {
2086            let mut wrapper = SliderStateWrapper {
2087                inner: SliderState { value: 0.0, min, max },
2088                ..Default::default()
2089            };
2090            let styled = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
2091            let (update, changes) =
2092                with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
2093                    apply_cursor_value(&mut wrapper, info)
2094                });
2095            assert_eq!(update, Update::DoNothing);
2096            let v = wrapper.inner.value;
2097            assert!(
2098                v.is_nan() || (v >= min.min(max) && v <= min.max(max)),
2099                "[{min}, {max}] interpolated to {v}, outside the bounds in either order",
2100            );
2101            // The thumb still stays on the rail whatever the bounds say — the
2102            // margin depends only on the cursor fraction, not on the range.
2103            for (_, margin) in pushed_margins(&changes) {
2104                assert!(
2105                    (0.0..=TRAVEL).contains(&margin),
2106                    "[{min}, {max}] slid the thumb to {margin}",
2107                );
2108            }
2109        }
2110    }
2111
2112    #[test]
2113    fn apply_cursor_value_keeps_the_value_inside_any_sane_range() {
2114        // The documented invariant on `SliderState::value`: "always within
2115        // [min, max]". `apply_cursor_value` writes it without a clamp, relying
2116        // purely on the cursor fraction being in [0, 1].
2117        for (min, max) in SANE_RANGES {
2118            for x in [-1.0e9_f32, -1.0, 0.0, 73.0, 200.0, 1.0e9] {
2119                let mut wrapper = SliderStateWrapper {
2120                    inner: SliderState { value: min, min, max },
2121                    ..Default::default()
2122                };
2123                let styled = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
2124                with_info(unlaid(styled), node(0), cursor(x, 8.0), |info| {
2125                    apply_cursor_value(&mut wrapper, info)
2126                });
2127                let v = wrapper.inner.value;
2128                assert!(
2129                    (min..=max).contains(&v),
2130                    "a press at x = {x} put the value at {v}, outside [{min}, {max}]",
2131                );
2132            }
2133        }
2134    }
2135
2136    #[test]
2137    fn apply_cursor_value_is_idempotent_for_a_stationary_cursor() {
2138        let mut wrapper = SliderStateWrapper {
2139            inner: SliderState { value: 0.0, min: 0.0, max: 100.0 },
2140            ..Default::default()
2141        };
2142        for _ in 0..3 {
2143            let styled = StyledDom::create_from_dom(Slider::create(0.0, 0.0, 100.0).dom());
2144            with_info(unlaid(styled), node(0), cursor(100.0, 8.0), |info| {
2145                apply_cursor_value(&mut wrapper, info)
2146            });
2147            assert_eq!(wrapper.inner.value, 50.0);
2148        }
2149    }
2150
2151    #[test]
2152    fn deliver_smoke_test_covers_every_handler_and_layout_combination() {
2153        // A last sweep: every handler x {laid out, not laid out} x hostile
2154        // cursors, asserting only that nothing unwinds and the state stays a
2155        // `SliderStateWrapper`.
2156        for handler in [
2157            on_slider_pointer_down as extern "C" fn(RefAny, CallbackInfo) -> Update,
2158            on_slider_pointer_move,
2159            on_slider_pointer_up,
2160        ] {
2161            for track in [None, Some(LogicalSize::new(0.0, 0.0)), Some(LogicalSize::new(1.0e9, 16.0))] {
2162                for at in [
2163                    OptionLogicalPosition::None,
2164                    cursor(0.0, 0.0),
2165                    cursor(-1.0e9, 0.0),
2166                    cursor(f32::NAN, 0.0),
2167                    cursor(f32::INFINITY, 0.0),
2168                ] {
2169                    let slider = Slider::create(0.0, 0.0, 100.0);
2170                    let (_, state) = wired(slider.clone());
2171                    let (_, changes) =
2172                        deliver(slider, &state, node(0), at, track, handler);
2173                    // Whatever happened, the shared state must still be readable.
2174                    let _ = read_state(&state);
2175                    for (_, margin) in pushed_margins(&changes) {
2176                        assert!(
2177                            margin.is_finite(),
2178                            "a non-finite thumb offset ({margin}) reached the DOM",
2179                        );
2180                    }
2181                }
2182            }
2183        }
2184    }
2185}