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}
80
81impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer>
82where
83    Theme: Catalog,
84    Renderer: text::Renderer,
85{
86    /// Creates a new vertical [`Scrollable`].
87    pub fn new(content: impl Into<Element<'a, Message, Theme, Renderer>>) -> Self {
88        Self::with_direction(content, Direction::default())
89    }
90
91    /// Creates a new [`Scrollable`] with the given [`Direction`].
92    pub fn with_direction(
93        content: impl Into<Element<'a, Message, Theme, Renderer>>,
94        direction: impl Into<Direction>,
95    ) -> Self {
96        Scrollable {
97            id: None,
98            width: Length::Fit,
99            height: Length::Fit,
100            direction: direction.into(),
101            auto_scroll: false,
102            content: content.into(),
103            on_scroll: None,
104            class: Theme::default(),
105        }
106    }
107
108    /// Makes the [`Scrollable`] scroll horizontally, with default [`Scrollbar`] settings.
109    pub fn horizontal(self) -> Self {
110        self.direction(Direction::Horizontal(Scrollbar::default()))
111    }
112
113    /// Sets the [`Direction`] of the [`Scrollable`].
114    pub fn direction(mut self, direction: impl Into<Direction>) -> Self {
115        self.direction = direction.into();
116        self
117    }
118
119    /// Sets the [`widget::Id`] of the [`Scrollable`].
120    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
121        self.id = Some(id.into());
122        self
123    }
124
125    /// Sets the width of the [`Scrollable`].
126    pub fn width(mut self, width: impl Into<Length>) -> Self {
127        self.width = width.into();
128        self
129    }
130
131    /// Sets the height of the [`Scrollable`].
132    pub fn height(mut self, height: impl Into<Length>) -> Self {
133        self.height = height.into();
134        self
135    }
136
137    /// Sets a function to call when the [`Scrollable`] is scrolled.
138    ///
139    /// The function takes the [`Viewport`] of the [`Scrollable`]
140    pub fn on_scroll(mut self, f: impl Fn(Viewport) -> Message + 'a) -> Self {
141        self.on_scroll = Some(Box::new(f));
142        self
143    }
144
145    /// Anchors the vertical [`Scrollable`] direction to the top.
146    pub fn anchor_top(self) -> Self {
147        self.anchor_y(Anchor::Start)
148    }
149
150    /// Anchors the vertical [`Scrollable`] direction to the bottom.
151    pub fn anchor_bottom(self) -> Self {
152        self.anchor_y(Anchor::End)
153    }
154
155    /// Anchors the horizontal [`Scrollable`] direction to the left.
156    pub fn anchor_left(self) -> Self {
157        self.anchor_x(Anchor::Start)
158    }
159
160    /// Anchors the horizontal [`Scrollable`] direction to the right.
161    pub fn anchor_right(self) -> Self {
162        self.anchor_x(Anchor::End)
163    }
164
165    /// Sets the [`Anchor`] of the horizontal direction of the [`Scrollable`], if applicable.
166    pub fn anchor_x(mut self, alignment: Anchor) -> Self {
167        match &mut self.direction {
168            Direction::Horizontal(horizontal) | Direction::Both { horizontal, .. } => {
169                horizontal.alignment = alignment;
170            }
171            Direction::Vertical { .. } => {}
172        }
173
174        self
175    }
176
177    /// Sets the [`Anchor`] of the vertical direction of the [`Scrollable`], if applicable.
178    pub fn anchor_y(mut self, alignment: Anchor) -> Self {
179        match &mut self.direction {
180            Direction::Vertical(vertical) | Direction::Both { vertical, .. } => {
181                vertical.alignment = alignment;
182            }
183            Direction::Horizontal { .. } => {}
184        }
185
186        self
187    }
188
189    /// Embeds the [`Scrollbar`] into the [`Scrollable`], instead of floating on top of the
190    /// content.
191    ///
192    /// The `spacing` provided will be used as space between the [`Scrollbar`] and the contents
193    /// of the [`Scrollable`].
194    pub fn spacing(mut self, new_spacing: impl Into<Pixels>) -> Self {
195        match &mut self.direction {
196            Direction::Horizontal(scrollbar) | Direction::Vertical(scrollbar) => {
197                scrollbar.spacing = Some(new_spacing.into().0);
198            }
199            Direction::Both { .. } => {}
200        }
201
202        self
203    }
204
205    /// Sets whether the user should be allowed to auto-scroll the [`Scrollable`]
206    /// with the middle mouse button.
207    ///
208    /// By default, it is disabled.
209    pub fn auto_scroll(mut self, auto_scroll: bool) -> Self {
210        self.auto_scroll = auto_scroll;
211        self
212    }
213
214    /// Sets the style of this [`Scrollable`].
215    #[must_use]
216    pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
217    where
218        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
219    {
220        self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
221        self
222    }
223
224    /// Sets the style class of the [`Scrollable`].
225    #[cfg(feature = "advanced")]
226    #[must_use]
227    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
228        self.class = class.into();
229        self
230    }
231}
232
233/// The direction of [`Scrollable`].
234#[derive(Debug, Clone, Copy, PartialEq)]
235pub enum Direction {
236    /// Vertical scrolling
237    Vertical(Scrollbar),
238    /// Horizontal scrolling
239    Horizontal(Scrollbar),
240    /// Both vertical and horizontal scrolling
241    Both {
242        /// The properties of the vertical scrollbar.
243        vertical: Scrollbar,
244        /// The properties of the horizontal scrollbar.
245        horizontal: Scrollbar,
246    },
247}
248
249impl Direction {
250    /// Returns the horizontal [`Scrollbar`], if any.
251    pub fn horizontal(&self) -> Option<&Scrollbar> {
252        match self {
253            Self::Horizontal(scrollbar) => Some(scrollbar),
254            Self::Both { horizontal, .. } => Some(horizontal),
255            Self::Vertical(_) => None,
256        }
257    }
258
259    /// Returns the vertical [`Scrollbar`], if any.
260    pub fn vertical(&self) -> Option<&Scrollbar> {
261        match self {
262            Self::Vertical(scrollbar) => Some(scrollbar),
263            Self::Both { vertical, .. } => Some(vertical),
264            Self::Horizontal(_) => None,
265        }
266    }
267
268    fn align(&self, delta: Vector) -> Vector {
269        let horizontal_alignment = self.horizontal().map(|p| p.alignment).unwrap_or_default();
270
271        let vertical_alignment = self.vertical().map(|p| p.alignment).unwrap_or_default();
272
273        let align = |alignment: Anchor, delta: f32| match alignment {
274            Anchor::Start => delta,
275            Anchor::End => -delta,
276        };
277
278        Vector::new(
279            align(horizontal_alignment, delta.x),
280            align(vertical_alignment, delta.y),
281        )
282    }
283}
284
285impl Default for Direction {
286    fn default() -> Self {
287        Self::Vertical(Scrollbar::default())
288    }
289}
290
291/// A scrollbar within a [`Scrollable`].
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub struct Scrollbar {
294    width: f32,
295    margin: f32,
296    scroller_width: f32,
297    alignment: Anchor,
298    spacing: Option<f32>,
299}
300
301impl Default for Scrollbar {
302    fn default() -> Self {
303        Self {
304            width: 10.0,
305            margin: 0.0,
306            scroller_width: 10.0,
307            alignment: Anchor::Start,
308            spacing: None,
309        }
310    }
311}
312
313impl Scrollbar {
314    /// Creates new [`Scrollbar`] for use in a [`Scrollable`].
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    /// Create a [`Scrollbar`] with zero width to allow a [`Scrollable`] to scroll without a visible
320    /// scroller.
321    pub fn hidden() -> Self {
322        Self::default().width(0).scroller_width(0)
323    }
324
325    /// Sets the scrollbar width of the [`Scrollbar`] .
326    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
327        self.width = width.into().0.max(0.0);
328        self
329    }
330
331    /// Sets the scrollbar margin of the [`Scrollbar`] .
332    pub fn margin(mut self, margin: impl Into<Pixels>) -> Self {
333        self.margin = margin.into().0;
334        self
335    }
336
337    /// Sets the scroller width of the [`Scrollbar`] .
338    pub fn scroller_width(mut self, scroller_width: impl Into<Pixels>) -> Self {
339        self.scroller_width = scroller_width.into().0.max(0.0);
340        self
341    }
342
343    /// Sets the [`Anchor`] of the [`Scrollbar`] .
344    pub fn anchor(mut self, alignment: Anchor) -> Self {
345        self.alignment = alignment;
346        self
347    }
348
349    /// Sets whether the [`Scrollbar`] should be embedded in the [`Scrollable`], using
350    /// the given spacing between itself and the contents.
351    ///
352    /// An embedded [`Scrollbar`] will always be displayed, will take layout space,
353    /// and will not float over the contents.
354    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
355        self.spacing = Some(spacing.into().0);
356        self
357    }
358}
359
360/// The anchor of the scroller of the [`Scrollable`] relative to its [`Viewport`]
361/// on a given axis.
362#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
363pub enum Anchor {
364    /// Scroller is anchored to the start of the [`Viewport`].
365    #[default]
366    Start,
367    /// Content is aligned to the end of the [`Viewport`].
368    End,
369}
370
371impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
372    for Scrollable<'_, Message, Theme, Renderer>
373where
374    Theme: Catalog,
375    Renderer: text::Renderer,
376{
377    fn tag(&self) -> tree::Tag {
378        tree::Tag::of::<State>()
379    }
380
381    fn state(&self) -> tree::State {
382        tree::State::new(State::new())
383    }
384
385    fn diff(&mut self, tree: &mut Tree) {
386        tree.diff_children(std::slice::from_mut(&mut self.content));
387
388        let size = self.content.as_widget().size();
389
390        if self.direction.horizontal().is_none() {
391            self.width = self.width.stack(size.width);
392        }
393
394        if self.direction.vertical().is_none() {
395            self.height = self.height.stack(size.height);
396        }
397    }
398
399    fn size(&self) -> Size<Length> {
400        Size {
401            width: self.width,
402            height: self.height,
403        }
404    }
405
406    fn layout(
407        &mut self,
408        tree: &mut Tree,
409        renderer: &Renderer,
410        limits: &layout::Limits,
411    ) -> layout::Node {
412        let mut layout = |right_padding, bottom_padding| {
413            layout::padded(
414                limits,
415                self.width,
416                self.height,
417                Padding {
418                    right: right_padding,
419                    bottom: bottom_padding,
420                    ..Padding::ZERO
421                },
422                |limits| {
423                    let is_horizontal = self.direction.horizontal().is_some();
424                    let is_vertical = self.direction.vertical().is_some();
425
426                    let child_limits = layout::Limits::with_compression(
427                        limits.min(),
428                        Size::new(
429                            if is_horizontal {
430                                f32::INFINITY
431                            } else {
432                                limits.max().width
433                            },
434                            if is_vertical {
435                                f32::INFINITY
436                            } else {
437                                limits.max().height
438                            },
439                        ),
440                        Size::new(is_horizontal, is_vertical),
441                    );
442
443                    self.content.as_widget_mut().layout(
444                        &mut tree.children[0],
445                        renderer,
446                        &child_limits,
447                    )
448                },
449            )
450        };
451
452        match self.direction {
453            Direction::Vertical(Scrollbar {
454                width,
455                margin,
456                spacing: Some(spacing),
457                ..
458            })
459            | Direction::Horizontal(Scrollbar {
460                width,
461                margin,
462                spacing: Some(spacing),
463                ..
464            }) => {
465                let is_vertical = matches!(self.direction, Direction::Vertical(_));
466
467                let padding = width + margin * 2.0 + spacing;
468                let state = tree.state.downcast_mut::<State>();
469
470                let status_quo = layout(
471                    if is_vertical && state.is_scrollbar_visible {
472                        padding
473                    } else {
474                        0.0
475                    },
476                    if !is_vertical && state.is_scrollbar_visible {
477                        padding
478                    } else {
479                        0.0
480                    },
481                );
482
483                let is_scrollbar_visible = if is_vertical {
484                    status_quo.children()[0].size().height > status_quo.size().height
485                } else {
486                    status_quo.children()[0].size().width > status_quo.size().width
487                };
488
489                if state.is_scrollbar_visible == is_scrollbar_visible {
490                    status_quo
491                } else {
492                    log::trace!("Scrollbar status quo has changed");
493                    state.is_scrollbar_visible = is_scrollbar_visible;
494
495                    layout(
496                        if is_vertical && state.is_scrollbar_visible {
497                            padding
498                        } else {
499                            0.0
500                        },
501                        if !is_vertical && state.is_scrollbar_visible {
502                            padding
503                        } else {
504                            0.0
505                        },
506                    )
507                }
508            }
509            _ => layout(0.0, 0.0),
510        }
511    }
512
513    fn operate(
514        &mut self,
515        tree: &mut Tree,
516        layout: Layout<'_>,
517        renderer: &Renderer,
518        operation: &mut dyn Operation,
519    ) {
520        let state = tree.state.downcast_mut::<State>();
521
522        let bounds = layout.bounds();
523        let content_layout = layout.children().next().unwrap();
524        let content_bounds = content_layout.bounds();
525        let translation = state.translation(self.direction, bounds, content_bounds);
526
527        operation.scrollable(self.id.as_ref(), bounds, content_bounds, translation, state);
528
529        operation.traverse(&mut |operation| {
530            self.content.as_widget_mut().operate(
531                &mut tree.children[0],
532                layout.children().next().unwrap(),
533                renderer,
534                operation,
535            );
536        });
537    }
538
539    fn update(
540        &mut self,
541        tree: &mut Tree,
542        event: &Event,
543        layout: Layout<'_>,
544        cursor: mouse::Cursor,
545        renderer: &Renderer,
546        shell: &mut Shell<'_, Message>,
547        _viewport: &Rectangle,
548    ) {
549        const AUTOSCROLL_DEADZONE: f32 = 20.0;
550        const AUTOSCROLL_SMOOTHNESS: f32 = 1.5;
551
552        let state = tree.state.downcast_mut::<State>();
553        let bounds = layout.bounds();
554        let cursor_over_scrollable = cursor.position_over(bounds);
555
556        let content = layout.children().next().unwrap();
557        let content_bounds = content.bounds();
558
559        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
560
561        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
562
563        let last_offsets = (state.offset_x, state.offset_y);
564
565        if let Some(last_scrolled) = state.last_scrolled {
566            let clear_transaction = match event {
567                Event::Mouse(
568                    mouse::Event::ButtonPressed(_)
569                    | mouse::Event::ButtonReleased(_)
570                    | mouse::Event::CursorLeft,
571                ) => true,
572                Event::Mouse(mouse::Event::CursorMoved { .. }) => {
573                    last_scrolled.elapsed() > Duration::from_millis(100)
574                }
575                _ => last_scrolled.elapsed() > Duration::from_millis(1500),
576            };
577
578            if clear_transaction {
579                state.last_scrolled = None;
580            }
581        }
582
583        let mut update = || {
584            if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at() {
585                match event {
586                    Event::Mouse(mouse::Event::CursorMoved { .. })
587                    | Event::Touch(touch::Event::FingerMoved { .. }) => {
588                        if let Some(scrollbar) = scrollbars.y {
589                            let Some(cursor_position) = cursor.land().position() else {
590                                return;
591                            };
592
593                            state.scroll_y_to(
594                                scrollbar.scroll_percentage_y(scroller_grabbed_at, cursor_position),
595                                bounds,
596                                content_bounds,
597                            );
598
599                            let _ = notify_scroll(
600                                state,
601                                &self.on_scroll,
602                                bounds,
603                                content_bounds,
604                                shell,
605                            );
606
607                            shell.capture_event();
608                        }
609                    }
610                    _ => {}
611                }
612            } else if mouse_over_y_scrollbar {
613                match event {
614                    Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
615                    | Event::Touch(touch::Event::FingerPressed { .. }) => {
616                        let Some(cursor_position) = cursor.position() else {
617                            return;
618                        };
619
620                        if let (Some(scroller_grabbed_at), Some(scrollbar)) =
621                            (scrollbars.grab_y_scroller(cursor_position), scrollbars.y)
622                        {
623                            state.scroll_y_to(
624                                scrollbar.scroll_percentage_y(scroller_grabbed_at, cursor_position),
625                                bounds,
626                                content_bounds,
627                            );
628
629                            state.interaction = Interaction::YScrollerGrabbed(scroller_grabbed_at);
630
631                            let _ = notify_scroll(
632                                state,
633                                &self.on_scroll,
634                                bounds,
635                                content_bounds,
636                                shell,
637                            );
638                        }
639
640                        shell.capture_event();
641                    }
642                    _ => {}
643                }
644            }
645
646            if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at() {
647                match event {
648                    Event::Mouse(mouse::Event::CursorMoved { .. })
649                    | Event::Touch(touch::Event::FingerMoved { .. }) => {
650                        let Some(cursor_position) = cursor.land().position() else {
651                            return;
652                        };
653
654                        if let Some(scrollbar) = scrollbars.x {
655                            state.scroll_x_to(
656                                scrollbar.scroll_percentage_x(scroller_grabbed_at, cursor_position),
657                                bounds,
658                                content_bounds,
659                            );
660
661                            let _ = notify_scroll(
662                                state,
663                                &self.on_scroll,
664                                bounds,
665                                content_bounds,
666                                shell,
667                            );
668                        }
669
670                        shell.capture_event();
671                    }
672                    _ => {}
673                }
674            } else if mouse_over_x_scrollbar {
675                match event {
676                    Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
677                    | Event::Touch(touch::Event::FingerPressed { .. }) => {
678                        let Some(cursor_position) = cursor.position() else {
679                            return;
680                        };
681
682                        if let (Some(scroller_grabbed_at), Some(scrollbar)) =
683                            (scrollbars.grab_x_scroller(cursor_position), scrollbars.x)
684                        {
685                            state.scroll_x_to(
686                                scrollbar.scroll_percentage_x(scroller_grabbed_at, cursor_position),
687                                bounds,
688                                content_bounds,
689                            );
690
691                            state.interaction = Interaction::XScrollerGrabbed(scroller_grabbed_at);
692
693                            let _ = notify_scroll(
694                                state,
695                                &self.on_scroll,
696                                bounds,
697                                content_bounds,
698                                shell,
699                            );
700
701                            shell.capture_event();
702                        }
703                    }
704                    _ => {}
705                }
706            }
707
708            if matches!(state.interaction, Interaction::AutoScrolling { .. })
709                && matches!(
710                    event,
711                    Event::Mouse(
712                        mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }
713                    ) | Event::Touch(_)
714                        | Event::Keyboard(_)
715                )
716            {
717                state.interaction = Interaction::None;
718                shell.capture_event();
719                shell.invalidate_layout();
720                shell.request_redraw();
721                return;
722            }
723
724            if state.last_scrolled.is_none()
725                || !matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. }))
726            {
727                let translation = state.translation(self.direction, bounds, content_bounds);
728
729                let cursor = match cursor_over_scrollable {
730                    Some(cursor_position)
731                        if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) =>
732                    {
733                        mouse::Cursor::Available(cursor_position + translation)
734                    }
735                    _ => cursor.levitate() + translation,
736                };
737
738                let had_input_method = shell.input_method().is_enabled();
739
740                self.content.as_widget_mut().update(
741                    &mut tree.children[0],
742                    event,
743                    content,
744                    cursor,
745                    renderer,
746                    shell,
747                    &Rectangle {
748                        y: bounds.y + translation.y,
749                        x: bounds.x + translation.x,
750                        ..bounds
751                    },
752                );
753
754                if !had_input_method
755                    && let InputMethod::Enabled { cursor, .. } = shell.input_method_mut()
756                {
757                    *cursor -= translation;
758                }
759            };
760
761            if matches!(
762                event,
763                Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
764                    | Event::Touch(
765                        touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }
766                    )
767            ) {
768                state.interaction = Interaction::None;
769                return;
770            }
771
772            if shell.is_event_captured() {
773                return;
774            }
775
776            match event {
777                Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
778                    if cursor_over_scrollable.is_none() {
779                        return;
780                    }
781
782                    let delta = match *delta {
783                        mouse::ScrollDelta::Lines { x, y } => {
784                            let is_shift_pressed = state.keyboard_modifiers.shift();
785
786                            // macOS automatically inverts the axes when Shift is pressed
787                            let (x, y) = if cfg!(target_os = "macos") && is_shift_pressed {
788                                (y, x)
789                            } else {
790                                (x, y)
791                            };
792
793                            let movement = if !is_shift_pressed {
794                                Vector::new(x, y)
795                            } else {
796                                Vector::new(y, x)
797                            };
798
799                            // TODO: Configurable speed/friction (?)
800                            -movement * 60.0
801                        }
802                        mouse::ScrollDelta::Pixels { x, y } => -Vector::new(x, y),
803                    };
804
805                    state.scroll(self.direction.align(delta), bounds, content_bounds);
806
807                    let has_scrolled =
808                        notify_scroll(state, &self.on_scroll, bounds, content_bounds, shell);
809
810                    let in_transaction = state.last_scrolled.is_some();
811
812                    if has_scrolled || in_transaction {
813                        shell.capture_event();
814                    }
815                }
816                Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
817                    if self.auto_scroll && matches!(state.interaction, Interaction::None) =>
818                {
819                    let Some(origin) = cursor_over_scrollable else {
820                        return;
821                    };
822
823                    state.interaction = Interaction::AutoScrolling {
824                        origin,
825                        current: origin,
826                        last_frame: None,
827                    };
828
829                    shell.capture_event();
830                    shell.invalidate_layout();
831                    shell.request_redraw();
832                }
833                Event::Touch(event)
834                    if matches!(state.interaction, Interaction::TouchScrolling(_))
835                        || (!mouse_over_y_scrollbar && !mouse_over_x_scrollbar) =>
836                {
837                    match event {
838                        touch::Event::FingerPressed { .. } => {
839                            let Some(position) = cursor_over_scrollable else {
840                                return;
841                            };
842
843                            state.interaction = Interaction::TouchScrolling(position);
844                        }
845                        touch::Event::FingerMoved { .. } => {
846                            let Interaction::TouchScrolling(scroll_box_touched_at) =
847                                state.interaction
848                            else {
849                                return;
850                            };
851
852                            let Some(cursor_position) = cursor.position() else {
853                                return;
854                            };
855
856                            let delta = Vector::new(
857                                scroll_box_touched_at.x - cursor_position.x,
858                                scroll_box_touched_at.y - cursor_position.y,
859                            );
860
861                            state.scroll(self.direction.align(delta), bounds, content_bounds);
862
863                            state.interaction = Interaction::TouchScrolling(cursor_position);
864
865                            // TODO: bubble up touch movements if not consumed.
866                            let _ = notify_scroll(
867                                state,
868                                &self.on_scroll,
869                                bounds,
870                                content_bounds,
871                                shell,
872                            );
873                        }
874                        _ => {}
875                    }
876
877                    shell.capture_event();
878                }
879                Event::Mouse(mouse::Event::CursorMoved { position }) => {
880                    if let Interaction::AutoScrolling {
881                        origin, last_frame, ..
882                    } = state.interaction
883                    {
884                        let delta = *position - origin;
885
886                        state.interaction = Interaction::AutoScrolling {
887                            origin,
888                            current: *position,
889                            last_frame,
890                        };
891
892                        if (delta.x.abs() >= AUTOSCROLL_DEADZONE
893                            || delta.y.abs() >= AUTOSCROLL_DEADZONE)
894                            && last_frame.is_none()
895                        {
896                            shell.request_redraw();
897                        }
898                    }
899                }
900                Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
901                    state.keyboard_modifiers = *modifiers;
902                }
903                Event::Window(window::Event::RedrawRequested(now)) => {
904                    if let Interaction::AutoScrolling {
905                        origin,
906                        current,
907                        last_frame,
908                    } = state.interaction
909                    {
910                        if last_frame == Some(*now) {
911                            shell.request_redraw();
912                            return;
913                        }
914
915                        state.interaction = Interaction::AutoScrolling {
916                            origin,
917                            current,
918                            last_frame: None,
919                        };
920
921                        let mut delta = current - origin;
922
923                        if delta.x.abs() < AUTOSCROLL_DEADZONE {
924                            delta.x = 0.0;
925                        }
926
927                        if delta.y.abs() < AUTOSCROLL_DEADZONE {
928                            delta.y = 0.0;
929                        }
930
931                        if delta.x != 0.0 || delta.y != 0.0 {
932                            let time_delta = if let Some(last_frame) = last_frame {
933                                *now - last_frame
934                            } else {
935                                Duration::ZERO
936                            };
937
938                            let scroll_factor = time_delta.as_secs_f32();
939
940                            state.scroll(
941                                self.direction.align(Vector::new(
942                                    delta.x.signum()
943                                        * delta.x.abs().powf(AUTOSCROLL_SMOOTHNESS)
944                                        * scroll_factor,
945                                    delta.y.signum()
946                                        * delta.y.abs().powf(AUTOSCROLL_SMOOTHNESS)
947                                        * scroll_factor,
948                                )),
949                                bounds,
950                                content_bounds,
951                            );
952
953                            let has_scrolled = notify_scroll(
954                                state,
955                                &self.on_scroll,
956                                bounds,
957                                content_bounds,
958                                shell,
959                            );
960
961                            if has_scrolled || time_delta.is_zero() {
962                                state.interaction = Interaction::AutoScrolling {
963                                    origin,
964                                    current,
965                                    last_frame: Some(*now),
966                                };
967
968                                shell.request_redraw();
969                            }
970
971                            return;
972                        }
973                    }
974
975                    let _ = notify_viewport(state, &self.on_scroll, bounds, content_bounds, shell);
976                }
977                _ => {}
978            }
979        };
980
981        update();
982
983        let status = if state.scrollers_grabbed() {
984            Status::Dragged {
985                is_horizontal_scrollbar_dragged: state.x_scroller_grabbed_at().is_some(),
986                is_vertical_scrollbar_dragged: state.y_scroller_grabbed_at().is_some(),
987                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
988                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
989            }
990        } else if cursor_over_scrollable.is_some() {
991            Status::Hovered {
992                is_horizontal_scrollbar_hovered: mouse_over_x_scrollbar,
993                is_vertical_scrollbar_hovered: mouse_over_y_scrollbar,
994                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
995                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
996            }
997        } else {
998            Status::Active {
999                is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
1000                is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
1001            }
1002        };
1003
1004        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
1005            state.last_status = Some(status);
1006        }
1007
1008        if last_offsets != (state.offset_x, state.offset_y)
1009            || state
1010                .last_status
1011                .is_some_and(|last_status| last_status != status)
1012        {
1013            shell.request_redraw();
1014        }
1015    }
1016
1017    fn draw(
1018        &self,
1019        tree: &Tree,
1020        renderer: &mut Renderer,
1021        theme: &Theme,
1022        defaults: &renderer::Style,
1023        layout: Layout<'_>,
1024        cursor: mouse::Cursor,
1025        viewport: &Rectangle,
1026    ) {
1027        let state = tree.state.downcast_ref::<State>();
1028
1029        let bounds = layout.bounds();
1030        let content_layout = layout.children().next().unwrap();
1031        let content_bounds = content_layout.bounds();
1032
1033        let Some(visible_bounds) = bounds.intersection(viewport) else {
1034            return;
1035        };
1036
1037        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1038
1039        let cursor_over_scrollable = cursor.position_over(bounds);
1040        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
1041
1042        let translation = state.translation(self.direction, bounds, content_bounds);
1043
1044        let cursor = match cursor_over_scrollable {
1045            Some(cursor_position) if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) => {
1046                mouse::Cursor::Available(cursor_position + translation)
1047            }
1048            _ => cursor.levitate() + translation,
1049        };
1050
1051        let style = theme.style(
1052            &self.class,
1053            state.last_status.unwrap_or(Status::Active {
1054                is_horizontal_scrollbar_disabled: false,
1055                is_vertical_scrollbar_disabled: false,
1056            }),
1057        );
1058
1059        container::draw_background(renderer, &style.container, layout.bounds());
1060
1061        // Draw inner content
1062        if scrollbars.active() {
1063            let scale_factor = renderer.hint_factor().unwrap_or(1.0);
1064            let translation = (translation * scale_factor).round() / scale_factor;
1065
1066            renderer.with_layer(visible_bounds, |renderer| {
1067                renderer.with_translation(
1068                    Vector::new(-translation.x, -translation.y),
1069                    |renderer| {
1070                        self.content.as_widget().draw(
1071                            &tree.children[0],
1072                            renderer,
1073                            theme,
1074                            defaults,
1075                            content_layout,
1076                            cursor,
1077                            &Rectangle {
1078                                y: visible_bounds.y + translation.y,
1079                                x: visible_bounds.x + translation.x,
1080                                ..visible_bounds
1081                            },
1082                        );
1083                    },
1084                );
1085            });
1086
1087            let draw_scrollbar =
1088                |renderer: &mut Renderer, style: Rail, scrollbar: &internals::Scrollbar| {
1089                    if scrollbar.bounds.width > 0.0
1090                        && scrollbar.bounds.height > 0.0
1091                        && (style.background.is_some()
1092                            || (style.border.color != Color::TRANSPARENT
1093                                && style.border.width > 0.0))
1094                    {
1095                        renderer.fill_quad(
1096                            renderer::Quad {
1097                                bounds: scrollbar.bounds,
1098                                border: style.border,
1099                                ..renderer::Quad::default()
1100                            },
1101                            style
1102                                .background
1103                                .unwrap_or(Background::Color(Color::TRANSPARENT)),
1104                        );
1105                    }
1106
1107                    if let Some(scroller) = scrollbar.scroller
1108                        && scroller.bounds.width > 0.0
1109                        && scroller.bounds.height > 0.0
1110                        && (style.scroller.background != Background::Color(Color::TRANSPARENT)
1111                            || (style.scroller.border.color != Color::TRANSPARENT
1112                                && style.scroller.border.width > 0.0))
1113                    {
1114                        renderer.fill_quad(
1115                            renderer::Quad {
1116                                bounds: scroller.bounds,
1117                                border: style.scroller.border,
1118                                ..renderer::Quad::default()
1119                            },
1120                            style.scroller.background,
1121                        );
1122                    }
1123                };
1124
1125            renderer.with_layer(
1126                Rectangle {
1127                    width: (visible_bounds.width + 2.0).min(viewport.width),
1128                    height: (visible_bounds.height + 2.0).min(viewport.height),
1129                    ..visible_bounds
1130                },
1131                |renderer| {
1132                    if let Some(scrollbar) = scrollbars.y {
1133                        draw_scrollbar(renderer, style.vertical_rail, &scrollbar);
1134                    }
1135
1136                    if let Some(scrollbar) = scrollbars.x {
1137                        draw_scrollbar(renderer, style.horizontal_rail, &scrollbar);
1138                    }
1139
1140                    if let (Some(x), Some(y)) = (scrollbars.x, scrollbars.y) {
1141                        let background = style.gap.or(style.container.background);
1142
1143                        if let Some(background) = background {
1144                            renderer.fill_quad(
1145                                renderer::Quad {
1146                                    bounds: Rectangle {
1147                                        x: y.bounds.x,
1148                                        y: x.bounds.y,
1149                                        width: y.bounds.width,
1150                                        height: x.bounds.height,
1151                                    },
1152                                    ..renderer::Quad::default()
1153                                },
1154                                background,
1155                            );
1156                        }
1157                    }
1158                },
1159            );
1160        } else {
1161            self.content.as_widget().draw(
1162                &tree.children[0],
1163                renderer,
1164                theme,
1165                defaults,
1166                content_layout,
1167                cursor,
1168                &Rectangle {
1169                    x: visible_bounds.x + translation.x,
1170                    y: visible_bounds.y + translation.y,
1171                    ..visible_bounds
1172                },
1173            );
1174        }
1175    }
1176
1177    fn mouse_interaction(
1178        &self,
1179        tree: &Tree,
1180        layout: Layout<'_>,
1181        cursor: mouse::Cursor,
1182        _viewport: &Rectangle,
1183        renderer: &Renderer,
1184    ) -> mouse::Interaction {
1185        let state = tree.state.downcast_ref::<State>();
1186        let bounds = layout.bounds();
1187        let cursor_over_scrollable = cursor.position_over(bounds);
1188
1189        let content_layout = layout.children().next().unwrap();
1190        let content_bounds = content_layout.bounds();
1191
1192        let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1193
1194        let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = scrollbars.is_mouse_over(cursor);
1195
1196        if state.scrollers_grabbed() {
1197            return mouse::Interaction::None;
1198        }
1199
1200        let translation = state.translation(self.direction, bounds, content_bounds);
1201
1202        let cursor = match cursor_over_scrollable {
1203            Some(cursor_position) if !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) => {
1204                mouse::Cursor::Available(cursor_position + translation)
1205            }
1206            _ => cursor.levitate() + translation,
1207        };
1208
1209        self.content.as_widget().mouse_interaction(
1210            &tree.children[0],
1211            content_layout,
1212            cursor,
1213            &Rectangle {
1214                y: bounds.y + translation.y,
1215                x: bounds.x + translation.x,
1216                ..bounds
1217            },
1218            renderer,
1219        )
1220    }
1221
1222    fn overlay<'b>(
1223        &'b mut self,
1224        tree: &'b mut Tree,
1225        layout: Layout<'b>,
1226        renderer: &Renderer,
1227        viewport: &Rectangle,
1228        translation: Vector,
1229    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
1230        let state = tree.state.downcast_ref::<State>();
1231        let bounds = layout.bounds();
1232        let content_layout = layout.children().next().unwrap();
1233        let content_bounds = content_layout.bounds();
1234        let visible_bounds = bounds.intersection(viewport).unwrap_or(*viewport);
1235        let offset = state.translation(self.direction, bounds, content_bounds);
1236
1237        let overlay = self.content.as_widget_mut().overlay(
1238            &mut tree.children[0],
1239            layout.children().next().unwrap(),
1240            renderer,
1241            &visible_bounds,
1242            translation - offset,
1243        );
1244
1245        let icon = if let Interaction::AutoScrolling { origin, .. } = state.interaction {
1246            let scrollbars = Scrollbars::new(state, self.direction, bounds, content_bounds);
1247
1248            Some(overlay::Element::new(Box::new(AutoScrollIcon {
1249                origin,
1250                vertical: scrollbars.y.is_some(),
1251                horizontal: scrollbars.x.is_some(),
1252                class: &self.class,
1253            })))
1254        } else {
1255            None
1256        };
1257
1258        match (overlay, icon) {
1259            (None, None) => None,
1260            (None, Some(icon)) => Some(icon),
1261            (Some(overlay), None) => Some(overlay),
1262            (Some(overlay), Some(icon)) => Some(overlay::Element::new(Box::new(
1263                overlay::Group::with_children(vec![overlay, icon]),
1264            ))),
1265        }
1266    }
1267}
1268
1269struct AutoScrollIcon<'a, Class> {
1270    origin: Point,
1271    vertical: bool,
1272    horizontal: bool,
1273    class: &'a Class,
1274}
1275
1276impl<Class> AutoScrollIcon<'_, Class> {
1277    const SIZE: f32 = 40.0;
1278    const DOT: f32 = Self::SIZE / 10.0;
1279    const PADDING: f32 = Self::SIZE / 10.0;
1280}
1281
1282impl<Message, Theme, Renderer> core::Overlay<Message, Theme, Renderer>
1283    for AutoScrollIcon<'_, Theme::Class<'_>>
1284where
1285    Renderer: text::Renderer,
1286    Theme: Catalog,
1287{
1288    fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node {
1289        layout::Node::new(Size::new(Self::SIZE, Self::SIZE))
1290            .move_to(self.origin - Vector::new(Self::SIZE, Self::SIZE) / 2.0)
1291    }
1292
1293    fn draw(
1294        &self,
1295        renderer: &mut Renderer,
1296        theme: &Theme,
1297        _style: &renderer::Style,
1298        layout: Layout<'_>,
1299        _cursor: mouse::Cursor,
1300    ) {
1301        let bounds = layout.bounds();
1302        let style = theme
1303            .style(
1304                self.class,
1305                Status::Active {
1306                    is_horizontal_scrollbar_disabled: false,
1307                    is_vertical_scrollbar_disabled: false,
1308                },
1309            )
1310            .auto_scroll;
1311
1312        renderer.with_layer(Rectangle::INFINITE, |renderer| {
1313            renderer.fill_quad(
1314                renderer::Quad {
1315                    bounds,
1316                    border: style.border,
1317                    shadow: style.shadow,
1318                    snap: false,
1319                },
1320                style.background,
1321            );
1322
1323            renderer.fill_quad(
1324                renderer::Quad {
1325                    bounds: Rectangle::new(
1326                        bounds.center() - Vector::new(Self::DOT, Self::DOT) / 2.0,
1327                        Size::new(Self::DOT, Self::DOT),
1328                    ),
1329                    border: border::rounded(bounds.width),
1330                    snap: false,
1331                    ..renderer::Quad::default()
1332                },
1333                style.icon,
1334            );
1335
1336            let arrow = core::Text {
1337                content: String::new(),
1338                bounds: bounds.size(),
1339                size: Pixels::from(12),
1340                line_height: text::LineHeight::Relative(1.0),
1341                font: Renderer::ICON_FONT,
1342                align_x: text::Alignment::Center,
1343                align_y: alignment::Vertical::Center,
1344                shaping: text::Shaping::Basic,
1345                wrapping: text::Wrapping::None,
1346                ellipsis: text::Ellipsis::None,
1347                hint_factor: None,
1348            };
1349
1350            if self.vertical {
1351                renderer.fill_text(
1352                    core::Text {
1353                        content: Renderer::SCROLL_UP_ICON.to_string(),
1354                        align_y: alignment::Vertical::Top,
1355                        ..arrow
1356                    },
1357                    Point::new(bounds.center_x(), bounds.y + Self::PADDING),
1358                    style.icon,
1359                    bounds,
1360                );
1361
1362                renderer.fill_text(
1363                    core::Text {
1364                        content: Renderer::SCROLL_DOWN_ICON.to_string(),
1365                        align_y: alignment::Vertical::Bottom,
1366                        ..arrow
1367                    },
1368                    Point::new(
1369                        bounds.center_x(),
1370                        bounds.y + bounds.height - Self::PADDING - 0.5,
1371                    ),
1372                    style.icon,
1373                    bounds,
1374                );
1375            }
1376
1377            if self.horizontal {
1378                renderer.fill_text(
1379                    core::Text {
1380                        content: Renderer::SCROLL_LEFT_ICON.to_string(),
1381                        align_x: text::Alignment::Left,
1382                        ..arrow
1383                    },
1384                    Point::new(bounds.x + Self::PADDING + 1.0, bounds.center_y() + 1.0),
1385                    style.icon,
1386                    bounds,
1387                );
1388
1389                renderer.fill_text(
1390                    core::Text {
1391                        content: Renderer::SCROLL_RIGHT_ICON.to_string(),
1392                        align_x: text::Alignment::Right,
1393                        ..arrow
1394                    },
1395                    Point::new(
1396                        bounds.x + bounds.width - Self::PADDING - 1.0,
1397                        bounds.center_y() + 1.0,
1398                    ),
1399                    style.icon,
1400                    bounds,
1401                );
1402            }
1403        });
1404    }
1405
1406    fn index(&self) -> f32 {
1407        f32::MAX
1408    }
1409}
1410
1411impl<'a, Message, Theme, Renderer> From<Scrollable<'a, Message, Theme, Renderer>>
1412    for Element<'a, Message, Theme, Renderer>
1413where
1414    Message: 'a,
1415    Theme: 'a + Catalog,
1416    Renderer: 'a + text::Renderer,
1417{
1418    fn from(
1419        text_input: Scrollable<'a, Message, Theme, Renderer>,
1420    ) -> Element<'a, Message, Theme, Renderer> {
1421        Element::new(text_input)
1422    }
1423}
1424
1425fn notify_scroll<Message>(
1426    state: &mut State,
1427    on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>,
1428    bounds: Rectangle,
1429    content_bounds: Rectangle,
1430    shell: &mut Shell<'_, Message>,
1431) -> bool {
1432    if notify_viewport(state, on_scroll, bounds, content_bounds, shell) {
1433        state.last_scrolled = Some(Instant::now());
1434
1435        true
1436    } else {
1437        false
1438    }
1439}
1440
1441fn notify_viewport<Message>(
1442    state: &mut State,
1443    on_scroll: &Option<Box<dyn Fn(Viewport) -> Message + '_>>,
1444    bounds: Rectangle,
1445    content_bounds: Rectangle,
1446    shell: &mut Shell<'_, Message>,
1447) -> bool {
1448    if content_bounds.width <= bounds.width && content_bounds.height <= bounds.height {
1449        return false;
1450    }
1451
1452    let viewport = Viewport {
1453        offset_x: state.offset_x,
1454        offset_y: state.offset_y,
1455        bounds,
1456        content_bounds,
1457    };
1458
1459    // Don't publish redundant viewports to shell
1460    if let Some(last_notified) = state.last_notified {
1461        let last_relative_offset = last_notified.relative_offset();
1462        let current_relative_offset = viewport.relative_offset();
1463
1464        let last_absolute_offset = last_notified.absolute_offset();
1465        let current_absolute_offset = viewport.absolute_offset();
1466
1467        let unchanged =
1468            |a: f32, b: f32| (a - b).abs() <= f32::EPSILON || (a.is_nan() && b.is_nan());
1469
1470        if last_notified.bounds == bounds
1471            && last_notified.content_bounds == content_bounds
1472            && unchanged(last_relative_offset.x, current_relative_offset.x)
1473            && unchanged(last_relative_offset.y, current_relative_offset.y)
1474            && unchanged(last_absolute_offset.x, current_absolute_offset.x)
1475            && unchanged(last_absolute_offset.y, current_absolute_offset.y)
1476        {
1477            return false;
1478        }
1479    }
1480
1481    state.last_notified = Some(viewport);
1482
1483    if let Some(on_scroll) = on_scroll {
1484        shell.publish(on_scroll(viewport));
1485    }
1486
1487    true
1488}
1489
1490#[derive(Debug, Clone, Copy)]
1491struct State {
1492    offset_y: Offset,
1493    offset_x: Offset,
1494    interaction: Interaction,
1495    keyboard_modifiers: keyboard::Modifiers,
1496    last_notified: Option<Viewport>,
1497    last_scrolled: Option<Instant>,
1498    is_scrollbar_visible: bool,
1499    last_status: Option<Status>,
1500}
1501
1502#[derive(Debug, Clone, Copy)]
1503enum Interaction {
1504    None,
1505    YScrollerGrabbed(f32),
1506    XScrollerGrabbed(f32),
1507    TouchScrolling(Point),
1508    AutoScrolling {
1509        origin: Point,
1510        current: Point,
1511        last_frame: Option<Instant>,
1512    },
1513}
1514
1515impl Default for State {
1516    fn default() -> Self {
1517        Self {
1518            offset_y: Offset::Absolute(0.0),
1519            offset_x: Offset::Absolute(0.0),
1520            interaction: Interaction::None,
1521            keyboard_modifiers: keyboard::Modifiers::default(),
1522            last_notified: None,
1523            last_scrolled: None,
1524            is_scrollbar_visible: true,
1525            last_status: None,
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}