Skip to main content

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