Skip to main content

yog_ui/
scroll.rs

1//! Driving a scrolling container (`widget::scroll_view()`) from the raw UI
2//! events a Yog screen receives (`scroll:DY`, `click:X:Y`, `drag:X:Y`,
3//! `release:X:Y`).
4//!
5//! ```ignore
6//! static LIST: Mutex<ScrollState> = Mutex::new(ScrollState::new());
7//!
8//! // render: feed the offset into the tree
9//! root.find_mut("list").unwrap().style.scroll_y = LIST.lock().unwrap().offset;
10//!
11//! // events: with the last frame's layout node of "list"
12//! if LIST.lock().unwrap().handle(event, node, 24.0) { return; }
13//! ```
14
15use crate::event::UiEvent;
16use crate::layout::LayoutNode;
17
18/// Scroll offset plus scrollbar-drag state of one scrolling container.
19#[derive(Debug, Clone, Copy, Default, PartialEq)]
20pub struct ScrollState {
21    /// Offset in GUI pixels (0 = top) — set it as the widget's `scroll_y`.
22    pub offset: f32,
23    /// While the knob is dragged: cursor y minus the knob's top.
24    grab: Option<f32>,
25}
26
27impl ScrollState {
28    pub const fn new() -> Self {
29        Self {
30            offset: 0.0,
31            grab: None,
32        }
33    }
34
35    /// The scrollbar knob is being dragged.
36    pub fn is_dragging(&self) -> bool {
37        self.grab.is_some()
38    }
39
40    /// Back to the top, ending any drag.
41    pub fn reset(&mut self) {
42        *self = Self::new();
43    }
44
45    /// Applies one UI event; `view` is the container's node from the frame
46    /// the user saw (`UiRoot::find_node`). `wheel_step`: pixels per wheel
47    /// notch. Returns `true` when the event was for the scrollbar/wheel —
48    /// the caller shouldn't treat it as a click on the content.
49    ///
50    /// - `scroll:DY` — wheel; positive = up.
51    /// - `click:X:Y` on the knob grabs it; on the track jumps there (knob
52    ///   centred on the cursor) and keeps dragging.
53    /// - `drag:X:Y` moves a grabbed knob; `release:X:Y` lets go.
54    pub fn handle(&mut self, event: &str, view: &LayoutNode, wheel_step: f32) -> bool {
55        self.handle_event(&UiEvent::parse(event), view, wheel_step)
56    }
57
58    /// [`Self::handle`] for a parsed event.
59    pub fn handle_event(
60        &mut self,
61        event: &UiEvent<'_>,
62        view: &LayoutNode,
63        wheel_step: f32,
64    ) -> bool {
65        let max = view.max_scroll();
66        match *event {
67            UiEvent::Scroll { dy } => {
68                self.offset = (self.offset.min(max) - dy * wheel_step).clamp(0.0, max);
69                true
70            }
71            UiEvent::Click { x: mx, y: my } => {
72                let Some((track, knob)) = view.scrollbar_rects() else {
73                    return false;
74                };
75                if !track.contains(mx, my) {
76                    return false;
77                }
78                let grab = if knob.contains(mx, my) {
79                    my - knob.y
80                } else {
81                    knob.h / 2.0
82                };
83                self.grab = Some(grab);
84                self.drag_to(my, view);
85                true
86            }
87            UiEvent::Drag { y: my, .. } => {
88                if self.grab.is_some() {
89                    self.drag_to(my, view);
90                    return true;
91                }
92                false
93            }
94            UiEvent::Release { .. } => self.grab.take().is_some(),
95            _ => false,
96        }
97    }
98
99    /// Scrolls the least amount that shows `top..top + h` (content
100    /// coordinates, 0 = the content's top) in a view `view_h` tall.
101    pub fn scroll_into_view(&mut self, top: f32, h: f32, view_h: f32) {
102        if top < self.offset {
103            self.offset = top.max(0.0);
104        } else if top + h > self.offset + view_h {
105            self.offset = (top + h - view_h).max(0.0);
106        }
107    }
108
109    /// Moves the knob so its top is at `my - grab`.
110    fn drag_to(&mut self, my: f32, view: &LayoutNode) {
111        let (Some(grab), Some((track, knob))) = (self.grab, view.scrollbar_rects()) else {
112            return;
113        };
114        let travel = (track.h - knob.h).max(1.0);
115        let t = ((my - grab - track.y) / travel).clamp(0.0, 1.0);
116        self.offset = t * view.max_scroll();
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::layout::{self, FlexDir};
124    use crate::widget;
125
126    /// A 100px-tall scroll view holding ten 30px rows (content 300px + gaps).
127    fn view(offset: f32) -> LayoutNode {
128        let list = widget::scroll_view()
129            .id("list")
130            .w(100.0)
131            .h(100.0)
132            .gap(0.0)
133            .scroll_y(offset)
134            .children((0..10).map(|i| {
135                widget::panel(FlexDir::Column)
136                    .w(widget::Units::Percent(100.0))
137                    .h(30.0)
138                    .on_click(format!("row:{i}"))
139            }));
140        let root = widget::panel(FlexDir::Column).child(list);
141        let node = layout::compute(&root, 400.0, 400.0);
142        layout::find_node(&node, "list").unwrap().clone()
143    }
144
145    #[test]
146    fn layout_clamps_and_applies_the_offset() {
147        let v = view(50.0);
148        assert_eq!(v.content_h, 300.0);
149        assert_eq!(v.max_scroll(), 200.0);
150        assert_eq!(v.scroll_y, 50.0);
151        assert_eq!(v.children[0].rect.y, -50.0);
152        // The scrollbar strip is reserved: rows stop short of it.
153        assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
154        assert_eq!(view(1000.0).scroll_y, 200.0);
155        // Rows scrolled out of the view can't be clicked.
156        let root_like = LayoutNode {
157            children: vec![v.clone()],
158            rect: v.rect,
159            ..Default::default()
160        };
161        assert!(layout::hit_test(&root_like, 10.0, 5.0)
162            .and_then(|n| n.on_click.as_deref())
163            .is_some_and(|e| e == "row:1"));
164    }
165
166    #[test]
167    fn wheel_and_knob_drag() {
168        let v = view(0.0);
169        let mut s = ScrollState::new();
170        assert!(s.handle("scroll:-2.00", &v, 30.0));
171        assert_eq!(s.offset, 60.0);
172        assert!(s.handle("scroll:10", &v, 30.0));
173        assert_eq!(s.offset, 0.0);
174        // Knob: 100 * 100 / 300 = 33.3 px tall at the top of the track (x 94..100).
175        assert!(
176            !s.handle("click:50:10", &v, 30.0),
177            "content click isn't ours"
178        );
179        assert!(s.handle("click:97:10", &v, 30.0));
180        assert!(s.is_dragging());
181        assert!(s.handle("drag:97:200", &v, 30.0));
182        assert_eq!(s.offset, 200.0);
183        assert!(s.handle("release:97:200", &v, 30.0));
184        assert!(!s.is_dragging());
185        assert!(!s.handle("drag:97:0", &v, 30.0));
186    }
187
188    #[test]
189    fn no_scrollbar_without_overflow() {
190        let list = widget::scroll_view().id("l").w(100.0).h(100.0);
191        let node = layout::compute(&list.child(widget::label("x")), 400.0, 400.0);
192        assert!(node.scrollbar_rects().is_none());
193        assert!(!ScrollState::new().handle("click:97:10", &node, 10.0));
194    }
195}