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