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