Skip to main content

maolan_widgets/
horizontal_scrollbar.rs

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}