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