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