1use crate::event::UiEvent;
16use crate::layout::LayoutNode;
17
18pub const WHEEL_STEP: f32 = 24.0;
20
21#[derive(Debug, Clone, Copy, Default, PartialEq)]
23pub struct ScrollState {
24 pub offset: f32,
26 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 pub fn is_dragging(&self) -> bool {
40 self.grab.is_some()
41 }
42
43 pub fn reset(&mut self) {
45 *self = Self::new();
46 }
47
48 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 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 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 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 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 assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
157 assert_eq!(view(1000.0).scroll_y, 200.0);
158 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 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}