Skip to main content

maolan_widgets/
slider.rs

1use crate::consts::DOUBLE_CLICK;
2#[cfg(test)]
3use iced::Point;
4use iced::advanced::Shell;
5use iced::advanced::layout::{self, Layout};
6use iced::advanced::renderer;
7use iced::advanced::widget::{self, Tree, Widget};
8use iced::mouse;
9use iced::{Border, Color, Element, Event, Length, Rectangle, Size};
10use std::time::Instant;
11
12#[derive(Clone, Copy, Debug, Default, PartialEq)]
13enum Orientation {
14    #[default]
15    Vertical,
16    Horizontal,
17}
18
19pub struct Slider<'a, Message> {
20    range: std::ops::RangeInclusive<f32>,
21    value: f32,
22    on_change: Box<dyn Fn(f32) -> Message + 'a>,
23    width: Length,
24    height: Length,
25    handle_height: f32,
26    step: Option<f32>,
27    double_click_reset: f32,
28    on_release: Option<Message>,
29    orientation: Orientation,
30}
31
32impl<'a, Message> Slider<'a, Message> {
33    pub fn new<F>(range: std::ops::RangeInclusive<f32>, value: f32, on_change: F) -> Self
34    where
35        F: Fn(f32) -> Message + 'a,
36    {
37        Self {
38            range,
39            value,
40            on_change: Box::new(on_change),
41            width: Length::Fixed(14.0),
42            height: Length::Fixed(300.0),
43            handle_height: 2.0,
44            step: None,
45            double_click_reset: 0.0,
46            on_release: None,
47            orientation: Orientation::Vertical,
48        }
49    }
50
51    pub fn width(mut self, width: Length) -> Self {
52        self.width = width;
53        self
54    }
55
56    pub fn height(mut self, height: Length) -> Self {
57        self.height = height;
58        self
59    }
60
61    pub fn step(mut self, step: f32) -> Self {
62        self.step = Some(step.abs()).filter(|step| *step > 0.0);
63        self
64    }
65
66    pub fn double_click_reset(mut self, value: f32) -> Self {
67        self.double_click_reset = value;
68        self
69    }
70
71    pub fn on_release(mut self, message: Message) -> Self {
72        self.on_release = Some(message);
73        self
74    }
75
76    pub fn horizontal(mut self) -> Self {
77        self.orientation = Orientation::Horizontal;
78        self
79    }
80}
81
82pub fn slider<'a, Message, F>(
83    range: std::ops::RangeInclusive<f32>,
84    value: f32,
85    on_change: F,
86) -> Slider<'a, Message>
87where
88    F: Fn(f32) -> Message + 'a,
89{
90    Slider::new(range, value, on_change)
91}
92
93#[derive(Default)]
94struct State {
95    is_dragging: bool,
96    last_click_at: Option<Instant>,
97    drag_start_x: f32,
98    drag_start_y: f32,
99    drag_start_value: f32,
100}
101
102impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer> for Slider<'a, Message>
103where
104    Renderer: renderer::Renderer,
105    Message: Clone,
106{
107    fn size(&self) -> Size<Length> {
108        Size {
109            width: self.width,
110            height: self.height,
111        }
112    }
113
114    fn layout(
115        &mut self,
116        _tree: &mut Tree,
117        _renderer: &Renderer,
118        limits: &layout::Limits,
119    ) -> layout::Node {
120        let size = limits.width(self.width).height(self.height).resolve(
121            self.width,
122            self.height,
123            Size::ZERO,
124        );
125
126        layout::Node::new(size)
127    }
128
129    fn draw(
130        &self,
131        _tree: &Tree,
132        renderer: &mut Renderer,
133        _theme: &Theme,
134        _style: &renderer::Style,
135        layout: Layout<'_>,
136        _cursor: mouse::Cursor,
137        _viewport: &Rectangle,
138    ) {
139        let bounds = layout.bounds();
140        let border_width = 1.0;
141        let twice_border = border_width * 2.0;
142        let normalized =
143            (self.value - self.range.start()) / (self.range.end() - self.range.start());
144
145        let back_color = Color::from_rgb(
146            0x42 as f32 / 255.0,
147            0x46 as f32 / 255.0,
148            0x4D as f32 / 255.0,
149        );
150        let border_color = Color::from_rgb(
151            0x30 as f32 / 255.0,
152            0x33 as f32 / 255.0,
153            0x3C as f32 / 255.0,
154        );
155        let filled_color = Color::from_rgb(
156            0x29 as f32 / 255.0,
157            0x66 as f32 / 255.0,
158            0xA3 as f32 / 255.0,
159        );
160        let handle_color = Color::from_rgb(
161            0x75 as f32 / 255.0,
162            0xC2 as f32 / 255.0,
163            0xFF as f32 / 255.0,
164        );
165
166        let border_radius = 2.0;
167        let handle_filled_gap = 1.0;
168
169        renderer.fill_quad(
170            renderer::Quad {
171                bounds: Rectangle {
172                    x: bounds.x,
173                    y: bounds.y,
174                    width: bounds.width,
175                    height: bounds.height,
176                },
177                border: Border {
178                    radius: border_radius.into(),
179                    width: border_width,
180                    color: border_color,
181                },
182                ..Default::default()
183            },
184            back_color,
185        );
186
187        match self.orientation {
188            Orientation::Vertical => {
189                let value_bounds_y = bounds.y + (self.handle_height / 2.0);
190                let value_bounds_height = bounds.height - self.handle_height;
191                let handle_offset = (value_bounds_y
192                    + (value_bounds_height - twice_border) * (1.0 - normalized))
193                    .round();
194
195                let filled_y_start = handle_offset + self.handle_height + handle_filled_gap;
196                let filled_height = bounds.y + bounds.height - filled_y_start;
197
198                if filled_height > 0.0 {
199                    renderer.fill_quad(
200                        renderer::Quad {
201                            bounds: Rectangle {
202                                x: bounds.x,
203                                y: filled_y_start,
204                                width: bounds.width,
205                                height: filled_height,
206                            },
207                            border: Border {
208                                radius: border_radius.into(),
209                                width: border_width,
210                                color: Color::TRANSPARENT,
211                            },
212                            ..Default::default()
213                        },
214                        filled_color,
215                    );
216                }
217
218                renderer.fill_quad(
219                    renderer::Quad {
220                        bounds: Rectangle {
221                            x: bounds.x,
222                            y: handle_offset,
223                            width: bounds.width,
224                            height: self.handle_height + twice_border,
225                        },
226                        border: Border {
227                            radius: border_radius.into(),
228                            width: border_width,
229                            color: Color::TRANSPARENT,
230                        },
231                        ..Default::default()
232                    },
233                    handle_color,
234                );
235            }
236            Orientation::Horizontal => {
237                let value_bounds_x = bounds.x + (self.handle_height / 2.0);
238                let value_bounds_width = bounds.width - self.handle_height;
239                let handle_offset =
240                    (value_bounds_x + (value_bounds_width - twice_border) * normalized).round();
241
242                let filled_x_end = handle_offset - handle_filled_gap;
243                let filled_width = filled_x_end - bounds.x;
244
245                if filled_width > 0.0 {
246                    renderer.fill_quad(
247                        renderer::Quad {
248                            bounds: Rectangle {
249                                x: bounds.x,
250                                y: bounds.y,
251                                width: filled_width,
252                                height: bounds.height,
253                            },
254                            border: Border {
255                                radius: border_radius.into(),
256                                width: border_width,
257                                color: Color::TRANSPARENT,
258                            },
259                            ..Default::default()
260                        },
261                        filled_color,
262                    );
263                }
264
265                renderer.fill_quad(
266                    renderer::Quad {
267                        bounds: Rectangle {
268                            x: handle_offset - self.handle_height / 2.0 - border_width,
269                            y: bounds.y,
270                            width: self.handle_height + twice_border,
271                            height: bounds.height,
272                        },
273                        border: Border {
274                            radius: border_radius.into(),
275                            width: border_width,
276                            color: Color::TRANSPARENT,
277                        },
278                        ..Default::default()
279                    },
280                    handle_color,
281                );
282            }
283        }
284    }
285
286    fn tag(&self) -> widget::tree::Tag {
287        widget::tree::Tag::of::<State>()
288    }
289
290    fn state(&self) -> widget::tree::State {
291        widget::tree::State::new(State::default())
292    }
293
294    fn update(
295        &mut self,
296        tree: &mut Tree,
297        event: &Event,
298        layout: Layout<'_>,
299        cursor: mouse::Cursor,
300        _renderer: &Renderer,
301        _clipboard: &mut dyn iced::advanced::Clipboard,
302        shell: &mut Shell<'_, Message>,
303        _viewport: &Rectangle,
304    ) {
305        let state = tree.state.downcast_mut::<State>();
306        let bounds = layout.bounds();
307
308        match event {
309            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
310                if cursor.is_over(bounds) =>
311            {
312                let now = Instant::now();
313                let is_double_click = state
314                    .last_click_at
315                    .is_some_and(|last| now.duration_since(last) <= DOUBLE_CLICK);
316                state.last_click_at = Some(now);
317                state.is_dragging = true;
318                if is_double_click {
319                    let default_value = self
320                        .double_click_reset
321                        .clamp(*self.range.start(), *self.range.end());
322                    shell.publish((self.on_change)(default_value));
323                    shell.capture_event();
324                } else if let Some(cursor_position) = cursor.position() {
325                    state.drag_start_x = cursor_position.x;
326                    state.drag_start_y = cursor_position.y;
327                    state.drag_start_value = self.value;
328                }
329            }
330            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
331                if state.is_dragging =>
332            {
333                state.is_dragging = false;
334                if let Some(message) = self.on_release.as_ref() {
335                    shell.publish(message.clone());
336                }
337            }
338            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
339                if state.is_dragging
340                    && let Some(cursor_position) = cursor.position()
341                {
342                    let range_size = self.range.end() - self.range.start();
343                    let value_change = match self.orientation {
344                        Orientation::Vertical => {
345                            let delta_y = state.drag_start_y - cursor_position.y;
346                            (delta_y / bounds.height.max(1.0)) * range_size
347                        }
348                        Orientation::Horizontal => {
349                            let delta_x = cursor_position.x - state.drag_start_x;
350                            (delta_x / bounds.width.max(1.0)) * range_size
351                        }
352                    };
353                    let raw_value = state.drag_start_value + value_change;
354                    let new_value = self.clamp_to_step(raw_value);
355                    shell.publish((self.on_change)(new_value));
356                }
357            }
358            _ => {}
359        }
360    }
361}
362
363impl<'a, Message> Slider<'a, Message> {
364    #[cfg(test)]
365    fn calculate_value(&self, cursor_position: iced::Point, bounds: Rectangle) -> f32 {
366        let y = cursor_position.y - bounds.y;
367        let normalized = 1.0 - (y / bounds.height).clamp(0.0, 1.0);
368        let value = self.range.start() + normalized * (self.range.end() - self.range.start());
369        self.clamp_to_step(value)
370    }
371
372    fn clamp_to_step(&self, value: f32) -> f32 {
373        let clamped = value.clamp(*self.range.start(), *self.range.end());
374        let Some(step) = self.step else {
375            return clamped;
376        };
377
378        let start = *self.range.start();
379        let end = *self.range.end();
380        let steps = ((clamped - start) / step).round();
381        (start + steps * step).clamp(start, end)
382    }
383}
384
385impl<'a, Message, Theme, Renderer> From<Slider<'a, Message>>
386    for Element<'a, Message, Theme, Renderer>
387where
388    Message: 'a + Clone,
389    Theme: 'a,
390    Renderer: renderer::Renderer + 'a,
391{
392    fn from(slider: Slider<'a, Message>) -> Self {
393        Self::new(slider)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use iced::Event;
401    use iced::advanced::{
402        Layout, Shell, clipboard, layout,
403        widget::{self, Tree, Widget},
404    };
405    use std::time::Instant;
406
407    fn test_tree_with_state(state: State) -> Tree {
408        Tree {
409            tag: widget::tree::Tag::of::<State>(),
410            state: widget::tree::State::new(state),
411            children: Vec::new(),
412        }
413    }
414
415    #[test]
416    fn calculate_value_clamps_to_range() {
417        let slider = Slider::new(0.0..=1.0, 0.5, |value| value);
418        let bounds = Rectangle {
419            x: 10.0,
420            y: 20.0,
421            width: 14.0,
422            height: 100.0,
423        };
424
425        assert_eq!(slider.calculate_value(Point::new(15.0, 20.0), bounds), 1.0);
426        assert_eq!(slider.calculate_value(Point::new(15.0, 120.0), bounds), 0.0);
427        assert!((slider.calculate_value(Point::new(15.0, 70.0), bounds) - 0.5).abs() < 0.001);
428    }
429
430    #[test]
431    fn calculate_value_snaps_to_step() {
432        let slider = Slider::new(-90.0..=20.0, 0.0, |value| value).step(1.0);
433        let bounds = Rectangle {
434            x: 0.0,
435            y: 0.0,
436            width: 14.0,
437            height: 110.0,
438        };
439
440        assert_eq!(slider.calculate_value(Point::new(7.0, 10.4), bounds), 10.0);
441        assert_eq!(slider.calculate_value(Point::new(7.0, 10.6), bounds), 9.0);
442    }
443
444    #[cfg(debug_assertions)]
445    #[test]
446    fn update_press_starts_drag_without_publishing() {
447        let mut slider = Slider::new(0.0..=1.0, 0.5, |value| value).height(Length::Fixed(100.0));
448        let mut tree = test_tree_with_state(State::default());
449        let node = layout::Node::new(Size::new(14.0, 100.0));
450        let layout = Layout::new(&node);
451        let mut messages = Vec::new();
452        let mut shell = Shell::new(&mut messages);
453        let renderer = ();
454        let mut clipboard = clipboard::Null;
455        let viewport = Rectangle::new(Point::ORIGIN, Size::new(14.0, 100.0));
456
457        <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
458            &mut slider,
459            &mut tree,
460            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
461            layout,
462            mouse::Cursor::Available(Point::new(7.0, 25.0)),
463            &renderer,
464            &mut clipboard,
465            &mut shell,
466            &viewport,
467        );
468
469        assert!(messages.is_empty());
470    }
471
472    #[cfg(debug_assertions)]
473    #[test]
474    fn update_drag_publishes_relative_value_change() {
475        let mut slider = Slider::new(0.0..=1.0, 0.5, |value| value).height(Length::Fixed(100.0));
476        let mut tree = test_tree_with_state(State::default());
477        let node = layout::Node::new(Size::new(14.0, 100.0));
478        let layout = Layout::new(&node);
479        let mut messages = Vec::new();
480        let renderer = ();
481        let mut clipboard = clipboard::Null;
482        let viewport = Rectangle::new(Point::ORIGIN, Size::new(14.0, 100.0));
483
484        {
485            let mut shell = Shell::new(&mut messages);
486            <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
487                &mut slider,
488                &mut tree,
489                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
490                layout,
491                mouse::Cursor::Available(Point::new(7.0, 75.0)),
492                &renderer,
493                &mut clipboard,
494                &mut shell,
495                &viewport,
496            );
497        }
498
499        {
500            let mut shell = Shell::new(&mut messages);
501            <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
502                &mut slider,
503                &mut tree,
504                &Event::Mouse(mouse::Event::CursorMoved {
505                    position: Point::new(7.0, 50.0),
506                }),
507                layout,
508                mouse::Cursor::Available(Point::new(7.0, 50.0)),
509                &renderer,
510                &mut clipboard,
511                &mut shell,
512                &viewport,
513            );
514        }
515
516        assert_eq!(messages.len(), 1);
517        assert!((messages[0] - 0.75).abs() < 0.01);
518    }
519
520    #[cfg(debug_assertions)]
521    #[test]
522    fn update_double_click_resets_to_zero() {
523        let mut slider = Slider::new(-90.0..=20.0, 6.0, |value| value).height(Length::Fixed(110.0));
524        let mut tree = test_tree_with_state(State {
525            is_dragging: false,
526            last_click_at: Some(Instant::now()),
527            ..State::default()
528        });
529        let node = layout::Node::new(Size::new(14.0, 110.0));
530        let layout = Layout::new(&node);
531        let mut messages = Vec::new();
532        let mut shell = Shell::new(&mut messages);
533        let renderer = ();
534        let mut clipboard = clipboard::Null;
535        let viewport = Rectangle::new(Point::ORIGIN, Size::new(14.0, 110.0));
536
537        <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
538            &mut slider,
539            &mut tree,
540            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
541            layout,
542            mouse::Cursor::Available(Point::new(7.0, 30.0)),
543            &renderer,
544            &mut clipboard,
545            &mut shell,
546            &viewport,
547        );
548
549        assert_eq!(messages, vec![0.0]);
550    }
551
552    #[cfg(debug_assertions)]
553    #[test]
554    fn update_double_click_resets_to_custom_value() {
555        let mut slider = Slider::new(0.0..=1.0, 0.2, |value| value)
556            .height(Length::Fixed(110.0))
557            .double_click_reset(0.75);
558        let mut tree = test_tree_with_state(State {
559            is_dragging: false,
560            last_click_at: Some(Instant::now()),
561            ..State::default()
562        });
563        let node = layout::Node::new(Size::new(14.0, 110.0));
564        let layout = Layout::new(&node);
565        let mut messages = Vec::new();
566        let mut shell = Shell::new(&mut messages);
567        let renderer = ();
568        let mut clipboard = clipboard::Null;
569        let viewport = Rectangle::new(Point::ORIGIN, Size::new(14.0, 110.0));
570
571        <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
572            &mut slider,
573            &mut tree,
574            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
575            layout,
576            mouse::Cursor::Available(Point::new(7.0, 30.0)),
577            &renderer,
578            &mut clipboard,
579            &mut shell,
580            &viewport,
581        );
582
583        assert_eq!(messages, vec![0.75]);
584    }
585
586    #[cfg(debug_assertions)]
587    #[test]
588    fn update_publishes_release_message() {
589        let mut slider = Slider::new(0.0..=1.0, 0.5, |value| value)
590            .height(Length::Fixed(100.0))
591            .on_release(99.0);
592        let mut tree = test_tree_with_state(State {
593            is_dragging: true,
594            last_click_at: None,
595            ..State::default()
596        });
597        let node = layout::Node::new(Size::new(14.0, 100.0));
598        let layout = Layout::new(&node);
599        let mut messages = Vec::new();
600        let mut shell = Shell::new(&mut messages);
601        let renderer = ();
602        let mut clipboard = clipboard::Null;
603        let viewport = Rectangle::new(Point::ORIGIN, Size::new(14.0, 100.0));
604
605        <Slider<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
606            &mut slider,
607            &mut tree,
608            &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
609            layout,
610            mouse::Cursor::Unavailable,
611            &renderer,
612            &mut clipboard,
613            &mut shell,
614            &viewport,
615        );
616
617        assert_eq!(messages, vec![99.0]);
618    }
619}