Skip to main content

apace_dom/node/
scrollbar.rs

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