Skip to main content

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