Skip to main content

maolan_widgets/
slider.rs

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