1use std::rc::Rc;
18
19use dioxus::html::geometry::PixelsVector2D;
20use dioxus::html::{MountedData, ScrollBehavior};
21use dioxus::prelude::*;
22
23use crate::core::{Point, Rect};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ScrollAxis {
28 #[default]
30 Y,
31 X,
33 Both,
35}
36
37pub fn edge_delta(
41 pos: Point,
42 rect: Rect,
43 threshold: f64,
44 speed: f64,
45 axis: ScrollAxis,
46) -> (f64, f64) {
47 if !rect.contains(pos) {
53 return (0.0, 0.0);
54 }
55 let ramp = |dist_into_band: f64| (dist_into_band / threshold.max(1.0)).clamp(0.0, 1.0) * speed;
56 let edge = |lo: f64, hi: f64| -> f64 {
62 if lo <= hi {
63 if lo < threshold {
64 -ramp(threshold - lo)
65 } else {
66 0.0
67 }
68 } else if hi < threshold {
69 ramp(threshold - hi)
70 } else {
71 0.0
72 }
73 };
74 let mut dx = 0.0;
75 let mut dy = 0.0;
76 if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
77 dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
78 }
79 if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
80 dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
81 }
82 (dx, dy)
83}
84
85fn pointer_move_should_scroll(
91 pointer_type: &str,
92 pressure: f32,
93 has_held_button: bool,
94 active: Option<bool>,
95) -> bool {
96 match active {
97 Some(active) => active,
98 None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
99 }
100}
101
102#[component]
105pub fn AutoScroll(
106 #[props(default = 48.0)]
108 threshold: f64,
109 #[props(default = 24.0)]
111 speed: f64,
112 #[props(default)]
114 axis: ScrollAxis,
115 #[props(default)]
119 active: Option<bool>,
120 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
121 children: Element,
122) -> Element {
123 let mut mounted = use_signal(|| None::<Rc<MountedData>>);
124 let busy = use_signal(|| false);
127
128 let scroll_for = move |point: Point| {
129 let Some(m) = mounted.peek().clone() else {
130 return;
131 };
132 if *busy.peek() {
133 return;
134 }
135 let mut busy = busy;
136 busy.set(true);
137 spawn(async move {
138 if let Ok(r) = m.get_client_rect().await {
139 let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
140 let (dx, dy) = edge_delta(point, rect, threshold, speed, axis);
141 if dx != 0.0 || dy != 0.0 {
142 if let Ok(offset) = m.get_scroll_offset().await {
143 let _ = m
144 .scroll(
145 PixelsVector2D::new(offset.x + dx, offset.y + dy),
146 ScrollBehavior::Instant,
147 )
148 .await;
149 }
150 }
151 }
152 busy.set(false);
153 });
154 };
155
156 rsx! {
157 div {
158 onmounted: move |evt: Event<MountedData>| {
159 mounted.set(Some(evt.data()));
160 },
161 ondragover: move |evt: DragEvent| {
165 let c = evt.client_coordinates();
166 scroll_for(Point::new(c.x, c.y));
167 },
168 onpointermove: move |evt: PointerEvent| {
171 if pointer_move_should_scroll(
172 &evt.pointer_type(),
173 evt.pressure(),
174 !evt.held_buttons().is_empty(),
175 active,
176 ) {
177 let c = evt.client_coordinates();
178 scroll_for(Point::new(c.x, c.y));
179 }
180 },
181 ..attributes,
182 {children}
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn deltas_ramp_toward_edges() {
193 let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
194 assert_eq!(
196 edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
197 (0.0, 0.0)
198 );
199 let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
201 assert!(dy < 0.0 && dy >= -24.0);
202 let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
204 assert_eq!(dy, 24.0);
205 let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
207 assert_eq!(dx, 0.0);
208 }
209
210 #[test]
211 fn no_scroll_when_pointer_leaves_the_container() {
212 let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
216 assert_eq!(
217 edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
218 (0.0, 0.0)
219 );
220 assert_eq!(
221 edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
222 (0.0, 0.0)
223 );
224 }
225
226 #[test]
227 fn narrow_container_scrolls_toward_the_nearer_edge() {
228 let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
231 let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
232 assert!(
233 dx > 0.0,
234 "near the right edge should scroll right, got {dx}"
235 );
236 let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
237 assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
238 }
239
240 #[test]
241 fn pointer_scroll_predicate_matches_active_pointer_drags() {
242 assert!(
243 pointer_move_should_scroll("mouse", 0.0, true, None),
244 "default mouse pointer drags keep a held button during movement"
245 );
246 assert!(
247 !pointer_move_should_scroll("mouse", 0.0, false, None),
248 "passive mouse hover must not scroll"
249 );
250 assert!(
251 pointer_move_should_scroll("touch", 0.5, false, None),
252 "touch contact can report pressure instead of held buttons"
253 );
254 assert!(
255 pointer_move_should_scroll("pen", 0.0, true, None),
256 "pen contact can also surface as held buttons"
257 );
258 assert!(
259 !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
260 "callers that track drag state can explicitly gate scrolling off"
261 );
262 assert!(
263 pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
264 "callers that track drag state can explicitly gate scrolling on"
265 );
266 }
267}