1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum SliderOrientation {
61 #[default]
63 Horizontal,
64 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
590pub 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 pub fn new() -> Self {
608 Self {
609 is_dragging: false,
610 focus: FocusRequester::new(),
611 is_hovered: false,
612 }
613 }
614
615 pub fn is_dragging(&self) -> bool {
617 self.is_dragging
618 }
619
620 pub fn set_dragging(&mut self, dragging: bool) {
622 self.is_dragging = dragging;
623 }
624
625 pub fn request_focus(&mut self) {
627 self.focus.request_focus();
628 }
629
630 pub fn clear_focus(&mut self) {
632 self.focus.unfocus();
633 }
634
635 pub fn is_focused(&self) -> bool {
637 self.focus.is_focused()
638 }
639
640 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
654pub enum SliderSize {
655 #[default]
657 ExtraSmall,
658 Small,
660 Medium,
662 Large,
664 ExtraLarge,
666}
667
668#[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 pub modifier: Modifier,
678 pub value: f32,
680 pub on_change: CallbackWith<f32>,
682 pub size: SliderSize,
684 pub active_track_color: Color,
686 pub inactive_track_color: Color,
688 pub active_tick_color: Color,
690 pub inactive_tick_color: Color,
692 pub thumb_diameter: Dp,
694 pub thumb_color: Color,
696 pub disabled: bool,
698 pub accessibility_label: Option<String>,
700 pub accessibility_description: Option<String>,
702 pub show_stop_indicator: bool,
704 pub steps: usize,
709 pub inset_icon: Option<Painter>,
712 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#[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 pub modifier: Modifier,
757 pub value: (f32, f32),
759
760 pub on_change: CallbackWith<(f32, f32)>,
762
763 pub size: SliderSize,
765
766 pub active_track_color: Color,
768
769 pub inactive_track_color: Color,
771 pub active_tick_color: Color,
773 pub inactive_tick_color: Color,
775
776 pub thumb_diameter: Dp,
778
779 pub thumb_color: Color,
781
782 pub disabled: bool,
784 pub accessibility_label: Option<String>,
786 pub accessibility_description: Option<String>,
788
789 pub show_stop_indicator: bool,
791 pub steps: usize,
796 pub controller: Option<State<RangeSliderController>>,
801 pub start_accessibility_label: Option<String>,
803 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#[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
875fn 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#[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 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 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 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 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 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 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#[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#[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}