Skip to main content

iced_cube/primitives/
scroll_area.rs

1//! Scrollable regions with a thin, rounded scrollbar.
2
3use iced::widget::scrollable::{self, AutoScroll, Rail, Scrollbar, Scroller, Status};
4use iced::widget::{Id, container};
5use iced::{Background, Border, Color, Element, Length, Shadow, Vector};
6
7use crate::theme::{Tokens, fade, mix, radius, space};
8
9/// Width of the scrollbar rail in logical pixels.
10pub const THICKNESS: f32 = 6.0;
11
12/// Which way the content scrolls.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
14pub enum Direction {
15    #[default]
16    Vertical,
17    Horizontal,
18    Both,
19}
20
21impl Direction {
22    pub const ALL: [Direction; 3] = [Direction::Vertical, Direction::Horizontal, Direction::Both];
23
24    fn scrollable(self) -> scrollable::Direction {
25        match self {
26            Direction::Vertical => scrollable::Direction::Vertical(scrollbar()),
27            Direction::Horizontal => scrollable::Direction::Horizontal(scrollbar()),
28            Direction::Both => scrollable::Direction::Both {
29                vertical: floating_scrollbar(),
30                horizontal: floating_scrollbar(),
31            },
32        }
33    }
34}
35
36/// The thin scrollbar used by single-direction scroll areas. It sits beside
37/// the content rather than over it.
38pub fn scrollbar() -> Scrollbar {
39    floating_scrollbar().spacing(space::XS)
40}
41
42/// The thin scrollbar used when scrolling both ways. iced cannot reserve
43/// space for two scrollbars, so these float over the content's edges.
44pub fn floating_scrollbar() -> Scrollbar {
45    Scrollbar::new()
46        .width(THICKNESS)
47        .scroller_width(THICKNESS)
48        .margin(2)
49}
50
51/// A scroll area builder. Convert it into an [`Element`] to render.
52pub struct ScrollArea<'a, Message> {
53    content: Element<'a, Message>,
54    direction: Direction,
55    width: Length,
56    height: Length,
57    id: Option<Id>,
58}
59
60impl<Message> std::fmt::Debug for ScrollArea<'_, Message> {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("ScrollArea")
63            .field("direction", &self.direction)
64            .field("width", &self.width)
65            .field("height", &self.height)
66            .finish_non_exhaustive()
67    }
68}
69
70/// Creates a vertically scrolling area around `content`.
71pub fn scroll_area<'a, Message>(
72    content: impl Into<Element<'a, Message>>,
73) -> ScrollArea<'a, Message> {
74    ScrollArea {
75        content: content.into(),
76        direction: Direction::default(),
77        width: Length::Shrink,
78        height: Length::Shrink,
79        id: None,
80    }
81}
82
83impl<'a, Message> ScrollArea<'a, Message> {
84    pub fn direction(mut self, direction: Direction) -> Self {
85        self.direction = direction;
86        self
87    }
88
89    pub fn width(mut self, width: impl Into<Length>) -> Self {
90        self.width = width.into();
91        self
92    }
93
94    pub fn height(mut self, height: impl Into<Length>) -> Self {
95        self.height = height.into();
96        self
97    }
98
99    /// Sets the widget id, for scrolling programmatically.
100    pub fn id(mut self, id: impl Into<Id>) -> Self {
101        self.id = Some(id.into());
102        self
103    }
104}
105
106impl<'a, Message: 'a> From<ScrollArea<'a, Message>> for Element<'a, Message> {
107    fn from(area: ScrollArea<'a, Message>) -> Self {
108        let mut scrollable =
109            scrollable::Scrollable::with_direction(area.content, area.direction.scrollable())
110                .width(area.width)
111                .height(area.height)
112                .style(|theme, status| style(&Tokens::of(theme), status));
113        if let Some(id) = area.id {
114            scrollable = scrollable.id(id);
115        }
116        scrollable.into()
117    }
118}
119
120/// How the pointer is interacting with one scrollbar.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum Interaction {
123    Idle,
124    Hovered,
125    Dragged,
126}
127
128/// Scroller colour for an interaction.
129pub fn scroller_colour(tokens: &Tokens, interaction: Interaction) -> Color {
130    match interaction {
131        Interaction::Idle => tokens.border,
132        Interaction::Hovered => mix(tokens.border, tokens.foreground, 0.25),
133        Interaction::Dragged => mix(tokens.border, tokens.foreground, 0.45),
134    }
135}
136
137/// Splits a scrollable status into vertical and horizontal interactions.
138pub fn interactions(status: Status) -> (Interaction, Interaction) {
139    let pick = |dragged: bool, hovered: bool| {
140        if dragged {
141            Interaction::Dragged
142        } else if hovered {
143            Interaction::Hovered
144        } else {
145            Interaction::Idle
146        }
147    };
148    match status {
149        Status::Active { .. } => (Interaction::Idle, Interaction::Idle),
150        Status::Hovered {
151            is_vertical_scrollbar_hovered,
152            is_horizontal_scrollbar_hovered,
153            ..
154        } => (
155            pick(false, is_vertical_scrollbar_hovered),
156            pick(false, is_horizontal_scrollbar_hovered),
157        ),
158        Status::Dragged {
159            is_vertical_scrollbar_dragged,
160            is_horizontal_scrollbar_dragged,
161            ..
162        } => (
163            pick(is_vertical_scrollbar_dragged, false),
164            pick(is_horizontal_scrollbar_dragged, false),
165        ),
166    }
167}
168
169/// The iced scrollable style for a status.
170pub fn style(tokens: &Tokens, status: Status) -> scrollable::Style {
171    let (vertical, horizontal) = interactions(status);
172    let rail = |interaction| Rail {
173        background: None,
174        border: Border::default(),
175        scroller: Scroller {
176            background: Background::Color(scroller_colour(tokens, interaction)),
177            border: Border {
178                radius: radius::FULL.into(),
179                ..Border::default()
180            },
181        },
182    };
183
184    scrollable::Style {
185        container: container::Style::default(),
186        vertical_rail: rail(vertical),
187        horizontal_rail: rail(horizontal),
188        gap: None,
189        auto_scroll: AutoScroll {
190            background: Background::Color(fade(tokens.background, 0.9)),
191            border: Border {
192                color: tokens.border,
193                width: 1.0,
194                radius: radius::FULL.into(),
195            },
196            shadow: Shadow {
197                color: fade(tokens.foreground, 0.1),
198                offset: Vector::new(0.0, 1.0),
199                blur_radius: 3.0,
200            },
201            icon: tokens.muted_foreground,
202        },
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::theme::{dark, light};
210
211    const ACTIVE: Status = Status::Active {
212        is_horizontal_scrollbar_disabled: false,
213        is_vertical_scrollbar_disabled: false,
214    };
215
216    fn hovered(vertical: bool, horizontal: bool) -> Status {
217        Status::Hovered {
218            is_horizontal_scrollbar_hovered: horizontal,
219            is_vertical_scrollbar_hovered: vertical,
220            is_horizontal_scrollbar_disabled: false,
221            is_vertical_scrollbar_disabled: false,
222        }
223    }
224
225    fn dragged(vertical: bool, horizontal: bool) -> Status {
226        Status::Dragged {
227            is_horizontal_scrollbar_dragged: horizontal,
228            is_vertical_scrollbar_dragged: vertical,
229            is_horizontal_scrollbar_disabled: false,
230            is_vertical_scrollbar_disabled: false,
231        }
232    }
233
234    fn scroller(rail: Rail) -> Background {
235        rail.scroller.background
236    }
237
238    #[test]
239    fn default_builder_is_vertical_and_shrinks() {
240        let area: ScrollArea<'_, ()> = scroll_area("content");
241        assert_eq!(area.direction, Direction::Vertical);
242        assert_eq!(area.width, Length::Shrink);
243        assert!(area.id.is_none());
244    }
245
246    #[test]
247    fn directions_map_to_the_right_scrollbars() {
248        let vertical = Direction::Vertical.scrollable();
249        assert!(vertical.vertical().is_some() && vertical.horizontal().is_none());
250        let horizontal = Direction::Horizontal.scrollable();
251        assert!(horizontal.vertical().is_none() && horizontal.horizontal().is_some());
252        let both = Direction::Both.scrollable();
253        assert!(both.vertical().is_some() && both.horizontal().is_some());
254    }
255
256    #[test]
257    fn scrollbars_are_thin() {
258        let floating = Scrollbar::new().width(6).scroller_width(6).margin(2);
259        assert_eq!(floating_scrollbar(), floating);
260        assert_eq!(scrollbar(), floating.spacing(4));
261    }
262
263    #[test]
264    fn only_both_directions_float() {
265        assert_eq!(
266            Direction::Vertical.scrollable().vertical(),
267            Some(&scrollbar())
268        );
269        assert_eq!(
270            Direction::Both.scrollable().horizontal(),
271            Some(&floating_scrollbar())
272        );
273    }
274
275    #[test]
276    fn interactions_track_each_axis() {
277        assert_eq!(interactions(ACTIVE), (Interaction::Idle, Interaction::Idle));
278        assert_eq!(
279            interactions(hovered(true, false)),
280            (Interaction::Hovered, Interaction::Idle)
281        );
282        assert_eq!(
283            interactions(dragged(false, true)),
284            (Interaction::Idle, Interaction::Dragged)
285        );
286    }
287
288    #[test]
289    fn scroller_strengthens_with_interaction() {
290        for theme in [light(), dark()] {
291            let tokens = Tokens::of(&theme);
292            let idle = style(&tokens, ACTIVE);
293            let hover = style(&tokens, hovered(true, true));
294            let drag = style(&tokens, dragged(true, true));
295            assert_ne!(scroller(idle.vertical_rail), scroller(hover.vertical_rail));
296            assert_ne!(scroller(hover.vertical_rail), scroller(drag.vertical_rail));
297            assert_ne!(
298                scroller(idle.horizontal_rail),
299                scroller(drag.horizontal_rail)
300            );
301        }
302    }
303
304    #[test]
305    fn rails_are_transparent_and_scrollers_rounded() {
306        for theme in [light(), dark()] {
307            let tokens = Tokens::of(&theme);
308            for status in [ACTIVE, hovered(true, false), dragged(false, true)] {
309                let style = style(&tokens, status);
310                for rail in [style.vertical_rail, style.horizontal_rail] {
311                    assert!(rail.background.is_none());
312                    assert_eq!(rail.scroller.border.radius, radius::FULL.into());
313                }
314            }
315        }
316    }
317
318    #[test]
319    fn idle_scroller_uses_border_token() {
320        let tokens = Tokens::of(&dark());
321        assert_eq!(scroller_colour(&tokens, Interaction::Idle), tokens.border);
322    }
323}