Skip to main content

fret_ui_kit/primitives/
slider.rs

1//! Slider primitives (Radix-aligned outcomes).
2//!
3//! Upstream reference:
4//! - `repo-ref/primitives/packages/react/slider/src/slider.tsx`
5//!
6//! This module currently focuses on the single-thumb slider outcome used by shadcn/ui. Radix
7//! supports multi-thumb sliders; the headless helpers now cover Radix multi-thumb value updates,
8//! but the higher-level widget recipes may still use the single-thumb surface.
9
10use std::sync::{Arc, LazyLock};
11
12use fret_core::{Axis, KeyCode, SemanticsRole};
13use fret_runtime::Model;
14use fret_ui::element::SemanticsProps;
15use fret_ui::{ElementContext, UiHost};
16
17use crate::primitives::direction::LayoutDirection;
18
19pub use crate::declarative::slider::{
20    start_slider_drag_from_pointer_axis, start_slider_drag_from_pointer_x,
21    update_single_slider_model_from_pointer_axis, update_single_slider_model_from_pointer_x,
22    update_slider_model_from_pointer_axis, update_slider_model_from_pointer_x,
23};
24pub use crate::headless::slider::{
25    SliderValuesUpdate, closest_value_index, format_semantics_value, has_min_steps_between_values,
26    next_sorted_values, normalize_value, snap_value, steps_between_values,
27    update_multi_thumb_values,
28};
29
30static THUMB_LABEL_MINIMUM: LazyLock<Arc<str>> = LazyLock::new(|| Arc::<str>::from("Minimum"));
31static THUMB_LABEL_MAXIMUM: LazyLock<Arc<str>> = LazyLock::new(|| Arc::<str>::from("Maximum"));
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum SliderOrientation {
35    #[default]
36    Horizontal,
37    Vertical,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SliderSlideDirection {
42    FromLeft,
43    FromRight,
44    FromBottom,
45    FromTop,
46}
47
48pub fn slider_axis(orientation: SliderOrientation) -> Axis {
49    match orientation {
50        SliderOrientation::Horizontal => Axis::Horizontal,
51        SliderOrientation::Vertical => Axis::Vertical,
52    }
53}
54
55pub fn slider_slide_direction(
56    orientation: SliderOrientation,
57    dir: LayoutDirection,
58    inverted: bool,
59) -> SliderSlideDirection {
60    match orientation {
61        SliderOrientation::Horizontal => {
62            let is_direction_ltr = dir == LayoutDirection::Ltr;
63            let is_sliding_from_left =
64                (is_direction_ltr && !inverted) || (!is_direction_ltr && inverted);
65            if is_sliding_from_left {
66                SliderSlideDirection::FromLeft
67            } else {
68                SliderSlideDirection::FromRight
69            }
70        }
71        SliderOrientation::Vertical => {
72            let is_sliding_from_bottom = !inverted;
73            if is_sliding_from_bottom {
74                SliderSlideDirection::FromBottom
75            } else {
76                SliderSlideDirection::FromTop
77            }
78        }
79    }
80}
81
82pub fn slider_min_at_axis_start(
83    orientation: SliderOrientation,
84    dir: LayoutDirection,
85    inverted: bool,
86) -> bool {
87    match slider_slide_direction(orientation, dir, inverted) {
88        SliderSlideDirection::FromLeft | SliderSlideDirection::FromTop => true,
89        SliderSlideDirection::FromRight | SliderSlideDirection::FromBottom => false,
90    }
91}
92
93/// Convert a value-normalized percent `t` into a position percent measured from the axis start
94/// (left/top), matching Radix `startEdge` outcomes.
95pub fn slider_position_t(
96    orientation: SliderOrientation,
97    dir: LayoutDirection,
98    inverted: bool,
99    value_t: f32,
100) -> f32 {
101    let pos_t = if slider_min_at_axis_start(orientation, dir, inverted) {
102        value_t
103    } else {
104        1.0 - value_t
105    };
106    pos_t.clamp(0.0, 1.0)
107}
108
109pub fn slider_is_back_key(slide_direction: SliderSlideDirection, key: KeyCode) -> bool {
110    // Radix reference:
111    // `repo-ref/primitives/packages/react/slider/src/slider.tsx` (`BACK_KEYS`).
112    match slide_direction {
113        SliderSlideDirection::FromLeft => matches!(
114            key,
115            KeyCode::Home | KeyCode::PageDown | KeyCode::ArrowDown | KeyCode::ArrowLeft
116        ),
117        SliderSlideDirection::FromRight => matches!(
118            key,
119            KeyCode::Home | KeyCode::PageDown | KeyCode::ArrowDown | KeyCode::ArrowRight
120        ),
121        SliderSlideDirection::FromBottom => matches!(
122            key,
123            KeyCode::Home | KeyCode::PageDown | KeyCode::ArrowDown | KeyCode::ArrowLeft
124        ),
125        SliderSlideDirection::FromTop => matches!(
126            key,
127            KeyCode::Home | KeyCode::PageDown | KeyCode::ArrowUp | KeyCode::ArrowLeft
128        ),
129    }
130}
131
132pub fn slider_step_direction_for_key(
133    slide_direction: SliderSlideDirection,
134    key: KeyCode,
135) -> Option<f32> {
136    match key {
137        KeyCode::PageUp
138        | KeyCode::PageDown
139        | KeyCode::ArrowUp
140        | KeyCode::ArrowDown
141        | KeyCode::ArrowLeft
142        | KeyCode::ArrowRight => Some(if slider_is_back_key(slide_direction, key) {
143            -1.0
144        } else {
145            1.0
146        }),
147        _ => None,
148    }
149}
150
151/// Returns a values model that behaves like Radix `useControllableState` (`value` / `defaultValue`).
152pub fn slider_use_values_model<H: UiHost>(
153    cx: &mut ElementContext<'_, H>,
154    controlled: Option<Model<Vec<f32>>>,
155    default_value: impl FnOnce() -> Vec<f32>,
156) -> crate::primitives::controllable_state::ControllableModel<Vec<f32>> {
157    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
158}
159
160/// Semantics wrapper props for a slider root.
161pub fn slider_root_semantics(
162    label: Option<Arc<str>>,
163    value: f32,
164    disabled: bool,
165) -> SemanticsProps {
166    SemanticsProps {
167        role: SemanticsRole::Generic,
168        label,
169        value: Some(format_semantics_value(value)),
170        numeric_value: value.is_finite().then_some(value as f64),
171        disabled,
172        ..Default::default()
173    }
174}
175
176/// Semantics wrapper props for an interactive slider thumb (Radix `role="slider"`).
177pub fn slider_thumb_semantics(
178    label: Option<Arc<str>>,
179    value: f32,
180    disabled: bool,
181) -> SemanticsProps {
182    SemanticsProps {
183        role: SemanticsRole::Slider,
184        label,
185        value: Some(format_semantics_value(value)),
186        numeric_value: value.is_finite().then_some(value as f64),
187        disabled,
188        ..Default::default()
189    }
190}
191
192/// Returns the default thumb label used by Radix Slider when there are multiple thumbs.
193///
194/// Reference:
195/// - `repo-ref/primitives/packages/react/slider/src/slider.tsx` (`getLabel`).
196pub fn slider_thumb_default_label(index: usize, total_values: usize) -> Option<Arc<str>> {
197    if index >= total_values {
198        return None;
199    }
200
201    match total_values {
202        0 | 1 => None,
203        2 => match index {
204            0 => Some(THUMB_LABEL_MINIMUM.clone()),
205            1 => Some(THUMB_LABEL_MAXIMUM.clone()),
206            _ => None,
207        },
208        _ => Some(Arc::<str>::from(format!(
209            "Value {} of {}",
210            index.saturating_add(1),
211            total_values
212        ))),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    use std::cell::Cell;
221    use std::sync::Arc;
222
223    use fret_app::App;
224    use fret_core::{AppWindowId, Point, Px, Rect, Size};
225
226    fn bounds() -> Rect {
227        Rect::new(
228            Point::new(Px(0.0), Px(0.0)),
229            Size::new(Px(200.0), Px(120.0)),
230        )
231    }
232
233    #[test]
234    fn slider_root_semantics_exposes_value_string() {
235        let label = Arc::<str>::from("slider");
236        let value = 12.0;
237
238        let out = slider_root_semantics(Some(label.clone()), value, false);
239        assert_eq!(out.label.as_deref(), Some(label.as_ref()));
240        assert_eq!(out.value, Some(format_semantics_value(value)));
241        assert_eq!(out.disabled, false);
242    }
243
244    #[test]
245    fn slider_use_values_model_prefers_controlled_and_does_not_call_default() {
246        let window = AppWindowId::default();
247        let mut app = App::new();
248        let b = bounds();
249
250        let controlled = app.models_mut().insert(vec![0.25]);
251        let called = Cell::new(0);
252
253        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
254            let out = slider_use_values_model(cx, Some(controlled.clone()), || {
255                called.set(called.get() + 1);
256                vec![0.0]
257            });
258            assert!(out.is_controlled());
259            assert_eq!(out.model(), controlled);
260        });
261
262        assert_eq!(called.get(), 0);
263    }
264
265    #[test]
266    fn slider_slide_direction_matches_radix_rules() {
267        assert_eq!(
268            slider_slide_direction(SliderOrientation::Horizontal, LayoutDirection::Ltr, false),
269            SliderSlideDirection::FromLeft
270        );
271        assert_eq!(
272            slider_slide_direction(SliderOrientation::Horizontal, LayoutDirection::Rtl, false),
273            SliderSlideDirection::FromRight
274        );
275        assert_eq!(
276            slider_slide_direction(SliderOrientation::Horizontal, LayoutDirection::Ltr, true),
277            SliderSlideDirection::FromRight
278        );
279        assert_eq!(
280            slider_slide_direction(SliderOrientation::Horizontal, LayoutDirection::Rtl, true),
281            SliderSlideDirection::FromLeft
282        );
283
284        assert_eq!(
285            slider_slide_direction(SliderOrientation::Vertical, LayoutDirection::Ltr, false),
286            SliderSlideDirection::FromBottom
287        );
288        assert_eq!(
289            slider_slide_direction(SliderOrientation::Vertical, LayoutDirection::Rtl, false),
290            SliderSlideDirection::FromBottom
291        );
292        assert_eq!(
293            slider_slide_direction(SliderOrientation::Vertical, LayoutDirection::Ltr, true),
294            SliderSlideDirection::FromTop
295        );
296    }
297
298    #[test]
299    fn slider_step_direction_for_key_uses_back_key_table() {
300        assert_eq!(
301            slider_step_direction_for_key(SliderSlideDirection::FromLeft, KeyCode::ArrowLeft),
302            Some(-1.0)
303        );
304        assert_eq!(
305            slider_step_direction_for_key(SliderSlideDirection::FromLeft, KeyCode::ArrowRight),
306            Some(1.0)
307        );
308        assert_eq!(
309            slider_step_direction_for_key(SliderSlideDirection::FromRight, KeyCode::ArrowLeft),
310            Some(1.0)
311        );
312        assert_eq!(
313            slider_step_direction_for_key(SliderSlideDirection::FromRight, KeyCode::ArrowRight),
314            Some(-1.0)
315        );
316        assert_eq!(
317            slider_step_direction_for_key(SliderSlideDirection::FromTop, KeyCode::ArrowUp),
318            Some(-1.0)
319        );
320        assert_eq!(
321            slider_step_direction_for_key(SliderSlideDirection::FromBottom, KeyCode::ArrowDown),
322            Some(-1.0)
323        );
324    }
325
326    #[test]
327    fn slider_thumb_default_label_matches_radix_get_label() {
328        assert_eq!(slider_thumb_default_label(0, 1), None);
329        assert_eq!(slider_thumb_default_label(0, 2).as_deref(), Some("Minimum"));
330        assert_eq!(slider_thumb_default_label(1, 2).as_deref(), Some("Maximum"));
331        assert_eq!(
332            slider_thumb_default_label(0, 3).as_deref(),
333            Some("Value 1 of 3")
334        );
335        assert_eq!(
336            slider_thumb_default_label(2, 3).as_deref(),
337            Some("Value 3 of 3")
338        );
339        assert_eq!(slider_thumb_default_label(3, 3), None);
340    }
341}