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}