Skip to main content

tessera_components/
slider.rs

1//! An interactive slider component for selecting a value in a range.
2//!
3//! ## Usage
4//!
5//! Use to allow users to select a value from a continuous range.
6use tessera_foundation::gesture::{DragRecognizer, TapRecognizer};
7use tessera_ui::{
8    AccessibilityActionHandler, AccessibilityNode, AxisConstraint, CallbackWith, Color,
9    ComputedData, Constraint, Dp, FocusProperties, FocusRequester, LayoutResult, MeasurementError,
10    Modifier, PointerInput, PointerInputModifierNode, Px, PxPosition, SemanticsModifierNode, State,
11    accesskit::{Action, Role},
12    layout::{MeasureScope, layout},
13    modifier::{CursorModifierExt as _, FocusModifierExt as _, ModifierCapabilityExt as _},
14    remember, tessera, use_context,
15};
16
17use crate::{
18    icon::icon,
19    image_vector::TintMode,
20    modifier::{InteractionState, InteractionVisualState},
21    painter::Painter,
22    res::I18N,
23    theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme},
24};
25
26use axis::{SliderAxis, SliderMeasureScope};
27use interaction::{
28    RangeSliderHandleWidths, SliderKeyboardModel, SliderKeyboardModifierNode,
29    apply_range_slider_semantics, apply_slider_semantics, handle_range_slider_state,
30    handle_slider_state, snap_fraction,
31};
32use layout::{
33    CenteredSliderLayout, RangeSliderLayout, SliderLayout, fallback_component_width,
34    range_slider_layout, resolve_component_width, slider_layout_with_handle_width,
35};
36use render::{
37    active_segment, inactive_segment, slider_handle, slider_tick, split_tracks, stop_indicator,
38};
39
40pub use indicator::{
41    SliderValueIndicatorHost, SliderValueIndicatorVisibility, slider_value_indicator_provider,
42};
43pub use interaction::RangeSliderController;
44
45mod axis;
46mod indicator;
47mod interaction;
48mod layout;
49#[cfg(test)]
50mod orientation_tests;
51mod render;
52
53const ACCESSIBILITY_STEP: f32 = 0.05;
54const MIN_TOUCH_TARGET: Dp = Dp(40.0);
55const HANDLE_GAP: Dp = Dp(6.0);
56const STOP_INDICATOR_DIAMETER: Dp = Dp(4.0);
57
58/// The physical axis used by a slider.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum SliderOrientation {
61    /// Values increase from left to right unless the direction is reversed.
62    #[default]
63    Horizontal,
64    /// Values increase from top to bottom unless the direction is reversed.
65    Vertical,
66}
67
68fn default_value_label(value: f32) -> String {
69    format!("{:.0}", (value * 100.0).round())
70}
71
72fn tick_fractions(steps: usize) -> Vec<f32> {
73    if steps == 0 {
74        return Vec::new();
75    }
76    let denom = steps as f32 + 1.0;
77    (0..=steps + 1).map(|i| i as f32 / denom).collect()
78}
79
80#[derive(Clone, PartialEq)]
81struct RangeThumbAccessibility {
82    axis: SliderAxis,
83    key: &'static str,
84    label: Option<String>,
85    description: Option<String>,
86    fallback_description: String,
87    steps: usize,
88    disabled: bool,
89    value: f32,
90    min: f32,
91    max: f32,
92    value_label: Option<String>,
93    keyboard_model: SliderKeyboardModel,
94    space_held: State<bool>,
95    on_change: CallbackWith<f32>,
96}
97
98#[derive(Clone)]
99struct RangeSliderThumbProps {
100    thumb_layout: SliderLayout,
101    handle_width: Px,
102    colors: SliderColors,
103    focus: FocusRequester,
104    accessibility: RangeThumbAccessibility,
105}
106
107fn range_thumb_label(args: &RangeSliderConfig, start: bool) -> String {
108    let override_label = if start {
109        &args.start_accessibility_label
110    } else {
111        &args.end_accessibility_label
112    };
113    if let Some(label) = override_label {
114        return label.clone();
115    }
116    let edge = I18N.text(if start { "md3-start" } else { "md3-end" });
117    match &args.accessibility_label {
118        Some(label) => I18N.format(
119            "md3-range-labeled-thumb",
120            [("label", label.as_str().into()), ("edge", edge.into())],
121        ),
122        None => I18N.format("md3-range-thumb", [("edge", edge.into())]),
123    }
124}
125
126fn apply_range_thumb_semantics(
127    accessibility: &mut AccessibilityNode,
128    action_handler: &mut Option<AccessibilityActionHandler>,
129    args: &RangeThumbAccessibility,
130) {
131    accessibility.role = Some(Role::Slider);
132    accessibility.orientation = Some(args.axis.accessibility_orientation());
133    accessibility.key = Some(args.key.to_string());
134    accessibility.label = args.label.clone();
135    accessibility.description = Some(
136        args.description
137            .as_ref()
138            .map(|description| format!("{description} ({})", args.fallback_description))
139            .unwrap_or_else(|| args.fallback_description.to_string()),
140    );
141    accessibility.numeric_value = Some(args.value as f64);
142    accessibility.value = args.value_label.clone();
143    accessibility.min_numeric_value = Some(args.min as f64);
144    accessibility.max_numeric_value = Some(args.max as f64);
145    accessibility.focusable = !args.disabled;
146    accessibility.disabled = args.disabled;
147    accessibility.actions.clear();
148
149    if args.disabled {
150        *action_handler = None;
151        return;
152    }
153
154    accessibility.actions.push(Action::Increment);
155    accessibility.actions.push(Action::Decrement);
156
157    let delta = if args.steps == 0 {
158        ACCESSIBILITY_STEP
159    } else {
160        1.0 / (args.steps as f32 + 1.0)
161    };
162    let value = args.value;
163    let min = args.min;
164    let max = args.max;
165    let steps = args.steps;
166    let on_change = args.on_change;
167    *action_handler = Some(Box::new(move |action| {
168        let next = match action {
169            Action::Increment => value + delta,
170            Action::Decrement => value - delta,
171            _ => return,
172        };
173        let next = snap_fraction(next, steps).clamp(min, max);
174        if (next - value).abs() > f32::EPSILON {
175            on_change.call(next);
176        }
177    }));
178}
179
180#[tessera]
181fn range_slider_thumb(
182    thumb_layout: Option<SliderLayout>,
183    handle_width: Option<Px>,
184    colors: Option<SliderColors>,
185    focus: Option<FocusRequester>,
186    accessibility: Option<RangeThumbAccessibility>,
187    indicator_anchor: Option<State<tessera_ui::LayoutCoordinates>>,
188) {
189    let thumb_layout = thumb_layout.expect("range_slider_thumb requires thumb layout to be set");
190    let handle_width = handle_width.unwrap_or(Px::ZERO);
191    let colors = colors.expect("range_slider_thumb requires colors to be set");
192    let focus = focus.expect("range_slider_thumb requires focus to be set");
193    let accessibility = accessibility.expect("range_slider_thumb requires accessibility to be set");
194    let modifier = apply_range_thumb_pointer_modifier(
195        Modifier::new()
196            .focus_requester(focus)
197            .focusable()
198            .focus_properties(
199                FocusProperties::new()
200                    .can_focus(!accessibility.disabled)
201                    .can_request_focus(!accessibility.disabled),
202            ),
203        accessibility,
204    );
205
206    let modifier = if let Some(anchor) = indicator_anchor {
207        modifier.on_layout_coordinates(move |rect| {
208            if anchor.get() != rect {
209                anchor.set(rect);
210            }
211        })
212    } else {
213        modifier
214    };
215    layout().modifier(modifier).child(move || {
216        slider_handle()
217            .layout(thumb_layout)
218            .width(handle_width)
219            .colors(colors)
220            .focused(focus.is_focus_visible());
221    });
222}
223
224#[derive(Clone)]
225struct FocusTargetModifier {
226    requester: FocusRequester,
227    disabled: bool,
228}
229
230impl FocusTargetModifier {
231    fn build(self) -> Modifier {
232        Modifier::new()
233            .focus_requester(self.requester)
234            .focusable()
235            .focus_properties(
236                FocusProperties::new()
237                    .can_focus(!self.disabled)
238                    .can_request_focus(!self.disabled),
239            )
240    }
241}
242
243struct RangeSliderThumbSemanticsModifierNode {
244    accessibility: RangeThumbAccessibility,
245}
246
247impl SemanticsModifierNode for RangeSliderThumbSemanticsModifierNode {
248    fn apply(
249        &self,
250        accessibility: &mut AccessibilityNode,
251        action_handler: &mut Option<AccessibilityActionHandler>,
252    ) {
253        apply_range_thumb_semantics(accessibility, action_handler, &self.accessibility);
254    }
255}
256
257fn apply_range_thumb_pointer_modifier(
258    base: Modifier,
259    accessibility: RangeThumbAccessibility,
260) -> Modifier {
261    base.push_keyboard_input(SliderKeyboardModifierNode {
262        axis: accessibility.axis,
263        model: accessibility.keyboard_model,
264        steps: accessibility.steps,
265        disabled: accessibility.disabled,
266        space_held: accessibility.space_held,
267        on_change: accessibility.on_change,
268    })
269    .push_semantics(RangeSliderThumbSemanticsModifierNode { accessibility })
270}
271
272struct SliderPointerModifierNode {
273    controller: State<SliderController>,
274    args: SliderConfig,
275    tap_recognizer: State<TapRecognizer>,
276    drag_recognizer: State<DragRecognizer>,
277}
278
279impl PointerInputModifierNode for SliderPointerModifierNode {
280    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
281        let visual = self
282            .controller
283            .with(|controller| controller.visual_state(&self.args));
284        let base_handle_width = self.args.thumb_diameter.to_px();
285        let pressed_handle_width = Px((base_handle_width.0 / 2).max(1));
286        let handle_width = if visual.is_dragged() || visual.is_pressed() || visual.is_focused() {
287            pressed_handle_width
288        } else {
289            base_handle_width
290        };
291        let resolved_layout = slider_layout_with_handle_width(
292            &self.args,
293            self.args.axis.main_length(input.computed_data),
294            handle_width,
295        );
296        handle_slider_state(
297            &mut input,
298            self.tap_recognizer,
299            self.drag_recognizer,
300            self.controller,
301            &self.args,
302            &resolved_layout,
303        );
304    }
305}
306
307struct SliderSemanticsModifierNode {
308    args: SliderConfig,
309    clamped_value: f32,
310}
311
312impl SemanticsModifierNode for SliderSemanticsModifierNode {
313    fn apply(
314        &self,
315        accessibility: &mut AccessibilityNode,
316        action_handler: &mut Option<AccessibilityActionHandler>,
317    ) {
318        apply_slider_semantics(
319            accessibility,
320            action_handler,
321            &self.args,
322            self.clamped_value,
323            &self.args.on_change,
324        );
325    }
326}
327
328fn apply_slider_pointer_modifier(
329    base: Modifier,
330    controller: State<SliderController>,
331    args: SliderConfig,
332    tap_recognizer: State<TapRecognizer>,
333    drag_recognizer: State<DragRecognizer>,
334    clamped_value: f32,
335    keyboard_value: State<f32>,
336    space_held: State<bool>,
337) -> Modifier {
338    let modifier = if !args.disabled {
339        base.hover_cursor_icon(tessera_ui::winit::window::CursorIcon::Pointer)
340    } else {
341        base
342    };
343    modifier
344        .push_keyboard_input(SliderKeyboardModifierNode {
345            axis: args.axis,
346            model: SliderKeyboardModel::Single(keyboard_value),
347            steps: args.steps,
348            disabled: args.disabled,
349            space_held,
350            on_change: args.on_change,
351        })
352        .push_semantics(SliderSemanticsModifierNode {
353            args: args.clone(),
354            clamped_value,
355        })
356        .push_pointer_input(SliderPointerModifierNode {
357            controller,
358            args,
359            tap_recognizer,
360            drag_recognizer,
361        })
362}
363
364struct RangeSliderPointerModifierNode {
365    state: State<RangeSliderController>,
366    args: RangeSliderConfig,
367    tap_recognizer: State<TapRecognizer>,
368    drag_recognizer: State<DragRecognizer>,
369}
370
371impl PointerInputModifierNode for RangeSliderPointerModifierNode {
372    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
373        let resolved_layout =
374            range_slider_layout(&self.args, self.args.axis.main_length(input.computed_data));
375        let base_handle_width = self.args.thumb_diameter.to_px();
376        let pressed_handle_width = Px((base_handle_width.0 / 2).max(1));
377        let (start_interacting, end_interacting) = self.state.with(|state| {
378            (
379                state.is_dragging_start || state.focus_start.is_focused(),
380                state.is_dragging_end || state.focus_end.is_focused(),
381            )
382        });
383        let start_handle_width = if start_interacting {
384            pressed_handle_width
385        } else {
386            base_handle_width
387        };
388        let end_handle_width = if end_interacting {
389            pressed_handle_width
390        } else {
391            base_handle_width
392        };
393        handle_range_slider_state(
394            &mut input,
395            self.tap_recognizer,
396            self.drag_recognizer,
397            &self.state,
398            &self.args,
399            &resolved_layout.base,
400            RangeSliderHandleWidths {
401                start: start_handle_width,
402                end: end_handle_width,
403            },
404        );
405    }
406}
407
408struct RangeSliderSemanticsModifierNode {
409    args: RangeSliderConfig,
410    start_value: f32,
411    end_value: f32,
412}
413
414impl SemanticsModifierNode for RangeSliderSemanticsModifierNode {
415    fn apply(
416        &self,
417        accessibility: &mut AccessibilityNode,
418        action_handler: &mut Option<AccessibilityActionHandler>,
419    ) {
420        apply_range_slider_semantics(
421            accessibility,
422            &self.args,
423            self.start_value,
424            self.end_value,
425            &self.args.on_change,
426        );
427        *action_handler = None;
428    }
429}
430
431fn apply_range_slider_pointer_modifier(
432    base: Modifier,
433    state: State<RangeSliderController>,
434    args: RangeSliderConfig,
435    tap_recognizer: State<TapRecognizer>,
436    drag_recognizer: State<DragRecognizer>,
437    start_value: f32,
438    end_value: f32,
439) -> Modifier {
440    let modifier = if !args.disabled {
441        base.hover_cursor_icon(tessera_ui::winit::window::CursorIcon::Pointer)
442    } else {
443        base
444    };
445    modifier
446        .push_semantics(RangeSliderSemanticsModifierNode {
447            args: args.clone(),
448            start_value,
449            end_value,
450        })
451        .push_pointer_input(RangeSliderPointerModifierNode {
452            state,
453            args,
454            tap_recognizer,
455            drag_recognizer,
456        })
457}
458
459struct RangeSliderMeasureArgs {
460    start: f32,
461    end: f32,
462    start_handle_width: Px,
463    end_handle_width: Px,
464    steps: usize,
465}
466
467#[derive(Clone)]
468struct Slider {
469    args: SliderConfig,
470    clamped_value: f32,
471    handle_width: Px,
472    has_inset_icon: bool,
473}
474
475impl PartialEq for Slider {
476    fn eq(&self, other: &Self) -> bool {
477        self.args.size == other.args.size
478            && self.args.axis == other.args.axis
479            && self.args.show_stop_indicator == other.args.show_stop_indicator
480            && self.args.steps == other.args.steps
481            && self.clamped_value == other.clamped_value
482            && self.handle_width == other.handle_width
483            && self.has_inset_icon == other.has_inset_icon
484    }
485}
486
487impl tessera_ui::LayoutModifierNode for Slider {
488    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
489        let mut result = LayoutResult::default();
490        let component_width = resolve_component_width(&self.args, input.parent_constraint());
491        let resolved_layout =
492            slider_layout_with_handle_width(&self.args, component_width, self.handle_width);
493        let mut scope = SliderMeasureScope::new(input, self.args.axis);
494        let result = measure_slider(
495            &mut scope,
496            &mut result,
497            resolved_layout,
498            self.clamped_value,
499            self.has_inset_icon && resolved_layout.icon_size.is_some(),
500            self.handle_width,
501            self.args.steps,
502        )?;
503        Ok(scope.finish(result))
504    }
505}
506
507#[derive(Clone)]
508struct CenteredSlider {
509    args: SliderConfig,
510    clamped_value: f32,
511    handle_width: Px,
512}
513
514impl PartialEq for CenteredSlider {
515    fn eq(&self, other: &Self) -> bool {
516        self.args.size == other.args.size
517            && self.args.axis == other.args.axis
518            && self.args.show_stop_indicator == other.args.show_stop_indicator
519            && self.args.steps == other.args.steps
520            && self.clamped_value == other.clamped_value
521            && self.handle_width == other.handle_width
522    }
523}
524
525impl tessera_ui::LayoutModifierNode for CenteredSlider {
526    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
527        let mut result = LayoutResult::default();
528        let component_width = resolve_component_width(&self.args, input.parent_constraint());
529        let resolved_layout = CenteredSliderLayout {
530            base: slider_layout_with_handle_width(&self.args, component_width, self.handle_width),
531        };
532        let mut scope = SliderMeasureScope::new(input, self.args.axis);
533        let result = measure_centered_slider(
534            &mut scope,
535            &mut result,
536            resolved_layout,
537            self.clamped_value,
538            self.handle_width,
539            self.args.steps,
540        )?;
541        Ok(scope.finish(result))
542    }
543}
544
545#[derive(Clone)]
546struct RangeSlider {
547    args: RangeSliderConfig,
548    slider: SliderConfig,
549    start: f32,
550    end: f32,
551    start_handle_width: Px,
552    end_handle_width: Px,
553}
554
555impl PartialEq for RangeSlider {
556    fn eq(&self, other: &Self) -> bool {
557        self.args.size == other.args.size
558            && self.args.axis == other.args.axis
559            && self.args.show_stop_indicator == other.args.show_stop_indicator
560            && self.args.steps == other.args.steps
561            && self.start == other.start
562            && self.end == other.end
563            && self.start_handle_width == other.start_handle_width
564            && self.end_handle_width == other.end_handle_width
565    }
566}
567
568impl tessera_ui::LayoutModifierNode for RangeSlider {
569    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
570        let mut result = LayoutResult::default();
571        let component_width = resolve_component_width(&self.slider, input.parent_constraint());
572        let resolved_layout = range_slider_layout(&self.args, component_width);
573        let mut scope = SliderMeasureScope::new(input, self.args.axis);
574        let result = measure_range_slider(
575            &mut scope,
576            &mut result,
577            resolved_layout,
578            RangeSliderMeasureArgs {
579                start: self.start,
580                end: self.end,
581                start_handle_width: self.start_handle_width,
582                end_handle_width: self.end_handle_width,
583                steps: self.args.steps,
584            },
585        )?;
586        Ok(scope.finish(result))
587    }
588}
589
590/// Controller for the `slider` component.
591pub struct SliderController {
592    is_dragging: bool,
593    focus: FocusRequester,
594    is_hovered: bool,
595}
596
597impl SliderController {
598    fn visual_state(&self, args: &SliderConfig) -> InteractionState {
599        let mut live = InteractionState::new();
600        live.set_dragged(self.is_dragging());
601        live.set_focused(self.is_focused());
602        live.set_hovered(self.is_hovered());
603        live.with_presentation(args.presentation_state, !args.disabled)
604    }
605
606    /// Creates a new slider controller.
607    pub fn new() -> Self {
608        Self {
609            is_dragging: false,
610            focus: FocusRequester::new(),
611            is_hovered: false,
612        }
613    }
614
615    /// Returns whether the slider handle is currently being dragged.
616    pub fn is_dragging(&self) -> bool {
617        self.is_dragging
618    }
619
620    /// Manually sets the dragging flag. Useful for custom gesture integrations.
621    pub fn set_dragging(&mut self, dragging: bool) {
622        self.is_dragging = dragging;
623    }
624
625    /// Requests focus for the slider.
626    pub fn request_focus(&mut self) {
627        self.focus.request_focus();
628    }
629
630    /// Clears focus from the slider if it is currently focused.
631    pub fn clear_focus(&mut self) {
632        self.focus.unfocus();
633    }
634
635    /// Returns `true` if this slider currently holds focus.
636    pub fn is_focused(&self) -> bool {
637        self.focus.is_focused()
638    }
639
640    /// Returns `true` if the cursor is hovering over this slider.
641    pub fn is_hovered(&self) -> bool {
642        self.is_hovered
643    }
644}
645
646impl Default for SliderController {
647    fn default() -> Self {
648        Self::new()
649    }
650}
651
652/// Size variants for the slider component.
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
654pub enum SliderSize {
655    /// Extra Small (default).
656    #[default]
657    ExtraSmall,
658    /// Small.
659    Small,
660    /// Medium.
661    Medium,
662    /// Large.
663    Large,
664    /// Extra Large.
665    ExtraLarge,
666}
667
668/// Arguments for the `slider` component.
669#[derive(Clone, PartialEq)]
670struct SliderConfig {
671    pub presentation_state: Option<InteractionVisualState>,
672    pub value_indicator: SliderValueIndicatorVisibility,
673    pub value_indicator_host: Option<SliderValueIndicatorHost>,
674    pub value_label: Option<String>,
675    axis: SliderAxis,
676    /// Modifier chain applied to the slider subtree.
677    pub modifier: Modifier,
678    /// The current value of the slider, ranging from 0.0 to 1.0.
679    pub value: f32,
680    /// Callback function triggered when the slider's value changes.
681    pub on_change: CallbackWith<f32>,
682    /// Size variant of the slider.
683    pub size: SliderSize,
684    /// The color of the active part of the track (progress fill).
685    pub active_track_color: Color,
686    /// The color of the inactive part of the track (background).
687    pub inactive_track_color: Color,
688    /// Foreground for tick marks on the active track.
689    pub active_tick_color: Color,
690    /// Foreground for inactive tick marks and stop indicators.
691    pub inactive_tick_color: Color,
692    /// The thickness of the handle indicator.
693    pub thumb_diameter: Dp,
694    /// Color of the handle indicator.
695    pub thumb_color: Color,
696    /// Disable interaction.
697    pub disabled: bool,
698    /// Optional accessibility label read by assistive technologies.
699    pub accessibility_label: Option<String>,
700    /// Optional accessibility description.
701    pub accessibility_description: Option<String>,
702    /// Whether to show the stop indicators at the ends of the track.
703    pub show_stop_indicator: bool,
704    /// Number of discrete steps between 0.0 and 1.0.
705    ///
706    /// When set to a value greater than 0, the slider value snaps to
707    /// `steps + 2` evenly spaced tick positions (including both ends).
708    pub steps: usize,
709    /// Optional icon content to display at the start of the slider (only for
710    /// Medium sizes and above).
711    pub inset_icon: Option<Painter>,
712    /// Optional external controller for drag and focus state.
713    ///
714    /// When this is `None`, `slider` and `centered_slider` create and own an
715    /// internal controller.
716    pub controller: Option<State<SliderController>>,
717}
718
719impl Default for SliderConfig {
720    fn default() -> Self {
721        Self {
722            presentation_state: None,
723            value_indicator: SliderValueIndicatorVisibility::Hidden,
724            value_indicator_host: None,
725            value_label: None,
726            axis: SliderAxis::default(),
727            modifier: Modifier::new(),
728            value: 0.0,
729            on_change: CallbackWith::default_value(),
730            size: SliderSize::default(),
731            active_track_color: Color::new(0.0, 0.5, 1.0, 1.0),
732            inactive_track_color: Color::new(0.85, 0.9, 0.95, 1.0),
733            active_tick_color: Color::WHITE,
734            inactive_tick_color: Color::BLACK,
735            thumb_diameter: Dp(4.0),
736            thumb_color: Color::new(0.0, 0.5, 1.0, 1.0),
737            disabled: false,
738            accessibility_label: None,
739            accessibility_description: None,
740            show_stop_indicator: true,
741            steps: 0,
742            inset_icon: None,
743            controller: None,
744        }
745    }
746}
747/// Arguments for the `range_slider` component.
748#[derive(Clone, PartialEq)]
749struct RangeSliderConfig {
750    pub value_indicator: SliderValueIndicatorVisibility,
751    pub value_indicator_host: Option<SliderValueIndicatorHost>,
752    pub start_value_label: Option<String>,
753    pub end_value_label: Option<String>,
754    axis: SliderAxis,
755    /// Modifier chain applied to the range slider subtree.
756    pub modifier: Modifier,
757    /// The current range values (start, end), each between 0.0 and 1.0.
758    pub value: (f32, f32),
759
760    /// Callback function triggered when the range values change.
761    pub on_change: CallbackWith<(f32, f32)>,
762
763    /// Size variant of the slider.
764    pub size: SliderSize,
765
766    /// The color of the active part of the track (range fill).
767    pub active_track_color: Color,
768
769    /// The color of the inactive part of the track (background).
770    pub inactive_track_color: Color,
771    /// Foreground for tick marks on the active track.
772    pub active_tick_color: Color,
773    /// Foreground for inactive tick marks and stop indicators.
774    pub inactive_tick_color: Color,
775
776    /// The thickness of the handle indicators.
777    pub thumb_diameter: Dp,
778
779    /// Color of the handle indicators.
780    pub thumb_color: Color,
781
782    /// Disable interaction.
783    pub disabled: bool,
784    /// Optional accessibility label.
785    pub accessibility_label: Option<String>,
786    /// Optional accessibility description.
787    pub accessibility_description: Option<String>,
788
789    /// Whether to show the stop indicators at the ends of the track.
790    pub show_stop_indicator: bool,
791    /// Number of discrete steps between 0.0 and 1.0.
792    ///
793    /// When set to a value greater than 0, the slider values snap to
794    /// `steps + 2` evenly spaced tick positions (including both ends).
795    pub steps: usize,
796    /// Optional external range slider controller.
797    ///
798    /// When this is `None`, `range_slider` creates and owns an internal
799    /// controller.
800    pub controller: Option<State<RangeSliderController>>,
801    /// Accessible name for the lower handle.
802    pub start_accessibility_label: Option<String>,
803    /// Accessible name for the upper handle.
804    pub end_accessibility_label: Option<String>,
805}
806
807impl Default for RangeSliderConfig {
808    fn default() -> Self {
809        Self {
810            value_indicator: SliderValueIndicatorVisibility::Hidden,
811            value_indicator_host: None,
812            start_value_label: None,
813            end_value_label: None,
814            axis: SliderAxis::default(),
815            modifier: Modifier::new(),
816            value: (0.0, 1.0),
817            on_change: CallbackWith::default_value(),
818            size: SliderSize::default(),
819            active_track_color: Color::new(0.0, 0.5, 1.0, 1.0),
820            inactive_track_color: Color::new(0.85, 0.9, 0.95, 1.0),
821            active_tick_color: Color::WHITE,
822            inactive_tick_color: Color::BLACK,
823            thumb_diameter: Dp(4.0),
824            thumb_color: Color::new(0.0, 0.5, 1.0, 1.0),
825            disabled: false,
826            accessibility_label: None,
827            accessibility_description: None,
828            show_stop_indicator: true,
829            steps: 0,
830            controller: None,
831            start_accessibility_label: None,
832            end_accessibility_label: None,
833        }
834    }
835}
836
837type SliderArgs = SliderConfig;
838type RangeSliderArgs = RangeSliderConfig;
839
840struct SliderParams {
841    presentation_state: Option<InteractionVisualState>,
842    value_indicator: Option<SliderValueIndicatorVisibility>,
843    value_indicator_host: Option<SliderValueIndicatorHost>,
844    value_label: Option<String>,
845    axis: SliderAxis,
846    modifier: Option<Modifier>,
847    value: f32,
848    on_change: Option<CallbackWith<f32>>,
849    size: SliderSize,
850    active_track_color: Option<Color>,
851    inactive_track_color: Option<Color>,
852    active_tick_color: Option<Color>,
853    inactive_tick_color: Option<Color>,
854    thumb_diameter: Option<Dp>,
855    thumb_color: Option<Color>,
856    disabled: bool,
857    accessibility_label: Option<String>,
858    accessibility_description: Option<String>,
859    show_stop_indicator: Option<bool>,
860    steps: usize,
861    inset_icon: Option<Painter>,
862    controller: Option<State<SliderController>>,
863}
864
865/// Slider colors derived from the active Material color scheme.
866#[derive(Clone, Copy)]
867struct SliderThemeColors {
868    active_track: Color,
869    inactive_track: Color,
870    active_tick: Color,
871    inactive_tick: Color,
872    thumb: Color,
873}
874
875/// Resolves the default slider colors from the active Material theme.
876fn slider_theme_colors(scheme: &MaterialColorScheme) -> SliderThemeColors {
877    SliderThemeColors {
878        active_track: scheme.primary,
879        inactive_track: scheme.secondary_container,
880        active_tick: scheme.on_primary,
881        inactive_tick: scheme.on_secondary_container,
882        thumb: scheme.primary,
883    }
884}
885
886fn slider_config_from_params(params: SliderParams, theme: SliderThemeColors) -> SliderConfig {
887    let defaults = SliderConfig::default();
888    SliderConfig {
889        presentation_state: params.presentation_state,
890        value_indicator: params.value_indicator.unwrap_or_default(),
891        value_indicator_host: params.value_indicator_host,
892        value_label: params.value_label,
893        axis: params.axis,
894        modifier: params.modifier.unwrap_or(defaults.modifier),
895        value: snap_fraction(params.value, params.steps),
896        on_change: params.on_change.unwrap_or_else(CallbackWith::default_value),
897        size: params.size,
898        active_track_color: params.active_track_color.unwrap_or(theme.active_track),
899        inactive_track_color: params.inactive_track_color.unwrap_or(theme.inactive_track),
900        active_tick_color: params.active_tick_color.unwrap_or(theme.active_tick),
901        inactive_tick_color: params.inactive_tick_color.unwrap_or(theme.inactive_tick),
902        thumb_diameter: params.thumb_diameter.unwrap_or(defaults.thumb_diameter),
903        thumb_color: params.thumb_color.unwrap_or(theme.thumb),
904        disabled: params.disabled,
905        accessibility_label: params.accessibility_label,
906        accessibility_description: params.accessibility_description,
907        show_stop_indicator: params
908            .show_stop_indicator
909            .unwrap_or(defaults.show_stop_indicator),
910        steps: params.steps,
911        inset_icon: params.inset_icon,
912        controller: params.controller,
913    }
914}
915
916struct RangeSliderParams {
917    value_indicator: Option<SliderValueIndicatorVisibility>,
918    value_indicator_host: Option<SliderValueIndicatorHost>,
919    start_value_label: Option<String>,
920    end_value_label: Option<String>,
921    axis: SliderAxis,
922    modifier: Option<Modifier>,
923    value: (f32, f32),
924    on_change: Option<CallbackWith<(f32, f32)>>,
925    size: SliderSize,
926    active_track_color: Option<Color>,
927    inactive_track_color: Option<Color>,
928    active_tick_color: Option<Color>,
929    inactive_tick_color: Option<Color>,
930    thumb_diameter: Option<Dp>,
931    thumb_color: Option<Color>,
932    disabled: bool,
933    accessibility_label: Option<String>,
934    accessibility_description: Option<String>,
935    show_stop_indicator: Option<bool>,
936    steps: usize,
937    controller: Option<State<RangeSliderController>>,
938    start_accessibility_label: Option<String>,
939    end_accessibility_label: Option<String>,
940}
941
942fn range_slider_config_from_params(
943    params: RangeSliderParams,
944    theme: SliderThemeColors,
945) -> RangeSliderConfig {
946    let defaults = RangeSliderConfig::default();
947    let start = snap_fraction(params.value.0, params.steps);
948    let end = snap_fraction(params.value.1, params.steps).max(start);
949    RangeSliderConfig {
950        value_indicator: params.value_indicator.unwrap_or_default(),
951        value_indicator_host: params.value_indicator_host,
952        start_value_label: params.start_value_label,
953        end_value_label: params.end_value_label,
954        axis: params.axis,
955        modifier: params.modifier.unwrap_or(defaults.modifier),
956        value: (start, end),
957        on_change: params.on_change.unwrap_or_else(CallbackWith::default_value),
958        size: params.size,
959        active_track_color: params.active_track_color.unwrap_or(theme.active_track),
960        inactive_track_color: params.inactive_track_color.unwrap_or(theme.inactive_track),
961        active_tick_color: params.active_tick_color.unwrap_or(theme.active_tick),
962        inactive_tick_color: params.inactive_tick_color.unwrap_or(theme.inactive_tick),
963        thumb_diameter: params.thumb_diameter.unwrap_or(defaults.thumb_diameter),
964        thumb_color: params.thumb_color.unwrap_or(theme.thumb),
965        disabled: params.disabled,
966        accessibility_label: params.accessibility_label,
967        accessibility_description: params.accessibility_description,
968        show_stop_indicator: params
969            .show_stop_indicator
970            .unwrap_or(defaults.show_stop_indicator),
971        steps: params.steps,
972        controller: params.controller,
973        start_accessibility_label: params.start_accessibility_label,
974        end_accessibility_label: params.end_accessibility_label,
975    }
976}
977
978fn measure_slider(
979    input: &mut SliderMeasureScope<'_, '_>,
980    result: &mut LayoutResult,
981    layout: SliderLayout,
982    clamped_value: f32,
983    has_inset_icon: bool,
984    handle_width: Px,
985    steps: usize,
986) -> Result<LayoutResult, MeasurementError> {
987    let children = input.children();
988    let self_width = layout.component_width;
989    let self_height = layout.component_height;
990
991    let active = children[0];
992    let inactive = children[1];
993
994    let mut current_index = 2;
995
996    let icon_id = if has_inset_icon {
997        let active = children[current_index];
998        let inactive = children[current_index + 1];
999        current_index += 2;
1000        Some((active, inactive))
1001    } else {
1002        None
1003    };
1004
1005    let tick_count = if steps == 0 { 0 } else { steps + 2 };
1006    let tick_ids = &children[current_index..current_index + tick_count];
1007    current_index += tick_count;
1008
1009    let stop_id = if layout.show_stop_indicator {
1010        let id = children.get(current_index).copied();
1011        current_index += 1;
1012        id
1013    } else {
1014        None
1015    };
1016
1017    let handle = children[current_index];
1018
1019    let active_width = layout.active_width(clamped_value);
1020    let inactive_width = layout.inactive_width(clamped_value);
1021
1022    let active_constraint = Constraint::new(
1023        AxisConstraint::exact(active_width),
1024        AxisConstraint::exact(layout.track_height),
1025    );
1026    active.measure(input, &active_constraint)?;
1027    result.place_child(active, PxPosition::new(Px(0), layout.track_y));
1028
1029    let inactive_constraint = Constraint::new(
1030        AxisConstraint::exact(inactive_width),
1031        AxisConstraint::exact(layout.track_height),
1032    );
1033    inactive.measure(input, &inactive_constraint)?;
1034    result.place_child(
1035        inactive,
1036        PxPosition::new(
1037            Px(active_width.0 + layout.handle_gap.0 * 2 + handle_width.0),
1038            layout.track_y,
1039        ),
1040    );
1041
1042    let handle_constraint = Constraint::new(
1043        AxisConstraint::exact(handle_width),
1044        AxisConstraint::exact(layout.handle_height),
1045    );
1046    handle.measure(input, &handle_constraint)?;
1047
1048    let handle_center = layout.handle_center(clamped_value);
1049    result.place_child(
1050        handle,
1051        PxPosition::from_f32_xy(
1052            handle_center.x - handle_width.to_f32() / 2.0,
1053            layout.handle_y.0 as f32,
1054        ),
1055    );
1056
1057    if let Some(stop) = stop_id {
1058        let stop_size = layout.stop_indicator_diameter;
1059        let stop_constraint = Constraint::new(
1060            AxisConstraint::exact(stop_size),
1061            AxisConstraint::exact(stop_size),
1062        );
1063        stop.measure(input, &stop_constraint)?;
1064        let stop_offset = layout.center_child_offset(layout.stop_indicator_diameter);
1065        let inactive_start = active_width.0 + layout.handle_gap.0 * 2 + handle_width.0;
1066        let corner = layout.track_corner_radius.to_px();
1067        let stop_center_x = Px(inactive_start + inactive_width.0 - corner.0);
1068        if layout.contains_marker((Px(inactive_start), inactive_width), stop_center_x.to_f32()) {
1069            result.place_child(
1070                stop,
1071                PxPosition::new(Px(stop_center_x.0 - stop_offset.0), layout.stop_indicator_y),
1072            );
1073        }
1074    }
1075
1076    if let Some((active_icon, inactive_icon)) = icon_id
1077        && let Some(icon_size) = layout.icon_size
1078    {
1079        let icon_constraint = Constraint::new(
1080            AxisConstraint::at_most(icon_size.into()),
1081            AxisConstraint::at_most(icon_size.into()),
1082        );
1083        let icon_measured = active_icon.measure(input, &icon_constraint)?;
1084        let icon_padding = layout.icon_padding.to_px();
1085        let icon_y = layout.track_y + Px((layout.track_height.0 - icon_measured.height.0) / 2);
1086        let required_width = icon_measured.width + icon_padding * 2;
1087        if active_width >= required_width {
1088            result.place_child(active_icon, PxPosition::new(icon_padding, icon_y));
1089        } else if inactive_width >= required_width {
1090            inactive_icon.measure(input, &icon_constraint)?;
1091            result.place_child(
1092                inactive_icon,
1093                PxPosition::new(
1094                    active_width + layout.handle_gap * 2 + handle_width + icon_padding,
1095                    icon_y,
1096                ),
1097            );
1098        }
1099    }
1100
1101    if steps > 0 {
1102        let tick_size = layout.stop_indicator_diameter;
1103        let tick_constraint = Constraint::new(
1104            AxisConstraint::exact(tick_size),
1105            AxisConstraint::exact(tick_size),
1106        );
1107        let tick_offset = layout.center_child_offset(tick_size);
1108        let start_x = layout.handle_gap.to_f32() + handle_width.to_f32() / 2.0;
1109        for (i, tick_id) in tick_ids.iter().copied().enumerate() {
1110            tick_id.measure(input, &tick_constraint)?;
1111            let fraction = i as f32 / (steps as f32 + 1.0);
1112            let tick_center_x = start_x + fraction * layout.track_total_width.to_f32();
1113            let inactive_start = active_width + layout.handle_gap * 2 + handle_width;
1114            if (layout.show_stop_indicator && i + 1 == tick_count)
1115                || ![(Px::ZERO, active_width), (inactive_start, inactive_width)]
1116                    .into_iter()
1117                    .any(|segment| layout.contains_marker(segment, tick_center_x.round()))
1118            {
1119                continue;
1120            }
1121            result.place_child(
1122                tick_id,
1123                PxPosition::new(
1124                    Px(tick_center_x.round() as i32 - tick_offset.0),
1125                    layout.stop_indicator_y,
1126                ),
1127            );
1128        }
1129    }
1130
1131    result.size = ComputedData {
1132        width: self_width,
1133        height: self_height,
1134    };
1135    Ok(result.clone())
1136}
1137
1138#[derive(Clone, PartialEq, Copy)]
1139struct SliderColors {
1140    active_track: Color,
1141    inactive_track: Color,
1142    active_tick: Color,
1143    inactive_tick: Color,
1144    thumb: Color,
1145}
1146
1147fn slider_colors(args: &SliderConfig, scheme: &MaterialColorScheme) -> SliderColors {
1148    if args.disabled {
1149        let disabled_thumb = scheme
1150            .surface
1151            .blend_over(scheme.on_surface, MaterialAlpha::DISABLED_CONTENT);
1152        return SliderColors {
1153            active_track: scheme
1154                .on_surface
1155                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1156            inactive_track: scheme
1157                .on_surface
1158                .with_alpha(MaterialAlpha::DISABLED_CONTAINER),
1159            active_tick: scheme.inverse_on_surface,
1160            inactive_tick: scheme
1161                .on_surface
1162                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1163            thumb: disabled_thumb,
1164        };
1165    }
1166
1167    SliderColors {
1168        active_track: args.active_track_color,
1169        inactive_track: args.inactive_track_color,
1170        active_tick: args.active_tick_color,
1171        inactive_tick: args.inactive_tick_color,
1172        thumb: args.thumb_color,
1173    }
1174}
1175
1176fn range_slider_colors(args: &RangeSliderConfig, scheme: &MaterialColorScheme) -> SliderColors {
1177    if args.disabled {
1178        let disabled_thumb = scheme
1179            .surface
1180            .blend_over(scheme.on_surface, MaterialAlpha::DISABLED_CONTENT);
1181        return SliderColors {
1182            active_track: scheme
1183                .on_surface
1184                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1185            inactive_track: scheme
1186                .on_surface
1187                .with_alpha(MaterialAlpha::DISABLED_CONTAINER),
1188            active_tick: scheme.inverse_on_surface,
1189            inactive_tick: scheme
1190                .on_surface
1191                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1192            thumb: disabled_thumb,
1193        };
1194    }
1195
1196    SliderColors {
1197        active_track: args.active_track_color,
1198        inactive_track: args.inactive_track_color,
1199        active_tick: args.active_tick_color,
1200        inactive_tick: args.inactive_tick_color,
1201        thumb: args.thumb_color,
1202    }
1203}
1204
1205/// # slider
1206///
1207/// Select a fractional value for settings with pointer and keyboard input.
1208///
1209/// ## Usage
1210///
1211/// Use for settings like volume or brightness, or for any user-adjustable
1212/// value.
1213///
1214/// Arrow keys move one step. Home and End select the allowed bounds, and
1215/// PageUp and PageDown move up to ten steps. Continuous sliders use 1% keyboard
1216/// steps.
1217///
1218/// Supplied values are clamped to 0–1 and, for discrete sliders, rounded to the
1219/// nearest tick. Ties round upward and NaN resolves to zero. This normalization
1220/// does not call `on_change` or modify caller-owned state.
1221///
1222/// ## Parameters
1223///
1224/// - `modifier` — slider placement and size constraints.
1225/// - `value`, `on_change` — controlled fraction and change callback.
1226/// - `orientation`, `reverse_direction` — physical axis and increasing-value
1227///   direction.
1228/// - `size`, `thumb_diameter` — track size and handle width.
1229/// - `active_track_color`, `inactive_track_color`, `thumb_color` — optional
1230///   colors.
1231/// - `active_tick_color`, `inactive_tick_color` — optional tick foregrounds;
1232///   inactive tick color also applies to end stops.
1233/// - `disabled` — whether pointer, keyboard, and accessibility input are
1234///   disabled.
1235/// - `accessibility_label`, `accessibility_description` — accessible control
1236///   name and details.
1237/// - `show_stop_indicator`, `steps` — end stops and the number of intermediate
1238///   steps.
1239/// - `inset_icon` — optional leading icon for larger size variants.
1240/// - `controller` — optional shared focus and drag state.
1241///
1242/// - `presentation_state` — optional visual interaction without changing focus
1243///   or gestures.
1244/// - `value_indicator` — hidden by default; show while focused/dragging or
1245///   always. Pass the host received from [`slider_value_indicator_provider`].
1246/// - `value_indicator_host` — overlay target received from the provider slot.
1247/// - `value_label` — optional formatted value for the floating label and screen
1248///   reader; defaults to a rounded percentage without a percent sign when
1249///   labels are enabled.
1250///
1251/// ## Examples
1252///
1253/// ```
1254/// # use tessera_ui::tessera;
1255/// # #[tessera]
1256/// # fn component() {
1257/// use tessera_components::modifier::ModifierExt as _;
1258/// use tessera_components::slider::slider;
1259/// use tessera_ui::{Dp, LayoutResult, Modifier};
1260/// # use tessera_components::theme::{MaterialTheme, material_theme};
1261///
1262/// # material_theme()
1263/// #     .theme(|| MaterialTheme::default())
1264/// #     .child(|| {
1265/// slider()
1266///     .modifier(Modifier::new().width(Dp(200.0)))
1267///     .value(0.5)
1268///     .on_change(|new_value| {
1269///         assert!((0.0..=1.0).contains(&new_value));
1270///     });
1271/// # });
1272/// # }
1273/// # tessera_ui::testing::layout_test(component).run();
1274/// ```
1275#[tessera]
1276pub fn slider(
1277    presentation_state: Option<InteractionVisualState>,
1278    modifier: Option<Modifier>,
1279    value: Option<f32>,
1280    on_change: Option<CallbackWith<f32>>,
1281    size: Option<SliderSize>,
1282    orientation: Option<SliderOrientation>,
1283    reverse_direction: Option<bool>,
1284    active_track_color: Option<Color>,
1285    inactive_track_color: Option<Color>,
1286    active_tick_color: Option<Color>,
1287    inactive_tick_color: Option<Color>,
1288    thumb_diameter: Option<Dp>,
1289    thumb_color: Option<Color>,
1290    disabled: Option<bool>,
1291    #[prop(into)] accessibility_label: Option<String>,
1292    #[prop(into)] accessibility_description: Option<String>,
1293    show_stop_indicator: Option<bool>,
1294    steps: Option<usize>,
1295    value_indicator: Option<SliderValueIndicatorVisibility>,
1296    value_indicator_host: Option<SliderValueIndicatorHost>,
1297    #[prop(into)] value_label: Option<String>,
1298    #[prop(into)] inset_icon: Option<Painter>,
1299    controller: Option<State<SliderController>>,
1300) {
1301    let scheme = use_context::<MaterialTheme>()
1302        .map(|theme| theme.get().color_scheme)
1303        .unwrap_or_else(|| MaterialTheme::default().color_scheme);
1304    let defaults = SliderConfig::default();
1305    let value = value.unwrap_or(defaults.value);
1306    let size = size.unwrap_or(defaults.size);
1307    let disabled = disabled.unwrap_or(defaults.disabled);
1308    let steps = steps.unwrap_or(defaults.steps);
1309    let args = slider_config_from_params(
1310        SliderParams {
1311            presentation_state,
1312            axis: SliderAxis {
1313                orientation: orientation.unwrap_or_default(),
1314                reversed: reverse_direction.unwrap_or(false),
1315            },
1316            modifier,
1317            value,
1318            on_change,
1319            size,
1320            active_track_color,
1321            inactive_track_color,
1322            active_tick_color,
1323            inactive_tick_color,
1324            thumb_diameter,
1325            thumb_color,
1326            disabled,
1327            accessibility_label,
1328            accessibility_description,
1329            show_stop_indicator,
1330            steps,
1331            value_indicator,
1332            value_indicator_host,
1333            value_label,
1334            inset_icon,
1335            controller,
1336        },
1337        slider_theme_colors(&scheme),
1338    );
1339    let controller = if let Some(controller) = args.controller {
1340        controller
1341    } else {
1342        remember(SliderController::new)
1343    };
1344    let tap_recognizer = remember(TapRecognizer::default);
1345    let drag_recognizer = remember(DragRecognizer::default);
1346    let mut resolved_args = args;
1347    resolved_args.controller = Some(controller);
1348    slider_content()
1349        .args(resolved_args)
1350        .tap_recognizer(tap_recognizer)
1351        .drag_recognizer(drag_recognizer);
1352}
1353
1354#[tessera]
1355fn slider_content(
1356    args: Option<SliderConfig>,
1357    tap_recognizer: Option<State<TapRecognizer>>,
1358    drag_recognizer: Option<State<DragRecognizer>>,
1359) {
1360    let args = args.expect("slider_content requires args to be set");
1361    let tap_recognizer = tap_recognizer.expect("slider_content requires tap recognizer to be set");
1362    let drag_recognizer =
1363        drag_recognizer.expect("slider_content requires drag recognizer to be set");
1364    let controller = args
1365        .controller
1366        .expect("slider_content requires controller to be set");
1367    let initial_width = fallback_component_width(&args);
1368    let clamped_value = args.value.clamp(0.0, 1.0);
1369    let keyboard_value = remember(|| clamped_value);
1370    if keyboard_value.get() != clamped_value {
1371        keyboard_value.set(clamped_value);
1372    }
1373    let space_held = remember(|| false);
1374    if space_held.get() && !controller.with(|c| c.is_focused()) {
1375        space_held.set(false);
1376    }
1377    let visual = controller.with(|c| c.visual_state(&args));
1378    let base_handle_width = args.thumb_diameter.to_px();
1379    let pressed_handle_width = Px((base_handle_width.0 / 2).max(1));
1380    let handle_width = if visual.is_dragged() || visual.is_pressed() || visual.is_focused() {
1381        pressed_handle_width
1382    } else {
1383        base_handle_width
1384    };
1385    let modifier = apply_slider_pointer_modifier(
1386        args.modifier.clone().then(
1387            FocusTargetModifier {
1388                requester: controller.with(|c| c.focus),
1389                disabled: args.disabled,
1390            }
1391            .build(),
1392        ),
1393        controller,
1394        args.clone(),
1395        tap_recognizer,
1396        drag_recognizer,
1397        clamped_value,
1398        keyboard_value,
1399        space_held,
1400    );
1401
1402    let indicator_anchor = if args.value_indicator != SliderValueIndicatorVisibility::Hidden {
1403        let anchor = remember(tessera_ui::LayoutCoordinates::default);
1404        indicator::register_indicator()
1405            .host_optional(args.value_indicator_host)
1406            .anchor(anchor)
1407            .label(
1408                args.value_label
1409                    .clone()
1410                    .unwrap_or_else(|| default_value_label(clamped_value)),
1411            )
1412            .visible(args.value_indicator.visible(
1413                visual.is_dragged() || visual.is_pressed() || visual.is_focused(),
1414                args.disabled,
1415            ))
1416            .orientation(args.axis.orientation)
1417            .interacting(visual.is_dragged() || visual.is_pressed() || visual.is_focused());
1418        Some(anchor)
1419    } else {
1420        None
1421    };
1422    let next = (
1423        args.clone(),
1424        initial_width,
1425        clamped_value,
1426        handle_width,
1427        indicator_anchor,
1428        visual.is_focused()
1429            && controller
1430                .with(|controller| !controller.is_focused() || controller.focus.is_focus_visible()),
1431    );
1432    let render_data = remember(|| next.clone());
1433    if render_data.with(|current| current != &next) {
1434        render_data.set(next);
1435    }
1436    layout()
1437        .modifier(modifier)
1438        .policy(Slider {
1439            args: args.clone(),
1440            clamped_value,
1441            handle_width,
1442            has_inset_icon: args.inset_icon.is_some(),
1443        })
1444        .child(move || {
1445            let (args, initial_width, clamped_value, handle_width, indicator_anchor, focused) =
1446                render_data.get();
1447            let slider_layout = slider_layout_with_handle_width(&args, initial_width, handle_width);
1448            let scheme = use_context::<MaterialTheme>()
1449                .map(|theme| theme.get().color_scheme)
1450                .unwrap_or_else(|| MaterialTheme::default().color_scheme);
1451            let colors = slider_colors(&args, &scheme);
1452
1453            active_segment().layout(slider_layout).colors(colors);
1454            inactive_segment().layout(slider_layout).colors(colors);
1455
1456            if let Some(icon_size) = slider_layout.icon_size
1457                && let Some(inset_icon) = args.inset_icon.as_ref()
1458            {
1459                let scheme = use_context::<MaterialTheme>()
1460                    .expect("MaterialTheme must be provided")
1461                    .get()
1462                    .color_scheme;
1463                let tint = if args.disabled {
1464                    scheme
1465                        .on_surface
1466                        .with_alpha(MaterialAlpha::DISABLED_CONTENT)
1467                } else {
1468                    scheme.on_primary
1469                };
1470
1471                icon()
1472                    .painter(inset_icon.clone())
1473                    .tint(tint)
1474                    .tint_mode(TintMode::Solid)
1475                    .size(icon_size);
1476                icon()
1477                    .painter(inset_icon.clone())
1478                    .tint(if args.disabled {
1479                        scheme
1480                            .on_surface
1481                            .with_alpha(MaterialAlpha::DISABLED_CONTENT)
1482                    } else {
1483                        scheme.on_secondary_container
1484                    })
1485                    .tint_mode(TintMode::Solid)
1486                    .size(icon_size);
1487            }
1488
1489            if args.steps > 0 {
1490                for fraction in tick_fractions(args.steps) {
1491                    let is_active = fraction <= clamped_value;
1492                    let color = if is_active {
1493                        colors.active_tick
1494                    } else {
1495                        colors.inactive_tick
1496                    };
1497                    slider_tick()
1498                        .diameter(slider_layout.stop_indicator_diameter)
1499                        .color(color);
1500                }
1501            }
1502            if slider_layout.show_stop_indicator {
1503                stop_indicator().layout(slider_layout).colors(colors);
1504            }
1505            slider_handle()
1506                .layout(slider_layout)
1507                .width(handle_width)
1508                .colors(colors)
1509                .focused(focused)
1510                .indicator_anchor_optional(indicator_anchor);
1511        });
1512}
1513
1514fn measure_centered_slider(
1515    input: &mut SliderMeasureScope<'_, '_>,
1516    result: &mut LayoutResult,
1517    layout: CenteredSliderLayout,
1518    value: f32,
1519    handle_width: Px,
1520    steps: usize,
1521) -> Result<LayoutResult, MeasurementError> {
1522    let children = input.children();
1523    let self_width = layout.base.component_width;
1524    let self_height = layout.base.component_height;
1525    let track_y = layout.base.track_y;
1526
1527    let left_inactive = children[0];
1528    let active = children[1];
1529    let right_inactive = children[2];
1530    let mut current_index = 3;
1531    let tick_count = if steps == 0 { 0 } else { steps + 2 };
1532    let tick_ids = &children[current_index..current_index + tick_count];
1533    current_index += tick_count;
1534
1535    let (left_stop_id, right_stop_id) = if layout.base.show_stop_indicator {
1536        let left = children[current_index];
1537        let right = children[current_index + 1];
1538        current_index += 2;
1539        (Some(left), Some(right))
1540    } else {
1541        (None, None)
1542    };
1543    let handle = children[current_index];
1544
1545    let segments = layout.segments(value);
1546
1547    // 1. Left Inactive
1548    left_inactive.measure(
1549        input,
1550        &Constraint::new(
1551            AxisConstraint::exact(segments.left_inactive.1),
1552            AxisConstraint::exact(layout.base.track_height),
1553        ),
1554    )?;
1555    result.place_child(
1556        left_inactive,
1557        PxPosition::new(segments.left_inactive.0, track_y),
1558    );
1559
1560    // 2. Active
1561    active.measure(
1562        input,
1563        &Constraint::new(
1564            AxisConstraint::exact(segments.active.1),
1565            AxisConstraint::exact(layout.base.track_height),
1566        ),
1567    )?;
1568    result.place_child(active, PxPosition::new(segments.active.0, track_y));
1569
1570    // 3. Right Inactive
1571    right_inactive.measure(
1572        input,
1573        &Constraint::new(
1574            AxisConstraint::exact(segments.right_inactive.1),
1575            AxisConstraint::exact(layout.base.track_height),
1576        ),
1577    )?;
1578    result.place_child(
1579        right_inactive,
1580        PxPosition::new(segments.right_inactive.0, track_y),
1581    );
1582
1583    // 4. Handle
1584    handle.measure(
1585        input,
1586        &Constraint::new(
1587            AxisConstraint::exact(handle_width),
1588            AxisConstraint::exact(layout.base.handle_height),
1589        ),
1590    )?;
1591    result.place_child(
1592        handle,
1593        PxPosition::from_f32_xy(
1594            segments.handle_center.x - handle_width.to_f32() / 2.0,
1595            layout.base.handle_y.0 as f32,
1596        ),
1597    );
1598
1599    if layout.base.show_stop_indicator {
1600        let (Some(left_stop_id), Some(right_stop_id)) = (left_stop_id, right_stop_id) else {
1601            return Err(MeasurementError::MeasureFnFailed(
1602                "Missing stop indicator children".to_string(),
1603            ));
1604        };
1605        // 5. Left Stop
1606        let stop_size = layout.base.stop_indicator_diameter;
1607        let stop_constraint = Constraint::new(
1608            AxisConstraint::exact(stop_size),
1609            AxisConstraint::exact(stop_size),
1610        );
1611        left_stop_id.measure(input, &stop_constraint)?;
1612
1613        let stop_offset = layout.base.center_child_offset(stop_size);
1614        let stop_padding = layout.stop_indicator_offset();
1615
1616        let left_stop_x = Px(stop_padding.0);
1617
1618        if layout
1619            .base
1620            .contains_marker(segments.left_inactive, left_stop_x.to_f32())
1621        {
1622            result.place_child(
1623                left_stop_id,
1624                PxPosition::new(
1625                    Px(left_stop_x.0 - stop_offset.0),
1626                    layout.base.stop_indicator_y,
1627                ),
1628            );
1629        }
1630
1631        // 6. Right Stop
1632        right_stop_id.measure(input, &stop_constraint)?;
1633        let right_stop_x = Px(self_width.0 - stop_padding.0);
1634
1635        if layout
1636            .base
1637            .contains_marker(segments.right_inactive, right_stop_x.to_f32())
1638        {
1639            result.place_child(
1640                right_stop_id,
1641                PxPosition::new(
1642                    Px(right_stop_x.0 - stop_offset.0),
1643                    layout.base.stop_indicator_y,
1644                ),
1645            );
1646        }
1647    }
1648
1649    if steps > 0 {
1650        let tick_size = layout.base.stop_indicator_diameter;
1651        let tick_constraint = Constraint::new(
1652            AxisConstraint::exact(tick_size),
1653            AxisConstraint::exact(tick_size),
1654        );
1655        let tick_offset = layout.base.center_child_offset(tick_size);
1656        let start_x = layout.base.handle_gap.to_f32() + handle_width.to_f32() / 2.0;
1657        for (i, tick_id) in tick_ids.iter().copied().enumerate() {
1658            tick_id.measure(input, &tick_constraint)?;
1659            let fraction = i as f32 / (steps as f32 + 1.0);
1660            let tick_center_x = start_x + fraction * layout.base.track_total_width.to_f32();
1661            if (layout.base.show_stop_indicator && (i == 0 || i + 1 == tick_count))
1662                || ![
1663                    segments.left_inactive,
1664                    segments.active,
1665                    segments.right_inactive,
1666                ]
1667                .into_iter()
1668                .any(|segment| layout.base.contains_marker(segment, tick_center_x.round()))
1669            {
1670                continue;
1671            }
1672            result.place_child(
1673                tick_id,
1674                PxPosition::new(
1675                    Px(tick_center_x.round() as i32 - tick_offset.0),
1676                    layout.base.stop_indicator_y,
1677                ),
1678            );
1679        }
1680    }
1681
1682    result.size = ComputedData {
1683        width: self_width,
1684        height: self_height,
1685    };
1686    Ok(result.clone())
1687}
1688
1689/// # centered_slider
1690///
1691/// Adjust a value around a neutral midpoint for balance and deviation settings.
1692///
1693/// ## Usage
1694///
1695/// Use for adjustments that have a neutral midpoint, such as balance controls
1696/// or deviation settings.
1697///
1698/// Arrow keys move one step. Home and End select the allowed bounds, and
1699/// PageUp and PageDown move up to ten steps. Continuous sliders use 1% keyboard
1700/// steps.
1701///
1702/// Supplied values are clamped to 0–1 and, for discrete sliders, rounded to the
1703/// nearest tick. Ties round upward and NaN resolves to zero. This normalization
1704/// does not call `on_change` or modify caller-owned state.
1705///
1706/// ## Parameters
1707///
1708/// - `modifier` — slider placement and size constraints.
1709/// - `value`, `on_change` — controlled fraction and change callback.
1710/// - `orientation`, `reverse_direction` — physical axis and increasing-value
1711///   direction.
1712/// - `size`, `thumb_diameter` — track size and handle width.
1713/// - `active_track_color`, `inactive_track_color`, `thumb_color` — optional
1714///   colors.
1715/// - `active_tick_color`, `inactive_tick_color` — optional tick foregrounds;
1716///   inactive tick color also applies to end stops.
1717/// - `disabled` — whether pointer, keyboard, and accessibility input are
1718///   disabled.
1719/// - `accessibility_label`, `accessibility_description` — accessible control
1720///   name and details.
1721/// - `show_stop_indicator`, `steps` — end stops and the number of intermediate
1722///   steps.
1723/// - `inset_icon` — optional leading icon for larger size variants.
1724/// - `controller` — optional shared focus and drag state.
1725///
1726/// - `presentation_state` — optional visual interaction without changing focus
1727///   or gestures.
1728/// - `value_indicator` — hidden by default; show while focused/dragging or
1729///   always. Pass the host received from [`slider_value_indicator_provider`].
1730/// - `value_indicator_host` — overlay target received from the provider slot.
1731/// - `value_label` — optional formatted value for the floating label and screen
1732///   reader; defaults to a rounded percentage without a percent sign when
1733///   labels are enabled.
1734///
1735/// ## Examples
1736///
1737/// ```
1738/// # use tessera_ui::tessera;
1739/// # #[tessera]
1740/// # fn component() {
1741/// use tessera_components::modifier::ModifierExt as _;
1742/// use tessera_components::slider::centered_slider;
1743/// use tessera_ui::{Dp, Modifier, remember};
1744/// # use tessera_components::theme::{MaterialTheme, material_theme};
1745///
1746/// let current_value = remember(|| 0.5f32);
1747///
1748/// current_value.set(0.75);
1749/// assert_eq!(current_value.get(), 0.75);
1750///
1751/// # material_theme()
1752/// #     .theme(|| MaterialTheme::default())
1753/// #     .child(move || {
1754/// centered_slider()
1755///     .modifier(Modifier::new().width(Dp(200.0)))
1756///     .value(current_value.get())
1757///     .on_change(move |new_value| {
1758///         current_value.set(new_value);
1759///     });
1760///
1761/// current_value.set(0.25);
1762/// assert_eq!(current_value.get(), 0.25);
1763/// # });
1764/// # }
1765/// # tessera_ui::testing::layout_test(component).run();
1766/// ```
1767#[tessera]
1768pub fn centered_slider(
1769    presentation_state: Option<InteractionVisualState>,
1770    modifier: Option<Modifier>,
1771    value: Option<f32>,
1772    on_change: Option<CallbackWith<f32>>,
1773    size: Option<SliderSize>,
1774    orientation: Option<SliderOrientation>,
1775    reverse_direction: Option<bool>,
1776    active_track_color: Option<Color>,
1777    inactive_track_color: Option<Color>,
1778    active_tick_color: Option<Color>,
1779    inactive_tick_color: Option<Color>,
1780    thumb_diameter: Option<Dp>,
1781    thumb_color: Option<Color>,
1782    disabled: Option<bool>,
1783    #[prop(into)] accessibility_label: Option<String>,
1784    #[prop(into)] accessibility_description: Option<String>,
1785    show_stop_indicator: Option<bool>,
1786    steps: Option<usize>,
1787    value_indicator: Option<SliderValueIndicatorVisibility>,
1788    value_indicator_host: Option<SliderValueIndicatorHost>,
1789    #[prop(into)] value_label: Option<String>,
1790    #[prop(into)] inset_icon: Option<Painter>,
1791    controller: Option<State<SliderController>>,
1792) {
1793    let scheme = use_context::<MaterialTheme>()
1794        .map(|theme| theme.get().color_scheme)
1795        .unwrap_or_else(|| MaterialTheme::default().color_scheme);
1796    let defaults = SliderConfig::default();
1797    let value = value.unwrap_or(defaults.value);
1798    let size = size.unwrap_or(defaults.size);
1799    let disabled = disabled.unwrap_or(defaults.disabled);
1800    let steps = steps.unwrap_or(defaults.steps);
1801    let args = slider_config_from_params(
1802        SliderParams {
1803            presentation_state,
1804            axis: SliderAxis {
1805                orientation: orientation.unwrap_or_default(),
1806                reversed: reverse_direction.unwrap_or(false),
1807            },
1808            modifier,
1809            value,
1810            on_change,
1811            size,
1812            active_track_color,
1813            inactive_track_color,
1814            active_tick_color,
1815            inactive_tick_color,
1816            thumb_diameter,
1817            thumb_color,
1818            disabled,
1819            accessibility_label,
1820            accessibility_description,
1821            show_stop_indicator,
1822            steps,
1823            value_indicator,
1824            value_indicator_host,
1825            value_label,
1826            inset_icon,
1827            controller,
1828        },
1829        slider_theme_colors(&scheme),
1830    );
1831    let controller = if let Some(controller) = args.controller {
1832        controller
1833    } else {
1834        remember(SliderController::new)
1835    };
1836    let tap_recognizer = remember(TapRecognizer::default);
1837    let drag_recognizer = remember(DragRecognizer::default);
1838    let mut resolved_args = args;
1839    resolved_args.controller = Some(controller);
1840    centered_slider_content()
1841        .args(resolved_args)
1842        .tap_recognizer(tap_recognizer)
1843        .drag_recognizer(drag_recognizer);
1844}
1845
1846#[tessera]
1847fn centered_slider_content(
1848    args: Option<SliderConfig>,
1849    tap_recognizer: Option<State<TapRecognizer>>,
1850    drag_recognizer: Option<State<DragRecognizer>>,
1851) {
1852    let args = args.expect("centered_slider_content requires args to be set");
1853    let tap_recognizer =
1854        tap_recognizer.expect("centered_slider_content requires tap recognizer to be set");
1855    let drag_recognizer =
1856        drag_recognizer.expect("centered_slider_content requires drag recognizer to be set");
1857    let controller = args
1858        .controller
1859        .expect("centered_slider_content requires controller to be set");
1860    let initial_width = fallback_component_width(&args);
1861    let clamped_value = args.value.clamp(0.0, 1.0);
1862    let keyboard_value = remember(|| clamped_value);
1863    if keyboard_value.get() != clamped_value {
1864        keyboard_value.set(clamped_value);
1865    }
1866    let space_held = remember(|| false);
1867    if space_held.get() && !controller.with(|c| c.is_focused()) {
1868        space_held.set(false);
1869    }
1870    let visual = controller.with(|c| c.visual_state(&args));
1871    let base_handle_width = args.thumb_diameter.to_px();
1872    let pressed_handle_width = Px((base_handle_width.0 / 2).max(1));
1873    let handle_width = if visual.is_dragged() || visual.is_pressed() || visual.is_focused() {
1874        pressed_handle_width
1875    } else {
1876        base_handle_width
1877    };
1878    let modifier = apply_slider_pointer_modifier(
1879        args.modifier.clone().then(
1880            FocusTargetModifier {
1881                requester: controller.with(|c| c.focus),
1882                disabled: args.disabled,
1883            }
1884            .build(),
1885        ),
1886        controller,
1887        args.clone(),
1888        tap_recognizer,
1889        drag_recognizer,
1890        clamped_value,
1891        keyboard_value,
1892        space_held,
1893    );
1894
1895    let indicator_anchor = if args.value_indicator != SliderValueIndicatorVisibility::Hidden {
1896        let anchor = remember(tessera_ui::LayoutCoordinates::default);
1897        indicator::register_indicator()
1898            .host_optional(args.value_indicator_host)
1899            .anchor(anchor)
1900            .label(
1901                args.value_label
1902                    .clone()
1903                    .unwrap_or_else(|| default_value_label(clamped_value)),
1904            )
1905            .visible(args.value_indicator.visible(
1906                visual.is_dragged() || visual.is_pressed() || visual.is_focused(),
1907                args.disabled,
1908            ))
1909            .orientation(args.axis.orientation)
1910            .interacting(visual.is_dragged() || visual.is_pressed() || visual.is_focused());
1911        Some(anchor)
1912    } else {
1913        None
1914    };
1915    let next = (
1916        args.clone(),
1917        initial_width,
1918        clamped_value,
1919        handle_width,
1920        indicator_anchor,
1921        visual.is_focused()
1922            && controller
1923                .with(|controller| !controller.is_focused() || controller.focus.is_focus_visible()),
1924    );
1925    let render_data = remember(|| next.clone());
1926    if render_data.with(|current| current != &next) {
1927        render_data.set(next);
1928    }
1929    layout()
1930        .modifier(modifier)
1931        .policy(CenteredSlider {
1932            args: args.clone(),
1933            clamped_value,
1934            handle_width,
1935        })
1936        .child(move || {
1937            let (args, initial_width, clamped_value, handle_width, indicator_anchor, focused) =
1938                render_data.get();
1939            let centered_layout = CenteredSliderLayout {
1940                base: slider_layout_with_handle_width(&args, initial_width, handle_width),
1941            };
1942            let scheme = use_context::<MaterialTheme>()
1943                .map(|theme| theme.get().color_scheme)
1944                .unwrap_or_else(|| MaterialTheme::default().color_scheme);
1945            let colors = slider_colors(&args, &scheme);
1946
1947            split_tracks().layout(centered_layout.base).colors(colors);
1948            if args.steps > 0 {
1949                let active_start = clamped_value.min(0.5);
1950                let active_end = clamped_value.max(0.5);
1951                for fraction in tick_fractions(args.steps) {
1952                    let is_active = fraction >= active_start && fraction <= active_end;
1953                    let color = if is_active {
1954                        colors.active_tick
1955                    } else {
1956                        colors.inactive_tick
1957                    };
1958                    slider_tick()
1959                        .diameter(centered_layout.base.stop_indicator_diameter)
1960                        .color(color);
1961                }
1962            }
1963            if centered_layout.base.show_stop_indicator {
1964                stop_indicator()
1965                    .layout(centered_layout.base)
1966                    .colors(colors)
1967                    .count(2);
1968            }
1969            slider_handle()
1970                .layout(centered_layout.base)
1971                .width(handle_width)
1972                .colors(colors)
1973                .focused(focused)
1974                .indicator_anchor_optional(indicator_anchor);
1975        });
1976}
1977
1978fn measure_range_slider(
1979    input: &mut SliderMeasureScope<'_, '_>,
1980    result: &mut LayoutResult,
1981    layout: RangeSliderLayout,
1982    args: RangeSliderMeasureArgs,
1983) -> Result<LayoutResult, MeasurementError> {
1984    let children = input.children();
1985    let self_width = layout.base.component_width;
1986    let self_height = layout.base.component_height;
1987    let track_y = layout.base.track_y;
1988
1989    let left_inactive = children[0];
1990    let active = children[1];
1991    let right_inactive = children[2];
1992    let mut current_index = 3;
1993    let tick_count = if args.steps == 0 { 0 } else { args.steps + 2 };
1994    let tick_ids = &children[current_index..current_index + tick_count];
1995    current_index += tick_count;
1996
1997    let (stop_start_id, stop_end_id) = if layout.base.show_stop_indicator {
1998        let start_id = children.get(current_index).copied();
1999        let end_id = children.get(current_index + 1).copied();
2000        current_index += 2;
2001        (start_id, end_id)
2002    } else {
2003        (None, None)
2004    };
2005
2006    let handle_start = children[current_index];
2007    let handle_end = children[current_index + 1];
2008
2009    let segments = layout.segments(
2010        args.start,
2011        args.end,
2012        args.start_handle_width,
2013        args.end_handle_width,
2014    );
2015
2016    left_inactive.measure(
2017        input,
2018        &Constraint::new(
2019            AxisConstraint::exact(segments.left_inactive.1),
2020            AxisConstraint::exact(layout.base.track_height),
2021        ),
2022    )?;
2023    result.place_child(
2024        left_inactive,
2025        PxPosition::new(segments.left_inactive.0, track_y),
2026    );
2027
2028    active.measure(
2029        input,
2030        &Constraint::new(
2031            AxisConstraint::exact(segments.active.1),
2032            AxisConstraint::exact(layout.base.track_height),
2033        ),
2034    )?;
2035    result.place_child(active, PxPosition::new(segments.active.0, track_y));
2036
2037    right_inactive.measure(
2038        input,
2039        &Constraint::new(
2040            AxisConstraint::exact(segments.right_inactive.1),
2041            AxisConstraint::exact(layout.base.track_height),
2042        ),
2043    )?;
2044    result.place_child(
2045        right_inactive,
2046        PxPosition::new(segments.right_inactive.0, track_y),
2047    );
2048
2049    let start_handle_constraint = Constraint::new(
2050        AxisConstraint::exact(args.start_handle_width),
2051        AxisConstraint::exact(layout.base.handle_height),
2052    );
2053    let end_handle_constraint = Constraint::new(
2054        AxisConstraint::exact(args.end_handle_width),
2055        AxisConstraint::exact(layout.base.handle_height),
2056    );
2057
2058    handle_start.measure(input, &start_handle_constraint)?;
2059    result.place_child(
2060        handle_start,
2061        PxPosition::from_f32_xy(
2062            segments.start_handle_center.x - args.start_handle_width.to_f32() / 2.0,
2063            layout.base.handle_y.0 as f32,
2064        ),
2065    );
2066
2067    handle_end.measure(input, &end_handle_constraint)?;
2068    result.place_child(
2069        handle_end,
2070        PxPosition::from_f32_xy(
2071            segments.end_handle_center.x - args.end_handle_width.to_f32() / 2.0,
2072            layout.base.handle_y.0 as f32,
2073        ),
2074    );
2075
2076    if args.steps > 0 {
2077        let tick_size = layout.base.stop_indicator_diameter;
2078        let tick_constraint = Constraint::new(
2079            AxisConstraint::exact(tick_size),
2080            AxisConstraint::exact(tick_size),
2081        );
2082        let tick_offset = layout.base.center_child_offset(tick_size);
2083
2084        let component_width = layout.base.component_width.to_f32();
2085        let gap = layout.base.handle_gap.to_f32();
2086        let start_half = args.start_handle_width.to_f32() / 2.0;
2087        let end_half = args.end_handle_width.to_f32() / 2.0;
2088        let track_total = (component_width - start_half - end_half - gap * 2.0).max(0.0);
2089        let start_x = gap + start_half;
2090        for (i, tick_id) in tick_ids.iter().copied().enumerate() {
2091            tick_id.measure(input, &tick_constraint)?;
2092            let fraction = i as f32 / (args.steps as f32 + 1.0);
2093            let tick_center_x = start_x + fraction * track_total;
2094            if (layout.base.show_stop_indicator && (i == 0 || i + 1 == tick_count))
2095                || ![
2096                    segments.left_inactive,
2097                    segments.active,
2098                    segments.right_inactive,
2099                ]
2100                .into_iter()
2101                .any(|segment| layout.base.contains_marker(segment, tick_center_x.round()))
2102            {
2103                continue;
2104            }
2105            result.place_child(
2106                tick_id,
2107                PxPosition::new(
2108                    Px(tick_center_x.round() as i32 - tick_offset.0),
2109                    layout.base.stop_indicator_y,
2110                ),
2111            );
2112        }
2113    }
2114
2115    if layout.base.show_stop_indicator {
2116        let (Some(stop_start_id), Some(stop_end_id)) = (stop_start_id, stop_end_id) else {
2117            return Err(MeasurementError::MeasureFnFailed(
2118                "Missing stop indicator children".to_string(),
2119            ));
2120        };
2121
2122        let stop_size = layout.base.stop_indicator_diameter;
2123        let stop_constraint = Constraint::new(
2124            AxisConstraint::exact(stop_size),
2125            AxisConstraint::exact(stop_size),
2126        );
2127        stop_start_id.measure(input, &stop_constraint)?;
2128
2129        let stop_offset = layout.base.center_child_offset(stop_size);
2130        let corner = layout.base.track_corner_radius.to_px();
2131        let start_stop_x = corner;
2132
2133        if layout
2134            .base
2135            .contains_marker(segments.left_inactive, start_stop_x.to_f32())
2136        {
2137            result.place_child(
2138                stop_start_id,
2139                PxPosition::new(
2140                    Px(start_stop_x.0 - stop_offset.0),
2141                    layout.base.stop_indicator_y,
2142                ),
2143            );
2144        }
2145
2146        stop_end_id.measure(input, &stop_constraint)?;
2147        let end_stop_x = Px(self_width.0 - corner.0);
2148
2149        if layout
2150            .base
2151            .contains_marker(segments.right_inactive, end_stop_x.to_f32())
2152        {
2153            result.place_child(
2154                stop_end_id,
2155                PxPosition::new(
2156                    Px(end_stop_x.0 - stop_offset.0),
2157                    layout.base.stop_indicator_y,
2158                ),
2159            );
2160        }
2161    }
2162
2163    result.size = ComputedData {
2164        width: self_width,
2165        height: self_height,
2166    };
2167    Ok(result.clone())
2168}
2169
2170/// # range_slider
2171///
2172/// Select interval endpoints using two independently focusable handles.
2173///
2174/// ## Usage
2175///
2176/// Use for filtering by range, setting minimum and maximum values, or defining
2177/// an interval.
2178///
2179/// Arrow keys move one step. Home and End select the allowed bounds, and
2180/// PageUp and PageDown move up to ten steps. Continuous sliders use 1% keyboard
2181/// steps. Tab keeps a fixed start-then-end handle order; handles cannot cross
2182/// their neighbor.
2183///
2184/// Supplied endpoints are clamped to 0–1 and rounded to the nearest discrete
2185/// ticks. Ties round upward and NaN resolves to zero. An end below the
2186/// normalized start is raised to the start. This normalization does not call
2187/// `on_change` or modify caller-owned state.
2188///
2189/// ## Parameters
2190///
2191/// - `modifier` — slider placement and size constraints.
2192/// - `value`, `on_change` — controlled interval and change callback.
2193/// - `orientation`, `reverse_direction` — physical axis and increasing-value
2194///   direction.
2195/// - `size`, `thumb_diameter` — track size and handle width.
2196/// - `active_track_color`, `inactive_track_color`, `thumb_color` — optional
2197///   colors.
2198/// - `active_tick_color`, `inactive_tick_color` — optional tick foregrounds;
2199///   inactive tick color also applies to end stops.
2200/// - `disabled` — whether pointer, keyboard, and accessibility input are
2201///   disabled.
2202/// - `accessibility_label`, `accessibility_description` — accessible control
2203///   name and details.
2204/// - `show_stop_indicator`, `steps` — end stops and the number of intermediate
2205///   steps.
2206/// - `controller` — optional shared focus and drag state.
2207/// - `start_accessibility_label`, `end_accessibility_label` — optional names
2208///   for each handle.
2209///
2210/// - `value_indicator` — hidden by default; show focused/dragged handles or all
2211///   enabled handles. Pass the host received from [`slider_value_indicator_provider`].
2212/// - `value_indicator_host` — overlay target received from the provider slot.
2213/// - `start_value_label`, `end_value_label` — optional formatted labels and
2214///   screen-reader values; defaults are rounded percentages without percent
2215///   signs.
2216///
2217/// ## Examples
2218///
2219/// ```
2220/// # use tessera_ui::tessera;
2221/// # #[tessera]
2222/// # fn component() {
2223/// use tessera_components::modifier::ModifierExt as _;
2224/// use tessera_components::slider::{SliderOrientation, range_slider};
2225/// use tessera_ui::{Dp, Modifier, remember};
2226/// # use tessera_components::theme::{MaterialTheme, material_theme};
2227/// let range_value = remember(|| (0.2f32, 0.8f32));
2228///
2229/// # material_theme()
2230/// #     .theme(|| MaterialTheme::default())
2231/// #     .child(move || {
2232/// range_slider()
2233///     .modifier(Modifier::new().height(Dp(200.0)))
2234///     .orientation(SliderOrientation::Vertical)
2235///     .reverse_direction(true)
2236///     .value(range_value.get())
2237///     .on_change(move |(start, end)| {
2238///         range_value.set((start, end));
2239///     });
2240/// assert_eq!(range_value.get(), (0.2, 0.8));
2241/// # });
2242/// # }
2243/// # tessera_ui::testing::layout_test(component).run();
2244/// ```
2245#[tessera]
2246pub fn range_slider(
2247    modifier: Option<Modifier>,
2248    value: Option<(f32, f32)>,
2249    on_change: Option<CallbackWith<(f32, f32)>>,
2250    size: Option<SliderSize>,
2251    orientation: Option<SliderOrientation>,
2252    reverse_direction: Option<bool>,
2253    active_track_color: Option<Color>,
2254    inactive_track_color: Option<Color>,
2255    active_tick_color: Option<Color>,
2256    inactive_tick_color: Option<Color>,
2257    thumb_diameter: Option<Dp>,
2258    thumb_color: Option<Color>,
2259    disabled: Option<bool>,
2260    #[prop(into)] accessibility_label: Option<String>,
2261    #[prop(into)] accessibility_description: Option<String>,
2262    show_stop_indicator: Option<bool>,
2263    steps: Option<usize>,
2264    value_indicator: Option<SliderValueIndicatorVisibility>,
2265    value_indicator_host: Option<SliderValueIndicatorHost>,
2266    #[prop(into)] start_value_label: Option<String>,
2267    #[prop(into)] end_value_label: Option<String>,
2268    controller: Option<State<RangeSliderController>>,
2269    #[prop(into)] start_accessibility_label: Option<String>,
2270    #[prop(into)] end_accessibility_label: Option<String>,
2271) {
2272    let scheme = use_context::<MaterialTheme>()
2273        .map(|theme| theme.get().color_scheme)
2274        .unwrap_or_else(|| MaterialTheme::default().color_scheme);
2275    let defaults = RangeSliderConfig::default();
2276    let value = value.unwrap_or(defaults.value);
2277    let size = size.unwrap_or(defaults.size);
2278    let disabled = disabled.unwrap_or(defaults.disabled);
2279    let steps = steps.unwrap_or(defaults.steps);
2280    let args = range_slider_config_from_params(
2281        RangeSliderParams {
2282            axis: SliderAxis {
2283                orientation: orientation.unwrap_or_default(),
2284                reversed: reverse_direction.unwrap_or(false),
2285            },
2286            modifier,
2287            value,
2288            on_change,
2289            size,
2290            active_track_color,
2291            inactive_track_color,
2292            active_tick_color,
2293            inactive_tick_color,
2294            thumb_diameter,
2295            thumb_color,
2296            disabled,
2297            accessibility_label,
2298            accessibility_description,
2299            show_stop_indicator,
2300            steps,
2301            value_indicator,
2302            value_indicator_host,
2303            start_value_label,
2304            end_value_label,
2305            controller,
2306            start_accessibility_label,
2307            end_accessibility_label,
2308        },
2309        slider_theme_colors(&scheme),
2310    );
2311    let state = if let Some(controller) = args.controller {
2312        controller
2313    } else {
2314        remember(RangeSliderController::new)
2315    };
2316    let tap_recognizer = remember(TapRecognizer::default);
2317    let drag_recognizer = remember(DragRecognizer::default);
2318    let mut resolved_args = args;
2319    resolved_args.controller = Some(state);
2320    range_slider_content()
2321        .args(resolved_args)
2322        .tap_recognizer(tap_recognizer)
2323        .drag_recognizer(drag_recognizer);
2324}
2325
2326#[tessera]
2327fn range_slider_content(
2328    args: Option<RangeSliderConfig>,
2329    tap_recognizer: Option<State<TapRecognizer>>,
2330    drag_recognizer: Option<State<DragRecognizer>>,
2331) {
2332    let args = args.expect("range_slider_content requires args to be set");
2333    let tap_recognizer =
2334        tap_recognizer.expect("range_slider_content requires tap recognizer to be set");
2335    let drag_recognizer =
2336        drag_recognizer.expect("range_slider_content requires drag recognizer to be set");
2337    let state = args
2338        .controller
2339        .expect("range_slider_content requires controller to be set");
2340    let dummy_slider_args = SliderConfig {
2341        axis: args.axis,
2342        size: args.size,
2343        show_stop_indicator: args.show_stop_indicator,
2344        ..SliderConfig::default()
2345    };
2346    let initial_width = fallback_component_width(&dummy_slider_args);
2347    let slider = dummy_slider_args.clone();
2348    let range_layout = range_slider_layout(&args, initial_width);
2349
2350    let start = args.value.0.clamp(0.0, 1.0);
2351    let end = args.value.1.clamp(start, 1.0);
2352    let keyboard_values = remember(|| (start, end));
2353    if keyboard_values.get() != (start, end) {
2354        keyboard_values.set((start, end));
2355    }
2356    let start_space_held = remember(|| false);
2357    let end_space_held = remember(|| false);
2358    let (start_focused, end_focused) =
2359        state.with(|s| (s.focus_start.is_focused(), s.focus_end.is_focused()));
2360    if start_space_held.get() && !start_focused {
2361        start_space_held.set(false);
2362    }
2363    if end_space_held.get() && !end_focused {
2364        end_space_held.set(false);
2365    }
2366
2367    let base_handle_width = args.thumb_diameter.to_px();
2368    let pressed_handle_width = Px((base_handle_width.0 / 2).max(1));
2369    let (start_interacting, end_interacting) = state.with(|s| {
2370        (
2371            s.is_dragging_start || s.focus_start.is_focused(),
2372            s.is_dragging_end || s.focus_end.is_focused(),
2373        )
2374    });
2375    let start_handle_width = if start_interacting {
2376        pressed_handle_width
2377    } else {
2378        base_handle_width
2379    };
2380    let end_handle_width = if end_interacting {
2381        pressed_handle_width
2382    } else {
2383        base_handle_width
2384    };
2385
2386    let modifier = apply_range_slider_pointer_modifier(
2387        args.modifier.clone(),
2388        state,
2389        args.clone(),
2390        tap_recognizer,
2391        drag_recognizer,
2392        start,
2393        end,
2394    );
2395
2396    let indicator_anchors = if args.value_indicator != SliderValueIndicatorVisibility::Hidden {
2397        let start_anchor = remember(tessera_ui::LayoutCoordinates::default);
2398        let end_anchor = remember(tessera_ui::LayoutCoordinates::default);
2399        indicator::register_indicator()
2400            .host_optional(args.value_indicator_host)
2401            .anchor(start_anchor)
2402            .label(
2403                args.start_value_label
2404                    .clone()
2405                    .unwrap_or_else(|| default_value_label(start)),
2406            )
2407            .visible(
2408                args.value_indicator
2409                    .visible(start_interacting, args.disabled),
2410            )
2411            .orientation(args.axis.orientation)
2412            .interacting(start_interacting);
2413        indicator::register_indicator()
2414            .host_optional(args.value_indicator_host)
2415            .anchor(end_anchor)
2416            .label(
2417                args.end_value_label
2418                    .clone()
2419                    .unwrap_or_else(|| default_value_label(end)),
2420            )
2421            .visible(args.value_indicator.visible(end_interacting, args.disabled))
2422            .orientation(args.axis.orientation)
2423            .interacting(end_interacting);
2424        [Some(start_anchor), Some(end_anchor)]
2425    } else {
2426        [None, None]
2427    };
2428    let next = (
2429        args.clone(),
2430        range_layout,
2431        start,
2432        end,
2433        start_handle_width,
2434        end_handle_width,
2435        indicator_anchors,
2436    );
2437    let render_data = remember(|| next.clone());
2438    if render_data.with(|current| current != &next) {
2439        render_data.set(next);
2440    }
2441    layout()
2442        .modifier(modifier)
2443        .policy(RangeSlider {
2444            args: args.clone(),
2445            slider,
2446            start,
2447            end,
2448            start_handle_width,
2449            end_handle_width,
2450        })
2451        .child(move || {
2452            let (
2453                args,
2454                range_layout,
2455                start,
2456                end,
2457                start_handle_width,
2458                end_handle_width,
2459                indicator_anchors,
2460            ) = render_data.get();
2461            let state = args.controller.expect("range slider controller");
2462            let scheme = use_context::<MaterialTheme>()
2463                .map(|theme| theme.get().color_scheme)
2464                .unwrap_or_else(|| MaterialTheme::default().color_scheme);
2465            let colors = range_slider_colors(&args, &scheme);
2466
2467            split_tracks().layout(range_layout.base).colors(colors);
2468            if args.steps > 0 {
2469                for fraction in tick_fractions(args.steps) {
2470                    let is_active = fraction >= start && fraction <= end;
2471                    let color = if is_active {
2472                        colors.active_tick
2473                    } else {
2474                        colors.inactive_tick
2475                    };
2476                    slider_tick()
2477                        .diameter(range_layout.base.stop_indicator_diameter)
2478                        .color(color);
2479                }
2480            }
2481            if range_layout.base.show_stop_indicator {
2482                stop_indicator()
2483                    .layout(range_layout.base)
2484                    .colors(colors)
2485                    .count(2);
2486            }
2487
2488            let start_thumb_args = RangeSliderThumbProps {
2489                thumb_layout: range_layout.base,
2490                handle_width: start_handle_width,
2491                colors,
2492                focus: state.with(|s| s.focus_start),
2493                accessibility: RangeThumbAccessibility {
2494                    key: "range_slider_start_thumb",
2495                    value_label: args.start_value_label.clone().or_else(|| {
2496                        (args.value_indicator != SliderValueIndicatorVisibility::Hidden)
2497                            .then(|| format!("{}%", default_value_label(start)))
2498                    }),
2499                    axis: args.axis,
2500                    label: Some(range_thumb_label(&args, true)),
2501                    description: args.accessibility_description.clone(),
2502                    fallback_description: I18N.text("md3-range-start"),
2503                    steps: args.steps,
2504                    disabled: args.disabled,
2505                    value: start,
2506                    min: 0.0,
2507                    max: end,
2508                    keyboard_model: SliderKeyboardModel::Start(keyboard_values),
2509                    space_held: start_space_held,
2510                    on_change: CallbackWith::new(move |new_start: f32| {
2511                        let on_change = render_data.with(|(args, ..)| args.on_change);
2512                        if keyboard_values.get().0 != new_start {
2513                            keyboard_values.with_mut(|value| value.0 = new_start.min(value.1));
2514                        }
2515                        on_change.call(keyboard_values.get());
2516                    }),
2517                },
2518            };
2519            range_slider_thumb()
2520                .thumb_layout(start_thumb_args.thumb_layout)
2521                .handle_width(start_thumb_args.handle_width)
2522                .colors(start_thumb_args.colors)
2523                .focus(start_thumb_args.focus)
2524                .accessibility(start_thumb_args.accessibility)
2525                .indicator_anchor_optional(indicator_anchors[0]);
2526
2527            let end_thumb_args = RangeSliderThumbProps {
2528                thumb_layout: range_layout.base,
2529                handle_width: end_handle_width,
2530                colors,
2531                focus: state.with(|s| s.focus_end),
2532                accessibility: RangeThumbAccessibility {
2533                    key: "range_slider_end_thumb",
2534                    value_label: args.end_value_label.clone().or_else(|| {
2535                        (args.value_indicator != SliderValueIndicatorVisibility::Hidden)
2536                            .then(|| format!("{}%", default_value_label(end)))
2537                    }),
2538                    axis: args.axis,
2539                    label: Some(range_thumb_label(&args, false)),
2540                    description: args.accessibility_description.clone(),
2541                    fallback_description: I18N.text("md3-range-end"),
2542                    steps: args.steps,
2543                    disabled: args.disabled,
2544                    value: end,
2545                    min: start,
2546                    max: 1.0,
2547                    keyboard_model: SliderKeyboardModel::End(keyboard_values),
2548                    space_held: end_space_held,
2549                    on_change: CallbackWith::new(move |new_end: f32| {
2550                        let on_change = render_data.with(|(args, ..)| args.on_change);
2551                        if keyboard_values.get().1 != new_end {
2552                            keyboard_values.with_mut(|value| value.1 = new_end.max(value.0));
2553                        }
2554                        on_change.call(keyboard_values.get());
2555                    }),
2556                },
2557            };
2558            range_slider_thumb()
2559                .thumb_layout(end_thumb_args.thumb_layout)
2560                .handle_width(end_thumb_args.handle_width)
2561                .colors(end_thumb_args.colors)
2562                .focus(end_thumb_args.focus)
2563                .accessibility(end_thumb_args.accessibility)
2564                .indicator_anchor_optional(indicator_anchors[1]);
2565        });
2566}
2567
2568#[cfg(test)]
2569mod tests {
2570    use tessera_ui::{
2571        HeadlessConfig, HeadlessRenderer,
2572        testing::{layout_test, pump_layout_test_session, start_layout_test_session},
2573    };
2574
2575    use crate::{column::column, modifier::ModifierExt as _, theme::material_theme};
2576
2577    use super::*;
2578
2579    #[derive(Clone, Default)]
2580    struct Settings {
2581        steps: usize,
2582        disabled: bool,
2583        width: Option<f64>,
2584    }
2585
2586    #[derive(Clone, Copy, Default)]
2587    struct FixtureHandles {
2588        range: Option<State<(f32, f32)>>,
2589        settings: Option<State<Settings>>,
2590    }
2591
2592    struct SliderTestTag(&'static str);
2593
2594    impl SemanticsModifierNode for SliderTestTag {
2595        fn apply(
2596            &self,
2597            accessibility: &mut AccessibilityNode,
2598            _handler: &mut Option<AccessibilityActionHandler>,
2599        ) {
2600            accessibility.key = Some(self.0.to_string());
2601        }
2602    }
2603
2604    #[tessera]
2605    fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
2606        let settings = remember(Settings::default);
2607        let range = remember(|| (0.2_f32, 0.8_f32));
2608        let single = remember(|| 0.5_f32);
2609        let centered = remember(|| 0.5_f32);
2610        observed.with_mut(|handles| handles.settings = Some(settings));
2611        observed.with_mut(|handles| handles.range = Some(range));
2612        material_theme().child(move || {
2613            column().children(move || {
2614                slider()
2615                    .modifier(
2616                        Modifier::new()
2617                            .width(Dp(settings.with(|settings| settings.width.unwrap_or(280.0))))
2618                            .push_semantics(SliderTestTag("slider")),
2619                    )
2620                    .value(single.get())
2621                    .on_change(move |value| single.set(value))
2622                    .steps(settings.with(|settings| settings.steps))
2623                    .disabled(settings.with(|settings| settings.disabled))
2624                    .accessibility_label("Volume");
2625                centered_slider()
2626                    .modifier(
2627                        Modifier::new()
2628                            .width(Dp(settings.with(|settings| settings.width.unwrap_or(280.0))))
2629                            .push_semantics(SliderTestTag("centered_slider")),
2630                    )
2631                    .value(centered.get())
2632                    .on_change(move |value| centered.set(value))
2633                    .steps(settings.with(|settings| settings.steps))
2634                    .disabled(settings.with(|settings| settings.disabled))
2635                    .accessibility_label("Balance");
2636                range_slider()
2637                    .modifier(
2638                        Modifier::new()
2639                            .width(Dp(settings.with(|settings| settings.width.unwrap_or(280.0))))
2640                            .push_semantics(SliderTestTag("range_slider")),
2641                    )
2642                    .value(range.get())
2643                    .on_change(move |value| range.set(value))
2644                    .steps(settings.with(|settings| settings.steps))
2645                    .disabled(settings.with(|settings| settings.disabled))
2646                    .accessibility_label("Price interval")
2647                    .start_accessibility_label("Minimum price")
2648                    .end_accessibility_label("Maximum price");
2649            });
2650        });
2651    }
2652
2653    #[test]
2654    fn sliders_survive_zero_width_and_restore_their_values_and_bounds() {
2655        let observed = tessera_ui::testing::TestProbe::default();
2656        let mut session = start_layout_test_session(layout_test({
2657            let observed = observed.clone();
2658            move || {
2659                fixture(observed.clone());
2660            }
2661        }));
2662        pump_layout_test_session(&mut session, 0);
2663        for (frame, width) in [0.0, 1.0, 3.0, 5.0, 280.0].into_iter().enumerate() {
2664            observed
2665                .get()
2666                .settings
2667                .unwrap()
2668                .with_mut(|settings| settings.width = Some(width));
2669            let snapshot = pump_layout_test_session(&mut session, (frame as u64 + 1) * 16_666_667);
2670            for selector in ["slider", "centered_slider", "range_slider"] {
2671                assert_eq!(snapshot.node(selector).bounds().width, Dp(width).to_px());
2672            }
2673            assert_eq!(observed.get().range.unwrap().get(), (0.2, 0.8));
2674        }
2675    }
2676
2677    #[test]
2678    fn range_thumb_actions_preserve_the_latest_other_endpoint() {
2679        let observed = tessera_ui::testing::TestProbe::default();
2680        let mut session = start_layout_test_session(layout_test({
2681            let observed = observed.clone();
2682            move || fixture(observed.clone())
2683        }));
2684        pump_layout_test_session(&mut session, 0);
2685        let range = observed.get().range.expect("range state");
2686        range.set((0.3, 0.6));
2687        pump_layout_test_session(&mut session, 16_666_667);
2688        for (frame, label, expected) in [
2689            (2, "Minimum price", (0.35_f32, 0.6_f32)),
2690            (3, "Maximum price", (0.35_f32, 0.65_f32)),
2691        ] {
2692            let snapshot = session.inspect_accessibility().expect("slider tree");
2693            let target = snapshot.find(label, Role::Slider).expect("range thumb").0;
2694            assert!(
2695                session.dispatch_accessibility_action(tessera_ui::accesskit::ActionRequest {
2696                    action: Action::Increment,
2697                    target_node: target,
2698                    target_tree: tessera_ui::accesskit::TreeId::ROOT,
2699                    data: None,
2700                })
2701            );
2702            let actual = range.get();
2703            assert!((actual.0 - expected.0).abs() < 1e-6 && (actual.1 - expected.1).abs() < 1e-6);
2704            pump_layout_test_session(&mut session, frame * 16_666_667);
2705        }
2706        let snapshot = session.inspect_accessibility().expect("slider tree");
2707        let start = snapshot.find("Minimum price", Role::Slider).unwrap().1;
2708        let end = snapshot.find("Maximum price", Role::Slider).unwrap().1;
2709        assert!((start.max_numeric_value().unwrap() - 0.65).abs() < 1e-6);
2710        assert!((end.min_numeric_value().unwrap() - 0.35).abs() < 1e-6);
2711    }
2712
2713    #[test]
2714    fn changing_steps_updates_actual_actions_and_disabling_removes_them() {
2715        let observed = tessera_ui::testing::TestProbe::default();
2716        let mut session = start_layout_test_session(layout_test({
2717            let observed = observed.clone();
2718            move || fixture(observed.clone())
2719        }));
2720        pump_layout_test_session(&mut session, 0);
2721        let settings = observed.get().settings.expect("slider settings");
2722        settings.with_mut(|settings| settings.steps = 3);
2723        pump_layout_test_session(&mut session, 16_666_667);
2724        let snapshot = session.inspect_accessibility().expect("slider tree");
2725        let target = snapshot.find("Minimum price", Role::Slider).unwrap().0;
2726        assert!(
2727            session.dispatch_accessibility_action(tessera_ui::accesskit::ActionRequest {
2728                action: Action::Increment,
2729                target_node: target,
2730                target_tree: tessera_ui::accesskit::TreeId::ROOT,
2731                data: None,
2732            })
2733        );
2734        assert_eq!(observed.get().range.unwrap().get(), (0.5, 0.75));
2735        settings.with_mut(|settings| {
2736            settings.steps = 0;
2737            settings.disabled = true;
2738        });
2739        pump_layout_test_session(&mut session, 33_333_334);
2740        let snapshot = session.inspect_accessibility().expect("slider tree");
2741        for label in ["Minimum price", "Maximum price"] {
2742            let (target, node) = snapshot.find(label, Role::Slider).expect("disabled thumb");
2743            assert!(node.is_disabled());
2744            assert!(!node.supports_action(Action::Focus));
2745            assert!(!node.supports_action(Action::Increment));
2746            assert!(!node.supports_action(Action::Decrement));
2747            assert!(
2748                !session.dispatch_accessibility_action(tessera_ui::accesskit::ActionRequest {
2749                    action: Action::Increment,
2750                    target_node: target,
2751                    target_tree: tessera_ui::accesskit::TreeId::ROOT,
2752                    data: None,
2753                })
2754            );
2755        }
2756        assert_eq!(observed.get().range.unwrap().get(), (0.5, 0.75));
2757    }
2758
2759    fn check_centered_slider_gaps(samples: u32) {
2760        let observed = tessera_ui::testing::TestProbe::default();
2761        let mut renderer = HeadlessRenderer::new(
2762            Box::new({
2763                let observed = observed.clone();
2764                move || {
2765                    fixture(observed.clone());
2766                }
2767            }),
2768            vec![Box::new(crate::TesseraComponents)],
2769            HeadlessConfig {
2770                width: 400,
2771                height: 300,
2772                sample_count: samples,
2773                ..Default::default()
2774            },
2775        )
2776        .unwrap();
2777        for frames in [2, 12] {
2778            renderer.render_frames(frames);
2779            let pixels = renderer.read_pixels().unwrap();
2780            let pixel = |x: usize, y: usize| &pixels[(y * 400 + x) * 4..(y * 400 + x + 1) * 4];
2781            let background = pixel(300, 72);
2782            for y in 65..79 {
2783                for (left, right) in [(135, 144), (136, 143)] {
2784                    assert_eq!(
2785                        pixel(left, y),
2786                        background,
2787                        "left gap, MSAA {samples}, {frames} frames"
2788                    );
2789                    assert_eq!(
2790                        pixel(right, y),
2791                        background,
2792                        "right gap, MSAA {samples}, {frames} frames"
2793                    );
2794                }
2795                assert_ne!(pixel(140, y), background, "vertical handle remains visible");
2796            }
2797        }
2798    }
2799
2800    #[test]
2801    #[ignore = "requires a native GPU adapter"]
2802    fn centered_slider_gaps_before_and_after_caching_msaa1() {
2803        check_centered_slider_gaps(1);
2804    }
2805
2806    #[test]
2807    #[ignore = "requires a native GPU adapter"]
2808    fn centered_slider_gaps_before_and_after_caching_msaa4() {
2809        check_centered_slider_gaps(4);
2810    }
2811
2812    #[test]
2813    #[ignore = "requires a native GPU adapter"]
2814    fn published_slider_semantics_keep_distinct_handles_and_current_limits() {
2815        fn control<'a>(
2816            tree: &'a tessera_ui::accesskit::TreeUpdate,
2817            label: &str,
2818        ) -> (
2819            tessera_ui::accesskit::NodeId,
2820            &'a tessera_ui::accesskit::Node,
2821        ) {
2822            let (id, node) = tree
2823                .nodes
2824                .iter()
2825                .find(|(_, node)| node.label() == Some(label))
2826                .expect("named control");
2827            (*id, node)
2828        }
2829        let observed = tessera_ui::testing::TestProbe::default();
2830        let mut renderer = HeadlessRenderer::new(
2831            Box::new({
2832                let observed = observed.clone();
2833                move || {
2834                    fixture(observed.clone());
2835                }
2836            }),
2837            vec![Box::new(crate::TesseraComponents)],
2838            HeadlessConfig {
2839                width: 400,
2840                height: 300,
2841                ..Default::default()
2842            },
2843        )
2844        .unwrap();
2845        renderer.render_frames(2);
2846        let before = renderer.accessibility_snapshot().unwrap();
2847        assert_eq!(
2848            before
2849                .nodes
2850                .iter()
2851                .filter(|(_, node)| node.role() == Role::Slider)
2852                .count(),
2853            4
2854        );
2855        let (start_id, start) = control(&before, "Minimum price");
2856        let (end_id, end) = control(&before, "Maximum price");
2857        assert_eq!(start.max_numeric_value(), Some(0.8_f32 as f64));
2858        assert_eq!(end.min_numeric_value(), Some(0.2_f32 as f64));
2859        let (_, group) = control(&before, "Price interval");
2860        assert_eq!(group.role(), Role::Group);
2861        assert!(!group.is_hidden());
2862        assert!(group.children().contains(&start_id) && group.children().contains(&end_id));
2863        observed.get().range.unwrap().set((0.3, 0.6));
2864        renderer.render_frames(2);
2865        let after = renderer.accessibility_snapshot().unwrap();
2866        let (id, start) = control(&after, "Minimum price");
2867        assert_eq!(id, start_id);
2868        assert_eq!(start.max_numeric_value(), Some(0.6_f32 as f64));
2869        let (id, end) = control(&after, "Maximum price");
2870        assert_eq!(id, end_id);
2871        assert_eq!(end.min_numeric_value(), Some(0.3_f32 as f64));
2872        observed
2873            .get()
2874            .settings
2875            .unwrap()
2876            .with_mut(|settings| settings.steps = 3);
2877        renderer.render_frames(2);
2878        let stepped = renderer.accessibility_snapshot().unwrap();
2879        assert_eq!(control(&stepped, "Minimum price").0, start_id);
2880        assert_eq!(control(&stepped, "Maximum price").0, end_id);
2881        assert_eq!(
2882            control(&stepped, "Price interval").1.children(),
2883            &[start_id, end_id]
2884        );
2885        observed
2886            .get()
2887            .settings
2888            .unwrap()
2889            .with_mut(|settings| settings.disabled = true);
2890        renderer.render_frames(2);
2891        let disabled = renderer.accessibility_snapshot().unwrap();
2892        for (_, node) in disabled
2893            .nodes
2894            .iter()
2895            .filter(|(_, node)| node.role() == Role::Slider)
2896        {
2897            assert!(node.is_disabled());
2898            assert!(!node.supports_action(Action::Focus));
2899            assert!(!node.supports_action(Action::Increment));
2900        }
2901    }
2902}