Skip to main content

material_ui_rs/widget/component/
slider.rs

1//! Material 3 slider constructors with token-backed layout defaults.
2
3use super::*;
4use iced_widget::core::keyboard;
5use iced_widget::core::keyboard::key::{self, Key};
6use iced_widget::slider::{Catalog as SliderCatalog, HandleShape, Status, Style, StyleFn};
7use num_traits::FromPrimitive;
8use std::fmt;
9use std::ops::RangeInclusive;
10
11/// A Material 3 continuous slider with thumb elevation.
12pub struct Slider<'a, T, Message> {
13    range: RangeInclusive<T>,
14    step: T,
15    shift_step: Option<T>,
16    value: T,
17    default: Option<T>,
18    on_change: Box<dyn Fn(T) -> Message + 'a>,
19    on_release: Option<Message>,
20    width: Length,
21    height: f32,
22    class: StyleFn<'a, Theme>,
23    status: Option<Status>,
24}
25
26impl<T, Message> fmt::Debug for Slider<'_, T, Message>
27where
28    T: fmt::Debug,
29{
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.debug_struct("Slider")
32            .field("range", &self.range)
33            .field("step", &self.step)
34            .field("shift_step", &self.shift_step)
35            .field("value", &self.value)
36            .field("default", &self.default)
37            .field("width", &self.width)
38            .field("height", &self.height)
39            .field("status", &self.status)
40            .finish_non_exhaustive()
41    }
42}
43
44impl<'a, T, Message> Slider<'a, T, Message>
45where
46    T: Copy + From<u8> + PartialOrd,
47    Message: Clone + 'a,
48{
49    fn new(range: RangeInclusive<T>, value: T, on_change: impl Fn(T) -> Message + 'a) -> Self {
50        let value = clamped_value(&range, value);
51
52        Self {
53            range,
54            step: T::from(1),
55            shift_step: None,
56            value,
57            default: None,
58            on_change: Box::new(on_change),
59            on_release: None,
60            width: Length::Fill,
61            height: tokens::component::slider::STATE_LAYER_SIZE,
62            class: Box::new(slider_style::default),
63            status: None,
64        }
65    }
66
67    /// Sets the optional default value.
68    pub fn default(mut self, default: impl Into<T>) -> Self {
69        self.default = Some(default.into());
70        self
71    }
72
73    /// Sets the release message.
74    pub fn on_release(mut self, on_release: Message) -> Self {
75        self.on_release = Some(on_release);
76        self
77    }
78
79    /// Sets the width of the slider.
80    pub fn width(mut self, width: impl Into<Length>) -> Self {
81        self.width = width.into();
82        self
83    }
84
85    /// Sets the height of the slider.
86    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
87        self.height = height.into().0;
88        self
89    }
90
91    /// Sets the step size of the slider.
92    pub fn step(mut self, step: impl Into<T>) -> Self {
93        self.step = step.into();
94        self
95    }
96
97    /// Sets the optional shift-step size.
98    pub fn shift_step(mut self, shift_step: impl Into<T>) -> Self {
99        self.shift_step = Some(shift_step.into());
100        self
101    }
102
103    /// Sets the slider style.
104    pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self {
105        self.class = Box::new(style);
106        self
107    }
108}
109
110pub fn continuous<'a, T, Message>(
111    range: RangeInclusive<T>,
112    value: T,
113    on_change: impl Fn(T) -> Message + 'a,
114) -> Slider<'a, T, Message>
115where
116    T: Copy + From<u8> + PartialOrd,
117    Message: Clone + 'a,
118{
119    Slider::new(range, value, on_change)
120}
121
122impl<'a, T, Message, Renderer> Widget<Message, Theme, Renderer> for Slider<'a, T, Message>
123where
124    T: Copy + Into<f64> + FromPrimitive,
125    Message: Clone,
126    Renderer: iced_widget::core::Renderer,
127{
128    fn tag(&self) -> tree::Tag {
129        tree::Tag::of::<State>()
130    }
131
132    fn state(&self) -> tree::State {
133        tree::State::new(State::default())
134    }
135
136    fn size(&self) -> Size<Length> {
137        Size {
138            width: self.width,
139            height: Length::Shrink,
140        }
141    }
142
143    fn layout(
144        &mut self,
145        _tree: &mut Tree,
146        _renderer: &Renderer,
147        limits: &layout::Limits,
148    ) -> layout::Node {
149        layout::atomic(limits, self.width, self.height)
150    }
151
152    fn update(
153        &mut self,
154        tree: &mut Tree,
155        event: &Event,
156        layout: Layout<'_>,
157        cursor: mouse::Cursor,
158        _renderer: &Renderer,
159        _clipboard: &mut dyn Clipboard,
160        shell: &mut Shell<'_, Message>,
161        _viewport: &Rectangle,
162    ) {
163        let state = tree.state.downcast_mut::<State>();
164
165        let mut update = || {
166            let current_value = self.value;
167
168            let locate = |cursor_position: Point| -> Option<T> {
169                let bounds = layout.bounds();
170
171                if cursor_position.x <= bounds.x {
172                    Some(*self.range.start())
173                } else if cursor_position.x >= bounds.x + bounds.width {
174                    Some(*self.range.end())
175                } else {
176                    let step = if state.keyboard_modifiers.shift() {
177                        self.shift_step.unwrap_or(self.step)
178                    } else {
179                        self.step
180                    }
181                    .into();
182
183                    let start = (*self.range.start()).into();
184                    let end = (*self.range.end()).into();
185                    let percent = f64::from(cursor_position.x - bounds.x) / f64::from(bounds.width);
186                    let steps = (percent * (end - start) / step).round();
187                    let value = steps * step + start;
188
189                    T::from_f64(value.min(end))
190                }
191            };
192
193            let increment = |value: T| -> Option<T> {
194                let step = if state.keyboard_modifiers.shift() {
195                    self.shift_step.unwrap_or(self.step)
196                } else {
197                    self.step
198                }
199                .into();
200
201                let steps = (value.into() / step).round();
202                let new_value = step * (steps + 1.0);
203
204                if new_value > (*self.range.end()).into() {
205                    return Some(*self.range.end());
206                }
207
208                T::from_f64(new_value)
209            };
210
211            let decrement = |value: T| -> Option<T> {
212                let step = if state.keyboard_modifiers.shift() {
213                    self.shift_step.unwrap_or(self.step)
214                } else {
215                    self.step
216                }
217                .into();
218
219                let steps = (value.into() / step).round();
220                let new_value = step * (steps - 1.0);
221
222                if new_value < (*self.range.start()).into() {
223                    return Some(*self.range.start());
224                }
225
226                T::from_f64(new_value)
227            };
228
229            let mut change = |new_value: T| {
230                if (self.value.into() - new_value.into()).abs() > f64::EPSILON {
231                    shell.publish((self.on_change)(new_value));
232                    self.value = new_value;
233                }
234            };
235
236            match &event {
237                Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
238                | Event::Touch(touch::Event::FingerPressed { .. }) => {
239                    if let Some(cursor_position) = cursor.position_over(layout.bounds()) {
240                        if state.keyboard_modifiers.command() {
241                            if let Some(default) = self.default {
242                                change(default);
243                            }
244
245                            state.is_dragging = false;
246                        } else {
247                            if let Some(value) = locate(cursor_position) {
248                                change(value);
249                            }
250
251                            state.is_dragging = true;
252                        }
253
254                        shell.capture_event();
255                    }
256                }
257                Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
258                | Event::Touch(touch::Event::FingerLifted { .. })
259                | Event::Touch(touch::Event::FingerLost { .. })
260                    if state.is_dragging =>
261                {
262                    if let Some(on_release) = self.on_release.clone() {
263                        shell.publish(on_release);
264                    }
265
266                    state.is_dragging = false;
267                }
268                Event::Mouse(mouse::Event::CursorMoved { .. })
269                | Event::Touch(touch::Event::FingerMoved { .. })
270                    if state.is_dragging =>
271                {
272                    if let Some(value) = cursor.land().position().and_then(locate) {
273                        change(value);
274                    }
275
276                    shell.capture_event();
277                }
278                Event::Mouse(mouse::Event::WheelScrolled { delta })
279                    if state.keyboard_modifiers.control() && cursor.is_over(layout.bounds()) =>
280                {
281                    let delta = match delta {
282                        mouse::ScrollDelta::Lines { x: _, y } => y,
283                        mouse::ScrollDelta::Pixels { x: _, y } => y,
284                    };
285
286                    if *delta < 0.0 {
287                        if let Some(value) = decrement(current_value) {
288                            change(value);
289                        }
290                    } else if let Some(value) = increment(current_value) {
291                        change(value);
292                    }
293
294                    shell.capture_event();
295                }
296                Event::Keyboard(keyboard::Event::KeyPressed { key, .. })
297                    if cursor.is_over(layout.bounds()) =>
298                {
299                    match key {
300                        Key::Named(key::Named::ArrowUp) => {
301                            if let Some(value) = increment(current_value) {
302                                change(value);
303                            }
304
305                            shell.capture_event();
306                        }
307                        Key::Named(key::Named::ArrowDown) => {
308                            if let Some(value) = decrement(current_value) {
309                                change(value);
310                            }
311
312                            shell.capture_event();
313                        }
314                        _ => (),
315                    }
316                }
317                Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
318                    state.keyboard_modifiers = *modifiers;
319                }
320                _ => {}
321            }
322        };
323
324        update();
325
326        let current_status = if state.is_dragging {
327            Status::Dragged
328        } else if cursor.is_over(layout.bounds()) {
329            Status::Hovered
330        } else {
331            Status::Active
332        };
333
334        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
335            self.status = Some(current_status);
336        } else if self.status.is_some_and(|status| status != current_status) {
337            shell.request_redraw();
338        }
339    }
340
341    fn draw(
342        &self,
343        _tree: &Tree,
344        renderer: &mut Renderer,
345        theme: &Theme,
346        _style: &renderer::Style,
347        layout: Layout<'_>,
348        _cursor: mouse::Cursor,
349        _viewport: &Rectangle,
350    ) {
351        let bounds = layout.bounds();
352        let style = theme.style(&self.class, self.status.unwrap_or(Status::Active));
353        let (handle_width, handle_height, handle_border_radius) = match style.handle.shape {
354            HandleShape::Circle { radius } => (radius * 2.0, radius * 2.0, radius.into()),
355            HandleShape::Rectangle {
356                width,
357                border_radius,
358            } => (f32::from(width), bounds.height, border_radius),
359        };
360        let thumb_bounds =
361            thumb_bounds_with_size(bounds, self.value, &self.range, handle_width, handle_height);
362        let rail_y = bounds.y + bounds.height / 2.0;
363
364        renderer.fill_quad(
365            renderer::Quad {
366                bounds: Rectangle {
367                    x: bounds.x,
368                    y: rail_y - style.rail.width / 2.0,
369                    width: thumb_bounds.x - bounds.x + handle_width / 2.0,
370                    height: style.rail.width,
371                },
372                border: style.rail.border,
373                ..renderer::Quad::default()
374            },
375            style.rail.backgrounds.0,
376        );
377
378        renderer.fill_quad(
379            renderer::Quad {
380                bounds: Rectangle {
381                    x: thumb_bounds.x + handle_width / 2.0,
382                    y: rail_y - style.rail.width / 2.0,
383                    width: bounds.width - (thumb_bounds.x - bounds.x) - handle_width / 2.0,
384                    height: style.rail.width,
385                },
386                border: style.rail.border,
387                ..renderer::Quad::default()
388            },
389            style.rail.backgrounds.1,
390        );
391
392        renderer.fill_quad(
393            renderer::Quad {
394                bounds: thumb_bounds,
395                border: Border {
396                    radius: handle_border_radius,
397                    width: style.handle.border_width,
398                    color: style.handle.border_color,
399                },
400                shadow: thumb_shadow(theme),
401                ..renderer::Quad::default()
402            },
403            style.handle.background,
404        );
405    }
406
407    fn mouse_interaction(
408        &self,
409        tree: &Tree,
410        layout: Layout<'_>,
411        cursor: mouse::Cursor,
412        _viewport: &Rectangle,
413        _renderer: &Renderer,
414    ) -> mouse::Interaction {
415        let state = tree.state.downcast_ref::<State>();
416
417        if state.is_dragging {
418            if cfg!(target_os = "windows") {
419                mouse::Interaction::Pointer
420            } else {
421                mouse::Interaction::Grabbing
422            }
423        } else if cursor.is_over(layout.bounds()) {
424            if cfg!(target_os = "windows") {
425                mouse::Interaction::Pointer
426            } else {
427                mouse::Interaction::Grab
428            }
429        } else {
430            mouse::Interaction::default()
431        }
432    }
433}
434
435impl<'a, T, Message, Renderer> From<Slider<'a, T, Message>>
436    for Element<'a, Message, Theme, Renderer>
437where
438    T: Copy + Into<f64> + FromPrimitive + 'a,
439    Message: Clone + 'a,
440    Renderer: iced_widget::core::Renderer + 'a,
441{
442    fn from(slider: Slider<'a, T, Message>) -> Self {
443        Element::new(slider)
444    }
445}
446
447fn clamped_value<T>(range: &RangeInclusive<T>, value: T) -> T
448where
449    T: Copy + PartialOrd,
450{
451    if value < *range.start() {
452        *range.start()
453    } else if value > *range.end() {
454        *range.end()
455    } else {
456        value
457    }
458}
459
460fn thumb_bounds_with_size<T>(
461    bounds: Rectangle,
462    value: T,
463    range: &RangeInclusive<T>,
464    handle_width: f32,
465    handle_height: f32,
466) -> Rectangle
467where
468    T: Copy + Into<f64>,
469{
470    let range_start = (*range.start()).into() as f32;
471    let range_end = (*range.end()).into() as f32;
472    let offset = if range_start >= range_end {
473        0.0
474    } else {
475        let value = (value.into() as f32).clamp(range_start, range_end);
476
477        (bounds.width - handle_width) * (value - range_start) / (range_end - range_start)
478    };
479    let rail_y = bounds.y + bounds.height / 2.0;
480
481    Rectangle {
482        x: bounds.x + offset,
483        y: rail_y - handle_height / 2.0,
484        width: handle_width,
485        height: handle_height,
486    }
487}
488
489fn thumb_shadow(theme: &Theme) -> iced_widget::core::Shadow {
490    crate::utils::shadow_from_elevation(
491        tokens::component::slider::HANDLE_ELEVATION,
492        theme.colors().shadow,
493    )
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
497struct State {
498    is_dragging: bool,
499    keyboard_modifiers: keyboard::Modifiers,
500}
501
502#[cfg(test)]
503#[path = "../../../tests/widget/component/slider.rs"]
504mod tests;