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