Skip to main content

gpui_base/
auto_scroll.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use gpui::{AsyncApp, Bounds, Context, Pixels, Point, Task, WeakEntity, px};
5
6/// Manages timer-based auto-scrolling during drag interactions.
7///
8/// Delta convention: positive moves toward the bottom and negative moves
9/// toward the top.
10pub struct AutoScroll {
11    /// Shared between the main thread and the background task.
12    /// Writing `None` is the stop signal; the task exits on its next tick.
13    shared: Arc<Mutex<Option<Pixels>>>,
14    task: Option<Task<()>>,
15    /// Last drag position, available to the interaction that owns selection.
16    pub last_drag_position: Option<Point<Pixels>>,
17}
18
19impl Default for AutoScroll {
20    fn default() -> Self {
21        Self {
22            shared: Arc::new(Mutex::new(None)),
23            task: None,
24            last_drag_position: None,
25        }
26    }
27}
28
29impl AutoScroll {
30    /// Returns the current scroll delta.
31    pub fn delta(&self) -> Option<Pixels> {
32        *self.shared.lock().unwrap()
33    }
34
35    /// Computes the scroll delta for a pointer Y position within the viewport.
36    pub fn compute_delta(y: Pixels, bounds: Bounds<Pixels>) -> Option<Pixels> {
37        const MIN_SPEED: f32 = 12.0;
38        const MAX_SPEED: f32 = 64.0;
39        // Trigger starts this far inside the bounds so scrolling works even in
40        // full-screen where the mouse can't travel far outside the element.
41        const INNER_ZONE: f32 = 16.0;
42        // Distance from the bounds edge to reach MAX_SPEED.
43        // Total ramp = INNER_ZONE + OUTER_RAMP, giving a single smooth curve
44        // with no flat sections or discontinuities.
45        const OUTER_RAMP: f32 = 80.0;
46
47        let bottom_trigger = bounds.bottom() - px(INNER_ZONE);
48        let top_trigger = bounds.top() + px(INNER_ZONE);
49
50        if y > bottom_trigger {
51            let t = ((y - bottom_trigger) / px(INNER_ZONE + OUTER_RAMP)).min(1.0);
52            Some(px(MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)))
53        } else if y < top_trigger {
54            let t = ((top_trigger - y) / px(INNER_ZONE + OUTER_RAMP)).min(1.0);
55            Some(px(-(MIN_SPEED + t * (MAX_SPEED - MIN_SPEED))))
56        } else {
57            None
58        }
59    }
60
61    /// Updates the scroll delta and starts the background task when needed.
62    ///
63    /// `tick` is called each frame (~60 fps) with the current delta.
64    /// It should perform the actual scroll action for this entity.
65    pub fn set<T, F>(&mut self, delta: Option<Pixels>, cx: &mut Context<T>, tick: F)
66    where
67        T: 'static,
68        F: Fn(Pixels, &mut T, &mut Context<T>) + Send + 'static,
69    {
70        let was_idle = self.task.is_none();
71        *self.shared.lock().unwrap() = delta;
72
73        if delta.is_none() {
74            self.task = None;
75            return;
76        }
77
78        if was_idle {
79            let shared = Arc::clone(&self.shared);
80            self.task = Some(cx.spawn(Self::task_loop(shared, tick)));
81        }
82    }
83
84    fn task_loop<T, F>(
85        shared: Arc<Mutex<Option<Pixels>>>,
86        tick: F,
87    ) -> impl AsyncFnOnce(WeakEntity<T>, &mut AsyncApp) + 'static
88    where
89        T: 'static,
90        F: Fn(Pixels, &mut T, &mut Context<T>) + Send + 'static,
91    {
92        async move |this: WeakEntity<T>, cx: &mut AsyncApp| {
93            loop {
94                cx.background_executor()
95                    .timer(Duration::from_millis(16))
96                    .await;
97                let Some(delta) = *shared.lock().unwrap() else {
98                    break;
99                };
100                let alive = this
101                    .update(cx, |state, cx| {
102                        tick(delta, state, cx);
103                        true
104                    })
105                    .unwrap_or(false);
106                if !alive {
107                    break;
108                }
109            }
110        }
111    }
112
113    /// Returns whether automatic scrolling is active.
114    pub fn is_active(&self) -> bool {
115        self.delta().is_some()
116    }
117
118    /// Stops automatic scrolling and clears the last drag position.
119    pub fn stop(&mut self) {
120        *self.shared.lock().unwrap() = None;
121        self.task = None;
122        self.last_drag_position = None;
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use gpui::{Bounds, point, size};
129
130    use super::*;
131
132    #[test]
133    fn delta_uses_dead_zone_and_symmetric_edge_ramps() {
134        let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.)));
135
136        assert_eq!(AutoScroll::compute_delta(px(150.), bounds), None);
137
138        let top = AutoScroll::compute_delta(px(80.), bounds).unwrap();
139        let bottom = AutoScroll::compute_delta(px(220.), bounds).unwrap();
140        assert_eq!(top, -bottom);
141        assert!(top < px(-12.));
142    }
143
144    #[test]
145    fn stop_clears_delta_and_drag_position() {
146        let mut scroll = AutoScroll::default();
147        *scroll.shared.lock().unwrap() = Some(px(20.));
148        scroll.last_drag_position = Some(point(px(1.), px(2.)));
149
150        scroll.stop();
151
152        assert!(!scroll.is_active());
153        assert_eq!(scroll.last_drag_position, None);
154    }
155}