Skip to main content

teksilo_widgets/common/
drag_autoscroll.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The drag auto-scroll edge ramp, implemented once.
5//!
6//! Every view that accepts a drag over a scrolling viewport — `ListView`,
7//! `TreeView`, `TableView`, `TreeTableView`, `GridView`'s marquee and the
8//! `TabBar` strip — scrolls when the pointer lingers near an edge. Each one
9//! carried its own `EDGE = 32.0` / `MAX_VELOCITY = 12.0` pair and its own copy
10//! of the ramp; this module is the single definition they now share.
11//!
12//! # Why the band is a pointer-kind question, not a density one
13//!
14//! The edge band is the distance from the viewport edge at which auto-scroll
15//! begins. What makes it right or wrong is how precisely the user can park a
16//! pointer near an edge, which is a property of the *device*: a mouse cursor
17//! lands where it is put, a fingertip covers roughly 9 mm of screen and its
18//! reported centre wanders. So the band widens for a coarse pointer and is
19//! unchanged for a precise one — it does not move with [`TargetDensity`],
20//! which describes how big the *UI's* targets are.
21//!
22//! The velocity cap is neither: it is a rate, not a dimension, and the same
23//! 12 dp per tick reads correctly under every pointer and every density.
24//!
25//! Reference: `engineering/docs/density-inventory.md` §3b, and the touch design's
26//! Constants table ("DnD edge auto-scroll band / max velocity").
27//!
28//! [`TargetDensity`]: teksilo_tokens::TargetDensity
29
30use teksilo_tokens::PointerKind;
31
32/// Edge band for a precise pointer (mouse, pen), in dp. The value every data
33/// view used before the touch programme, kept exactly.
34pub const EDGE_BAND_PRECISE: f32 = 32.0;
35
36/// Edge band for a coarse pointer (a finger), in dp — twice the precise band,
37/// because a contact patch's reported centre cannot be parked as finely as a
38/// cursor.
39pub const EDGE_BAND_COARSE: f32 = 64.0;
40
41/// Cap on the per-tick auto-scroll step, in dp. A rate, not a dimension: the
42/// same under every pointer kind and every density.
43pub const MAX_VELOCITY: f32 = 12.0;
44
45/// The edge band for a pointer kind.
46///
47/// [`PointerKind::Mouse`], [`PointerKind::Pen`] and [`PointerKind::Unknown`]
48/// get [`EDGE_BAND_PRECISE`]; only [`PointerKind::Touch`] widens.
49pub fn band_for(kind: PointerKind) -> f32 {
50    if kind.is_coarse() {
51        EDGE_BAND_COARSE
52    } else {
53        EDGE_BAND_PRECISE
54    }
55}
56
57/// The auto-scroll step, in dp, for a pointer at `pointer_main` along a
58/// viewport of `viewport_extent`.
59///
60/// Zero outside both bands; ramps linearly inside a band and saturates at
61/// [`MAX_VELOCITY`] past the viewport edge (a captured drag keeps reporting
62/// positions beyond the boundary). Negative scrolls toward the leading edge.
63///
64/// Pure, so every caller's ramp can be unit tested without a widget tree.
65pub fn step(pointer_main: f32, viewport_extent: f32, band: f32) -> f32 {
66    if band <= 0.0 {
67        return 0.0;
68    }
69    let leading_in = (band - pointer_main).max(0.0);
70    let trailing_in = (pointer_main - (viewport_extent - band)).max(0.0);
71    if leading_in > 0.0 {
72        -(leading_in / band).min(1.0) * MAX_VELOCITY
73    } else if trailing_in > 0.0 {
74        (trailing_in / band).min(1.0) * MAX_VELOCITY
75    } else {
76        0.0
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// A mouse keeps the exact band every view hard-coded before this module
85    /// existed — the no-regression statement for the whole de-duplication.
86    #[test]
87    fn a_precise_pointer_keeps_the_original_band() {
88        assert_eq!(band_for(PointerKind::Mouse), 32.0);
89        assert_eq!(
90            band_for(PointerKind::Pen(teksilo_tokens::PenKind::Pen)),
91            32.0
92        );
93        assert_eq!(band_for(PointerKind::Unknown), 32.0);
94    }
95
96    #[test]
97    fn a_finger_gets_a_wider_band() {
98        assert_eq!(band_for(PointerKind::Touch), 64.0);
99    }
100
101    #[test]
102    fn the_ramp_is_zero_in_the_middle_and_saturates_past_the_edge() {
103        let band = EDGE_BAND_PRECISE;
104        assert_eq!(step(200.0, 400.0, band), 0.0);
105        assert!(step(10.0, 400.0, band) < 0.0);
106        assert!(step(390.0, 400.0, band) > 0.0);
107        assert!((step(-50.0, 400.0, band) + MAX_VELOCITY).abs() < 1e-6);
108        assert!((step(500.0, 400.0, band) - MAX_VELOCITY).abs() < 1e-6);
109    }
110
111    #[test]
112    fn a_zero_band_never_scrolls() {
113        assert_eq!(step(0.0, 400.0, 0.0), 0.0);
114    }
115}