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