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