Skip to main content

iced_widget/
scrollable.rs

1//! Scrollables let users navigate an endless amount of content with a scrollbar.
2//!
3//! # Example
4//! ```no_run
5//! # mod iced { pub mod widget { pub use iced_widget::*; } }
6//! # pub type State = ();
7//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
8//! use iced::widget::{column, scrollable, space};
9//!
10//! enum Message {
11//!     // ...
12//! }
13//!
14//! fn view(state: &State) -> Element<'_, Message> {
15//!     scrollable(column![
16//!         "Scroll me!",
17//!         space().height(3000),
18//!         "You did it!",
19//!     ]).into()
20//! }
21//! ```
22use crate::container;
23use crate::core::alignment;
24use crate::core::border::{self, Border};
25use crate::core::keyboard;
26use crate::core::layout;
27use crate::core::mouse;
28use crate::core::overlay;
29use crate::core::renderer;
30use crate::core::text;
31use crate::core::time::{Duration, Instant};
32use crate::core::touch;
33use crate::core::widget;
34use crate::core::widget::operation::{self, Operation};
35use crate::core::widget::tree::{self, Tree};
36use crate::core::window;
37use crate::core::{
38    self, Background, Color, Element, Event, InputMethod, Layout, Length, Padding, Pixels, Point,
39    Rectangle, Shadow, Shell, Size, Theme, Vector, Widget,
40};
41
42pub use operation::scrollable::{AbsoluteOffset, RelativeOffset};
43
44/// A widget that can vertically display an infinite amount of content with a
45/// scrollbar.
46///
47/// # Example
48/// ```no_run
49/// # mod iced { pub mod widget { pub use iced_widget::*; } }
50/// # pub type State = ();
51/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
52/// use iced::widget::{column, scrollable, space};
53///
54/// enum Message {
55///     // ...
56/// }
57///
58/// fn view(state: &State) -> Element<'_, Message> {
59///     scrollable(column![
60///         "Scroll me!",
61///         space().height(3000),
62///         "You did it!",
63///     ]).into()
64/// }
65/// ```
66pub struct Scrollable<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
67where
68    Theme: Catalog,
69    Renderer: text::Renderer,
70{
71    id: Option<widget::Id>,
72    width: Length,
73    height: Length,
74    direction: Direction,
75    auto_scroll: bool,
76    content: Element<'a, Message, Theme, Renderer>,
77    on_scroll: Option<Box<dyn Fn(Viewport) -> Message + 'a>>,
78    class: Theme::Class<'a>,
79    last_status: Option<Status>,
80}
81
82impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer>
83where
84    Theme: Catalog,
85    Renderer: text::Renderer,
86{
87    /// Creates a new vertical [`Scrollable`].
88    pub fn new(content: impl Into<Element<'a, Message, Theme, Renderer>>) -> Self {
89        Self::with_direction(content, Direction::default())
90    }
91
92    /// Creates a new [`Scrollable`] with the given [`Direction`].
93    pub fn with_direction(
94        content: impl Into<Element<'a, Message, Theme, Renderer>>,
95        direction: impl Into<Direction>,
96    ) -> Self {
97        Scrollable {
98            id: None,
99            width: Length::Fit,
100            height: Length::Fit,
101            direction: direction.into(),
102            auto_scroll: false,
103            content: content.into(),
104            on_scroll: None,
105            class: Theme::default(),
106            last_status: None,
107        }
108    }
109
110    /// Makes the [`Scrollable`] scroll horizontally, with default [`Scrollbar`] settings.
111    pub fn horizontal(self) -> Self {
112        self.direction(Direction::Horizontal(Scrollbar::default()))
113    }
114
115    /// Sets the [`Direction`] of the [`Scrollable`].
116    pub fn direction(mut self, direction: impl Into<Direction>) -> Self {
117        self.direction = direction.into();
118        self
119    }
120
121    /// Sets the [`widget::Id`] of the [`Scrollable`].
122    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
123        self.id = Some(id.into());
124        self
125    }
126
127    /// Sets the width of the [`Scrollable`].
128    pub fn width(mut self, width: impl Into<Length>) -> Self {
129        self.width = width.into();
130        self
131    }
132
133    /// Sets the height of the [`Scrollable`].
134    pub fn height(mut self, height: impl Into<Length>) -> Self {
135        self.height = height.into();
136        self
137    }
138
139    /// Sets a function to call when the [`Scrollable`] is scrolled.
140    ///
141    /// The function takes the [`Viewport`] of the [`Scrollable`]
142    pub fn on_scroll(mut self, f: impl Fn(Viewport) -> Message + 'a) -> Self {
143        self.on_scroll = Some(Box::new(f));
144        self
145    }
146
147    /// Anchors the vertical [`Scrollable`] direction to the top.
148    pub fn anchor_top(self) -> Self {
149        self.anchor_y(Anchor::Start)
150    }
151
152    /// Anchors the vertical [`Scrollable`] direction to the bottom.
153    pub fn anchor_bottom(self) -> Self {
154        self.anchor_y(Anchor::End)
155    }
156
157    /// Anchors the horizontal [`Scrollable`] direction to the left.
158    pub fn anchor_left(self) -> Self {
159        self.anchor_x(Anchor::Start)
160    }
161
162    /// Anchors the horizontal [`Scrollable`] direction to the right.
163    pub fn anchor_right(self) -> Self {
164        self.anchor_x(Anchor::End)
165    }
166
167    /// Sets the [`Anchor`] of the horizontal direction of the [`Scrollable`], if applicable.
168    pub fn anchor_x(mut self, alignment: Anchor) -> Self {
169        match &mut self.direction {
170            Direction::Horizontal(horizontal) | Direction::Both { horizontal, .. } => {
171                horizontal.alignment = alignment;
172            }
173            Direction::Vertical { .. } => {}
174        }
175
176        self
177    }
178
179    /// Sets the [`Anchor`] of the vertical direction of the [`Scrollable`], if applicable.
180    pub fn anchor_y(mut self, alignment: Anchor) -> Self {
181        match &mut self.direction {
182            Direction::Vertical(vertical) | Direction::Both { vertical, .. } => {
183                vertical.alignment = alignment;
184            }
185            Direction::Horizontal { .. } => {}
186        }
187
188        self
189    }
190
191    /// Embeds the [`Scrollbar`] into the [`Scrollable`], instead of floating on top of the
192    /// content.
193    ///
194    /// The `spacing` provided will be used as space between the [`Scrollbar`] and the contents
195    /// of the [`Scrollable`].
196    pub fn spacing(mut self, new_spacing: impl Into<Pixels>) -> Self {
197        match &mut self.direction {
198            Direction::Horizontal(scrollbar) | Direction::Vertical(scrollbar) => {
199                scrollbar.spacing = Some(new_spacing.into().0);
200            }
201            Direction::Both { .. } => {}
202        }
203
204        self
205    }
206
207    /// Sets whether the user should be allowed to auto-scroll the [`Scrollable`]
208    /// with the middle mouse button.
209    ///
210    /// By default, it is disabled.
211    pub fn auto_scroll(mut self, auto_scroll: bool) -> Self {
212        self.auto_scroll = auto_scroll;
213        self
214    }
215
216    /// Sets the style of this [`Scrollable`].
217    #[must_use]
218    pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
219    where
220        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
221    {
222        self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
223        self
224    }
225
226    /// Sets the style class of the [`Scrollable`].
227    #[cfg(feature = "advanced")]
228    #[must_use]
229    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
230        self.class = class.into();
231        self
232    }
233}
234
235/// The direction of [`Scrollable`].
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub enum Direction {
238    /// Vertical scrolling
239    Vertical(Scrollbar),
240    /// Horizontal scrolling
241    Horizontal(Scrollbar),
242    /// Both vertical and horizontal scrolling
243    Both {
244        /// The properties of the vertical scrollbar.
245        vertical: Scrollbar,
246        /// The properties of the horizontal scrollbar.
247        horizontal: Scrollbar,
248    },
249}
250
251impl Direction {
252    /// Returns the horizontal [`Scrollbar`], if any.
253    pub fn horizontal(&self) -> Option<&Scrollbar> {
254        match self {
255            Self::Horizontal(scrollbar) => Some(scrollbar),
256            Self::Both { horizontal, .. } => Some(horizontal),
257            Self::Vertical(_) => None,
258        }
259    }
260
261    /// Returns the vertical [`Scrollbar`], if any.
262    pub fn vertical(&self) -> Option<&Scrollbar> {
263        match self {
264            Self::Vertical(scrollbar) => Some(scrollbar),
265            Self::Both { vertical, .. } => Some(vertical),
266            Self::Horizontal(_) => None,
267        }
268    }
269
270    fn align(&self, delta: Vector) -> Vector {
271        let horizontal_alignment = self.horizontal().map(|p| p.alignment).unwrap_or_default();
272
273        let vertical_alignment = self.vertical().map(|p| p.alignment).unwrap_or_default();
274
275        let align = |alignment: Anchor, delta: f32| match alignment {
276            Anchor::Start => delta,
277            Anchor::End => -delta,
278        };
279
280        Vector::new(
281            align(horizontal_alignment, delta.x),
282            align(vertical_alignment, delta.y),
283        )
284    }
285}
286
287impl Default for Direction {
288    fn default() -> Self {
289        Self::Vertical(Scrollbar::default())
290    }
291}
292
293/// A scrollbar within a [`Scrollable`].
294#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct Scrollbar {
296    width: f32,
297    margin: f32,
298    scroller_width: f32,
299    alignment: Anchor,
300    spacing: Option<f32>,
301}
302
303impl Default for Scrollbar {
304    fn default() -> Self {
305        Self {
306            width: 10.0,
307            margin: 0.0,
308            scroller_width: 10.0,
309            alignment: Anchor::Start,
310            spacing: None,
311        }
312    }
313}
314
315impl Scrollbar {
316    /// Creates new [`Scrollbar`] for use in a [`Scrollable`].
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Create a [`Scrollbar`] with zero width to allow a [`Scrollable`] to scroll without a visible
322    /// scroller.
323    pub fn hidden() -> Self {
324        Self::default().width(0).scroller_width(0)
325    }
326
327    /// Sets the scrollbar width of the [`Scrollbar`] .
328    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
329        self.width = width.into().0.max(0.0);
330        self
331    }
332
333    /// Sets the scrollbar margin of the [`Scrollbar`] .
334    pub fn margin(mut self, margin: impl Into<Pixels>) -> Self {
335        self.margin = margin.into().0;
336        self
337    }
338
339    /// Sets the scroller width of the [`Scrollbar`] .
340    pub fn scroller_width(mut self, scroller_width: impl Into<Pixels>) -> Self {
341        self.scroller_width = scroller_width.into().0.max(0.0);
342        self
343    }
344
345    /// Sets the [`Anchor`] of the [`Scrollbar`] .
346    pub fn anchor(mut self, alignment: Anchor) -> Self {
347        self.alignment = alignment;
348        self
349    }
350
351    /// Sets whether the [`Scrollbar`] should be embedded in the [`Scrollable`], using
352    /// the given spacing between itself and the contents.
353    ///
354    /// An embedded [`Scrollbar`] will always be displayed, will take layout space,
355    /// and will not float over the contents.
356    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
357        self.spacing = Some(spacing.into().0);
358        self
359    }
360}
361
362/// The anchor of the scroller of the [`Scrollable`] relative to its [`Viewport`]
363/// on a given axis.
364#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
365pub enum Anchor {
366    /// Scroller is anchored to the start of the [`Viewport`].
367    #[default]
368    Start,
369    /// Content is aligned to the end of the [`Viewport`].
370    End,
371}
372
373impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
374    for Scrollable<'_, Message, Theme, Renderer>
375where
376    Theme: Catalog,
377    Renderer: text::Renderer,
378{
379    fn tag(&self) -> tree::Tag {
380        tree::Tag::of::<State>()
381    }
382
383    fn state(&self) -> tree::State {
384        tree::State::new(State::new())
385    }
386
387    fn diff(&mut self, tree: &mut Tree) {
388        tree.diff_children(std::slice::from_mut(&mut self.content));
389
390        let size = self.content.as_widget().size();
391
392        if self.direction.horizontal().is_none() {
393            self.width = self.width.enclose(size.width);
394        }
395
396        if self.direction.vertical().is_none() {
397            self.height = self.height.enclose(size.height);
398        }
399    }
400
401    fn size(&self) -> Size<Length> {
402        Size {
403            width: self.width,
404            height: self.height,
405        }
406    }
407
408    fn layout(
409        &mut self,
410        tree: &mut Tree,
411        renderer: &Renderer,
412        limits: &layout::Limits,
413    ) -> layout::Node {
414        let mut layout = |right_padding, bottom_padding| {
415            layout::padded(
416                limits,
417                self.width,
418                self.height,
419                Padding {
420                    right: right_padding,
421                    bottom: bottom_padding,
422                    ..Padding::ZERO
423                },
424                |limits| {
425                    let is_horizontal = self.direction.horizontal().is_some();
426                    let is_vertical = self.direction.vertical().is_some();
427
428                    let child_limits = layout::Limits::with_compression(
429                        limits.min(),
430                        Size::new(
431                            if is_horizontal {
432                                f32::INFINITY
433                            } else {
434                                limits.max().width
435                            },
436                            if is_vertical {
437                                f32::INFINITY
438                            } else {
439                                limits.max().height
440                            },
441                        ),
442                        Size::new(is_horizontal, is_vertical),
443                    );
444
445                    self.content.as_widget_mut().layout(
446                        &mut tree.children[0],
447                        renderer,
448                        &child_limits,
449                    )
450                },
451            )
452        };
453
454        match self.direction {
455            Direction::Vertical(Scrollbar {
456                width,
457                margin,
458                spacing: Some(spacing),
459                ..
460            })
461            | Direction::Horizontal(Scrollbar {
462                width,
463                margin,
464                spacing: Some(spacing),
465                ..
466            }) => {
467                let is_vertical = matches!(self.direction, Direction::Vertical(_));
468
469                let padding = width + margin * 2.0 + spacing;
470                let state = tree.state.downcast_mut::<State>();
471
472                let status_quo = layout(
473                    if is_vertical && state.is_scrollbar_visible {
474                        padding
475                    } else {
476                        0.0
477                    },
478                    if !is_vertical && state.is_scrollbar_visible {
479                        padding
480                    } else {
481                        0.0
482                    },
483                );
484
485                let is_scrollbar_visible = if is_vertical {
486                    status_quo.children()[0].size().height > status_quo.size().height
487                } else {
488                    status_quo.children()[0].size().width > status_quo.size().width
489                };
490
491                if state.is_scrollbar_visible == is_scrollbar_visible {
492                    status_quo
493                } else {
494                    log::trace!("Scrollbar status quo has changed");
495                    state.is_scrollbar_visible = is_scrollbar_visible;
496
497                    layout(
498                        if is_vertical && state.is_scrollbar_visible {
499                            padding
500                        } else {
501                            0.0
502                        },
503                        if !is_vertical && state.is_scrollbar_visible {
504                            padding
505                        } else {
506                            0.0
507                        },
508                    )
509                }
510            }
511            _ => layout(0.0, 0.0),
512        }
513    }
514
515    fn operate(
516        &mut self,
517        tree: &mut Tree,
518        layout: Layout<'_>,
519        renderer: &Renderer,
520        operation: &mut dyn Operation,
521    ) {
522        let state = tree.state.downcast_mut::<State>();
523
524        let bounds = layout.bounds();
525        let content_layout = layout.children().next().unwrap();
526        let content_bounds = content_layout.bounds();
527        let translation = state.translation(self.direction, bounds, content_bounds);
528
529        operation.scrollable(self.id.as_ref(), bounds, content_bounds, translation, state);
530
531        operation.traverse(&mut |operation| {
532            self.content.as_widget_mut().operate(
533                &mut tree.children[0],
534                layout.children().next().unwrap(),
535                renderer,
536                operation,
537            );
538        });
539    }
540
541    fn update(
542        &mut self,
543        tree: &mut Tree,
544        event: &Event,
545        layout: Layout<'_>,
546        cursor: mouse::Cursor,
547        renderer: &Renderer,
548        shell: &mut Shell<'_, Message>,
549        _viewport: &Rectangle,
550    ) {
551        const AUTOSCROLL_DEADZONE: f32 = 20.0;
552        const AUTOSCROLL_SMOOTHNESS: f32 = 1.5;
553
554        let state = tree.state.downcast_mut::<State>();
555        let bounds = layout.bounds();
556        let cursor_over_scrollable = cursor.position_over(bounds);
557
558        let content = layout.children().next().unwrap();
559        let content_bounds = content.bounds();
560
561        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
562
563        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
564
565        let last_offsets = (state.offset_x, state.offset_y);
566
567        if let Some(last_scrolled) = state.last_scrolled {
568            let clear_transaction = match event {
569                Event::Mouse(
570                    mouse::Event::ButtonPressed(_)
571                    | mouse::Event::ButtonReleased(_)
572                    | mouse::Event::CursorLeft,
573                ) => true,
574                Event::Mouse(mouse::Event::CursorMoved { .. }) => {
575                    last_scrolled.elapsed() > Duration::from_millis(100)
576                }
577                _ => last_scrolled.elapsed() > Duration::from_millis(1500),
578            };
579
580            if clear_transaction {
581                state.last_scrolled = None;
582            }
583        }
584
585        let mut update = || {
586            if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at() {
587                match event {
588                    Event::Mouse(mouse::Event::CursorMoved { .. })
589                    | Event::Touch(touch::Event::FingerMoved { .. }) => {
590                        if let Some(scrollbar) = scrollbars.y {
591                            let Some(cursor_position) = cursor.land().position() else {
592                                return;
593                            };
594
595                            state.scroll_y_to(
596                                scrollbar.scroll_percentage_y(scroller_grabbed_at, cursor_position),
597                                bounds,
598                                content_bounds,
599                            );
600
601                            let _ = notify_scroll(
602                                state,
603                                &self.on_scroll,
604                                bounds,
605                                content_bounds,
606                                shell,
607                            );
608
609                            shell.capture_event();
610                        }
611                    }
612                    _ => {}
613                }
614            } else if mouse_over_y_scrollbar {
615                match event {
616                    Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
617                    | Event::Touch(touch::Event::FingerPressed { .. }) => {
618                        let Some(cursor_position) = cursor.position() else {
619                            return;
620                        };
621
622                        if let (Some(scroller_grabbed_at), Some(scrollbar)) =
623                            (scrollbars.grab_y_scroller(cursor_position), scrollbars.y)
624                        {
625                            state.scroll_y_to(
626                                scrollbar.scroll_percentage_y(scroller_grabbed_at, cursor_position),
627                                bounds,
628                                content_bounds,
629                            );
630
631                            state.interaction = Interaction::YScrollerGrabbed(scroller_grabbed_at);
632
633                            let _ = notify_scroll(
634                                state,
635                                &self.on_scroll,
636                                bounds,
637                                content_bounds,
638                                shell,
639                            );
640                        }
641
642                        shell.capture_event();
643                    }
644                    _ => {}
645                }
646            }
647
648            if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at() {
649                match event {
650                    Event::Mouse(mouse::Event::CursorMoved { .. })
651                    | Event::Touch(touch::Event::FingerMoved { .. }) => {
652                        let Some(cursor_position) = cursor.land().position() else {
653                            return;
654                        };
655
656                        if let Some(scrollbar) = scrollbars.x {
657                            state.scroll_x_to(
658                                scrollbar.scroll_percentage_x(scroller_grabbed_at, cursor_position),
659                                bounds,
660                                content_bounds,
661                            );
662
663                            let _ = notify_scroll(
664                                state,
665                                &self.on_scroll,
666                                bounds,
667                                content_bounds,
668                                shell,
669                            );
670                        }
671
672                        shell.capture_event();
673                    }
674                    _ => {}
675                }
676            } else if mouse_over_x_scrollbar {
677                match event {
678                    Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
679                    | Event::Touch(touch::Event::FingerPressed { .. }) => {
680                        let Some(cursor_position) = cursor.position() else {
681                            return;
682                        };
683
684                        if let (Some(scroller_grabbed_at), Some(scrollbar)) =
685                            (scrollbars.grab_x_scroller(cursor_position), scrollbars.x)
686                        {
687                            state.scroll_x_to(
688                                scrollbar.scroll_percentage_x(scroller_grabbed_at, cursor_position),
689                                bounds,
690                                content_bounds,
691                            );
692
693                            state.interaction = Interaction::XScrollerGrabbed(scroller_grabbed_at);
694
695                            let _ = notify_scroll(
696                                state,
697                                &self.on_scroll,
698                                bounds,
699                                content_bounds,
700                                shell,
701                            );
702
703                            shell.capture_event();
704                        }
705                    }
706                    _ => {}
707                }
708            }
709
710            if matches!(state.interaction, Interaction::AutoScrolling { .. })
711                && matches!(
712                    event,
713                    Event::Mouse(
714                        mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }
715                    ) | Event::Touch(_)
716                        | Event::Keyboard(_)
717                )
718            {
719                state.interaction = Interaction::None;
720                shell.capture_event();
721                shell.invalidate_layout();
722                shell.request_redraw();
723                return;
724            }
725
726            if state.last_scrolled.is_none()
727                || !matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. }))
728            {
729                let translation = state.translation(self.direction, bounds, content_bounds);
730
731                let cursor = match cursor_over_scrollable {
732                    Some(cursor_position)
733                        if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) =>
734                    {
735                        mouse::Cursor::Available(cursor_position + translation)
736                    }
737                    _ => cursor.levitate() + translation,
738                };
739
740                let had_input_method = shell.input_method().is_enabled();
741
742                self.content.as_widget_mut().update(
743                    &mut tree.children[0],
744                    event,
745                    content,
746                    cursor,
747                    renderer,
748                    shell,
749                    &Rectangle {
750                        y: bounds.y + translation.y,
751                        x: bounds.x + translation.x,
752                        ..bounds
753                    },
754                );
755
756                if !had_input_method
757                    && let InputMethod::Enabled { cursor, .. } = shell.input_method_mut()
758                {
759                    *cursor -= translation;
760                }
761            };
762
763            if matches!(
764                event,
765                Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
766                    | Event::Touch(
767                        touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }
768                    )
769            ) {
770                state.interaction = Interaction::None;
771                return;
772            }
773
774            if shell.is_event_captured() {
775                return;
776            }
777
778            match event {
779                Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
780                    if cursor_over_scrollable.is_none() {
781                        return;
782                    }
783
784                    let delta = match *delta {
785                        mouse::ScrollDelta::Lines { x, y } => {
786                            let is_shift_pressed = state.keyboard_modifiers.shift();
787
788                            // macOS automatically inverts the axes when Shift is pressed
789                            let (x, y) = if cfg!(target_os = "macos") && is_shift_pressed {
790                                (y, x)
791                            } else {
792                                (x, y)
793                            };
794
795                            let movement = if !is_shift_pressed {
796                                Vector::new(x, y)
797                            } else {
798                                Vector::new(y, x)
799                            };
800
801                            // TODO: Configurable speed/friction (?)
802                            -movement * 60.0
803                        }
804                        mouse::ScrollDelta::Pixels { x, y } => -Vector::new(x, y),
805                    };
806
807                    state.scroll(self.direction.align(delta), bounds, content_bounds);
808
809                    let has_scrolled =
810                        notify_scroll(state, &self.on_scroll, bounds, content_bounds, shell);
811
812                    let in_transaction = state.last_scrolled.is_some();
813
814                    if has_scrolled || in_transaction {
815                        shell.capture_event();
816                    }
817                }
818                Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
819                    if self.auto_scroll && matches!(state.interaction, Interaction::None) =>
820                {
821                    let Some(origin) = cursor_over_scrollable else {
822                        return;
823                    };
824
825                    state.interaction = Interaction::AutoScrolling {
826                        origin,
827                        current: origin,
828                        last_frame: None,
829                    };
830
831                    shell.capture_event();
832                    shell.invalidate_layout();
833                    shell.request_redraw();
834                }
835                Event::Touch(event)
836                    if matches!(state.interaction, Interaction::TouchScrolling(_))
837                        || (!mouse_over_y_scrollbar && !mouse_over_x_scrollbar) =>
838                {
839                    match event {
840                        touch::Event::FingerPressed { .. } => {
841                            let Some(position) = cursor_over_scrollable else {
842                                return;
843                            };
844
845                            state.interaction = Interaction::TouchScrolling(position);
846                        }
847                        touch::Event::FingerMoved { .. } => {
848                            let Interaction::TouchScrolling(scroll_box_touched_at) =
849                                state.interaction
850                            else {
851                                return;
852                            };
853
854                            let Some(cursor_position) = cursor.position() else {
855                                return;
856                            };
857
858                            let delta = Vector::new(
859                                scroll_box_touched_at.x - cursor_position.x,
860                                scroll_box_touched_at.y - cursor_position.y,
861                            );
862
863                            state.scroll(self.direction.align(delta), bounds, content_bounds);
864
865                            state.interaction = Interaction::TouchScrolling(cursor_position);
866
867                            // TODO: bubble up touch movements if not consumed.
868                            let _ = notify_scroll(
869                                state,
870                                &self.on_scroll,
871                                bounds,
872                                content_bounds,
873                                shell,
874                            );
875                        }
876                        _ => {}
877                    }
878
879                    shell.capture_event();
880                }
881                Event::Mouse(mouse::Event::CursorMoved { position }) => {
882                    if let Interaction::AutoScrolling {
883                        origin, last_frame, ..
884                    } = state.interaction
885                    {
886                        let delta = *position - origin;
887
888                        state.interaction = Interaction::AutoScrolling {
889                            origin,
890                            current: *position,
891                            last_frame,
892                        };
893
894                        if (delta.x.abs() >= AUTOSCROLL_DEADZONE
895                            || delta.y.abs() >= AUTOSCROLL_DEADZONE)
896                            && last_frame.is_none()
897                        {
898                            shell.request_redraw();
899                        }
900                    }
901                }
902                Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
903                    state.keyboard_modifiers = *modifiers;
904                }
905                Event::Window(window::Event::RedrawRequested(now)) => {
906                    if let Interaction::AutoScrolling {
907                        origin,
908                        current,
909                        last_frame,
910                    } = state.interaction
911                    {
912                        if last_frame == Some(*now) {
913                            shell.request_redraw();
914                            return;
915                        }
916
917                        state.interaction = Interaction::AutoScrolling {
918                            origin,
919                            current,
920                            last_frame: None,
921                        };
922
923                        let mut delta = current - origin;
924
925                        if delta.x.abs() < AUTOSCROLL_DEADZONE {
926                            delta.x = 0.0;
927                        }
928
929                        if delta.y.abs() < AUTOSCROLL_DEADZONE {
930                            delta.y = 0.0;
931                        }
932
933                        if delta.x != 0.0 || delta.y != 0.0 {
934                            let time_delta = if let Some(last_frame) = last_frame {
935                                *now - last_frame
936                            } else {
937                                Duration::ZERO
938                            };
939
940                            let scroll_factor = time_delta.as_secs_f32();
941
942                            state.scroll(
943                                self.direction.align(Vector::new(
944                                    delta.x.signum()
945                                        * delta.x.abs().powf(AUTOSCROLL_SMOOTHNESS)
946                                        * scroll_factor,
947                                    delta.y.signum()
948                                        * delta.y.abs().powf(AUTOSCROLL_SMOOTHNESS)
949                                        * scroll_factor,
950                                )),
951                                bounds,
952                                content_bounds,
953                            );
954
955                            let has_scrolled = notify_scroll(
956                                state,
957                                &self.on_scroll,
958                                bounds,
959                                content_bounds,
960                                shell,
961                            );
962
963                            if has_scrolled || time_delta.is_zero() {
964                                state.interaction = Interaction::AutoScrolling {
965                                    origin,
966                                    current,
967                                    last_frame: Some(*now),
968                                };
969
970                                shell.request_redraw();
971                            }
972
973                            return;
974                        }
975                    }
976
977                    let _ = notify_viewport(state, &self.on_scroll, bounds, content_bounds, shell);
978                }
979                _ => {}
980            }
981        };
982
983        update();
984
985        let status = if state.scrollers_grabbed() {
986            Status::Dragged {
987                is_horizontal_scrollbar_dragged: state.x_scroller_grabbed_at().is_some(),
988                is_vertical_scrollbar_dragged: state.y_scroller_grabbed_at().is_some(),
989                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
990                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
991            }
992        } else if cursor_over_scrollable.is_some() {
993            Status::Hovered {
994                is_horizontal_scrollbar_hovered: mouse_over_x_scrollbar,
995                is_vertical_scrollbar_hovered: mouse_over_y_scrollbar,
996                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
997                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
998            }
999        } else {
1000            Status::Active {
1001                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
1002                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
1003            }
1004        };
1005
1006        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
1007            self.last_status = Some(status);
1008        }
1009
1010        if last_offsets != (state.offset_x, state.offset_y)
1011            || self
1012                .last_status
1013                .is_some_and(|last_status| last_status != status)
1014        {
1015            shell.request_redraw();
1016        }
1017    }
1018
1019    fn draw(
1020        &self,
1021        tree: &Tree,
1022        renderer: &mut Renderer,
1023        theme: &Theme,
1024        defaults: &renderer::Style,
1025        layout: Layout<'_>,
1026        cursor: mouse::Cursor,
1027        viewport: &Rectangle,
1028    ) {
1029        let state = tree.state.downcast_ref::<State>();
1030
1031        let bounds = layout.bounds();
1032        let content_layout = layout.children().next().unwrap();
1033        let content_bounds = content_layout.bounds();
1034
1035        let Some(visible_bounds) = bounds.intersection(viewport) else {
1036            return;
1037        };
1038
1039        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1040
1041        let cursor_over_scrollable = cursor.position_over(bounds);
1042        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
1043
1044        let translation = state.translation(self.direction, bounds, content_bounds);
1045
1046        let cursor = match cursor_over_scrollable {
1047            Some(cursor_position) if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) => {
1048                mouse::Cursor::Available(cursor_position + translation)
1049            }
1050            _ => cursor.levitate() + translation,
1051        };
1052
1053        let style = theme.style(
1054            &self.class,
1055            self.last_status.unwrap_or(Status::Active {
1056                is_horizontal_scrollbar_disabled: false,
1057                is_vertical_scrollbar_disabled: false,
1058            }),
1059        );
1060
1061        container::draw_background(renderer, &style.container, layout.bounds());
1062
1063        // Draw inner content
1064        if scrollbars.active() {
1065            let scale_factor = renderer.scale_factor().unwrap_or(1.0);
1066            let translation = (translation * scale_factor).round() / scale_factor;
1067
1068            renderer.with_layer(visible_bounds, |renderer| {
1069                renderer.with_translation(
1070                    Vector::new(-translation.x, -translation.y),
1071                    |renderer| {
1072                        self.content.as_widget().draw(
1073                            &tree.children[0],
1074                            renderer,
1075                            theme,
1076                            defaults,
1077                            content_layout,
1078                            cursor,
1079                            &Rectangle {
1080                                y: visible_bounds.y + translation.y,
1081                                x: visible_bounds.x + translation.x,
1082                                ..visible_bounds
1083                            },
1084                        );
1085                    },
1086                );
1087            });
1088
1089            let draw_scrollbar =
1090                |renderer: &mut Renderer, style: Rail, scrollbar: &internals::Scrollbar| {
1091                    if scrollbar.bounds.width > 0.0
1092                        && scrollbar.bounds.height > 0.0
1093                        && (style.background.is_some()
1094                            || (style.border.color != Color::TRANSPARENT
1095                                && style.border.width > 0.0))
1096                    {
1097                        renderer.fill_quad(
1098                            renderer::Quad {
1099                                bounds: scrollbar.bounds,
1100                                border: style.border,
1101                                ..renderer::Quad::default()
1102                            },
1103                            style
1104                                .background
1105                                .unwrap_or(Background::Color(Color::TRANSPARENT)),
1106                        );
1107                    }
1108
1109                    if let Some(scroller) = scrollbar.scroller
1110                        && scroller.bounds.width > 0.0
1111                        && scroller.bounds.height > 0.0
1112                        && (style.scroller.background != Background::Color(Color::TRANSPARENT)
1113                            || (style.scroller.border.color != Color::TRANSPARENT
1114                                && style.scroller.border.width > 0.0))
1115                    {
1116                        renderer.fill_quad(
1117                            renderer::Quad {
1118                                bounds: scroller.bounds,
1119                                border: style.scroller.border,
1120                                ..renderer::Quad::default()
1121                            },
1122                            style.scroller.background,
1123                        );
1124                    }
1125                };
1126
1127            renderer.with_layer(
1128                Rectangle {
1129                    width: (visible_bounds.width + 2.0).min(viewport.width),
1130                    height: (visible_bounds.height + 2.0).min(viewport.height),
1131                    ..visible_bounds
1132                },
1133                |renderer| {
1134                    if let Some(scrollbar) = scrollbars.y {
1135                        draw_scrollbar(renderer, style.vertical_rail, &scrollbar);
1136                    }
1137
1138                    if let Some(scrollbar) = scrollbars.x {
1139                        draw_scrollbar(renderer, style.horizontal_rail, &scrollbar);
1140                    }
1141
1142                    if let (Some(x), Some(y)) = (scrollbars.x, scrollbars.y) {
1143                        let background = style.gap.or(style.container.background);
1144
1145                        if let Some(background) = background {
1146                            renderer.fill_quad(
1147                                renderer::Quad {
1148                                    bounds: Rectangle {
1149                                        x: y.bounds.x,
1150                                        y: x.bounds.y,
1151                                        width: y.bounds.width,
1152                                        height: x.bounds.height,
1153                                    },
1154                                    ..renderer::Quad::default()
1155                                },
1156                                background,
1157                            );
1158                        }
1159                    }
1160                },
1161            );
1162        } else {
1163            self.content.as_widget().draw(
1164                &tree.children[0],
1165                renderer,
1166                theme,
1167                defaults,
1168                content_layout,
1169                cursor,
1170                &Rectangle {
1171                    x: visible_bounds.x + translation.x,
1172                    y: visible_bounds.y + translation.y,
1173                    ..visible_bounds
1174                },
1175            );
1176        }
1177    }
1178
1179    fn mouse_interaction(
1180        &self,
1181        tree: &Tree,
1182        layout: Layout<'_>,
1183        cursor: mouse::Cursor,
1184        _viewport: &Rectangle,
1185        renderer: &Renderer,
1186    ) -> mouse::Interaction {
1187        let state = tree.state.downcast_ref::<State>();
1188        let bounds = layout.bounds();
1189        let cursor_over_scrollable = cursor.position_over(bounds);
1190
1191        let content_layout = layout.children().next().unwrap();
1192        let content_bounds = content_layout.bounds();
1193
1194        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1195
1196        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
1197
1198        if state.scrollers_grabbed() {
1199            return mouse::Interaction::None;
1200        }
1201
1202        let translation = state.translation(self.direction, bounds, content_bounds);
1203
1204        let cursor = match cursor_over_scrollable {
1205            Some(cursor_position) if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) => {
1206                mouse::Cursor::Available(cursor_position + translation)
1207            }
1208            _ => cursor.levitate() + translation,
1209        };
1210
1211        self.content.as_widget().mouse_interaction(
1212            &tree.children[0],
1213            content_layout,
1214            cursor,
1215            &Rectangle {
1216                y: bounds.y + translation.y,
1217                x: bounds.x + translation.x,
1218                ..bounds
1219            },
1220            renderer,
1221        )
1222    }
1223
1224    fn overlay<'b>(
1225        &'b mut self,
1226        tree: &'b mut Tree,
1227        layout: Layout<'b>,
1228        renderer: &Renderer,
1229        viewport: &Rectangle,
1230        translation: Vector,
1231    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
1232        let state = tree.state.downcast_ref::<State>();
1233        let bounds = layout.bounds();
1234        let content_layout = layout.children().next().unwrap();
1235        let content_bounds = content_layout.bounds();
1236        let visible_bounds = bounds.intersection(viewport).unwrap_or(*viewport);
1237        let offset = state.translation(self.direction, bounds, content_bounds);
1238
1239        let overlay = self.content.as_widget_mut().overlay(
1240            &mut tree.children[0],
1241            layout.children().next().unwrap(),
1242            renderer,
1243            &visible_bounds,
1244            translation - offset,
1245        );
1246
1247        let icon = if let Interaction::AutoScrolling { origin, .. } = state.interaction {
1248            let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1249
1250            Some(overlay::Element::new(Box::new(AutoScrollIcon {
1251                origin,
1252                vertical: scrollbars.y.is_some(),
1253                horizontal: scrollbars.x.is_some(),
1254                class: &self.class,
1255            })))
1256        } else {
1257            None
1258        };
1259
1260        match (overlay, icon) {
1261            (None, None) => None,
1262            (None, Some(icon)) => Some(icon),
1263            (Some(overlay), None) => Some(overlay),
1264            (Some(overlay), Some(icon)) => Some(overlay::Element::new(Box::new(
1265                overlay::Group::with_children(vec![overlay, icon]),
1266            ))),
1267        }
1268    }
1269}
1270
1271struct AutoScrollIcon<'a, Class> {
1272    origin: Point,
1273    vertical: bool,
1274    horizontal: bool,
1275    class: &'a Class,
1276}
1277
1278impl<Class> AutoScrollIcon<'_, Class> {
1279    const SIZE: f32 = 40.0;
1280    const DOT: f32 = Self::SIZE / 10.0;
1281    const PADDING: f32 = Self::SIZE / 10.0;
1282}
1283
1284impl<Message, Theme, Renderer> core::Overlay<Message, Theme, Renderer>
1285    for AutoScrollIcon<'_, Theme::Class<'_>>
1286where
1287    Renderer: text::Renderer,
1288    Theme: Catalog,
1289{
1290    fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node {
1291        layout::Node::new(Size::new(Self::SIZE, Self::SIZE))
1292            .move_to(self.origin - Vector::new(Self::SIZE, Self::SIZE) / 2.0)
1293    }
1294
1295    fn draw(
1296        &self,
1297        renderer: &mut Renderer,
1298        theme: &Theme,
1299        _style: &renderer::Style,
1300        layout: Layout<'_>,
1301        _cursor: mouse::Cursor,
1302    ) {
1303        let bounds = layout.bounds();
1304        let style = theme
1305            .style(
1306                self.class,
1307                Status::Active {
1308                    is_horizontal_scrollbar_disabled: false,
1309                    is_vertical_scrollbar_disabled: false,
1310                },
1311            )
1312            .auto_scroll;
1313
1314        renderer.with_layer(Rectangle::INFINITE, |renderer| {
1315            renderer.fill_quad(
1316                renderer::Quad {
1317                    bounds,
1318                    border: style.border,
1319                    shadow: style.shadow,
1320                    snap: false,
1321                },
1322                style.background,
1323            );
1324
1325            renderer.fill_quad(
1326                renderer::Quad {
1327                    bounds: Rectangle::new(
1328                        bounds.center() - Vector::new(Self::DOT, Self::DOT) / 2.0,
1329                        Size::new(Self::DOT, Self::DOT),
1330                    ),
1331                    border: border::rounded(bounds.width),
1332                    snap: false,
1333                    ..renderer::Quad::default()
1334                },
1335                style.icon,
1336            );
1337
1338            let arrow = core::Text {
1339                content: String::new(),
1340                bounds: bounds.size(),
1341                size: Pixels::from(12),
1342                line_height: text::LineHeight::Relative(1.0),
1343                font: Renderer::ICON_FONT,
1344                align_x: text::Alignment::Center,
1345                align_y: alignment::Vertical::Center,
1346                shaping: text::Shaping::Basic,
1347                wrapping: text::Wrapping::None,
1348                ellipsis: text::Ellipsis::None,
1349                hint_factor: None,
1350            };
1351
1352            if self.vertical {
1353                renderer.fill_text(
1354                    core::Text {
1355                        content: Renderer::SCROLL_UP_ICON.to_string(),
1356                        align_y: alignment::Vertical::Top,
1357                        ..arrow
1358                    },
1359                    Point::new(bounds.center_x(), bounds.y + Self::PADDING),
1360                    style.icon,
1361                    bounds,
1362                );
1363
1364                renderer.fill_text(
1365                    core::Text {
1366                        content: Renderer::SCROLL_DOWN_ICON.to_string(),
1367                        align_y: alignment::Vertical::Bottom,
1368                        ..arrow
1369                    },
1370                    Point::new(
1371                        bounds.center_x(),
1372                        bounds.y + bounds.height - Self::PADDING - 0.5,
1373                    ),
1374                    style.icon,
1375                    bounds,
1376                );
1377            }
1378
1379            if self.horizontal {
1380                renderer.fill_text(
1381                    core::Text {
1382                        content: Renderer::SCROLL_LEFT_ICON.to_string(),
1383                        align_x: text::Alignment::Left,
1384                        ..arrow
1385                    },
1386                    Point::new(bounds.x + Self::PADDING + 1.0, bounds.center_y() + 1.0),
1387                    style.icon,
1388                    bounds,
1389                );
1390
1391                renderer.fill_text(
1392                    core::Text {
1393                        content: Renderer::SCROLL_RIGHT_ICON.to_string(),
1394                        align_x: text::Alignment::Right,
1395                        ..arrow
1396                    },
1397                    Point::new(
1398                        bounds.x + bounds.width - Self::PADDING - 1.0,
1399                        bounds.center_y() + 1.0,
1400                    ),
1401                    style.icon,
1402                    bounds,
1403                );
1404            }
1405        });
1406    }
1407
1408    fn index(&self) -> f32 {
1409        f32::MAX
1410    }
1411}
1412
1413impl<'a, Message, Theme, Renderer> From<Scrollable<'a, Message, Theme, Renderer>>
1414    for Element<'a, Message, Theme, Renderer>
1415where
1416    Message: 'a,
1417    Theme: 'a + Catalog,
1418    Renderer: 'a + text::Renderer,
1419{
1420    fn from(
1421        text_input: Scrollable<'a, Message, Theme, Renderer>,
1422    ) -> Element<'a, Message, Theme, Renderer> {
1423        Element::new(text_input)
1424    }
1425}
1426
1427fn notify_scroll<Message>(
1428    state: &mut State,
1429    on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>,
1430    bounds: Rectangle,
1431    content_bounds: Rectangle,
1432    shell: &mut Shell<'_, Message>,
1433) -> bool {
1434    if notify_viewport(state, on_scroll, bounds, content_bounds, shell) {
1435        state.last_scrolled = Some(Instant::now());
1436
1437        true
1438    } else {
1439        false
1440    }
1441}
1442
1443fn notify_viewport<Message>(
1444    state: &mut State,
1445    on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>,
1446    bounds: Rectangle,
1447    content_bounds: Rectangle,
1448    shell: &mut Shell<'_, Message>,
1449) -> bool {
1450    if content_bounds.width <= bounds.width && content_bounds.height <= bounds.height {
1451        return false;
1452    }
1453
1454    let viewport = Viewport {
1455        offset_x: state.offset_x,
1456        offset_y: state.offset_y,
1457        bounds,
1458        content_bounds,
1459    };
1460
1461    // Don't publish redundant viewports to shell
1462    if let Some(last_notified) = state.last_notified {
1463        let last_relative_offset = last_notified.relative_offset();
1464        let current_relative_offset = viewport.relative_offset();
1465
1466        let last_absolute_offset = last_notified.absolute_offset();
1467        let current_absolute_offset = viewport.absolute_offset();
1468
1469        let unchanged =
1470            |a: f32, b: f32| (a - b).abs() <= f32::EPSILON || (a.is_nan() && b.is_nan());
1471
1472        if last_notified.bounds == bounds
1473            && last_notified.content_bounds == content_bounds
1474            && unchanged(last_relative_offset.x, current_relative_offset.x)
1475            && unchanged(last_relative_offset.y, current_relative_offset.y)
1476            && unchanged(last_absolute_offset.x, current_absolute_offset.x)
1477            && unchanged(last_absolute_offset.y, current_absolute_offset.y)
1478        {
1479            return false;
1480        }
1481    }
1482
1483    state.last_notified = Some(viewport);
1484
1485    if let Some(on_scroll) = on_scroll {
1486        shell.publish(on_scroll(viewport));
1487    }
1488
1489    true
1490}
1491
1492#[derive(Debug, Clone, Copy)]
1493struct State {
1494    offset_y: Offset,
1495    offset_x: Offset,
1496    interaction: Interaction,
1497    keyboard_modifiers: keyboard::Modifiers,
1498    last_notified: Option<Viewport>,
1499    last_scrolled: Option<Instant>,
1500    is_scrollbar_visible: bool,
1501}
1502
1503#[derive(Debug, Clone, Copy)]
1504enum Interaction {
1505    None,
1506    YScrollerGrabbed(f32),
1507    XScrollerGrabbed(f32),
1508    TouchScrolling(Point),
1509    AutoScrolling {
1510        origin: Point,
1511        current: Point,
1512        last_frame: Option<Instant>,
1513    },
1514}
1515
1516impl Default for State {
1517    fn default() -> Self {
1518        Self {
1519            offset_y: Offset::Absolute(0.0),
1520            offset_x: Offset::Absolute(0.0),
1521            interaction: Interaction::None,
1522            keyboard_modifiers: keyboard::Modifiers::default(),
1523            last_notified: None,
1524            last_scrolled: None,
1525            is_scrollbar_visible: true,
1526        }
1527    }
1528}
1529
1530impl operation::Scrollable for State {
1531    fn snap_to(&mut self, offset: RelativeOffset<Option<f32>>) {
1532        State::snap_to(self, offset);
1533    }
1534
1535    fn scroll_to(&mut self, offset: AbsoluteOffset<Option<f32>>) {
1536        State::scroll_to(self, offset);
1537    }
1538
1539    fn scroll_by(&mut self, offset: AbsoluteOffset, bounds: Rectangle, content_bounds: Rectangle) {
1540        State::scroll_by(self, offset, bounds, content_bounds);
1541    }
1542}
1543
1544#[derive(Debug, Clone, Copy, PartialEq)]
1545enum Offset {
1546    Absolute(f32),
1547    Relative(f32),
1548}
1549
1550impl Offset {
1551    fn absolute(self, viewport: f32, content: f32) -> f32 {
1552        match self {
1553            Offset::Absolute(absolute) => absolute.min((content - viewport).max(0.0)),
1554            Offset::Relative(percentage) => ((content - viewport) * percentage).max(0.0),
1555        }
1556    }
1557
1558    fn translation(self, viewport: f32, content: f32, alignment: Anchor) -> f32 {
1559        let offset = self.absolute(viewport, content);
1560
1561        match alignment {
1562            Anchor::Start => offset,
1563            Anchor::End => ((content - viewport).max(0.0) - offset).max(0.0),
1564        }
1565    }
1566}
1567
1568/// The current [`Viewport`] of the [`Scrollable`].
1569#[derive(Debug, Clone, Copy)]
1570pub struct Viewport {
1571    offset_x: Offset,
1572    offset_y: Offset,
1573    bounds: Rectangle,
1574    content_bounds: Rectangle,
1575}
1576
1577impl Viewport {
1578    /// Returns the [`AbsoluteOffset`] of the current [`Viewport`].
1579    pub fn absolute_offset(&self) -> AbsoluteOffset {
1580        let x = self
1581            .offset_x
1582            .absolute(self.bounds.width, self.content_bounds.width);
1583        let y = self
1584            .offset_y
1585            .absolute(self.bounds.height, self.content_bounds.height);
1586
1587        AbsoluteOffset { x, y }
1588    }
1589
1590    /// Returns the [`AbsoluteOffset`] of the current [`Viewport`], but with its
1591    /// alignment reversed.
1592    ///
1593    /// This method can be useful to switch the alignment of a [`Scrollable`]
1594    /// while maintaining its scrolling position.
1595    pub fn absolute_offset_reversed(&self) -> AbsoluteOffset {
1596        let AbsoluteOffset { x, y } = self.absolute_offset();
1597
1598        AbsoluteOffset {
1599            x: (self.content_bounds.width - self.bounds.width).max(0.0) - x,
1600            y: (self.content_bounds.height - self.bounds.height).max(0.0) - y,
1601        }
1602    }
1603
1604    /// Returns the [`RelativeOffset`] of the current [`Viewport`].
1605    pub fn relative_offset(&self) -> RelativeOffset {
1606        let AbsoluteOffset { x, y } = self.absolute_offset();
1607
1608        let x = x / (self.content_bounds.width - self.bounds.width);
1609        let y = y / (self.content_bounds.height - self.bounds.height);
1610
1611        RelativeOffset { x, y }
1612    }
1613
1614    /// Returns the bounds of the current [`Viewport`].
1615    pub fn bounds(&self) -> Rectangle {
1616        self.bounds
1617    }
1618
1619    /// Returns the content bounds of the current [`Viewport`].
1620    pub fn content_bounds(&self) -> Rectangle {
1621        self.content_bounds
1622    }
1623}
1624
1625impl State {
1626    fn new() -> Self {
1627        State::default()
1628    }
1629
1630    fn scroll(&mut self, delta: Vector<f32>, bounds: Rectangle, content_bounds: Rectangle) {
1631        if bounds.height < content_bounds.height {
1632            self.offset_y = Offset::Absolute(
1633                (self.offset_y.absolute(bounds.height, content_bounds.height) + delta.y)
1634                    .clamp(0.0, content_bounds.height - bounds.height),
1635            );
1636        }
1637
1638        if bounds.width < content_bounds.width {
1639            self.offset_x = Offset::Absolute(
1640                (self.offset_x.absolute(bounds.width, content_bounds.width) + delta.x)
1641                    .clamp(0.0, content_bounds.width - bounds.width),
1642            );
1643        }
1644    }
1645
1646    fn scroll_y_to(&mut self, percentage: f32, bounds: Rectangle, content_bounds: Rectangle) {
1647        self.offset_y = Offset::Relative(percentage.clamp(0.0, 1.0));
1648        self.unsnap(bounds, content_bounds);
1649    }
1650
1651    fn scroll_x_to(&mut self, percentage: f32, bounds: Rectangle, content_bounds: Rectangle) {
1652        self.offset_x = Offset::Relative(percentage.clamp(0.0, 1.0));
1653        self.unsnap(bounds, content_bounds);
1654    }
1655
1656    fn snap_to(&mut self, offset: RelativeOffset<Option<f32>>) {
1657        if let Some(x) = offset.x {
1658            self.offset_x = Offset::Relative(x.clamp(0.0, 1.0));
1659        }
1660
1661        if let Some(y) = offset.y {
1662            self.offset_y = Offset::Relative(y.clamp(0.0, 1.0));
1663        }
1664    }
1665
1666    fn scroll_to(&mut self, offset: AbsoluteOffset<Option<f32>>) {
1667        if let Some(x) = offset.x {
1668            self.offset_x = Offset::Absolute(x.max(0.0));
1669        }
1670
1671        if let Some(y) = offset.y {
1672            self.offset_y = Offset::Absolute(y.max(0.0));
1673        }
1674    }
1675
1676    /// Scroll by the provided [`AbsoluteOffset`].
1677    fn scroll_by(&mut self, offset: AbsoluteOffset, bounds: Rectangle, content_bounds: Rectangle) {
1678        self.scroll(Vector::new(offset.x, offset.y), bounds, content_bounds);
1679    }
1680
1681    /// Unsnaps the current scroll position, if snapped, given the bounds of the
1682    /// [`Scrollable`] and its contents.
1683    fn unsnap(&mut self, bounds: Rectangle, content_bounds: Rectangle) {
1684        self.offset_x =
1685            Offset::Absolute(self.offset_x.absolute(bounds.width, content_bounds.width));
1686        self.offset_y =
1687            Offset::Absolute(self.offset_y.absolute(bounds.height, content_bounds.height));
1688    }
1689
1690    /// Returns the scrolling translation of the [`State`], given a [`Direction`],
1691    /// the bounds of the [`Scrollable`] and its contents.
1692    fn translation(
1693        &self,
1694        direction: Direction,
1695        bounds: Rectangle,
1696        content_bounds: Rectangle,
1697    ) -> Vector {
1698        Vector::new(
1699            if let Some(horizontal) = direction.horizontal() {
1700                self.offset_x
1701                    .translation(bounds.width, content_bounds.width, horizontal.alignment)
1702                    .round()
1703            } else {
1704                0.0
1705            },
1706            if let Some(vertical) = direction.vertical() {
1707                self.offset_y
1708                    .translation(bounds.height, content_bounds.height, vertical.alignment)
1709                    .round()
1710            } else {
1711                0.0
1712            },
1713        )
1714    }
1715
1716    fn scrollers_grabbed(&self) -> bool {
1717        matches!(
1718            self.interaction,
1719            Interaction::YScrollerGrabbed(_) | Interaction::XScrollerGrabbed(_),
1720        )
1721    }
1722
1723    pub fn y_scroller_grabbed_at(&self) -> Option<f32> {
1724        let Interaction::YScrollerGrabbed(at) = self.interaction else {
1725            return None;
1726        };
1727
1728        Some(at)
1729    }
1730
1731    pub fn x_scroller_grabbed_at(&self) -> Option<f32> {
1732        let Interaction::XScrollerGrabbed(at) = self.interaction else {
1733            return None;
1734        };
1735
1736        Some(at)
1737    }
1738}
1739
1740#[derive(Debug)]
1741/// State of both [`Scrollbar`]s.
1742struct Scrollbars {
1743    y: Option<internals::Scrollbar>,
1744    x: Option<internals::Scrollbar>,
1745}
1746
1747impl Scrollbars {
1748    /// Create y and/or x scrollbar(s) if content is overflowing the [`Scrollable`] bounds.
1749    fn new(
1750        state: &State,
1751        direction: Direction,
1752        bounds: Rectangle,
1753        content_bounds: Rectangle,
1754    ) -> Self {
1755        let translation = state.translation(direction, bounds, content_bounds);
1756
1757        let show_scrollbar_x = direction
1758            .horizontal()
1759            .filter(|_scrollbar| content_bounds.width > bounds.width);
1760
1761        let show_scrollbar_y = direction
1762            .vertical()
1763            .filter(|_scrollbar| content_bounds.height > bounds.height);
1764
1765        let y_scrollbar = if let Some(vertical) = show_scrollbar_y {
1766            let Scrollbar {
1767                width,
1768                margin,
1769                scroller_width,
1770                ..
1771            } = *vertical;
1772
1773            // Adjust the height of the vertical scrollbar if the horizontal scrollbar
1774            // is present
1775            let x_scrollbar_height =
1776                show_scrollbar_x.map_or(0.0, |h| h.width.max(h.scroller_width) + h.margin);
1777
1778            let total_scrollbar_width = width.max(scroller_width) + 2.0 * margin;
1779
1780            // Total bounds of the scrollbar + margin + scroller width
1781            let total_scrollbar_bounds = Rectangle {
1782                x: bounds.x + bounds.width - total_scrollbar_width,
1783                y: bounds.y,
1784                width: total_scrollbar_width,
1785                height: (bounds.height - x_scrollbar_height).max(0.0),
1786            };
1787
1788            // Bounds of just the scrollbar
1789            let scrollbar_bounds = Rectangle {
1790                x: bounds.x + bounds.width - total_scrollbar_width / 2.0 - width / 2.0,
1791                y: bounds.y,
1792                width,
1793                height: (bounds.height - x_scrollbar_height).max(0.0),
1794            };
1795
1796            let ratio = bounds.height / content_bounds.height;
1797
1798            let scroller = if ratio >= 1.0 {
1799                None
1800            } else {
1801                // min height for easier grabbing with super tall content
1802                let scroller_height = (scrollbar_bounds.height * ratio).max(2.0);
1803                let scroller_offset =
1804                    translation.y * ratio * scrollbar_bounds.height / bounds.height;
1805
1806                let scroller_bounds = Rectangle {
1807                    x: bounds.x + bounds.width - total_scrollbar_width / 2.0 - scroller_width / 2.0,
1808                    y: (scrollbar_bounds.y + scroller_offset).max(0.0),
1809                    width: scroller_width,
1810                    height: scroller_height,
1811                };
1812
1813                Some(internals::Scroller {
1814                    bounds: scroller_bounds,
1815                })
1816            };
1817
1818            Some(internals::Scrollbar {
1819                total_bounds: total_scrollbar_bounds,
1820                bounds: scrollbar_bounds,
1821                scroller,
1822                alignment: vertical.alignment,
1823                disabled: content_bounds.height <= bounds.height,
1824            })
1825        } else {
1826            None
1827        };
1828
1829        let x_scrollbar = if let Some(horizontal) = show_scrollbar_x {
1830            let Scrollbar {
1831                width,
1832                margin,
1833                scroller_width,
1834                ..
1835            } = *horizontal;
1836
1837            // Need to adjust the width of the horizontal scrollbar if the vertical scrollbar
1838            // is present
1839            let scrollbar_y_width =
1840                y_scrollbar.map_or(0.0, |scrollbar| scrollbar.total_bounds.width);
1841
1842            let total_scrollbar_height = width.max(scroller_width) + 2.0 * margin;
1843
1844            // Total bounds of the scrollbar + margin + scroller width
1845            let total_scrollbar_bounds = Rectangle {
1846                x: bounds.x,
1847                y: bounds.y + bounds.height - total_scrollbar_height,
1848                width: (bounds.width - scrollbar_y_width).max(0.0),
1849                height: total_scrollbar_height,
1850            };
1851
1852            // Bounds of just the scrollbar
1853            let scrollbar_bounds = Rectangle {
1854                x: bounds.x,
1855                y: bounds.y + bounds.height - total_scrollbar_height / 2.0 - width / 2.0,
1856                width: (bounds.width - scrollbar_y_width).max(0.0),
1857                height: width,
1858            };
1859
1860            let ratio = bounds.width / content_bounds.width;
1861
1862            let scroller = if ratio >= 1.0 {
1863                None
1864            } else {
1865                // min width for easier grabbing with extra wide content
1866                let scroller_length = (scrollbar_bounds.width * ratio).max(2.0);
1867                let scroller_offset = translation.x * ratio * scrollbar_bounds.width / bounds.width;
1868
1869                let scroller_bounds = Rectangle {
1870                    x: (scrollbar_bounds.x + scroller_offset).max(0.0),
1871                    y: bounds.y + bounds.height
1872                        - total_scrollbar_height / 2.0
1873                        - scroller_width / 2.0,
1874                    width: scroller_length,
1875                    height: scroller_width,
1876                };
1877
1878                Some(internals::Scroller {
1879                    bounds: scroller_bounds,
1880                })
1881            };
1882
1883            Some(internals::Scrollbar {
1884                total_bounds: total_scrollbar_bounds,
1885                bounds: scrollbar_bounds,
1886                scroller,
1887                alignment: horizontal.alignment,
1888                disabled: content_bounds.width <= bounds.width,
1889            })
1890        } else {
1891            None
1892        };
1893
1894        Self {
1895            y: y_scrollbar,
1896            x: x_scrollbar,
1897        }
1898    }
1899
1900    fn is_mouse_over(&self, cursor: mouse::Cursor) -> (bool, bool) {
1901        if let Some(cursor_position) = cursor.position() {
1902            (
1903                self.y
1904                    .as_ref()
1905                    .map(|scrollbar| scrollbar.is_mouse_over(cursor_position))
1906                    .unwrap_or(false),
1907                self.x
1908                    .as_ref()
1909                    .map(|scrollbar| scrollbar.is_mouse_over(cursor_position))
1910                    .unwrap_or(false),
1911            )
1912        } else {
1913            (false, false)
1914        }
1915    }
1916
1917    fn is_y_disabled(&self) -> bool {
1918        self.y.map(|y| y.disabled).unwrap_or(false)
1919    }
1920
1921    fn is_x_disabled(&self) -> bool {
1922        self.x.map(|x| x.disabled).unwrap_or(false)
1923    }
1924
1925    fn grab_y_scroller(&self, cursor_position: Point) -> Option<f32> {
1926        let scrollbar = self.y?;
1927        let scroller = scrollbar.scroller?;
1928
1929        if scrollbar.total_bounds.contains(cursor_position) {
1930            Some(if scroller.bounds.contains(cursor_position) {
1931                (cursor_position.y - scroller.bounds.y) / scroller.bounds.height
1932            } else {
1933                0.5
1934            })
1935        } else {
1936            None
1937        }
1938    }
1939
1940    fn grab_x_scroller(&self, cursor_position: Point) -> Option<f32> {
1941        let scrollbar = self.x?;
1942        let scroller = scrollbar.scroller?;
1943
1944        if scrollbar.total_bounds.contains(cursor_position) {
1945            Some(if scroller.bounds.contains(cursor_position) {
1946                (cursor_position.x - scroller.bounds.x) / scroller.bounds.width
1947            } else {
1948                0.5
1949            })
1950        } else {
1951            None
1952        }
1953    }
1954
1955    fn active(&self) -> bool {
1956        self.y.is_some() || self.x.is_some()
1957    }
1958}
1959
1960pub(super) mod internals {
1961    use crate::core::{Point, Rectangle};
1962
1963    use super::Anchor;
1964
1965    #[derive(Debug, Copy, Clone)]
1966    pub struct Scrollbar {
1967        pub total_bounds: Rectangle,
1968        pub bounds: Rectangle,
1969        pub scroller: Option<Scroller>,
1970        pub alignment: Anchor,
1971        pub disabled: bool,
1972    }
1973
1974    impl Scrollbar {
1975        /// Returns whether the mouse is over the scrollbar or not.
1976        pub fn is_mouse_over(&self, cursor_position: Point) -> bool {
1977            self.total_bounds.contains(cursor_position)
1978        }
1979
1980        /// Returns the y-axis scrolled percentage from the cursor position.
1981        pub fn scroll_percentage_y(&self, grabbed_at: f32, cursor_position: Point) -> f32 {
1982            if let Some(scroller) = self.scroller {
1983                let percentage =
1984                    (cursor_position.y - self.bounds.y - scroller.bounds.height * grabbed_at)
1985                        / (self.bounds.height - scroller.bounds.height);
1986
1987                match self.alignment {
1988                    Anchor::Start => percentage,
1989                    Anchor::End => 1.0 - percentage,
1990                }
1991            } else {
1992                0.0
1993            }
1994        }
1995
1996        /// Returns the x-axis scrolled percentage from the cursor position.
1997        pub fn scroll_percentage_x(&self, grabbed_at: f32, cursor_position: Point) -> f32 {
1998            if let Some(scroller) = self.scroller {
1999                let percentage =
2000                    (cursor_position.x - self.bounds.x - scroller.bounds.width * grabbed_at)
2001                        / (self.bounds.width - scroller.bounds.width);
2002
2003                match self.alignment {
2004                    Anchor::Start => percentage,
2005                    Anchor::End => 1.0 - percentage,
2006                }
2007            } else {
2008                0.0
2009            }
2010        }
2011    }
2012
2013    /// The handle of a [`Scrollbar`].
2014    #[derive(Debug, Clone, Copy)]
2015    pub struct Scroller {
2016        /// The bounds of the [`Scroller`].
2017        pub bounds: Rectangle,
2018    }
2019}
2020
2021/// The possible status of a [`Scrollable`].
2022#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2023pub enum Status {
2024    /// The [`Scrollable`] can be interacted with.
2025    Active {
2026        /// Whether or not the horizontal scrollbar is disabled meaning the content isn't overflowing.
2027        is_horizontal_scrollbar_disabled: bool,
2028        /// Whether or not the vertical scrollbar is disabled meaning the content isn't overflowing.
2029        is_vertical_scrollbar_disabled: bool,
2030    },
2031    /// The [`Scrollable`] is being hovered.
2032    Hovered {
2033        /// Indicates if the horizontal scrollbar is being hovered.
2034        is_horizontal_scrollbar_hovered: bool,
2035        /// Indicates if the vertical scrollbar is being hovered.
2036        is_vertical_scrollbar_hovered: bool,
2037        /// Whether or not the horizontal scrollbar is disabled meaning the content isn't overflowing.
2038        is_horizontal_scrollbar_disabled: bool,
2039        /// Whether or not the vertical scrollbar is disabled meaning the content isn't overflowing.
2040        is_vertical_scrollbar_disabled: bool,
2041    },
2042    /// The [`Scrollable`] is being dragged.
2043    Dragged {
2044        /// Indicates if the horizontal scrollbar is being dragged.
2045        is_horizontal_scrollbar_dragged: bool,
2046        /// Indicates if the vertical scrollbar is being dragged.
2047        is_vertical_scrollbar_dragged: bool,
2048        /// Whether or not the horizontal scrollbar is disabled meaning the content isn't overflowing.
2049        is_horizontal_scrollbar_disabled: bool,
2050        /// Whether or not the vertical scrollbar is disabled meaning the content isn't overflowing.
2051        is_vertical_scrollbar_disabled: bool,
2052    },
2053}
2054
2055/// The appearance of a scrollable.
2056#[derive(Debug, Clone, Copy, PartialEq)]
2057pub struct Style {
2058    /// The [`container::Style`] of a scrollable.
2059    pub container: container::Style,
2060    /// The vertical [`Rail`] appearance.
2061    pub vertical_rail: Rail,
2062    /// The horizontal [`Rail`] appearance.
2063    pub horizontal_rail: Rail,
2064    /// The [`Background`] of the gap between a horizontal and vertical scrollbar.
2065    pub gap: Option<Background>,
2066    /// The appearance of the [`AutoScroll`] overlay.
2067    pub auto_scroll: AutoScroll,
2068}
2069
2070/// The appearance of the scrollbar of a scrollable.
2071#[derive(Debug, Clone, Copy, PartialEq)]
2072pub struct Rail {
2073    /// The [`Background`] of a scrollbar.
2074    pub background: Option<Background>,
2075    /// The [`Border`] of a scrollbar.
2076    pub border: Border,
2077    /// The appearance of the [`Scroller`] of a scrollbar.
2078    pub scroller: Scroller,
2079}
2080
2081/// The appearance of the scroller of a scrollable.
2082#[derive(Debug, Clone, Copy, PartialEq)]
2083pub struct Scroller {
2084    /// The [`Background`] of the scroller.
2085    pub background: Background,
2086    /// The [`Border`] of the scroller.
2087    pub border: Border,
2088}
2089
2090/// The appearance of the autoscroll overlay of a scrollable.
2091#[derive(Debug, Clone, Copy, PartialEq)]
2092pub struct AutoScroll {
2093    /// The [`Background`] of the [`AutoScroll`] overlay.
2094    pub background: Background,
2095    /// The [`Border`] of the [`AutoScroll`] overlay.
2096    pub border: Border,
2097    /// Thje [`Shadow`] of the [`AutoScroll`] overlay.
2098    pub shadow: Shadow,
2099    /// The [`Color`] for the arrow icons of the [`AutoScroll`] overlay.
2100    pub icon: Color,
2101}
2102
2103/// The theme catalog of a [`Scrollable`].
2104pub trait Catalog {
2105    /// The item class of the [`Catalog`].
2106    type Class<'a>;
2107
2108    /// The default class produced by the [`Catalog`].
2109    fn default<'a>() -> Self::Class<'a>;
2110
2111    /// The [`Style`] of a class with the given status.
2112    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
2113}
2114
2115/// A styling function for a [`Scrollable`].
2116pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
2117
2118impl Catalog for Theme {
2119    type Class<'a> = StyleFn<'a, Self>;
2120
2121    fn default<'a>() -> Self::Class<'a> {
2122        Box::new(default)
2123    }
2124
2125    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
2126        class(self, status)
2127    }
2128}
2129
2130/// The default style of a [`Scrollable`].
2131pub fn default(theme: &Theme, status: Status) -> Style {
2132    let palette = theme.palette();
2133
2134    let scrollbar = Rail {
2135        background: Some(palette.background.weak.color.into()),
2136        border: border::rounded(2),
2137        scroller: Scroller {
2138            background: palette.background.strongest.color.into(),
2139            border: border::rounded(2),
2140        },
2141    };
2142
2143    let auto_scroll = AutoScroll {
2144        background: palette.background.base.color.scale_alpha(0.9).into(),
2145        border: border::rounded(u32::MAX)
2146            .width(1)
2147            .color(palette.background.base.text.scale_alpha(0.8)),
2148        shadow: Shadow {
2149            color: Color::BLACK.scale_alpha(0.7),
2150            offset: Vector::ZERO,
2151            blur_radius: 2.0,
2152        },
2153        icon: palette.background.base.text.scale_alpha(0.8),
2154    };
2155
2156    match status {
2157        Status::Active { .. } => Style {
2158            container: container::Style::default(),
2159            vertical_rail: scrollbar,
2160            horizontal_rail: scrollbar,
2161            gap: None,
2162            auto_scroll,
2163        },
2164        Status::Hovered {
2165            is_horizontal_scrollbar_hovered,
2166            is_vertical_scrollbar_hovered,
2167            ..
2168        } => {
2169            let hovered_scrollbar = Rail {
2170                scroller: Scroller {
2171                    background: palette.primary.strong.color.into(),
2172                    ..scrollbar.scroller
2173                },
2174                ..scrollbar
2175            };
2176
2177            Style {
2178                container: container::Style::default(),
2179                vertical_rail: if is_vertical_scrollbar_hovered {
2180                    hovered_scrollbar
2181                } else {
2182                    scrollbar
2183                },
2184                horizontal_rail: if is_horizontal_scrollbar_hovered {
2185                    hovered_scrollbar
2186                } else {
2187                    scrollbar
2188                },
2189                gap: None,
2190                auto_scroll,
2191            }
2192        }
2193        Status::Dragged {
2194            is_horizontal_scrollbar_dragged,
2195            is_vertical_scrollbar_dragged,
2196            ..
2197        } => {
2198            let dragged_scrollbar = Rail {
2199                scroller: Scroller {
2200                    background: palette.primary.base.color.into(),
2201                    ..scrollbar.scroller
2202                },
2203                ..scrollbar
2204            };
2205
2206            Style {
2207                container: container::Style::default(),
2208                vertical_rail: if is_vertical_scrollbar_dragged {
2209                    dragged_scrollbar
2210                } else {
2211                    scrollbar
2212                },
2213                horizontal_rail: if is_horizontal_scrollbar_dragged {
2214                    dragged_scrollbar
2215                } else {
2216                    scrollbar
2217                },
2218                gap: None,
2219                auto_scroll,
2220            }
2221        }
2222    }
2223}