1use crate::layout::LayoutNode;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq)]
19pub struct ScrollState {
20 pub offset: f32,
22 grab: Option<f32>,
24}
25
26fn 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 pub fn is_dragging(&self) -> bool {
42 self.grab.is_some()
43 }
44
45 pub fn reset(&mut self) {
47 *self = Self::new();
48 }
49
50 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 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 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 assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
142 assert_eq!(view(1000.0).scroll_y, 200.0);
143 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 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}