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
25pub 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 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 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}