1use crate::event::UiEvent;
16use crate::layout::LayoutNode;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq)]
20pub struct ScrollState {
21 pub offset: f32,
23 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 pub fn is_dragging(&self) -> bool {
37 self.grab.is_some()
38 }
39
40 pub fn reset(&mut self) {
42 *self = Self::new();
43 }
44
45 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 pub fn handle_event(&mut self, event: &UiEvent<'_>, view: &LayoutNode, wheel_step: f32) -> bool {
60 let max = view.max_scroll();
61 match *event {
62 UiEvent::Scroll { dy } => {
63 self.offset = (self.offset.min(max) - dy * wheel_step).clamp(0.0, max);
64 true
65 }
66 UiEvent::Click { x: mx, y: my } => {
67 let Some((track, knob)) = view.scrollbar_rects() else {
68 return false;
69 };
70 if !track.contains(mx, my) {
71 return false;
72 }
73 let grab = if knob.contains(mx, my) {
74 my - knob.y
75 } else {
76 knob.h / 2.0
77 };
78 self.grab = Some(grab);
79 self.drag_to(my, view);
80 true
81 }
82 UiEvent::Drag { y: my, .. } => {
83 if self.grab.is_some() {
84 self.drag_to(my, view);
85 return true;
86 }
87 false
88 }
89 UiEvent::Release { .. } => self.grab.take().is_some(),
90 _ => false,
91 }
92 }
93
94 pub fn scroll_into_view(&mut self, top: f32, h: f32, view_h: f32) {
97 if top < self.offset {
98 self.offset = top.max(0.0);
99 } else if top + h > self.offset + view_h {
100 self.offset = (top + h - view_h).max(0.0);
101 }
102 }
103
104 fn drag_to(&mut self, my: f32, view: &LayoutNode) {
106 let (Some(grab), Some((track, knob))) = (self.grab, view.scrollbar_rects()) else {
107 return;
108 };
109 let travel = (track.h - knob.h).max(1.0);
110 let t = ((my - grab - track.y) / travel).clamp(0.0, 1.0);
111 self.offset = t * view.max_scroll();
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::layout::{self, FlexDir};
119 use crate::widget;
120
121 fn view(offset: f32) -> LayoutNode {
123 let list = widget::scroll_view()
124 .id("list")
125 .w(100.0)
126 .h(100.0)
127 .gap(0.0)
128 .scroll_y(offset)
129 .children((0..10).map(|i| {
130 widget::panel(FlexDir::Column)
131 .w(widget::Units::Percent(100.0))
132 .h(30.0)
133 .on_click(format!("row:{i}"))
134 }));
135 let root = widget::panel(FlexDir::Column).child(list);
136 let node = layout::compute(&root, 400.0, 400.0);
137 layout::find_node(&node, "list").unwrap().clone()
138 }
139
140 #[test]
141 fn layout_clamps_and_applies_the_offset() {
142 let v = view(50.0);
143 assert_eq!(v.content_h, 300.0);
144 assert_eq!(v.max_scroll(), 200.0);
145 assert_eq!(v.scroll_y, 50.0);
146 assert_eq!(v.children[0].rect.y, -50.0);
147 assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
149 assert_eq!(view(1000.0).scroll_y, 200.0);
150 let root_like = LayoutNode {
152 children: vec![v.clone()],
153 rect: v.rect,
154 ..Default::default()
155 };
156 assert!(layout::hit_test(&root_like, 10.0, 5.0)
157 .and_then(|n| n.on_click.as_deref())
158 .is_some_and(|e| e == "row:1"));
159 }
160
161 #[test]
162 fn wheel_and_knob_drag() {
163 let v = view(0.0);
164 let mut s = ScrollState::new();
165 assert!(s.handle("scroll:-2.00", &v, 30.0));
166 assert_eq!(s.offset, 60.0);
167 assert!(s.handle("scroll:10", &v, 30.0));
168 assert_eq!(s.offset, 0.0);
169 assert!(
171 !s.handle("click:50:10", &v, 30.0),
172 "content click isn't ours"
173 );
174 assert!(s.handle("click:97:10", &v, 30.0));
175 assert!(s.is_dragging());
176 assert!(s.handle("drag:97:200", &v, 30.0));
177 assert_eq!(s.offset, 200.0);
178 assert!(s.handle("release:97:200", &v, 30.0));
179 assert!(!s.is_dragging());
180 assert!(!s.handle("drag:97:0", &v, 30.0));
181 }
182
183 #[test]
184 fn no_scrollbar_without_overflow() {
185 let list = widget::scroll_view().id("l").w(100.0).h(100.0);
186 let node = layout::compute(&list.child(widget::label("x")), 400.0, 400.0);
187 assert!(node.scrollbar_rects().is_none());
188 assert!(!ScrollState::new().handle("click:97:10", &node, 10.0));
189 }
190}