Skip to main content

blitz_dom/node/
scrollbar.rs

1//! Overlay scrollbar geometry and css-scrollbars-1 style accessors for
2//! [`Node`]. Geometry is shared between painting (blitz-paint) and thumb
3//! hit-testing so the two cannot drift.
4
5use kurbo::Rect as KurboRect;
6use taffy::AbsoluteAxis;
7use web_time::Duration;
8
9use super::Node;
10
11/// How long overlay scrollbars stay fully opaque after their last activity
12/// (a scroll, or the pointer leaving the thumb), and how long the fade-out
13/// takes. Chromium's overlay values.
14pub(crate) const FADE_DELAY: Duration = Duration::from_millis(500);
15pub(crate) const FADE_DURATION: Duration = Duration::from_millis(200);
16
17/// Overlay scrollbar opacity as a function of time since the scroll
18/// container's last scrollbar activity: fully opaque through the fade
19/// delay, then fading linearly to hidden.
20pub(crate) fn opacity_at(elapsed: Duration) -> f32 {
21    match elapsed.checked_sub(FADE_DELAY) {
22        None => 1.0,
23        Some(fading) => 1.0 - (fading.as_secs_f32() / FADE_DURATION.as_secs_f32()).min(1.0),
24    }
25}
26
27/// A specific scrollbar: one axis of one scroll container.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ScrollbarRef {
30    pub node_id: usize,
31    pub axis: AbsoluteAxis,
32}
33
34/// The computed value of `scrollbar-width` (css-scrollbars-1). A local
35/// mirror of the stylo type, which isn't exposed to the servo engine yet
36/// (servo/stylo#413).
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum ScrollbarWidth {
39    #[default]
40    Auto,
41    Thin,
42    None,
43}
44
45/// The computed value of `scrollbar-color` (css-scrollbars-1). A local
46/// mirror of the stylo type, which isn't exposed to the servo engine yet
47/// (servo/stylo#413). Colors are fully resolved (no `currentColor`).
48#[derive(Clone, Debug, Default, PartialEq)]
49pub enum ScrollbarColor {
50    #[default]
51    Auto,
52    Colors {
53        thumb: style::color::AbsoluteColor,
54        track: style::color::AbsoluteColor,
55    },
56}
57
58impl Node {
59    /// The node's used `scrollbar-width`.
60    pub fn scrollbar_width(&self) -> ScrollbarWidth {
61        // TODO: read the computed style once stylo exposes scrollbar-width
62        // to the servo engine (servo/stylo#413):
63        // match self.primary_styles().map(|s| s.clone_scrollbar_width()) { .. }
64        ScrollbarWidth::Auto
65    }
66
67    /// The node's used `scrollbar-color`.
68    pub fn scrollbar_color(&self) -> ScrollbarColor {
69        // TODO: read the computed style once stylo exposes scrollbar-color
70        // to the servo engine (servo/stylo#413), resolving the colors
71        // against the element's `color`:
72        // self.primary_styles().map(|s| s.clone_scrollbar_color()) { .. }
73        ScrollbarColor::Auto
74    }
75
76    /// Whether the node shows an overlay scrollbar in the given axis:
77    /// always for `overflow: scroll`, only when the content overflows for
78    /// `overflow: auto`, never otherwise — and never when
79    /// `scrollbar-width: none`.
80    pub fn wants_scrollbar(&self, axis: AbsoluteAxis) -> bool {
81        use style::values::computed::Overflow;
82        let Some(style) = self.primary_styles() else {
83            return false;
84        };
85        if self.scrollbar_width() == ScrollbarWidth::None {
86            return false;
87        }
88        let (overflow, scroll_extent) = match axis {
89            AbsoluteAxis::Horizontal => (
90                style.clone_overflow_x(),
91                self.final_layout.scroll_width() as f64,
92            ),
93            AbsoluteAxis::Vertical => (
94                style.clone_overflow_y(),
95                self.final_layout.scroll_height() as f64,
96            ),
97        };
98        match overflow {
99            Overflow::Scroll => true,
100            Overflow::Auto => scroll_extent > 0.5,
101            _ => false,
102        }
103    }
104
105    /// The scrollport (padding box) in (unscaled) CSS px relative to the
106    /// node's border-box origin. Taffy has content-box helpers but none for
107    /// the padding box.
108    fn scrollport(&self) -> KurboRect {
109        let layout = &self.final_layout;
110        KurboRect::new(
111            layout.border.left as f64,
112            layout.border.top as f64,
113            layout.size.width as f64 - layout.border.right as f64,
114            layout.size.height as f64 - layout.border.bottom as f64,
115        )
116    }
117
118    /// Geometry of the overlay scrollbar thumb for the given axis, in
119    /// (unscaled) CSS px relative to the node's border-box origin. `None`
120    /// if there is no scrollable overflow in that axis.
121    pub fn scrollbar_thumb(&self, axis: AbsoluteAxis) -> Option<KurboRect> {
122        // Matches Chromium's overlay thumb in its interactive state.
123        const THUMB_THICKNESS: f64 = 10.0;
124        const THIN_THUMB_THICKNESS: f64 = 6.0;
125        const THUMB_MARGIN: f64 = 2.0;
126        const MIN_THUMB_LENGTH: f64 = 32.0;
127
128        let layout = &self.final_layout;
129        let scroll_extent = match axis {
130            AbsoluteAxis::Horizontal => layout.scroll_width() as f64,
131            AbsoluteAxis::Vertical => layout.scroll_height() as f64,
132        };
133        if scroll_extent <= 0.5 {
134            return None;
135        }
136
137        let thickness = match self.scrollbar_width() {
138            ScrollbarWidth::Thin => THIN_THUMB_THICKNESS,
139            _ => THUMB_THICKNESS,
140        };
141
142        let port = self.scrollport();
143        let (viewport_len, scroll_offset) = match axis {
144            AbsoluteAxis::Horizontal => (port.width(), self.scroll_offset.x),
145            AbsoluteAxis::Vertical => (port.height(), self.scroll_offset.y),
146        };
147        let thumb_len = (viewport_len * viewport_len / (viewport_len + scroll_extent))
148            .max(MIN_THUMB_LENGTH)
149            .min(viewport_len);
150        let progress = (scroll_offset / scroll_extent).clamp(0.0, 1.0);
151        // Round a sub-pixel displacement up to a whole pixel so any nonzero
152        // scroll visibly moves the thumb off the origin.
153        let thumb_start = match progress * (viewport_len - thumb_len) {
154            start if start > 0.0 && start < 1.0 => 1.0,
155            start => start,
156        };
157
158        Some(match axis {
159            AbsoluteAxis::Horizontal => KurboRect::new(
160                port.x0 + thumb_start,
161                port.y1 - THUMB_MARGIN - thickness,
162                port.x0 + thumb_start + thumb_len,
163                port.y1 - THUMB_MARGIN,
164            ),
165            AbsoluteAxis::Vertical => KurboRect::new(
166                port.x1 - THUMB_MARGIN - thickness,
167                port.y0 + thumb_start,
168                port.x1 - THUMB_MARGIN,
169                port.y0 + thumb_start + thumb_len,
170            ),
171        })
172    }
173
174    /// Content px scrolled per thumb px dragged, for the given axis.
175    pub fn scrollbar_drag_ratio(&self, axis: AbsoluteAxis) -> f64 {
176        let Some(thumb) = self.scrollbar_thumb(axis) else {
177            return 0.0;
178        };
179        let port = self.scrollport();
180        let (scroll_extent, viewport_len, thumb_len) = match axis {
181            AbsoluteAxis::Horizontal => (
182                self.final_layout.scroll_width() as f64,
183                port.width(),
184                thumb.width(),
185            ),
186            AbsoluteAxis::Vertical => (
187                self.final_layout.scroll_height() as f64,
188                port.height(),
189                thumb.height(),
190            ),
191        };
192        let track_play = viewport_len - thumb_len;
193        if track_play <= 0.0 {
194            return 0.0;
195        }
196        scroll_extent / track_play
197    }
198
199    /// The scrollbar thumb containing the given point (in this node's
200    /// border-box coordinates), if any. The `scrollbars` feature's single
201    /// behavioral gate: returning `None` keeps unpainted thumbs from ever
202    /// claiming pointer events.
203    pub(crate) fn scrollbar_at_local(&self, x: f64, y: f64) -> Option<ScrollbarRef> {
204        if !cfg!(feature = "scrollbars") {
205            return None;
206        }
207        for axis in [AbsoluteAxis::Vertical, AbsoluteAxis::Horizontal] {
208            if !self.wants_scrollbar(axis) {
209                continue;
210            }
211            if let Some(thumb) = self.scrollbar_thumb(axis)
212                && thumb.contains(kurbo::Point::new(x, y))
213            {
214                return Some(ScrollbarRef {
215                    node_id: self.id,
216                    axis,
217                });
218            }
219        }
220        None
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn opacity_holds_through_the_fade_delay_then_fades_out() {
230        assert_eq!(opacity_at(Duration::ZERO), 1.0);
231        assert_eq!(opacity_at(FADE_DELAY), 1.0);
232        let mid_fade = opacity_at(FADE_DELAY + FADE_DURATION / 2);
233        assert!((mid_fade - 0.5).abs() < 0.01, "got {mid_fade}");
234        assert_eq!(opacity_at(FADE_DELAY + FADE_DURATION), 0.0);
235        assert_eq!(opacity_at(Duration::from_secs(3600)), 0.0);
236    }
237}