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