blitz_dom/node/
scrollbar.rs1use blitz_traits::node_id::NodeId;
6use kurbo::Rect as KurboRect;
7use taffy::AbsoluteAxis;
8use web_time::Duration;
9
10use super::Node;
11
12pub(crate) const FADE_DELAY: Duration = Duration::from_millis(500);
16pub(crate) const FADE_DURATION: Duration = Duration::from_millis(200);
17
18pub(crate) fn opacity_at(elapsed: Duration) -> f32 {
22 match elapsed.checked_sub(FADE_DELAY) {
23 None => 1.0,
24 Some(fading) => 1.0 - (fading.as_secs_f32() / FADE_DURATION.as_secs_f32()).min(1.0),
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ScrollbarRef {
31 pub node_id: NodeId,
32 pub axis: AbsoluteAxis,
33}
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum ScrollbarWidth {
40 #[default]
41 Auto,
42 Thin,
43 None,
44}
45
46#[derive(Clone, Debug, Default, PartialEq)]
50pub enum ScrollbarColor {
51 #[default]
52 Auto,
53 Colors {
54 thumb: style::color::AbsoluteColor,
55 track: style::color::AbsoluteColor,
56 },
57}
58
59impl Node {
60 pub fn scrollbar_width(&self) -> ScrollbarWidth {
62 ScrollbarWidth::Auto
66 }
67
68 pub fn scrollbar_color(&self) -> ScrollbarColor {
70 ScrollbarColor::Auto
75 }
76
77 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.final_layout().scroll_width() as f64,
93 ),
94 AbsoluteAxis::Vertical => (
95 style.clone_overflow_y(),
96 self.final_layout().scroll_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 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 pub fn scrollbar_thumb(&self, axis: AbsoluteAxis) -> Option<KurboRect> {
123 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 layout = self.final_layout();
130 let scroll_extent = match axis {
131 AbsoluteAxis::Horizontal => layout.scroll_width() as f64,
132 AbsoluteAxis::Vertical => layout.scroll_height() as f64,
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_extent).clamp(0.0, 1.0);
152 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 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.final_layout().scroll_width() as f64,
184 port.width(),
185 thumb.width(),
186 ),
187 AbsoluteAxis::Vertical => (
188 self.final_layout().scroll_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 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}