Skip to main content

tessera_ui/
scroll.rs

1//! Platform scroll delta normalization helpers.
2//!
3//! ## Usage
4//!
5//! Normalize wheel deltas before applying them inside scrollable containers.
6
7use crate::{Dp, ScrollDeltaUnit, ScrollEventContent, ScrollEventSource};
8
9/// Platform-specific wheel scroll normalization parameters.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct PlatformScrollConfig {
12    /// Density-independent distance consumed for one wheel line step.
13    pub wheel_line_step: Dp,
14}
15
16impl PlatformScrollConfig {
17    /// Returns the default configuration for the current compilation target.
18    pub fn current() -> Self {
19        // Browser line units differ from the wheel ticks supplied by desktop
20        // backends. Desktop defaults match Chromium's conversion for each OS.
21        let step = if cfg!(target_family = "wasm") {
22            16.0
23        } else if cfg!(target_os = "windows") {
24            100.0
25        } else if cfg!(target_os = "linux") {
26            53.0
27        } else {
28            40.0
29        };
30        Self {
31            wheel_line_step: Dp(step),
32        }
33    }
34
35    /// Converts a raw platform scroll delta into physical scroll pixels.
36    pub fn normalize_scroll_delta(
37        self,
38        delta_x: f32,
39        delta_y: f32,
40        unit: ScrollDeltaUnit,
41        source: ScrollEventSource,
42    ) -> (f32, f32) {
43        let multiplier = match (source, unit) {
44            (ScrollEventSource::Wheel, ScrollDeltaUnit::Line) => {
45                self.wheel_line_step.to_pixels_f64() as f32
46            }
47            _ => 1.0,
48        };
49        (delta_x * multiplier, delta_y * multiplier)
50    }
51}
52
53impl Default for PlatformScrollConfig {
54    fn default() -> Self {
55        Self::current()
56    }
57}
58
59/// Returns the platform scroll configuration for the current target.
60pub fn platform_scroll_config() -> PlatformScrollConfig {
61    PlatformScrollConfig::current()
62}
63
64/// Converts a raw platform scroll delta into physical scroll pixels.
65pub fn normalize_platform_scroll_delta(
66    delta_x: f32,
67    delta_y: f32,
68    unit: ScrollDeltaUnit,
69    source: ScrollEventSource,
70) -> (f32, f32) {
71    platform_scroll_config().normalize_scroll_delta(delta_x, delta_y, unit, source)
72}
73
74pub(crate) fn is_discrete_wheel(event: &ScrollEventContent) -> bool {
75    event.source == ScrollEventSource::Wheel
76        && (event.unit == ScrollDeltaUnit::Line || is_wheel_step(event.delta_x, event.delta_y))
77}
78
79pub(crate) fn is_discrete_platform_wheel(event: &ScrollEventContent) -> bool {
80    match event.unit {
81        // Browser line units and AppKit non-precise wheel ticks are discrete.
82        ScrollDeltaUnit::Line if cfg!(any(target_family = "wasm", target_os = "macos")) => true,
83        // Windows legacy messages and X11 may report fractional ticks for
84        // high-resolution devices. Match the browser's pixel classification.
85        ScrollDeltaUnit::Line => {
86            let (x, y) = event.pixel_delta();
87            is_wheel_step(x, y)
88        }
89        // Native pixel deltas already identify continuous input and may carry
90        // OS-generated momentum. The Web does not expose the wheel device kind.
91        ScrollDeltaUnit::Pixel => cfg!(target_family = "wasm") && is_discrete_wheel(event),
92    }
93}
94
95fn is_wheel_step(x: f32, y: f32) -> bool {
96    let threshold = Dp(40.0).to_pixels_f64() as f32;
97    x.abs().max(y.abs()) >= threshold
98}
99
100#[cfg(test)]
101mod tests {
102    use super::{PlatformScrollConfig, is_discrete_platform_wheel, is_discrete_wheel};
103    use crate::{Dp, ScrollDeltaUnit, ScrollEventContent, ScrollEventSource};
104
105    #[test]
106    fn wheel_line_delta_is_scaled_by_platform_step() {
107        let _runtime_session = crate::RuntimeSession::new();
108        let _runtime_scope = _runtime_session.enter();
109        let config = PlatformScrollConfig {
110            wheel_line_step: Dp(24.0),
111        };
112        let (x, y) = config.normalize_scroll_delta(
113            1.0,
114            -2.0,
115            ScrollDeltaUnit::Line,
116            ScrollEventSource::Wheel,
117        );
118        assert_eq!((x, y), (24.0, -48.0));
119    }
120
121    #[test]
122    fn wheel_classification_uses_logical_distance_and_native_precision() {
123        let session = crate::RuntimeSession::new();
124        session.run(|| {
125            crate::dp::set_scale_factor(2.0);
126            let mut event = ScrollEventContent {
127                delta_x: 0.0,
128                delta_y: -70.0,
129                unit: ScrollDeltaUnit::Pixel,
130                source: ScrollEventSource::Wheel,
131            };
132            assert!(!is_discrete_wheel(&event));
133            event.delta_y = -120.0;
134            assert!(is_discrete_wheel(&event));
135            assert_eq!(
136                is_discrete_platform_wheel(&event),
137                cfg!(target_family = "wasm")
138            );
139        });
140    }
141
142    #[test]
143    fn wheel_pixel_delta_is_left_untouched() {
144        let _runtime_session = crate::RuntimeSession::new();
145        let _runtime_scope = _runtime_session.enter();
146        let config = PlatformScrollConfig {
147            wheel_line_step: Dp(24.0),
148        };
149        let (x, y) = config.normalize_scroll_delta(
150            3.0,
151            -5.0,
152            ScrollDeltaUnit::Pixel,
153            ScrollEventSource::Wheel,
154        );
155        assert_eq!((x, y), (3.0, -5.0));
156    }
157}