1use 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
9pub const THICKNESS: f32 = 6.0;
11
12#[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
36pub fn scrollbar() -> Scrollbar {
39 floating_scrollbar().spacing(space::XS)
40}
41
42pub fn floating_scrollbar() -> Scrollbar {
45 Scrollbar::new()
46 .width(THICKNESS)
47 .scroller_width(THICKNESS)
48 .margin(2)
49}
50
51pub 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
70pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum Interaction {
123 Idle,
124 Hovered,
125 Dragged,
126}
127
128pub 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
137pub 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
169pub 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}