Skip to main content

maolan_widgets/
range_slider.rs

1use crate::consts::DOUBLE_CLICK;
2#[cfg(test)]
3use iced::Point;
4use iced::advanced::Shell;
5use iced::advanced::layout::{self, Layout};
6use iced::advanced::renderer;
7use iced::advanced::widget::{self, Tree, Widget};
8use iced::{Border, Color, Element, Event, Length, Rectangle, Size};
9use iced::{keyboard, mouse};
10use std::time::Instant;
11
12#[derive(Clone, Copy, Debug, Default, PartialEq)]
13enum Orientation {
14    #[default]
15    Vertical,
16    Horizontal,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20enum DraggedHandle {
21    Low,
22    High,
23}
24
25/// A slider with two handles editing a `low..=high` value pair (for example
26/// velocity or key ranges). Defaults to a vertical orientation; call
27/// [`RangeSlider::horizontal`] for a horizontal layout. Dragging moves the
28/// high handle, Shift+dragging moves the low handle, and the handles cannot
29/// cross. Double-click resets the pair to the full range.
30pub struct RangeSlider<'a, Message> {
31    range: std::ops::RangeInclusive<f32>,
32    low: f32,
33    high: f32,
34    on_change: Box<dyn Fn(f32, f32) -> Message + 'a>,
35    width: Length,
36    height: Length,
37    handle_size: f32,
38    step: Option<f32>,
39    orientation: Orientation,
40}
41
42impl<'a, Message> RangeSlider<'a, Message> {
43    pub fn new<F>(range: std::ops::RangeInclusive<f32>, low: f32, high: f32, on_change: F) -> Self
44    where
45        F: Fn(f32, f32) -> Message + 'a,
46    {
47        Self {
48            range,
49            low,
50            high,
51            on_change: Box::new(on_change),
52            width: Length::Fixed(14.0),
53            height: Length::Fixed(300.0),
54            handle_size: 4.0,
55            step: None,
56            orientation: Orientation::Vertical,
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 handle_size(mut self, handle_size: f32) -> Self {
71        self.handle_size = handle_size.max(1.0);
72        self
73    }
74
75    pub fn step(mut self, step: f32) -> Self {
76        self.step = Some(step.abs()).filter(|step| *step > 0.0);
77        self
78    }
79
80    pub fn horizontal(mut self) -> Self {
81        self.orientation = Orientation::Horizontal;
82        self
83    }
84
85    fn handle_position(&self, value: f32, bounds: Rectangle) -> f32 {
86        let (inner_origin, inner_length) = self.inner_track(bounds);
87        handle_position(
88            value,
89            *self.range.start(),
90            *self.range.end(),
91            inner_origin,
92            inner_length,
93            self.orientation,
94        )
95    }
96
97    fn inner_track(&self, bounds: Rectangle) -> (f32, f32) {
98        match self.orientation {
99            Orientation::Vertical => (
100                bounds.y + self.handle_size / 2.0,
101                (bounds.height - self.handle_size).max(1.0),
102            ),
103            Orientation::Horizontal => (
104                bounds.x + self.handle_size / 2.0,
105                (bounds.width - self.handle_size).max(1.0),
106            ),
107        }
108    }
109}
110
111pub fn range_slider<'a, Message, F>(
112    range: std::ops::RangeInclusive<f32>,
113    low: f32,
114    high: f32,
115    on_change: F,
116) -> RangeSlider<'a, Message>
117where
118    F: Fn(f32, f32) -> Message + 'a,
119{
120    RangeSlider::new(range, low, high, on_change)
121}
122
123fn handle_position(
124    value: f32,
125    start: f32,
126    end: f32,
127    inner_origin: f32,
128    inner_length: f32,
129    orientation: Orientation,
130) -> f32 {
131    let normalized = if end > start {
132        (value - start) / (end - start)
133    } else {
134        0.0
135    }
136    .clamp(0.0, 1.0);
137    match orientation {
138        Orientation::Horizontal => inner_origin + normalized * inner_length,
139        Orientation::Vertical => inner_origin + (1.0 - normalized) * inner_length,
140    }
141}
142
143fn drag_value(
144    start_value: f32,
145    start_position: f32,
146    cursor_position: f32,
147    inner_length: f32,
148    range_size: f32,
149    orientation: Orientation,
150) -> f32 {
151    let delta = match orientation {
152        Orientation::Horizontal => cursor_position - start_position,
153        Orientation::Vertical => start_position - cursor_position,
154    };
155    start_value + (delta / inner_length.max(1.0)) * range_size
156}
157
158fn clamp_to_step(value: f32, start: f32, end: f32, step: Option<f32>) -> f32 {
159    let clamped = value.clamp(start, end);
160    let Some(step) = step else {
161        return clamped;
162    };
163    let steps = ((clamped - start) / step).round();
164    (start + steps * step).clamp(start, end)
165}
166
167fn clamp_pair(
168    dragged: f32,
169    other: f32,
170    handle: DraggedHandle,
171    start: f32,
172    end: f32,
173    step: Option<f32>,
174) -> (f32, f32) {
175    match handle {
176        DraggedHandle::Low => {
177            let low = clamp_to_step(dragged.min(other), start, end, step);
178            (low, clamp_to_step(other, start, end, step).max(low))
179        }
180        DraggedHandle::High => {
181            let high = clamp_to_step(dragged.max(other), start, end, step);
182            (clamp_to_step(other, start, end, step).min(high), high)
183        }
184    }
185}
186
187#[derive(Default)]
188struct State {
189    dragging: Option<DraggedHandle>,
190    last_click_at: Option<Instant>,
191    modifiers: keyboard::Modifiers,
192    drag_start_x: f32,
193    drag_start_y: f32,
194    drag_start_low: f32,
195    drag_start_high: f32,
196}
197
198impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer> for RangeSlider<'a, Message>
199where
200    Renderer: renderer::Renderer,
201    Message: Clone,
202{
203    fn size(&self) -> Size<Length> {
204        Size {
205            width: self.width,
206            height: self.height,
207        }
208    }
209
210    fn layout(
211        &mut self,
212        _tree: &mut Tree,
213        _renderer: &Renderer,
214        limits: &layout::Limits,
215    ) -> layout::Node {
216        let size = limits.width(self.width).height(self.height).resolve(
217            self.width,
218            self.height,
219            Size::ZERO,
220        );
221
222        layout::Node::new(size)
223    }
224
225    fn draw(
226        &self,
227        _tree: &Tree,
228        renderer: &mut Renderer,
229        _theme: &Theme,
230        _style: &renderer::Style,
231        layout: Layout<'_>,
232        _cursor: mouse::Cursor,
233        _viewport: &Rectangle,
234    ) {
235        let bounds = layout.bounds();
236        let border_width = 1.0;
237
238        let back_color = Color::from_rgb(
239            0x42 as f32 / 255.0,
240            0x46 as f32 / 255.0,
241            0x4D as f32 / 255.0,
242        );
243        let border_color = Color::from_rgb(
244            0x30 as f32 / 255.0,
245            0x33 as f32 / 255.0,
246            0x3C as f32 / 255.0,
247        );
248        let filled_color = Color::from_rgb(
249            0x29 as f32 / 255.0,
250            0x66 as f32 / 255.0,
251            0xA3 as f32 / 255.0,
252        );
253        let handle_color = Color::from_rgb(
254            0x75 as f32 / 255.0,
255            0xC2 as f32 / 255.0,
256            0xFF as f32 / 255.0,
257        );
258
259        let border_radius = 2.0;
260
261        renderer.fill_quad(
262            renderer::Quad {
263                bounds: Rectangle {
264                    x: bounds.x,
265                    y: bounds.y,
266                    width: bounds.width,
267                    height: bounds.height,
268                },
269                border: Border {
270                    radius: border_radius.into(),
271                    width: border_width,
272                    color: border_color,
273                },
274                ..Default::default()
275            },
276            back_color,
277        );
278
279        let low_position = self.handle_position(self.low, bounds);
280        let high_position = self.handle_position(self.high, bounds);
281
282        match self.orientation {
283            Orientation::Vertical => {
284                let band_top = low_position.min(high_position);
285                let band_height = (high_position - low_position).abs();
286                if band_height > 0.0 {
287                    renderer.fill_quad(
288                        renderer::Quad {
289                            bounds: Rectangle {
290                                x: bounds.x,
291                                y: band_top,
292                                width: bounds.width,
293                                height: band_height,
294                            },
295                            border: Border {
296                                radius: border_radius.into(),
297                                width: border_width,
298                                color: Color::TRANSPARENT,
299                            },
300                            ..Default::default()
301                        },
302                        filled_color,
303                    );
304                }
305                for position in [low_position, high_position] {
306                    renderer.fill_quad(
307                        renderer::Quad {
308                            bounds: Rectangle {
309                                x: bounds.x,
310                                y: position - self.handle_size / 2.0 - border_width,
311                                width: bounds.width,
312                                height: self.handle_size + border_width * 2.0,
313                            },
314                            border: Border {
315                                radius: border_radius.into(),
316                                width: border_width,
317                                color: Color::TRANSPARENT,
318                            },
319                            ..Default::default()
320                        },
321                        handle_color,
322                    );
323                }
324            }
325            Orientation::Horizontal => {
326                let band_left = low_position.min(high_position);
327                let band_width = (high_position - low_position).abs();
328                if band_width > 0.0 {
329                    renderer.fill_quad(
330                        renderer::Quad {
331                            bounds: Rectangle {
332                                x: band_left,
333                                y: bounds.y,
334                                width: band_width,
335                                height: bounds.height,
336                            },
337                            border: Border {
338                                radius: border_radius.into(),
339                                width: border_width,
340                                color: Color::TRANSPARENT,
341                            },
342                            ..Default::default()
343                        },
344                        filled_color,
345                    );
346                }
347                for position in [low_position, high_position] {
348                    renderer.fill_quad(
349                        renderer::Quad {
350                            bounds: Rectangle {
351                                x: position - self.handle_size / 2.0 - border_width,
352                                y: bounds.y,
353                                width: self.handle_size + border_width * 2.0,
354                                height: bounds.height,
355                            },
356                            border: Border {
357                                radius: border_radius.into(),
358                                width: border_width,
359                                color: Color::TRANSPARENT,
360                            },
361                            ..Default::default()
362                        },
363                        handle_color,
364                    );
365                }
366            }
367        }
368    }
369
370    fn tag(&self) -> widget::tree::Tag {
371        widget::tree::Tag::of::<State>()
372    }
373
374    fn state(&self) -> widget::tree::State {
375        widget::tree::State::new(State::default())
376    }
377
378    fn update(
379        &mut self,
380        tree: &mut Tree,
381        event: &Event,
382        layout: Layout<'_>,
383        cursor: mouse::Cursor,
384        _renderer: &Renderer,
385        _clipboard: &mut dyn iced::advanced::Clipboard,
386        shell: &mut Shell<'_, Message>,
387        _viewport: &Rectangle,
388    ) {
389        let state = tree.state.downcast_mut::<State>();
390        let bounds = layout.bounds();
391
392        match event {
393            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
394                state.modifiers = *modifiers;
395            }
396            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
397                if cursor.is_over(bounds) =>
398            {
399                let now = Instant::now();
400                let is_double_click = state
401                    .last_click_at
402                    .is_some_and(|last| now.duration_since(last) <= DOUBLE_CLICK);
403                state.last_click_at = Some(now);
404                if is_double_click {
405                    shell.publish((self.on_change)(*self.range.start(), *self.range.end()));
406                    shell.capture_event();
407                } else if let Some(cursor_position) = cursor.position() {
408                    state.dragging = Some(if state.modifiers.shift() {
409                        DraggedHandle::Low
410                    } else {
411                        DraggedHandle::High
412                    });
413                    state.drag_start_x = cursor_position.x;
414                    state.drag_start_y = cursor_position.y;
415                    state.drag_start_low = self.low;
416                    state.drag_start_high = self.high;
417                }
418            }
419            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
420                if state.dragging.is_some() =>
421            {
422                state.dragging = None;
423            }
424            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
425                if let Some(handle) = state.dragging
426                    && let Some(cursor_position) = cursor.position()
427                {
428                    let (_, inner_length) = self.inner_track(bounds);
429                    let range_size = self.range.end() - self.range.start();
430                    let start_position = match self.orientation {
431                        Orientation::Horizontal => state.drag_start_x,
432                        Orientation::Vertical => state.drag_start_y,
433                    };
434                    let start_value = match handle {
435                        DraggedHandle::Low => state.drag_start_low,
436                        DraggedHandle::High => state.drag_start_high,
437                    };
438                    let cursor_position = match self.orientation {
439                        Orientation::Horizontal => cursor_position.x,
440                        Orientation::Vertical => cursor_position.y,
441                    };
442                    let dragged = drag_value(
443                        start_value,
444                        start_position,
445                        cursor_position,
446                        inner_length,
447                        range_size,
448                        self.orientation,
449                    );
450                    let other = match handle {
451                        DraggedHandle::Low => state.drag_start_high,
452                        DraggedHandle::High => state.drag_start_low,
453                    };
454                    let (low, high) = clamp_pair(
455                        dragged,
456                        other,
457                        handle,
458                        *self.range.start(),
459                        *self.range.end(),
460                        self.step,
461                    );
462                    shell.publish((self.on_change)(low, high));
463                }
464            }
465            _ => {}
466        }
467    }
468}
469
470impl<'a, Message, Theme, Renderer> From<RangeSlider<'a, Message>>
471    for Element<'a, Message, Theme, Renderer>
472where
473    Message: 'a + Clone,
474    Theme: 'a,
475    Renderer: renderer::Renderer + 'a,
476{
477    fn from(slider: RangeSlider<'a, Message>) -> Self {
478        Self::new(slider)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use iced::Event;
486    use iced::advanced::{
487        Layout, Shell, clipboard, layout,
488        widget::{self, Tree, Widget},
489    };
490    use std::time::Instant;
491
492    fn test_tree_with_state(state: State) -> Tree {
493        Tree {
494            tag: widget::tree::Tag::of::<State>(),
495            state: widget::tree::State::new(state),
496            children: Vec::new(),
497        }
498    }
499
500    #[test]
501    fn handle_position_maps_range_to_inner_track() {
502        assert_eq!(
503            handle_position(0.0, 0.0, 1.0, 10.0, 80.0, Orientation::Horizontal),
504            10.0
505        );
506        assert_eq!(
507            handle_position(1.0, 0.0, 1.0, 10.0, 80.0, Orientation::Horizontal),
508            90.0
509        );
510        assert_eq!(
511            handle_position(0.5, 0.0, 1.0, 10.0, 80.0, Orientation::Horizontal),
512            50.0
513        );
514        assert_eq!(
515            handle_position(0.0, 0.0, 1.0, 10.0, 80.0, Orientation::Vertical),
516            90.0
517        );
518        assert_eq!(
519            handle_position(1.0, 0.0, 1.0, 10.0, 80.0, Orientation::Vertical),
520            10.0
521        );
522    }
523
524    #[test]
525    fn handle_position_clamps_out_of_range_values() {
526        assert_eq!(
527            handle_position(2.0, 0.0, 1.0, 0.0, 100.0, Orientation::Horizontal),
528            100.0
529        );
530        assert_eq!(
531            handle_position(-1.0, 0.0, 1.0, 0.0, 100.0, Orientation::Horizontal),
532            0.0
533        );
534    }
535
536    #[test]
537    fn drag_value_scales_cursor_delta_to_range() {
538        assert_eq!(
539            drag_value(0.5, 50.0, 60.0, 100.0, 1.0, Orientation::Horizontal),
540            0.6
541        );
542        assert_eq!(
543            drag_value(0.5, 50.0, 40.0, 100.0, 1.0, Orientation::Horizontal),
544            0.4
545        );
546        assert_eq!(
547            drag_value(0.5, 50.0, 40.0, 100.0, 1.0, Orientation::Vertical),
548            0.6
549        );
550        assert_eq!(
551            drag_value(0.5, 50.0, 60.0, 100.0, 1.0, Orientation::Vertical),
552            0.4
553        );
554    }
555
556    #[test]
557    fn clamp_pair_prevents_handles_crossing() {
558        let (low, high) = clamp_pair(0.8, 0.6, DraggedHandle::Low, 0.0, 1.0, None);
559        assert_eq!((low, high), (0.6, 0.6));
560        let (low, high) = clamp_pair(0.2, 0.4, DraggedHandle::High, 0.0, 1.0, None);
561        assert_eq!((low, high), (0.4, 0.4));
562    }
563
564    #[test]
565    fn clamp_pair_snaps_to_step() {
566        let (low, high) = clamp_pair(0.26, 0.74, DraggedHandle::Low, 0.0, 1.0, Some(0.1));
567        assert_eq!((low, high), (0.3, 0.7));
568    }
569
570    #[test]
571    fn clamp_pair_clamps_to_range() {
572        let (low, high) = clamp_pair(1.4, 0.5, DraggedHandle::High, 0.0, 1.0, None);
573        assert_eq!((low, high), (0.5, 1.0));
574    }
575
576    #[cfg(debug_assertions)]
577    fn press(slider: &mut RangeSlider<'_, (f32, f32)>, tree: &mut Tree, at: Point) {
578        let node = layout::Node::new(Size::new(150.0, 11.0));
579        let layout = Layout::new(&node);
580        let mut messages = Vec::new();
581        let mut shell = Shell::new(&mut messages);
582        let renderer = ();
583        let mut clipboard = clipboard::Null;
584        let viewport = Rectangle::new(Point::ORIGIN, Size::new(150.0, 11.0));
585        <RangeSlider<'_, (f32, f32)> as Widget<(f32, f32), iced::Theme, ()>>::update(
586            slider,
587            tree,
588            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
589            layout,
590            mouse::Cursor::Available(at),
591            &renderer,
592            &mut clipboard,
593            &mut shell,
594            &viewport,
595        );
596        assert!(messages.is_empty());
597    }
598
599    #[cfg(debug_assertions)]
600    fn drag_to(
601        slider: &mut RangeSlider<'_, (f32, f32)>,
602        tree: &mut Tree,
603        from: Point,
604        to: Point,
605    ) -> Vec<(f32, f32)> {
606        press(slider, tree, from);
607        let node = layout::Node::new(Size::new(150.0, 11.0));
608        let layout = Layout::new(&node);
609        let mut messages = Vec::new();
610        let renderer = ();
611        let mut clipboard = clipboard::Null;
612        let viewport = Rectangle::new(Point::ORIGIN, Size::new(150.0, 11.0));
613        {
614            let mut shell = Shell::new(&mut messages);
615            <RangeSlider<'_, (f32, f32)> as Widget<(f32, f32), iced::Theme, ()>>::update(
616                slider,
617                tree,
618                &Event::Mouse(mouse::Event::CursorMoved { position: to }),
619                layout,
620                mouse::Cursor::Available(to),
621                &renderer,
622                &mut clipboard,
623                &mut shell,
624                &viewport,
625            );
626        }
627        messages
628    }
629
630    #[cfg(debug_assertions)]
631    #[test]
632    fn shift_drag_low_handle_stops_at_high_handle() {
633        let mut slider = RangeSlider::new(0.0..=1.0, 0.2, 0.8, |low, high| (low, high))
634            .horizontal()
635            .width(Length::Fixed(150.0))
636            .height(Length::Fixed(11.0));
637        let mut tree = test_tree_with_state(State {
638            modifiers: keyboard::Modifiers::SHIFT,
639            ..State::default()
640        });
641
642        let messages = drag_to(
643            &mut slider,
644            &mut tree,
645            Point::new(45.0, 5.0),
646            Point::new(150.0, 5.0),
647        );
648
649        assert_eq!(messages.len(), 1);
650        let (low, high) = messages[0];
651        assert_eq!(
652            (low, high),
653            (0.8, 0.8),
654            "low should clamp at high instead of crossing: {low}"
655        );
656    }
657
658    #[cfg(debug_assertions)]
659    #[test]
660    fn plain_drag_moves_high_handle_even_near_low_handle() {
661        let mut slider = RangeSlider::new(0.0..=1.0, 0.2, 0.8, |low, high| (low, high))
662            .horizontal()
663            .width(Length::Fixed(150.0))
664            .height(Length::Fixed(11.0));
665        let mut tree = test_tree_with_state(State::default());
666
667        // Press near the low handle (x=45); a plain drag must still move high.
668        let messages = drag_to(
669            &mut slider,
670            &mut tree,
671            Point::new(45.0, 5.0),
672            Point::new(75.0, 5.0),
673        );
674
675        assert_eq!(messages.len(), 1);
676        let (low, high) = messages[0];
677        assert_eq!(low, 0.2);
678        assert!(
679            high > 0.8,
680            "plain drag should move high toward the range end: {high}"
681        );
682    }
683
684    #[cfg(debug_assertions)]
685    #[test]
686    fn shift_drag_moves_low_handle() {
687        let mut slider = RangeSlider::new(0.0..=1.0, 0.2, 0.8, |low, high| (low, high))
688            .horizontal()
689            .width(Length::Fixed(150.0))
690            .height(Length::Fixed(11.0));
691        let mut tree = test_tree_with_state(State {
692            modifiers: keyboard::Modifiers::SHIFT,
693            ..State::default()
694        });
695
696        // Press near the high handle (x=120); shift+drag must move low.
697        let messages = drag_to(
698            &mut slider,
699            &mut tree,
700            Point::new(120.0, 5.0),
701            Point::new(90.0, 5.0),
702        );
703
704        assert_eq!(messages.len(), 1);
705        let (low, high) = messages[0];
706        assert!(
707            low < 0.2,
708            "shift drag should move low toward the range start: {low}"
709        );
710        assert_eq!(high, 0.8);
711    }
712
713    #[cfg(debug_assertions)]
714    #[test]
715    fn double_click_resets_to_full_range() {
716        let mut slider = RangeSlider::new(0.0..=127.0, 20.0, 100.0, |low, high| (low, high))
717            .horizontal()
718            .width(Length::Fixed(150.0))
719            .height(Length::Fixed(11.0));
720        let mut tree = test_tree_with_state(State {
721            dragging: None,
722            last_click_at: Some(Instant::now()),
723            ..State::default()
724        });
725        let node = layout::Node::new(Size::new(150.0, 11.0));
726        let layout = Layout::new(&node);
727        let mut messages = Vec::new();
728        let mut shell = Shell::new(&mut messages);
729        let renderer = ();
730        let mut clipboard = clipboard::Null;
731        let viewport = Rectangle::new(Point::ORIGIN, Size::new(150.0, 11.0));
732
733        <RangeSlider<'_, (f32, f32)> as Widget<(f32, f32), iced::Theme, ()>>::update(
734            &mut slider,
735            &mut tree,
736            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
737            layout,
738            mouse::Cursor::Available(Point::new(75.0, 5.0)),
739            &renderer,
740            &mut clipboard,
741            &mut shell,
742            &viewport,
743        );
744
745        assert_eq!(messages, vec![(0.0, 127.0)]);
746    }
747}