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