Skip to main content

freya_components/
slider.rs

1use freya_core::prelude::*;
2use torin::prelude::*;
3
4use crate::{
5    define_theme,
6    get_theme,
7};
8
9define_theme! {
10    %[component]
11    pub Slider {
12        %[fields]
13        background: Color,
14        thumb_background: Color,
15        thumb_inner_background: Color,
16        border_fill: Color,
17    }
18}
19
20/// Slider component.
21///
22/// You must pass a percentage from 0.0 to 100.0 and listen for value changes with `on_moved` and then decide if this changes are applicable,
23/// and if so, apply them.
24///
25/// # Example
26/// ```rust
27/// # use freya::prelude::*;
28/// fn app() -> impl IntoElement {
29///     let mut percentage = use_state(|| 25.0);
30///
31///     Slider::new(move |per| percentage.set(per)).value(percentage())
32/// }
33/// ```
34/// See the [interactive components demo](https://freyaui.dev/demo).
35#[derive(Clone, PartialEq)]
36pub struct Slider {
37    pub(crate) theme: Option<SliderThemePartial>,
38    value: f64,
39    on_moved: EventHandler<f64>,
40    size: Size,
41    direction: Direction,
42    enabled: bool,
43    scroll_enabled: bool,
44    cursor_icon: Option<CursorIcon>,
45    step: Option<f64>,
46    key: DiffKey,
47}
48
49impl KeyExt for Slider {
50    fn key_mut(&mut self) -> &mut DiffKey {
51        &mut self.key
52    }
53}
54
55impl Slider {
56    pub fn new(on_moved: impl Into<EventHandler<f64>>) -> Self {
57        Self {
58            theme: None,
59            value: 0.0,
60            on_moved: on_moved.into(),
61            size: Size::fill(),
62            direction: Direction::Horizontal,
63            enabled: true,
64            scroll_enabled: true,
65            cursor_icon: None,
66            step: None,
67            key: DiffKey::None,
68        }
69    }
70
71    pub fn enabled(mut self, enabled: impl Into<bool>) -> Self {
72        self.enabled = enabled.into();
73        self
74    }
75
76    /// Control whether the value can be changed by scrolling with the mouse wheel. Enabled by default.
77    ///
78    /// You might want to disable it when the slider is inside a scrollable container.
79    pub fn scroll_enabled(mut self, scroll_enabled: impl Into<bool>) -> Self {
80        self.scroll_enabled = scroll_enabled.into();
81        self
82    }
83
84    pub fn value(mut self, value: f64) -> Self {
85        self.value = value.clamp(0.0, 100.0);
86        self
87    }
88
89    pub fn theme(mut self, theme: SliderThemePartial) -> Self {
90        self.theme = Some(theme);
91        self
92    }
93
94    pub fn size(mut self, size: Size) -> Self {
95        self.size = size;
96        self
97    }
98
99    pub fn direction(mut self, direction: Direction) -> Self {
100        self.direction = direction;
101        self
102    }
103
104    /// Override the cursor icon shown when hovering over this component while enabled.
105    pub fn cursor(mut self, cursor_icon: impl Into<Option<CursorIcon>>) -> Self {
106        self.cursor_icon = cursor_icon.into();
107        self
108    }
109
110    /// Snap the values emitted by `on_moved` to multiples of the given step.
111    pub fn step(mut self, step: f64) -> Self {
112        self.step = Some(step);
113        self
114    }
115}
116
117impl Component for Slider {
118    fn render(&self) -> impl IntoElement {
119        let theme = get_theme!(&self.theme, SliderThemePreference, "slider");
120        let a11y_id = use_a11y();
121        let focus = use_focus(a11y_id);
122        let mut clicking = use_state(|| false);
123        let mut size = use_state(Area::default);
124
125        let direction_is_vertical = self.direction == Direction::Vertical;
126        let value = self.value;
127        let on_moved = self.on_moved.clone();
128
129        let step = self.step.filter(|step| *step > 0.0);
130        let snap = move |value: f64| -> f64 {
131            let value = value.clamp(0.0, 100.0);
132            match step {
133                Some(step) => ((value / step).round() * step).min(100.0),
134                None => value,
135            }
136        };
137        let keyboard_step = step.unwrap_or(4.0);
138
139        let on_key_down = {
140            let on_moved = self.on_moved.clone();
141            move |e: Event<KeyboardEventData>| match e.key {
142                Key::Named(NamedKey::ArrowLeft) if !direction_is_vertical => {
143                    e.stop_propagation();
144                    on_moved.call(snap(value - keyboard_step));
145                }
146                Key::Named(NamedKey::ArrowRight) if !direction_is_vertical => {
147                    e.stop_propagation();
148                    on_moved.call(snap(value + keyboard_step));
149                }
150                Key::Named(NamedKey::ArrowUp) if direction_is_vertical => {
151                    e.stop_propagation();
152                    on_moved.call(snap(value + keyboard_step));
153                }
154                Key::Named(NamedKey::ArrowDown) if direction_is_vertical => {
155                    e.stop_propagation();
156                    on_moved.call(snap(value - keyboard_step));
157                }
158                _ => {}
159            }
160        };
161
162        let calc_percentage = move |x: f64, y: f64| -> f64 {
163            let pct = if direction_is_vertical {
164                let y = y - 8.0;
165                100. - (y / (size.read().height() as f64 - 15.0) * 100.0)
166            } else {
167                let x = x - 8.0;
168                x / (size.read().width() as f64 - 15.) * 100.0
169            };
170            snap(pct)
171        };
172
173        let on_pointer_down = {
174            let on_moved = self.on_moved.clone();
175            move |e: Event<PointerEventData>| {
176                if !e.data().is_primary() {
177                    return;
178                }
179                a11y_id.request_focus();
180                clicking.set(true);
181                e.stop_propagation();
182                let coordinates = e.element_location();
183                on_moved.call(calc_percentage(coordinates.x, coordinates.y));
184            }
185        };
186
187        let on_global_pointer_up = move |_: Event<PointerEventData>| {
188            clicking.set(false);
189        };
190
191        let on_capture_global_pointer_move = move |e: Event<PointerEventData>| {
192            if *clicking.peek() {
193                e.prevent_default();
194                let coordinates = e.global_location();
195                on_moved.call(calc_percentage(
196                    coordinates.x - size.read().min_x() as f64,
197                    coordinates.y - size.read().min_y() as f64,
198                ));
199            }
200        };
201
202        let on_wheel = {
203            let on_moved = self.on_moved.clone();
204            move |e: Event<WheelEventData>| {
205                if e.delta_y == 0.0 {
206                    return;
207                }
208                e.stop_propagation();
209                let delta = match step {
210                    Some(step) => step * e.delta_y.signum(),
211                    None => e.delta_y * 0.1,
212                };
213                on_moved.call(snap(value + delta));
214            }
215        };
216
217        let border = if focus() == Focus::Keyboard {
218            Border::new()
219                .fill(theme.border_fill)
220                .width(2.)
221                .alignment(BorderAlignment::Inner)
222        } else {
223            Border::new()
224                .fill(Color::TRANSPARENT)
225                .width(0.)
226                .alignment(BorderAlignment::Inner)
227        };
228
229        let (slider_width, slider_height) = if direction_is_vertical {
230            (Size::auto(), self.size.clone())
231        } else {
232            (self.size.clone(), Size::auto())
233        };
234
235        let (inner_slider_width, inner_slider_height) = if direction_is_vertical {
236            (Size::px(6.), self.size.clone())
237        } else {
238            (self.size.clone(), Size::px(6.))
239        };
240
241        let track_size = Size::func_data(
242            move |ctx| Some(value as f32 / 100. * (ctx.parent - 15.)),
243            &(value as i32),
244        );
245
246        let (track_width, track_height) = if direction_is_vertical {
247            (Size::px(6.), track_size)
248        } else {
249            (track_size, Size::px(6.))
250        };
251
252        let (thumb_offset_x, thumb_offset_y) = if direction_is_vertical {
253            (-6., 3.)
254        } else {
255            (-3., -6.)
256        };
257
258        let thumb_main_align = if direction_is_vertical {
259            Alignment::end()
260        } else {
261            Alignment::start()
262        };
263
264        let padding = if direction_is_vertical {
265            (0., 8.)
266        } else {
267            (8., 0.)
268        };
269
270        let thumb = rect()
271            .width(Size::fill())
272            .offset_x(thumb_offset_x)
273            .offset_y(thumb_offset_y)
274            .child(
275                rect()
276                    .width(Size::px(18.))
277                    .height(Size::px(18.))
278                    .corner_radius(50.)
279                    .background(theme.thumb_background.mul_if(!self.enabled, 0.85))
280                    .padding(4.)
281                    .child(
282                        rect()
283                            .expanded()
284                            .background(theme.thumb_inner_background.mul_if(!self.enabled, 0.85))
285                            .corner_radius(50.),
286                    ),
287            );
288
289        let track = rect()
290            .width(track_width)
291            .height(track_height)
292            .background(theme.thumb_inner_background.mul_if(!self.enabled, 0.85))
293            .corner_radius(50.);
294
295        rect()
296            .a11y_id(a11y_id)
297            .a11y_enabled(self.enabled)
298            .a11y_focusable(self.enabled)
299            .a11y_role(AccessibilityRole::Slider)
300            .on_sized(move |e: Event<SizedEventData>| size.set(e.area))
301            .maybe(self.enabled, |rect| {
302                rect.on_key_down(on_key_down)
303                    .on_pointer_down(on_pointer_down)
304                    .on_capture_global_pointer_move(on_capture_global_pointer_move)
305                    .on_global_pointer_up(on_global_pointer_up)
306                    .maybe(self.scroll_enabled, |el| el.on_wheel(on_wheel))
307            })
308            .cursor(if self.enabled {
309                self.cursor_icon
310            } else {
311                CursorIcon::NotAllowed.into()
312            })
313            .border(border)
314            .corner_radius(50.)
315            .padding(padding)
316            .width(slider_width)
317            .height(slider_height)
318            .child(
319                rect()
320                    .width(inner_slider_width)
321                    .height(inner_slider_height)
322                    .background(theme.background.mul_if(!self.enabled, 0.85))
323                    .corner_radius(50.)
324                    .direction(self.direction)
325                    .main_align(thumb_main_align)
326                    .children(if direction_is_vertical {
327                        vec![thumb, track]
328                    } else {
329                        vec![track, thumb]
330                    }),
331            )
332    }
333
334    fn render_key(&self) -> DiffKey {
335        self.key.clone().or(self.default_key())
336    }
337}