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