Skip to main content

ferroui_themes_material/
range_slider.rs

1// Copyright The Android Open Source Project (reference behavior).
2// Copyright 2026 FerroUI contributors (Rust adaptation).
3// SPDX-License-Identifier: Apache-2.0
4//! Material range selection with two independent keyboard and accessibility targets.
5//!
6//! Geometry and horizontal interaction follow AndroidX Material3 `Slider.kt` and
7//! `SliderTokens.kt`, revision e4d933c98df7baa7ea2b83eea6e086915961e0ff. The vertical
8//! orientation is a FerroUI extension using the same geometry and range contracts.
9
10use ferroui_base::data::BindingMode;
11use ferroui_base::input::{
12    IPointer, InputElement, InputElementImpl, InputElementImplExt, Key, KeyEventArgs,
13    PointerCaptureLostEventArgs, PointerEventArgs, PointerPressedEventArgs,
14    PointerReleasedEventArgs, PointerType,
15};
16use ferroui_base::interactivity::{
17    InteractiveImpl, RoutedEvent, RoutedEventArgs, RoutingStrategies,
18};
19use ferroui_base::layout::{LayoutableImpl, Orientation};
20use ferroui_base::media::{BoxShadows, DrawingContext, FlowDirection, IBrush};
21use ferroui_base::reactive::IDisposable;
22use ferroui_base::{
23    FerroObject, FerroObjectImpl, FerroObjectImplExt, FerroProperty, FerroPropertyChangedEventArgs,
24    Point, Rect, Ref, RoundedRect, Size, StyledElementImpl, StyledProperty, StyledPropertyOptions,
25    Visual, VisualImpl, VisualImplExt, VisualTreeAttachmentEventArgs, WeakRef, ferro_class,
26    ferro_class_info, ferro_impl_classes, ferro_properties, ferro_routed_event, instantiate,
27};
28use ferroui_controls::automation::peers::{
29    AutomationControlType, AutomationPeer, AutomationPeerImpl, ControlAutomationPeer,
30    ControlAutomationPeerImpl, RangeBaseAutomationPeer, RangeBaseAutomationPeerImpl,
31};
32use ferroui_controls::automation::provider::IRangeValueProvider;
33use ferroui_controls::automation::{AutomationProperties, ElementNotEnabledException};
34use ferroui_controls::primitives::{
35    RangeBase, RangeBaseValueChangedEventArgs, TemplateAppliedEventArgs, TemplatedControl,
36    TemplatedControlImpl, TemplatedControlImplExt,
37};
38use ferroui_controls::{ControlImpl, Panel, PanelImpl};
39use std::cell::{Cell, RefCell};
40use std::rc::Rc;
41
42const HANDLE_WIDTH: f64 = 4.0;
43const TRACK_HEIGHT: f64 = 16.0;
44const TRACK_GAP: f64 = 6.0;
45const STOP_SIZE: f64 = 4.0;
46const TARGET_SIZE: f64 = 48.0;
47const TOUCH_SLOP: f64 = 8.0;
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum MaterialRangeThumb {
51    Start,
52    End,
53}
54
55struct Drag {
56    pointer: Rc<dyn IPointer>,
57    origin: Point,
58    thumb: MaterialRangeThumb,
59    dragging: bool,
60    coincident: bool,
61}
62
63/// A bounded range selected by two focusable Material slider handles.
64#[repr(C)]
65pub struct MaterialRangeSlider {
66    base: TemplatedControl,
67    layout: RefCell<Option<Ref<MaterialRangeSliderLayout>>>,
68    start_thumb: RefCell<Option<Ref<MaterialRangeSliderThumb>>>,
69    end_thumb: RefCell<Option<Ref<MaterialRangeSliderThumb>>>,
70    subscriptions: RefCell<Vec<Rc<dyn IDisposable>>>,
71    synchronizing: Cell<bool>,
72    drag: RefCell<Option<Drag>>,
73    edited: Cell<bool>,
74}
75ferro_class!(MaterialRangeSlider: TemplatedControl);
76ferro_class_info!(MaterialRangeSlider {
77    new: MaterialRangeSlider::new
78});
79ferro_impl_classes!(MaterialRangeSlider: StyledElementImpl, LayoutableImpl, InteractiveImpl, ControlImpl);
80
81impl FerroObjectImpl for MaterialRangeSlider {
82    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
83        Self::parent_on_property_changed(this, change);
84        let p = change.property();
85        if p == Self::minimum_property().as_property()
86            || p == Self::maximum_property().as_property()
87            || p == Self::range_start_property().as_property()
88            || p == Self::range_end_property().as_property()
89            || p == Self::steps_property().as_property()
90            || p == Self::orientation_property().as_property()
91            || p == Self::is_direction_reversed_property().as_property()
92            || p == Visual::flow_direction_property().as_property()
93            || p == Self::start_label_property().as_property()
94            || p == Self::end_label_property().as_property()
95        {
96            this.synchronize();
97        }
98        if p == Self::orientation_property().as_property() {
99            this.pseudo_classes()
100                .set(":vertical", this.orientation() == Orientation::Vertical);
101            this.pseudo_classes()
102                .set(":horizontal", this.orientation() == Orientation::Horizontal);
103        }
104        if p == InputElement::is_effectively_enabled_property().as_property()
105            || p == Self::is_read_only_property().as_property()
106        {
107            if !this.is_effectively_enabled() || this.is_read_only() {
108                this.cancel_drag();
109            }
110        }
111        if let Some(layout) = this.layout.borrow().as_ref() {
112            layout.invalidate_visual();
113        }
114    }
115    fn constructed(this: &Self) {
116        Self::parent_constructed(this);
117        this.pseudo_classes().set(":horizontal", true);
118    }
119}
120
121impl VisualImpl for MaterialRangeSlider {
122    fn bypass_flow_direction_policies(_: &Self) -> bool {
123        true
124    }
125    fn on_detached_from_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
126        this.cancel_drag();
127        Self::parent_on_detached_from_visual_tree(this, e);
128    }
129}
130
131impl TemplatedControlImpl for MaterialRangeSlider {
132    fn on_apply_template(this: &Self, e: &TemplateAppliedEventArgs) {
133        this.cancel_drag();
134        for subscription in this.subscriptions.borrow_mut().drain(..) {
135            subscription.dispose();
136        }
137        for thumb in [&this.start_thumb, &this.end_thumb] {
138            if let Some(thumb) = thumb.borrow_mut().take() {
139                *thumb.owner.borrow_mut() = None;
140            }
141        }
142        if let Some(layout) = this.layout.borrow_mut().take() {
143            *layout.owner.borrow_mut() = None;
144        }
145        Self::parent_on_apply_template(this, e);
146        *this.layout.borrow_mut() = e
147            .name_scope()
148            .find_as::<MaterialRangeSliderLayout>("PART_Layout");
149        *this.start_thumb.borrow_mut() = e
150            .name_scope()
151            .find_as::<MaterialRangeSliderThumb>("PART_StartThumb");
152        *this.end_thumb.borrow_mut() = e
153            .name_scope()
154            .find_as::<MaterialRangeSliderThumb>("PART_EndThumb");
155        let weak = this.to_ref().downgrade();
156        if let Some(layout) = this.layout.borrow().as_ref() {
157            *layout.owner.borrow_mut() = Some(weak.clone());
158        }
159        for (slot, role) in [
160            (&this.start_thumb, MaterialRangeThumb::Start),
161            (&this.end_thumb, MaterialRangeThumb::End),
162        ] {
163            if let Some(thumb) = slot.borrow().as_ref() {
164                *thumb.owner.borrow_mut() = Some(weak.clone());
165                thumb.role.set(role);
166                let weak = weak.clone();
167                let subscription = thumb.add_disposable_handler_with(
168                    RangeBase::value_changed_event(),
169                    move |_, e: &RangeBaseValueChangedEventArgs| {
170                        if let Some(owner) = weak.upgrade() {
171                            if !owner.synchronizing.get() {
172                                owner.set_thumb_value(role, e.new_value());
173                            }
174                        }
175                    },
176                    RoutingStrategies::BUBBLE,
177                    false,
178                );
179                this.subscriptions.borrow_mut().push(subscription);
180            }
181        }
182        this.synchronize();
183    }
184}
185
186impl InputElementImpl for MaterialRangeSlider {
187    fn on_pointer_pressed(this: &Self, e: &PointerPressedEventArgs) {
188        Self::parent_on_pointer_pressed(this, e);
189        if !this.is_effectively_enabled() || this.is_read_only() || this.drag.borrow().is_some() {
190            return;
191        }
192        if e.pointer().type_() == PointerType::Mouse
193            && !e
194                .get_current_point(Some(this))
195                .properties
196                .is_left_button_pressed
197        {
198            return;
199        }
200        let Some(layout) = this.layout.borrow().clone() else {
201            return;
202        };
203        let position = e.get_position(Some(&layout));
204        let geometry = this.geometry(layout.bounds().size());
205        let logical = geometry.fraction_at(position);
206        let start = this.fraction(this.range_start());
207        let end = this.fraction(this.range_end());
208        let first_distance = (start - logical).abs();
209        let second_distance = (end - logical).abs();
210        let thumb = if first_distance < second_distance
211            || (first_distance == second_distance && start > logical)
212        {
213            MaterialRangeThumb::Start
214        } else {
215            MaterialRangeThumb::End
216        };
217        let dragging = e.pointer().type_() == PointerType::Mouse;
218        *this.drag.borrow_mut() = Some(Drag {
219            pointer: e.pointer().clone(),
220            origin: position,
221            thumb,
222            dragging,
223            coincident: (geometry.start - geometry.end).abs() <= TOUCH_SLOP,
224        });
225        this.press_thumb(thumb, true);
226        if let Some(thumb) = this.thumb(thumb) {
227            thumb.focus();
228        }
229        if dragging {
230            e.pointer().capture(Some(&this.to_ref().upcast()));
231            e.prevent_gesture_recognition();
232            this.set_thumb_value(thumb, this.value_at_fraction(logical));
233            e.set_handled(true);
234        }
235    }
236    fn on_pointer_moved(this: &Self, e: &PointerEventArgs) {
237        Self::parent_on_pointer_moved(this, e);
238        let Some(layout) = this.layout.borrow().clone() else {
239            return;
240        };
241        let position = e.get_position(Some(&layout));
242        let geometry = this.geometry(layout.bounds().size());
243        let mut cancel = false;
244        let mut acquired = false;
245        let thumb;
246        {
247            let mut drag = this.drag.borrow_mut();
248            let Some(drag) = drag.as_mut() else {
249                return;
250            };
251            if drag.pointer.id() != e.pointer().id() {
252                return;
253            }
254            if !drag.dragging {
255                let delta = position - drag.origin;
256                let (primary, cross) = if this.orientation() == Orientation::Horizontal {
257                    (delta.x, delta.y)
258                } else {
259                    (delta.y, delta.x)
260                };
261                if cross.abs() > TOUCH_SLOP && cross.abs() > primary.abs() {
262                    cancel = true;
263                } else if primary.abs() > TOUCH_SLOP {
264                    if drag.coincident {
265                        let direction = if geometry.reversed { -primary } else { primary };
266                        drag.thumb = if direction < 0.0 {
267                            MaterialRangeThumb::Start
268                        } else {
269                            MaterialRangeThumb::End
270                        };
271                    }
272                    drag.dragging = true;
273                    acquired = true;
274                } else {
275                    return;
276                }
277            }
278            thumb = drag.thumb;
279        }
280        if cancel {
281            this.cancel_drag();
282            return;
283        }
284        if acquired {
285            this.press_thumb(
286                MaterialRangeThumb::Start,
287                thumb == MaterialRangeThumb::Start,
288            );
289            this.press_thumb(MaterialRangeThumb::End, thumb == MaterialRangeThumb::End);
290            e.pointer().capture(Some(&this.to_ref().upcast()));
291            e.prevent_gesture_recognition();
292        }
293        this.set_thumb_value(
294            thumb,
295            this.value_at_fraction(geometry.fraction_at(position)),
296        );
297        e.set_handled(true);
298    }
299    fn on_pointer_released(this: &Self, e: &PointerReleasedEventArgs) {
300        Self::parent_on_pointer_released(this, e);
301        let role = this
302            .drag
303            .borrow()
304            .as_ref()
305            .filter(|drag| drag.pointer.id() == e.pointer().id())
306            .map(|drag| drag.thumb);
307        let Some(role) = role else {
308            return;
309        };
310        if let Some(layout) = this.layout.borrow().clone() {
311            let geometry = this.geometry(layout.bounds().size());
312            this.set_thumb_value(
313                role,
314                this.value_at_fraction(geometry.fraction_at(e.get_position(Some(&layout)))),
315            );
316        }
317        this.cancel_drag();
318        e.set_handled(true);
319    }
320    fn on_pointer_capture_lost(this: &Self, e: &PointerCaptureLostEventArgs) {
321        if this
322            .drag
323            .borrow()
324            .as_ref()
325            .is_some_and(|drag| drag.pointer.id() == e.pointer().id())
326        {
327            this.cancel_drag();
328        }
329        Self::parent_on_pointer_capture_lost(this, e);
330    }
331}
332
333ferro_properties! { impl MaterialRangeSlider {
334    pub fn minimum_property() -> StyledProperty<f64> { FerroProperty::register_with::<Self, _>("Minimum", StyledPropertyOptions::new(0.0).validate(|v: &f64| v.is_finite())) }
335    pub fn maximum_property() -> StyledProperty<f64> { FerroProperty::register_with::<Self, _>("Maximum", StyledPropertyOptions::new(100.0).validate(|v: &f64| v.is_finite()).coerce(|o, v| v.max(o.get_value(Self::minimum_property())))) }
336    pub fn range_start_property() -> StyledProperty<f64> { FerroProperty::register_with::<Self, _>("RangeStart", StyledPropertyOptions::new(0.0).default_binding_mode(BindingMode::TwoWay).validate(|v: &f64| v.is_finite()).coerce(Self::coerce_start)) }
337    pub fn range_end_property() -> StyledProperty<f64> { FerroProperty::register_with::<Self, _>("RangeEnd", StyledPropertyOptions::new(100.0).default_binding_mode(BindingMode::TwoWay).validate(|v: &f64| v.is_finite()).coerce(Self::coerce_end)) }
338    pub fn steps_property() -> StyledProperty<i32> { FerroProperty::register_with::<Self, _>("Steps", StyledPropertyOptions::new(0).validate(|v: &i32| *v >= 0)) }
339    pub fn orientation_property() -> StyledProperty<Orientation> { FerroProperty::register::<Self, _>("Orientation", Orientation::Horizontal) }
340    pub fn is_direction_reversed_property() -> StyledProperty<bool> { FerroProperty::register::<Self, _>("IsDirectionReversed", false) }
341    pub fn is_read_only_property() -> StyledProperty<bool> { FerroProperty::register::<Self, _>("IsReadOnly", false) }
342    pub fn start_label_property() -> StyledProperty<String> { FerroProperty::register::<Self, _>("StartLabel", "Range start".to_owned()) }
343    pub fn end_label_property() -> StyledProperty<String> { FerroProperty::register::<Self, _>("EndLabel", "Range end".to_owned()) }
344    pub fn active_tick_brush_property() -> StyledProperty<Option<Rc<dyn IBrush>>> { FerroProperty::register::<Self, _>("ActiveTickBrush", None) }
345    pub fn inactive_tick_brush_property() -> StyledProperty<Option<Rc<dyn IBrush>>> { FerroProperty::register::<Self, _>("InactiveTickBrush", None) }
346}}
347
348impl MaterialRangeSlider {
349    ferro_routed_event!(
350        pub fn value_change_finished_event() -> RoutedEvent<RoutedEventArgs> {
351            RoutedEvent::register::<Self, _>("ValueChangeFinished", RoutingStrategies::BUBBLE)
352        }
353    );
354    pub fn construct() -> Self {
355        Self {
356            base: TemplatedControl::construct(),
357            layout: RefCell::new(None),
358            start_thumb: RefCell::new(None),
359            end_thumb: RefCell::new(None),
360            subscriptions: RefCell::new(Vec::new()),
361            synchronizing: Cell::new(false),
362            drag: RefCell::new(None),
363            edited: Cell::new(false),
364        }
365    }
366    pub fn new() -> Ref<Self> {
367        instantiate(Self::construct())
368    }
369    pub fn minimum(&self) -> f64 {
370        self.get_value(Self::minimum_property())
371    }
372    pub fn set_minimum(&self, v: f64) {
373        self.set_value(Self::minimum_property(), v);
374    }
375    pub fn maximum(&self) -> f64 {
376        self.get_value(Self::maximum_property())
377    }
378    pub fn set_maximum(&self, v: f64) {
379        self.set_value(Self::maximum_property(), v);
380    }
381    pub fn range_start(&self) -> f64 {
382        self.get_value(Self::range_start_property())
383    }
384    pub fn set_range_start(&self, v: f64) {
385        self.set_value(Self::range_start_property(), v);
386    }
387    pub fn range_end(&self) -> f64 {
388        self.get_value(Self::range_end_property())
389    }
390    pub fn set_range_end(&self, v: f64) {
391        self.set_value(Self::range_end_property(), v);
392    }
393    /// Number of evenly spaced interior stops; zero permits continuous pointer selection.
394    pub fn steps(&self) -> i32 {
395        self.get_value(Self::steps_property())
396    }
397    pub fn set_steps(&self, v: i32) {
398        self.set_value(Self::steps_property(), v);
399    }
400    pub fn orientation(&self) -> Orientation {
401        self.get_value(Self::orientation_property())
402    }
403    pub fn set_orientation(&self, v: Orientation) {
404        self.set_value(Self::orientation_property(), v);
405    }
406    pub fn is_direction_reversed(&self) -> bool {
407        self.get_value(Self::is_direction_reversed_property())
408    }
409    pub fn set_is_direction_reversed(&self, v: bool) {
410        self.set_value(Self::is_direction_reversed_property(), v);
411    }
412    pub fn is_read_only(&self) -> bool {
413        self.get_value(Self::is_read_only_property())
414    }
415    pub fn set_is_read_only(&self, v: bool) {
416        self.set_value(Self::is_read_only_property(), v);
417    }
418    pub fn start_label(&self) -> String {
419        self.get_value(Self::start_label_property())
420    }
421    pub fn set_start_label(&self, v: impl Into<String>) {
422        self.set_value(Self::start_label_property(), v.into());
423    }
424    pub fn end_label(&self) -> String {
425        self.get_value(Self::end_label_property())
426    }
427    pub fn set_end_label(&self, v: impl Into<String>) {
428        self.set_value(Self::end_label_property(), v.into());
429    }
430    pub fn active_tick_brush(&self) -> Option<Rc<dyn IBrush>> {
431        self.get_value(Self::active_tick_brush_property())
432    }
433    pub fn set_active_tick_brush(&self, v: Option<Rc<dyn IBrush>>) {
434        self.set_value(Self::active_tick_brush_property(), v);
435    }
436    pub fn inactive_tick_brush(&self) -> Option<Rc<dyn IBrush>> {
437        self.get_value(Self::inactive_tick_brush_property())
438    }
439    pub fn set_inactive_tick_brush(&self, v: Option<Rc<dyn IBrush>>) {
440        self.set_value(Self::inactive_tick_brush_property(), v);
441    }
442
443    fn coerce_start(o: &FerroObject, value: f64) -> f64 {
444        let minimum = o.get_value(Self::minimum_property());
445        let maximum = o.get_value(Self::maximum_property()).max(minimum);
446        snap(value, minimum, maximum, o.get_value(Self::steps_property())).clamp(
447            minimum,
448            o.get_value(Self::range_end_property())
449                .clamp(minimum, maximum),
450        )
451    }
452    fn coerce_end(o: &FerroObject, value: f64) -> f64 {
453        let minimum = o.get_value(Self::minimum_property());
454        let maximum = o.get_value(Self::maximum_property()).max(minimum);
455        snap(value, minimum, maximum, o.get_value(Self::steps_property())).clamp(
456            o.get_value(Self::range_start_property())
457                .clamp(minimum, maximum),
458            maximum,
459        )
460    }
461    fn fraction(&self, value: f64) -> f64 {
462        if self.maximum() <= self.minimum() {
463            0.0
464        } else {
465            ((value - self.minimum()) / (self.maximum() - self.minimum())).clamp(0.0, 1.0)
466        }
467    }
468    fn value_at_fraction(&self, fraction: f64) -> f64 {
469        self.minimum() + fraction * (self.maximum() - self.minimum())
470    }
471    fn reversed(&self) -> bool {
472        match self.orientation() {
473            Orientation::Horizontal => {
474                self.is_direction_reversed() ^ (self.flow_direction() == FlowDirection::RightToLeft)
475            }
476            Orientation::Vertical => !self.is_direction_reversed(),
477        }
478    }
479    fn geometry(&self, size: Size) -> Geometry {
480        Geometry::new(
481            size,
482            self.orientation(),
483            self.reversed(),
484            self.fraction(self.range_start()),
485            self.fraction(self.range_end()),
486            self.steps() > 0,
487            self.thumb(MaterialRangeThumb::Start)
488                .map_or(HANDLE_WIDTH, |thumb| thumb.handle_thickness()),
489            self.thumb(MaterialRangeThumb::End)
490                .map_or(HANDLE_WIDTH, |thumb| thumb.handle_thickness()),
491        )
492    }
493    pub fn thumb(&self, role: MaterialRangeThumb) -> Option<Ref<MaterialRangeSliderThumb>> {
494        match role {
495            MaterialRangeThumb::Start => self.start_thumb.borrow().clone(),
496            MaterialRangeThumb::End => self.end_thumb.borrow().clone(),
497        }
498    }
499    fn synchronize(&self) {
500        if self.synchronizing.replace(true) {
501            return;
502        }
503        self.coerce_value(Self::maximum_property().as_property());
504        self.coerce_value(Self::range_end_property().as_property());
505        self.coerce_value(Self::range_start_property().as_property());
506        let increment = (self.maximum() - self.minimum())
507            / if self.steps() > 0 {
508                self.steps() as f64 + 1.0
509            } else {
510                100.0
511            };
512        let page = (if self.steps() > 0 {
513            self.steps() as f64 + 1.0
514        } else {
515            100.0
516        } / 10.0)
517            .floor()
518            .clamp(1.0, 10.0)
519            * increment;
520        for role in [MaterialRangeThumb::Start, MaterialRangeThumb::End] {
521            if let Some(thumb) = self.thumb(role) {
522                // Reset the full range before narrowing it, avoiding stale coercion when both
523                // endpoints move to the other side of their old interval.
524                thumb.set_minimum(self.minimum());
525                thumb.set_maximum(self.maximum());
526                match role {
527                    MaterialRangeThumb::Start => {
528                        thumb.set_maximum(self.range_end());
529                        thumb.set_range_value(self.range_start());
530                    }
531                    MaterialRangeThumb::End => {
532                        thumb.set_minimum(self.range_start());
533                        thumb.set_range_value(self.range_end());
534                    }
535                }
536                thumb.set_small_change(increment);
537                thumb.set_large_change(page);
538                thumb.set_orientation(self.orientation());
539                AutomationProperties::set_name(
540                    &thumb,
541                    Some(&match role {
542                        MaterialRangeThumb::Start => self.start_label(),
543                        MaterialRangeThumb::End => self.end_label(),
544                    }),
545                );
546            }
547        }
548        self.synchronizing.set(false);
549        if let Some(layout) = self.layout.borrow().as_ref() {
550            layout.invalidate_measure();
551            layout.invalidate_arrange();
552            layout.invalidate_visual();
553        }
554    }
555    fn set_thumb_value(&self, role: MaterialRangeThumb, value: f64) {
556        if !value.is_finite() || !self.is_effectively_enabled() || self.is_read_only() {
557            return;
558        }
559        let before = (self.range_start(), self.range_end());
560        let value = snap(value, self.minimum(), self.maximum(), self.steps());
561        // Store the accepted interactive value, not an out-of-range desired value that
562        // dependency-property coercion could restore when the other handle later moves.
563        let value = match role {
564            MaterialRangeThumb::Start => value.clamp(self.minimum(), self.range_end()),
565            MaterialRangeThumb::End => value.clamp(self.range_start(), self.maximum()),
566        };
567        match role {
568            MaterialRangeThumb::Start => {
569                self.set_current_value(Self::range_start_property(), value)
570            }
571            MaterialRangeThumb::End => self.set_current_value(Self::range_end_property(), value),
572        }
573        if before != (self.range_start(), self.range_end()) {
574            self.edited.set(true);
575        }
576        // A child RangeBase may have been set to an unsnapped value by automation.
577        self.synchronize();
578    }
579    fn press_thumb(&self, role: MaterialRangeThumb, pressed: bool) {
580        if let Some(thumb) = self.thumb(role) {
581            thumb.pseudo_classes().set(":pressed", pressed);
582        }
583    }
584    fn cancel_drag(&self) {
585        let drag = self.drag.borrow_mut().take();
586        self.press_thumb(MaterialRangeThumb::Start, false);
587        self.press_thumb(MaterialRangeThumb::End, false);
588        if let Some(drag) = drag {
589            if drag
590                .pointer
591                .captured()
592                .is_some_and(|element| element == self.to_ref().upcast::<InputElement>())
593            {
594                drag.pointer.capture(None);
595            }
596            self.finish_edit();
597        }
598    }
599    fn finish_edit(&self) {
600        if self.edited.replace(false) {
601            self.raise_event(&RoutedEventArgs::with_event(
602                Self::value_change_finished_event(),
603            ));
604        }
605    }
606    fn handle_key(&self, role: MaterialRangeThumb, key: Key) -> bool {
607        if !self.is_effectively_enabled() || self.is_read_only() {
608            return false;
609        }
610        let Some(thumb) = self.thumb(role) else {
611            return false;
612        };
613        let increment = thumb.small_change();
614        let directional = match (self.orientation(), key) {
615            (Orientation::Horizontal, Key::Left) | (Orientation::Vertical, Key::Up) => {
616                Some(if self.reversed() {
617                    increment
618                } else {
619                    -increment
620                })
621            }
622            (Orientation::Horizontal, Key::Right) | (Orientation::Vertical, Key::Down) => {
623                Some(if self.reversed() {
624                    -increment
625                } else {
626                    increment
627                })
628            }
629            _ => None,
630        };
631        let value = if let Some(delta) = directional {
632            thumb.value() + delta
633        } else {
634            match key {
635                Key::Home => thumb.minimum(),
636                Key::End => thumb.maximum(),
637                Key::PageUp => thumb.value() + thumb.large_change(),
638                Key::PageDown => thumb.value() - thumb.large_change(),
639                _ => return false,
640            }
641        };
642        self.set_thumb_value(role, value);
643        true
644    }
645}
646
647fn snap(value: f64, minimum: f64, maximum: f64, steps: i32) -> f64 {
648    if maximum <= minimum {
649        return minimum;
650    }
651    let value = value.clamp(minimum, maximum);
652    if steps == 0 {
653        value
654    } else {
655        let intervals = steps as f64 + 1.0;
656        minimum
657            + (((value - minimum) / (maximum - minimum) * intervals).round() / intervals)
658                * (maximum - minimum)
659    }
660}
661
662/// A real range control used for one endpoint, with its own focus and RangeValue provider.
663#[repr(C)]
664pub struct MaterialRangeSliderThumb {
665    base: RangeBase,
666    owner: RefCell<Option<WeakRef<MaterialRangeSlider>>>,
667    role: Cell<MaterialRangeThumb>,
668    key_active: Cell<bool>,
669}
670ferro_class!(MaterialRangeSliderThumb: RangeBase);
671ferro_class_info!(MaterialRangeSliderThumb {
672    new: MaterialRangeSliderThumb::new
673});
674ferro_impl_classes!(MaterialRangeSliderThumb: StyledElementImpl, LayoutableImpl, InteractiveImpl, TemplatedControlImpl);
675impl FerroObjectImpl for MaterialRangeSliderThumb {
676    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
677        Self::parent_on_property_changed(this, change);
678        if change.property() == Self::handle_thickness_property().as_property() {
679            if let Some(owner) = this.owner() {
680                if let Some(layout) = owner.layout.borrow().as_ref() {
681                    layout.invalidate_arrange();
682                    layout.invalidate_visual();
683                }
684            }
685        }
686    }
687}
688impl VisualImpl for MaterialRangeSliderThumb {
689    fn bypass_flow_direction_policies(_: &Self) -> bool {
690        true
691    }
692}
693impl ControlImpl for MaterialRangeSliderThumb {
694    fn on_create_automation_peer(this: &Self) -> Ref<AutomationPeer> {
695        MaterialRangeSliderThumbAutomationPeer::new(this).upcast()
696    }
697}
698impl InputElementImpl for MaterialRangeSliderThumb {
699    fn on_key_down(this: &Self, e: &KeyEventArgs) {
700        Self::parent_on_key_down(this, e);
701        if e.handled() || !e.key_modifiers.is_empty() {
702            return;
703        }
704        if this
705            .owner()
706            .is_some_and(|owner| owner.handle_key(this.role.get(), e.key))
707        {
708            this.key_active.set(true);
709            e.set_handled(true);
710        }
711    }
712    fn on_key_up(this: &Self, e: &KeyEventArgs) {
713        Self::parent_on_key_up(this, e);
714        if this.key_active.replace(false) {
715            if let Some(owner) = this.owner() {
716                owner.finish_edit();
717            }
718        }
719    }
720}
721ferro_properties! { impl MaterialRangeSliderThumb {
722    pub fn handle_thickness_property() -> StyledProperty<f64> { FerroProperty::register_with::<Self, _>("HandleThickness", StyledPropertyOptions::new(HANDLE_WIDTH).validate(|v: &f64| v.is_finite() && *v >= 0.0)) }
723    pub fn orientation_property() -> StyledProperty<Orientation> { FerroProperty::register::<Self, _>("Orientation", Orientation::Horizontal) }
724}}
725impl MaterialRangeSliderThumb {
726    fn static_constructor() {
727        InputElement::focusable_property().override_default_value::<Self>(true);
728    }
729    pub fn construct() -> Self {
730        Self {
731            base: RangeBase::construct(),
732            owner: RefCell::new(None),
733            role: Cell::new(MaterialRangeThumb::Start),
734            key_active: Cell::new(false),
735        }
736    }
737    pub fn new() -> Ref<Self> {
738        instantiate(Self::construct())
739    }
740    fn owner(&self) -> Option<Ref<MaterialRangeSlider>> {
741        self.owner.borrow().as_ref().and_then(WeakRef::upgrade)
742    }
743    pub fn handle_thickness(&self) -> f64 {
744        self.get_value(Self::handle_thickness_property())
745    }
746    pub fn set_handle_thickness(&self, v: f64) {
747        self.set_value(Self::handle_thickness_property(), v);
748    }
749    pub fn orientation(&self) -> Orientation {
750        self.get_value(Self::orientation_property())
751    }
752    pub fn set_orientation(&self, v: Orientation) {
753        self.set_value(Self::orientation_property(), v);
754    }
755}
756
757#[repr(C)]
758pub struct MaterialRangeSliderThumbAutomationPeer {
759    base: RangeBaseAutomationPeer,
760}
761ferro_class!(MaterialRangeSliderThumbAutomationPeer: RangeBaseAutomationPeer);
762ferro_class_info!(MaterialRangeSliderThumbAutomationPeer {
763    interfaces: [Rc<dyn IRangeValueProvider> => MaterialRangeSliderThumbAutomationPeer::provider]
764});
765ferro_impl_classes!(MaterialRangeSliderThumbAutomationPeer: FerroObjectImpl, ControlAutomationPeerImpl, RangeBaseAutomationPeerImpl);
766impl AutomationPeerImpl for MaterialRangeSliderThumbAutomationPeer {
767    fn get_class_name_core(_: &Self) -> String {
768        "MaterialRangeSliderThumb".to_owned()
769    }
770    fn get_automation_control_type_core(_: &Self) -> AutomationControlType {
771        AutomationControlType::Slider
772    }
773}
774impl MaterialRangeSliderThumbAutomationPeer {
775    pub fn new(owner: &MaterialRangeSliderThumb) -> Ref<Self> {
776        instantiate(Self {
777            base: RangeBaseAutomationPeer::construct(owner),
778        })
779    }
780    fn provider(peer: Ref<Self>) -> Rc<dyn IRangeValueProvider> {
781        Rc::new(RangeThumbProvider(peer))
782    }
783    fn thumb(&self) -> Ref<MaterialRangeSliderThumb> {
784        ControlAutomationPeer::owner(self)
785            .cast()
786            .expect("range thumb peer owner")
787    }
788}
789struct RangeThumbProvider(Ref<MaterialRangeSliderThumbAutomationPeer>);
790impl IRangeValueProvider for RangeThumbProvider {
791    fn peer(&self) -> Ref<AutomationPeer> {
792        self.0.clone().upcast()
793    }
794    fn is_read_only(&self) -> bool {
795        self.0
796            .thumb()
797            .owner()
798            .is_none_or(|owner| owner.is_read_only())
799    }
800    fn minimum(&self) -> f64 {
801        self.0.thumb().minimum()
802    }
803    fn maximum(&self) -> f64 {
804        self.0.thumb().maximum()
805    }
806    fn value(&self) -> f64 {
807        self.0.thumb().value()
808    }
809    fn large_change(&self) -> f64 {
810        self.0.thumb().large_change()
811    }
812    fn small_change(&self) -> f64 {
813        self.0.thumb().small_change()
814    }
815    fn set_value(&self, value: f64) -> Result<(), ElementNotEnabledException> {
816        self.0.ensure_enabled()?;
817        let thumb = self.0.thumb();
818        let owner = thumb.owner().ok_or_else(ElementNotEnabledException::new)?;
819        if owner.is_read_only() {
820            return Err(ElementNotEnabledException::with_message(
821                "Range slider is read-only.",
822            ));
823        }
824        if value.is_finite() {
825            owner.set_thumb_value(thumb.role.get(), value);
826            owner.finish_edit();
827        }
828        Ok(())
829    }
830}
831
832/// Template panel drawing native track segments and positioning the two 48dp input cells.
833#[repr(C)]
834pub struct MaterialRangeSliderLayout {
835    base: Panel,
836    owner: RefCell<Option<WeakRef<MaterialRangeSlider>>>,
837}
838ferro_class!(MaterialRangeSliderLayout: Panel);
839ferro_class_info!(MaterialRangeSliderLayout {
840    new: MaterialRangeSliderLayout::new
841});
842ferro_impl_classes!(MaterialRangeSliderLayout: FerroObjectImpl, StyledElementImpl, InteractiveImpl, InputElementImpl, ControlImpl, PanelImpl);
843impl MaterialRangeSliderLayout {
844    pub fn construct() -> Self {
845        Self {
846            base: Panel::construct(),
847            owner: RefCell::new(None),
848        }
849    }
850    pub fn new() -> Ref<Self> {
851        instantiate(Self::construct())
852    }
853    fn owner(&self) -> Option<Ref<MaterialRangeSlider>> {
854        self.owner.borrow().as_ref().and_then(WeakRef::upgrade)
855    }
856}
857impl LayoutableImpl for MaterialRangeSliderLayout {
858    fn measure_override(this: &Self, available: Size) -> Size {
859        for child in this.children().snapshot().iter() {
860            child.measure(Size::new(TARGET_SIZE, TARGET_SIZE));
861        }
862        let vertical = this
863            .owner()
864            .is_some_and(|owner| owner.orientation() == Orientation::Vertical);
865        if vertical {
866            Size::new(
867                TARGET_SIZE,
868                if available.height.is_finite() {
869                    available.height.max(TARGET_SIZE)
870                } else {
871                    200.0
872                },
873            )
874        } else {
875            Size::new(
876                if available.width.is_finite() {
877                    available.width.max(TARGET_SIZE)
878                } else {
879                    200.0
880                },
881                TARGET_SIZE,
882            )
883        }
884    }
885    fn arrange_override(this: &Self, size: Size) -> Size {
886        if let Some(owner) = this.owner() {
887            let geometry = owner.geometry(size);
888            for (index, child) in this.children().snapshot().iter().enumerate() {
889                let position = if index == 0 {
890                    geometry.start
891                } else {
892                    geometry.end
893                };
894                let rect = if geometry.vertical {
895                    Rect::new(
896                        (size.width - TARGET_SIZE) / 2.0,
897                        position - TARGET_SIZE / 2.0,
898                        TARGET_SIZE,
899                        TARGET_SIZE,
900                    )
901                } else {
902                    Rect::new(
903                        position - TARGET_SIZE / 2.0,
904                        (size.height - TARGET_SIZE) / 2.0,
905                        TARGET_SIZE,
906                        TARGET_SIZE,
907                    )
908                };
909                child.arrange(rect);
910            }
911        }
912        size
913    }
914}
915impl VisualImpl for MaterialRangeSliderLayout {
916    fn bypass_flow_direction_policies(_: &Self) -> bool {
917        true
918    }
919    fn render(this: &Self, dc: &mut DrawingContext) {
920        Self::parent_render(this, dc);
921        let Some(owner) = this.owner() else {
922            return;
923        };
924        let g = owner.geometry(this.bounds().size());
925        if g.extent <= HANDLE_WIDTH {
926            return;
927        }
928        let half_start = owner
929            .thumb(MaterialRangeThumb::Start)
930            .map_or(2.0, |thumb| thumb.handle_thickness() / 2.0);
931        let half_end = owner
932            .thumb(MaterialRangeThumb::End)
933            .map_or(2.0, |thumb| thumb.handle_thickness() / 2.0);
934        let (low, high, low_gap, high_gap) = if g.start <= g.end {
935            (g.start, g.end, half_start + TRACK_GAP, half_end + TRACK_GAP)
936        } else {
937            (g.end, g.start, half_end + TRACK_GAP, half_start + TRACK_GAP)
938        };
939        let active = owner.foreground();
940        let inactive = owner.background();
941        g.segment(dc, inactive.as_ref(), g.inset, low - low_gap, 8.0, 2.0);
942        g.segment(
943            dc,
944            active.as_ref(),
945            low + low_gap,
946            high - high_gap,
947            2.0,
948            2.0,
949        );
950        g.segment(
951            dc,
952            inactive.as_ref(),
953            high + high_gap,
954            g.extent - g.far_inset,
955            2.0,
956            8.0,
957        );
958        if owner.steps() > 0 {
959            let intervals = owner.steps() as usize + 1;
960            // Subpixel stops collapse visually; bound work by the actual pixel footprint.
961            let stride =
962                ((intervals as f64 * STOP_SIZE / (g.extent - g.inset - g.far_inset)).ceil()
963                    as usize)
964                    .max(1);
965            // The two end ticks are replaced by stop indicators in AndroidX.
966            for step in (1..intervals).step_by(stride) {
967                let position = g.coordinate(step as f64 / intervals as f64);
968                if (position - g.start).abs() < half_start + TRACK_GAP
969                    || (position - g.end).abs() < half_end + TRACK_GAP
970                {
971                    continue;
972                }
973                let brush = if position > low && position < high {
974                    owner.active_tick_brush()
975                } else {
976                    owner.inactive_tick_brush()
977                };
978                g.dot(dc, brush.as_ref(), position);
979            }
980        }
981        for position in [g.inset + 8.0, g.extent - g.far_inset - 8.0] {
982            if position > 0.0
983                && (position - g.start).abs() >= half_start + TRACK_GAP
984                && (position - g.end).abs() >= half_end + TRACK_GAP
985            {
986                g.dot(dc, owner.inactive_tick_brush().as_ref(), position);
987            }
988        }
989    }
990}
991
992#[derive(Clone, Copy, Debug)]
993struct Geometry {
994    extent: f64,
995    cross: f64,
996    inset: f64,
997    far_inset: f64,
998    raw_min: f64,
999    raw_max: f64,
1000    start: f64,
1001    end: f64,
1002    vertical: bool,
1003    reversed: bool,
1004    discrete: bool,
1005}
1006impl Geometry {
1007    fn new(
1008        size: Size,
1009        orientation: Orientation,
1010        reversed: bool,
1011        start: f64,
1012        end: f64,
1013        discrete: bool,
1014        start_width: f64,
1015        end_width: f64,
1016    ) -> Self {
1017        let vertical = orientation == Orientation::Vertical;
1018        let (extent, cross) = if vertical {
1019            (size.height, size.width / 2.0)
1020        } else {
1021            (size.width, size.height / 2.0)
1022        };
1023        let raw_max = (extent - end_width / 2.0).max(0.0);
1024        let raw_min = (start_width / 2.0).min(raw_max);
1025        let mut result = Self {
1026            extent: extent.max(0.0),
1027            cross,
1028            inset: if reversed { extent - raw_max } else { raw_min },
1029            far_inset: if reversed { raw_min } else { extent - raw_max },
1030            raw_min,
1031            raw_max,
1032            start: 0.0,
1033            end: 0.0,
1034            vertical,
1035            reversed,
1036            discrete,
1037        };
1038        result.start = result.coordinate(start);
1039        result.end = result.coordinate(end);
1040        result
1041    }
1042    fn coordinate(self, fraction: f64) -> f64 {
1043        let fraction = fraction.clamp(0.0, 1.0);
1044        let span = self.raw_max - self.raw_min;
1045        // Native discrete interior handles follow the rounded track's tick inset. Endpoint
1046        // handles remain at the exact ends. Pointer conversion intentionally stays linear.
1047        let corner = if self.discrete && fraction > 0.0 && fraction < 1.0 {
1048            8.0_f64.min(span / 2.0)
1049        } else {
1050            0.0
1051        };
1052        let position = self.raw_min + corner + fraction * (span - corner * 2.0);
1053        if self.reversed {
1054            self.extent - position
1055        } else {
1056            position
1057        }
1058    }
1059    fn fraction_at(self, point: Point) -> f64 {
1060        let span = self.raw_max - self.raw_min;
1061        if span <= 0.0 {
1062            return 0.0;
1063        }
1064        let position = if self.vertical { point.y } else { point.x };
1065        let position = if self.reversed {
1066            self.extent - position
1067        } else {
1068            position
1069        };
1070        ((position - self.raw_min) / span).clamp(0.0, 1.0)
1071    }
1072    fn segment(
1073        self,
1074        dc: &mut DrawingContext,
1075        brush: Option<&Rc<dyn IBrush>>,
1076        from: f64,
1077        to: f64,
1078        first_radius: f64,
1079        last_radius: f64,
1080    ) {
1081        if to <= from || brush.is_none() {
1082            return;
1083        }
1084        let rect = if self.vertical {
1085            Rect::new(
1086                self.cross - TRACK_HEIGHT / 2.0,
1087                from,
1088                TRACK_HEIGHT,
1089                to - from,
1090            )
1091        } else {
1092            Rect::new(
1093                from,
1094                self.cross - TRACK_HEIGHT / 2.0,
1095                to - from,
1096                TRACK_HEIGHT,
1097            )
1098        };
1099        let first = first_radius.min((to - from) / 2.0);
1100        let last = last_radius.min((to - from) / 2.0);
1101        let rounded = if self.vertical {
1102            RoundedRect::from_radii(rect, first, first, last, last)
1103        } else {
1104            RoundedRect::from_radii(rect, first, last, last, first)
1105        };
1106        dc.draw_rounded_rectangle(brush, None, rounded, &BoxShadows::default());
1107    }
1108    fn dot(self, dc: &mut DrawingContext, brush: Option<&Rc<dyn IBrush>>, position: f64) {
1109        let point = if self.vertical {
1110            Point::new(self.cross, position)
1111        } else {
1112            Point::new(position, self.cross)
1113        };
1114        dc.draw_ellipse(
1115            brush,
1116            None,
1117            Rect::new(
1118                point.x - STOP_SIZE / 2.0,
1119                point.y - STOP_SIZE / 2.0,
1120                STOP_SIZE,
1121                STOP_SIZE,
1122            ),
1123        );
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130    use ferroui_base::input::RawInputModifiers;
1131    use ferroui_base::input::{
1132        KeyModifiers, MouseButton, Pointer, PointerPointProperties, PointerUpdateKind,
1133    };
1134    use ferroui_base::layout::{HorizontalAlignment, VerticalAlignment};
1135    use ferroui_controls::{Border, Control, Window};
1136
1137    fn widget() -> (Ref<MaterialRangeSlider>, Ref<Window>) {
1138        let slider = MaterialRangeSlider::new();
1139        slider.set_range_start(20.0);
1140        slider.set_range_end(80.0);
1141        slider.set_width(240.0);
1142        slider.set_height(48.0);
1143        slider.set_horizontal_alignment(HorizontalAlignment::Left);
1144        slider.set_vertical_alignment(VerticalAlignment::Top);
1145        let window = Window::new();
1146        window.set_width(400.0);
1147        window.set_height(320.0);
1148        window.set_content(Some(Control::boxed(&slider)));
1149        window.show();
1150        window.update_layout();
1151        (slider, window)
1152    }
1153
1154    fn key(thumb: &MaterialRangeSliderThumb, key: Key, down: bool) -> bool {
1155        let mut event = KeyEventArgs::new();
1156        event.key = key;
1157        event.set_routed_event(Some(if down {
1158            InputElement::key_down_event()
1159        } else {
1160            InputElement::key_up_event()
1161        }));
1162        thumb.raise_event(&event);
1163        event.handled()
1164    }
1165
1166    fn point(slider: &MaterialRangeSlider, window: &Window, fraction: f64, cross: f64) -> Point {
1167        let layout = slider.layout.borrow().clone().unwrap();
1168        let geometry = slider.geometry(layout.bounds().size());
1169        let p = if geometry.vertical {
1170            Point::new(cross, geometry.coordinate(fraction))
1171        } else {
1172            Point::new(geometry.coordinate(fraction), cross)
1173        };
1174        layout.translate_point(p, window).unwrap()
1175    }
1176
1177    fn press(slider: &MaterialRangeSlider, window: &Window, pointer: &Rc<Pointer>, point: Point) {
1178        let event = PointerPressedEventArgs::new(
1179            slider.to_ref(),
1180            pointer.clone(),
1181            window,
1182            point,
1183            0,
1184            PointerPointProperties::new(
1185                RawInputModifiers::LEFT_MOUSE_BUTTON,
1186                PointerUpdateKind::LeftButtonPressed,
1187            ),
1188            KeyModifiers::NONE,
1189            1,
1190        );
1191        slider.raise_event(&event);
1192    }
1193
1194    fn moved(
1195        slider: &MaterialRangeSlider,
1196        window: &Window,
1197        pointer: &Rc<Pointer>,
1198        point: Point,
1199    ) -> bool {
1200        let event = PointerEventArgs::new(
1201            Some(InputElement::pointer_moved_event()),
1202            slider.to_ref(),
1203            pointer.clone(),
1204            Some(window),
1205            point,
1206            1,
1207            PointerPointProperties::new(
1208                RawInputModifiers::LEFT_MOUSE_BUTTON,
1209                PointerUpdateKind::Other,
1210            ),
1211            KeyModifiers::NONE,
1212        );
1213        slider.raise_event(&event);
1214        event.handled()
1215    }
1216
1217    fn release(slider: &MaterialRangeSlider, window: &Window, pointer: &Rc<Pointer>, point: Point) {
1218        let event = PointerReleasedEventArgs::new(
1219            slider.to_ref(),
1220            pointer.clone(),
1221            window,
1222            point,
1223            2,
1224            PointerPointProperties::new(
1225                RawInputModifiers::NONE,
1226                PointerUpdateKind::LeftButtonReleased,
1227            ),
1228            KeyModifiers::NONE,
1229            MouseButton::Left,
1230        );
1231        slider.raise_event(&event);
1232    }
1233
1234    #[test]
1235    fn native_geometry_insets_and_logical_fraction_round_trip_in_both_directions() {
1236        for orientation in [Orientation::Horizontal, Orientation::Vertical] {
1237            let size = if orientation == Orientation::Horizontal {
1238                Size::new(240.0, 48.0)
1239            } else {
1240                Size::new(48.0, 240.0)
1241            };
1242            for reversed in [false, true] {
1243                let g = Geometry::new(size, orientation, reversed, 0.2, 0.8, false, 4.0, 4.0);
1244                assert_eq!(2.0, g.inset);
1245                assert_eq!(240.0, g.extent);
1246                for fraction in [0.0, 0.2, 0.5, 0.8, 1.0] {
1247                    let p = if g.vertical {
1248                        Point::new(24.0, g.coordinate(fraction))
1249                    } else {
1250                        Point::new(g.coordinate(fraction), 24.0)
1251                    };
1252                    assert!((g.fraction_at(p) - fraction).abs() < 1e-12);
1253                }
1254            }
1255        }
1256    }
1257
1258    #[test]
1259    fn discrete_steps_include_endpoints_and_nonzero_minimum() {
1260        assert_eq!(40.0, snap(41.0, 10.0, 110.0, 9));
1261        assert_eq!(50.0, snap(45.0, 10.0, 110.0, 9));
1262        assert_eq!(10.0, snap(-500.0, 10.0, 110.0, 9));
1263        assert_eq!(110.0, snap(500.0, 10.0, 110.0, 9));
1264        assert_eq!(41.0, snap(41.0, 10.0, 110.0, 0));
1265        assert_eq!(10.0, snap(500.0, 10.0, 10.0, 9));
1266    }
1267
1268    #[test]
1269    fn discrete_corner_inset_is_visual_while_pointer_values_remain_linear() {
1270        let g = Geometry::new(
1271            Size::new(240.0, 48.0),
1272            Orientation::Horizontal,
1273            false,
1274            0.2,
1275            0.8,
1276            true,
1277            4.0,
1278            4.0,
1279        );
1280        assert_eq!(2.0, g.coordinate(0.0));
1281        assert_eq!(238.0, g.coordinate(1.0));
1282        assert_eq!(54.0, g.coordinate(0.2)); // 2dp handle half + 8dp corner + 0.2 * 220dp.
1283        assert!((g.fraction_at(Point::new(49.2, 24.0)) - 0.2).abs() < 1e-12);
1284        let pressed = Geometry::new(
1285            Size::new(240.0, 48.0),
1286            Orientation::Horizontal,
1287            true,
1288            0.0,
1289            1.0,
1290            false,
1291            2.0,
1292            4.0,
1293        );
1294        assert_eq!(239.0, pressed.start);
1295        assert_eq!(2.0, pressed.end);
1296    }
1297
1298    #[test]
1299    fn endpoint_properties_enforce_order_and_recoerce_when_range_changes() {
1300        let _app = crate::tests::support::start_themed_application();
1301        let slider = MaterialRangeSlider::new();
1302        slider.set_range_start(20.0);
1303        slider.set_range_end(80.0);
1304        slider.set_range_start(90.0);
1305        assert_eq!(80.0, slider.range_start());
1306        slider.set_range_end(10.0);
1307        assert_eq!(80.0, slider.range_end());
1308        slider.set_maximum(40.0);
1309        assert_eq!((40.0, 40.0), (slider.range_start(), slider.range_end()));
1310        slider.set_minimum(50.0);
1311        assert_eq!(
1312            (50.0, 50.0, 50.0),
1313            (slider.maximum(), slider.range_start(), slider.range_end())
1314        );
1315    }
1316
1317    #[test]
1318    fn compiled_template_positions_two_real_handles_and_keeps_touch_cells() {
1319        let _app = crate::tests::support::start_themed_application();
1320        let (slider, window) = widget();
1321        for role in [MaterialRangeThumb::Start, MaterialRangeThumb::End] {
1322            let thumb = slider.thumb(role).expect("compiled thumb");
1323            assert_eq!(Size::new(48.0, 48.0), thumb.bounds().size());
1324            assert!(thumb.focusable());
1325            let handle = thumb
1326                .get_visual_descendants()
1327                .find(|node| node.name().as_deref() == Some("PART_Handle"))
1328                .unwrap()
1329                .cast::<Border>()
1330                .unwrap();
1331            assert_eq!(Size::new(4.0, 44.0), handle.bounds().size());
1332        }
1333        let a = slider.thumb(MaterialRangeThumb::Start).unwrap();
1334        let b = slider.thumb(MaterialRangeThumb::End).unwrap();
1335        assert!(a.bounds().x < b.bounds().x);
1336        slider.set_flow_direction(FlowDirection::RightToLeft);
1337        window.update_layout();
1338        assert!(a.bounds().x > b.bounds().x);
1339        window.close();
1340    }
1341
1342    #[test]
1343    fn keyboard_steps_rtl_page_and_bounds_match_native_range_contract() {
1344        let _app = crate::tests::support::start_themed_application();
1345        let (slider, window) = widget();
1346        let start = slider.thumb(MaterialRangeThumb::Start).unwrap();
1347        assert!(key(&start, Key::Right, true));
1348        assert_eq!(21.0, slider.range_start());
1349        assert!(
1350            !key(&start, Key::Up, true),
1351            "horizontal range slider must leave vertical arrows to its host"
1352        );
1353        slider.set_flow_direction(FlowDirection::RightToLeft);
1354        assert!(key(&start, Key::Right, true));
1355        assert_eq!(20.0, slider.range_start());
1356        slider.set_steps(9);
1357        assert!(key(&start, Key::Left, true));
1358        assert_eq!(30.0, slider.range_start());
1359        assert!(key(&start, Key::End, true));
1360        assert_eq!(80.0, slider.range_start());
1361        let end = slider.thumb(MaterialRangeThumb::End).unwrap();
1362        assert!(key(&end, Key::Home, true));
1363        assert_eq!(80.0, slider.range_end());
1364        assert!(key(&end, Key::PageUp, true));
1365        assert_eq!(90.0, slider.range_end());
1366        window.close();
1367    }
1368
1369    #[test]
1370    fn each_thumb_has_its_own_named_bounded_range_provider_and_disabled_policy() {
1371        let _app = crate::tests::support::start_themed_application();
1372        let (slider, window) = widget();
1373        slider.set_steps(9);
1374        slider.set_start_label("Minimum price");
1375        slider.set_end_label("Maximum price");
1376        let start = slider.thumb(MaterialRangeThumb::Start).unwrap();
1377        let end = slider.thumb(MaterialRangeThumb::End).unwrap();
1378        let start_peer = ControlAutomationPeer::create_peer_for_element(&start);
1379        let end_peer = ControlAutomationPeer::create_peer_for_element(&end);
1380        assert_eq!(
1381            AutomationControlType::Slider,
1382            start_peer.get_automation_control_type()
1383        );
1384        assert_eq!("Minimum price", start_peer.get_name());
1385        assert_eq!("Maximum price", end_peer.get_name());
1386        let first = start_peer
1387            .get_provider::<dyn IRangeValueProvider>()
1388            .unwrap();
1389        let second = end_peer.get_provider::<dyn IRangeValueProvider>().unwrap();
1390        assert_eq!(
1391            (0.0, 80.0, 20.0),
1392            (first.minimum(), first.maximum(), first.value())
1393        );
1394        assert_eq!(
1395            (20.0, 100.0, 80.0),
1396            (second.minimum(), second.maximum(), second.value())
1397        );
1398        first.set_value(47.0).unwrap();
1399        assert_eq!(50.0, slider.range_start());
1400        assert_eq!(50.0, second.minimum());
1401        second.set_value(30.0).unwrap();
1402        assert_eq!(50.0, slider.range_end());
1403        slider.set_is_read_only(true);
1404        assert!(first.is_read_only());
1405        assert!(first.set_value(10.0).is_err());
1406        slider.set_is_read_only(false);
1407        slider.set_is_enabled(false);
1408        assert!(first.set_value(10.0).is_err());
1409        assert_eq!(50.0, slider.range_start());
1410        window.close();
1411    }
1412
1413    #[test]
1414    fn mouse_drag_selects_nearest_thumb_captures_and_cannot_cross() {
1415        let _app = crate::tests::support::start_themed_application();
1416        let (slider, window) = widget();
1417        let pointer = Pointer::new(Pointer::get_next_free_id(), PointerType::Mouse, true);
1418        let initial = point(&slider, &window, 0.25, 24.0);
1419        press(&slider, &window, &pointer, initial);
1420        assert_eq!(25.0, slider.range_start());
1421        assert!(pointer.captured().is_some());
1422        let end = point(&slider, &window, 0.95, 24.0);
1423        assert!(moved(&slider, &window, &pointer, end));
1424        assert_eq!(80.0, slider.range_start());
1425        release(&slider, &window, &pointer, end);
1426        assert!(pointer.captured().is_none());
1427        assert!(slider.drag.borrow().is_none());
1428        window.close();
1429    }
1430
1431    #[test]
1432    fn touch_cross_axis_drag_yields_to_scroll_and_never_changes_value() {
1433        let _app = crate::tests::support::start_themed_application();
1434        let (slider, window) = widget();
1435        let pointer = Pointer::new(Pointer::get_next_free_id(), PointerType::Touch, true);
1436        press(
1437            &slider,
1438            &window,
1439            &pointer,
1440            point(&slider, &window, 0.25, 24.0),
1441        );
1442        assert_eq!(20.0, slider.range_start());
1443        assert!(!moved(
1444            &slider,
1445            &window,
1446            &pointer,
1447            point(&slider, &window, 0.25, 50.0)
1448        ));
1449        assert!(slider.drag.borrow().is_none());
1450        assert!(pointer.captured().is_none());
1451        assert_eq!(20.0, slider.range_start());
1452        window.close();
1453    }
1454
1455    #[test]
1456    fn coincident_touch_handles_choose_drag_direction_and_release_commits_once() {
1457        let _app = crate::tests::support::start_themed_application();
1458        let (slider, window) = widget();
1459        slider.set_range_start(50.0);
1460        slider.set_range_end(50.0);
1461        let count = Rc::new(Cell::new(0));
1462        let counter = count.clone();
1463        slider.add_handler(
1464            MaterialRangeSlider::value_change_finished_event(),
1465            move |_, _| counter.set(counter.get() + 1),
1466        );
1467        let pointer = Pointer::new(Pointer::get_next_free_id(), PointerType::Touch, true);
1468        press(
1469            &slider,
1470            &window,
1471            &pointer,
1472            point(&slider, &window, 0.5, 24.0),
1473        );
1474        assert!(moved(
1475            &slider,
1476            &window,
1477            &pointer,
1478            point(&slider, &window, 0.3, 24.0)
1479        ));
1480        assert_eq!((30.0, 50.0), (slider.range_start(), slider.range_end()));
1481        release(
1482            &slider,
1483            &window,
1484            &pointer,
1485            point(&slider, &window, 0.3, 24.0),
1486        );
1487        assert_eq!(1, count.get());
1488        window.close();
1489    }
1490
1491    #[test]
1492    fn detaching_captured_range_releases_pointer_and_pressed_state() {
1493        let _app = crate::tests::support::start_themed_application();
1494        let (slider, window) = widget();
1495        let pointer = Pointer::new(Pointer::get_next_free_id(), PointerType::Mouse, true);
1496        press(
1497            &slider,
1498            &window,
1499            &pointer,
1500            point(&slider, &window, 0.3, 24.0),
1501        );
1502        assert!(pointer.captured().is_some());
1503        window.set_content(None);
1504        window.update_layout();
1505        assert!(pointer.captured().is_none());
1506        assert!(slider.drag.borrow().is_none());
1507        assert!(
1508            !slider
1509                .thumb(MaterialRangeThumb::Start)
1510                .unwrap()
1511                .classes()
1512                .contains(":pressed")
1513        );
1514        window.close();
1515    }
1516
1517    #[test]
1518    fn vertical_extension_rotates_handle_and_keyboard_geometry() {
1519        let _app = crate::tests::support::start_themed_application();
1520        let (slider, window) = widget();
1521        slider.set_orientation(Orientation::Vertical);
1522        slider.set_width(48.0);
1523        slider.set_height(240.0);
1524        window.update_layout();
1525        let start = slider.thumb(MaterialRangeThumb::Start).unwrap();
1526        let end = slider.thumb(MaterialRangeThumb::End).unwrap();
1527        assert!(start.bounds().y > end.bounds().y);
1528        let handle = start
1529            .get_visual_descendants()
1530            .find(|node| node.name().as_deref() == Some("PART_Handle"))
1531            .unwrap()
1532            .cast::<Border>()
1533            .unwrap();
1534        assert_eq!(Size::new(44.0, 4.0), handle.bounds().size());
1535        assert!(key(&start, Key::Up, true));
1536        assert_eq!(21.0, slider.range_start());
1537        assert!(!key(&start, Key::Left, true));
1538        window.close();
1539    }
1540}