Skip to main content

densa_widgets/
scroll.rs

1use densa_core::{Point, Rect, Size};
2use densa_draw::Canvas;
3use densa_ui::{
4    AccessAction, AccessNode, Event, EventCx, Handled, Length, Overflow, PaintCx, PointerButton,
5    PointerKind, Role, ScrollDelta, Style, Widget,
6};
7
8/// Logical pixels one wheel notch scrolls.
9const LINE: f32 = 48.0;
10/// Scroll bar thickness, in logical pixels.
11const BAR: f32 = 6.0;
12/// The shortest a scroll bar's thumb gets, in logical pixels.
13const MIN_THUMB: f32 = 24.0;
14
15/// Scrolls its children with the wheel, a touchpad or by dragging its scroll bars.
16///
17/// Give it a style with [`Overflow::Scroll`], as [`ScrollView::style`] does: its children
18/// are then laid out at their own size and cut at its edges. A wheel scroll it cannot use
19/// (already at the end) goes on to the views around it.
20#[derive(Debug, Default)]
21pub struct ScrollView {
22    /// The axis being dragged by its thumb, and where on the thumb it was grabbed.
23    drag: Option<(Axis, f32)>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Axis {
28    Horizontal,
29    Vertical,
30}
31
32impl ScrollView {
33    /// A scroll view.
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// A column filling its parent's free space, allowed to be smaller than its content, and
39    /// scrolling the overflow.
40    pub fn style() -> Style {
41        Style::column()
42            .grow(1.0)
43            .min_size(Length::Px(0.0), Length::Px(0.0))
44            .overflow(Overflow::Scroll)
45    }
46
47    fn scroll_by(cx: &mut EventCx<'_>, dx: f32, dy: f32) -> Handled {
48        let at = cx.scroll();
49        let moved = cx.scroll_to(Point::new(at.x + dx, at.y + dy));
50        (moved != at).into()
51    }
52}
53
54/// Where the thumb along `axis` is, if the content overflows on it.
55fn thumb(axis: Axis, rect: Rect, content: Size, scroll: Point, scale: f32) -> Option<Rect> {
56    let (shown, total, offset) = match axis {
57        Axis::Vertical => (rect.height(), content.height, scroll.y),
58        Axis::Horizontal => (rect.width(), content.width, scroll.x),
59    };
60    if total <= shown || shown <= 0.0 {
61        return None;
62    }
63    let length = (shown * shown / total).max(MIN_THUMB * scale).min(shown);
64    let start = offset / (total - shown) * (shown - length);
65    let bar = BAR * scale;
66    Some(match axis {
67        Axis::Vertical => Rect::new(rect.right() - bar, rect.top() + start, bar, length),
68        Axis::Horizontal => Rect::new(rect.left() + start, rect.bottom() - bar, length, bar),
69    })
70}
71
72impl Widget for ScrollView {
73    fn accessibility(&self, cx: &PaintCx<'_>, node: &mut AccessNode) {
74        node.set_role(Role::ScrollView);
75        let (at, content, view) = (cx.scroll(), cx.content_size(), cx.rect());
76        let (room_x, room_y) = (content.width - view.width(), content.height - view.height());
77        for (action, can) in [
78            (AccessAction::ScrollUp, at.y > 0.0),
79            (AccessAction::ScrollDown, at.y < room_y),
80            (AccessAction::ScrollLeft, at.x > 0.0),
81            (AccessAction::ScrollRight, at.x < room_x),
82        ] {
83            if can {
84                node.add_action(action);
85            }
86        }
87    }
88
89    fn paint_over(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
90        let theme = cx.theme();
91        for axis in [Axis::Vertical, Axis::Horizontal] {
92            let at = thumb(axis, cx.rect(), cx.content_size(), cx.scroll(), cx.scale());
93            if let Some(at) = at {
94                let alpha = if self.drag.is_some_and(|(a, _)| a == axis) {
95                    0.65
96                } else {
97                    0.45
98                };
99                let color = theme.text_dim.with_alpha(alpha);
100                canvas.fill_rect(at, color);
101            }
102        }
103    }
104
105    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
106        let Event::Pointer(pointer) = event else {
107            return Handled::No;
108        };
109        let at = pointer.position;
110        match pointer.kind {
111            PointerKind::Scroll(ScrollDelta::Lines(dx, dy)) => {
112                let line = cx.px(LINE);
113                Self::scroll_by(cx, dx * line, dy * line)
114            }
115            PointerKind::Scroll(ScrollDelta::Pixels(dx, dy)) => Self::scroll_by(cx, dx, dy),
116            PointerKind::Down(PointerButton::Primary) => {
117                let (rect, content, scroll) = (cx.rect(), cx.content_size(), cx.scroll());
118                for axis in [Axis::Vertical, Axis::Horizontal] {
119                    if let Some(bar) = thumb(axis, rect, content, scroll, cx.scale()) {
120                        if bar.contains(at) {
121                            let grab = match axis {
122                                Axis::Vertical => at.y - bar.top(),
123                                Axis::Horizontal => at.x - bar.left(),
124                            };
125                            self.drag = Some((axis, grab));
126                            cx.request_paint();
127                            return Handled::Yes;
128                        }
129                    }
130                }
131                Handled::No
132            }
133            PointerKind::Move => {
134                let Some((axis, grab)) = self.drag else {
135                    return Handled::No;
136                };
137                let (rect, content, scroll) = (cx.rect(), cx.content_size(), cx.scroll());
138                let Some(bar) = thumb(axis, rect, content, scroll, cx.scale()) else {
139                    return Handled::Yes;
140                };
141                // Thumb travel maps onto scroll travel.
142                let (track, length, total, shown, pointer, start) = match axis {
143                    Axis::Vertical => (
144                        rect.height(),
145                        bar.height(),
146                        content.height,
147                        rect.height(),
148                        at.y,
149                        rect.top(),
150                    ),
151                    Axis::Horizontal => (
152                        rect.width(),
153                        bar.width(),
154                        content.width,
155                        rect.width(),
156                        at.x,
157                        rect.left(),
158                    ),
159                };
160                let travel = (track - length).max(1.0);
161                let offset = (pointer - grab - start) / travel * (total - shown);
162                let target = match axis {
163                    Axis::Vertical => Point::new(scroll.x, offset),
164                    Axis::Horizontal => Point::new(offset, scroll.y),
165                };
166                cx.scroll_to(target);
167                Handled::Yes
168            }
169            PointerKind::Up(PointerButton::Primary) | PointerKind::Cancel
170                if self.drag.take().is_some() =>
171            {
172                cx.request_paint();
173                Handled::Yes
174            }
175            _ => Handled::No,
176        }
177    }
178}