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(
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 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 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 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 assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
154 assert_eq!(view(1000.0).scroll_y, 200.0);
155 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 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}