1use crate::env::ScrollStateMap;
2use fission_ir::{CoreIR, FlexDirection, LayoutOp, Op, WidgetId};
3use fission_layout::{LayoutPoint, LayoutRect, LayoutSnapshot};
4
5pub const SCROLLBAR_INSET: f32 = 2.0;
6pub const SCROLLBAR_THICKNESS: f32 = 6.0;
7pub const SCROLLBAR_MIN_THUMB: f32 = 24.0;
8pub const SCROLLBAR_HIT_SLOP: f32 = 4.0;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ScrollbarAxis {
16 Horizontal,
17 Vertical,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct ScrollbarGeometry {
22 pub node_id: WidgetId,
23 pub axis: ScrollbarAxis,
24 pub rail_rect: LayoutRect,
25 pub thumb_rect: LayoutRect,
26 pub offset: f32,
27 pub max_offset: f32,
28 pub track_travel: f32,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ScrollbarHitKind {
33 Thumb,
34 Rail,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct ScrollbarHit {
39 pub geometry: ScrollbarGeometry,
40 pub kind: ScrollbarHitKind,
41 pub pointer_to_thumb_start: f32,
42 pub layout_point: LayoutPoint,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct ScrollbarDragState {
47 pub node_id: WidgetId,
48 pub pointer_to_thumb_start: f32,
49}
50
51pub fn scrollbar_geometry_for_node(
52 ir: &CoreIR,
53 layout: &LayoutSnapshot,
54 scroll_map: &ScrollStateMap,
55 node_id: WidgetId,
56) -> Option<ScrollbarGeometry> {
57 let node = ir.nodes.get(&node_id)?;
58 let Op::Layout(LayoutOp::Scroll {
59 direction,
60 show_scrollbar,
61 ..
62 }) = &node.op
63 else {
64 return None;
65 };
66 if !show_scrollbar {
67 return None;
68 }
69
70 let geom = layout.get_node_geometry(node_id)?;
71 let rect = geom.rect;
72 let (axis, viewport_extent, content_extent, rail_rect) = match direction {
73 FlexDirection::Column => {
74 let rail_extent = (rect.size.height - SCROLLBAR_INSET * 2.0).max(0.0);
75 (
76 ScrollbarAxis::Vertical,
77 rect.size.height,
78 geom.content_size.height,
79 LayoutRect::new(
80 rect.origin.x + rect.size.width - SCROLLBAR_THICKNESS - SCROLLBAR_INSET,
81 rect.origin.y + SCROLLBAR_INSET,
82 SCROLLBAR_THICKNESS,
83 rail_extent,
84 ),
85 )
86 }
87 FlexDirection::Row => {
88 let rail_extent = (rect.size.width - SCROLLBAR_INSET * 2.0).max(0.0);
89 (
90 ScrollbarAxis::Horizontal,
91 rect.size.width,
92 geom.content_size.width,
93 LayoutRect::new(
94 rect.origin.x + SCROLLBAR_INSET,
95 rect.origin.y + rect.size.height - SCROLLBAR_THICKNESS - SCROLLBAR_INSET,
96 rail_extent,
97 SCROLLBAR_THICKNESS,
98 ),
99 )
100 }
101 };
102
103 if viewport_extent <= 0.0 || content_extent <= viewport_extent + 0.5 {
104 return None;
105 }
106
107 let rail_extent = axis_extent(axis, rail_rect);
108 if rail_extent <= 0.0 {
109 return None;
110 }
111
112 let max_offset = (content_extent - viewport_extent).max(0.0);
113 let offset = scroll_map.get_offset(node_id).clamp(0.0, max_offset);
114 let min_thumb = SCROLLBAR_MIN_THUMB.min(rail_extent);
115 let thumb_extent =
116 ((viewport_extent / content_extent) * rail_extent).clamp(min_thumb, rail_extent);
117 let track_travel = (rail_extent - thumb_extent).max(0.0);
118 let thumb_start = axis_start(axis, rail_rect)
119 + if max_offset > 0.0 && track_travel > 0.0 {
120 (offset / max_offset) * track_travel
121 } else {
122 0.0
123 };
124
125 let thumb_rect = match axis {
126 ScrollbarAxis::Vertical => LayoutRect::new(
127 rail_rect.origin.x,
128 thumb_start,
129 SCROLLBAR_THICKNESS,
130 thumb_extent,
131 ),
132 ScrollbarAxis::Horizontal => LayoutRect::new(
133 thumb_start,
134 rail_rect.origin.y,
135 thumb_extent,
136 SCROLLBAR_THICKNESS,
137 ),
138 };
139
140 Some(ScrollbarGeometry {
141 node_id,
142 axis,
143 rail_rect,
144 thumb_rect,
145 offset,
146 max_offset,
147 track_travel,
148 })
149}
150
151pub fn scrollbar_hit_test(
152 ir: &CoreIR,
153 layout: &LayoutSnapshot,
154 scroll_map: &ScrollStateMap,
155 point: LayoutPoint,
156) -> Option<ScrollbarHit> {
157 let root = ir.root?;
158 scrollbar_hit_test_recursive(root, ir, layout, scroll_map, point)
159}
160
161pub fn scrollbar_drag_offset(geometry: ScrollbarGeometry, point: LayoutPoint) -> f32 {
162 scrollbar_drag_offset_with_grab(geometry, point, geometry.thumb_extent() * 0.5)
163}
164
165pub fn scrollbar_point_for_node(
166 ir: &CoreIR,
167 scroll_map: &ScrollStateMap,
168 node_id: WidgetId,
169 mut point: LayoutPoint,
170) -> LayoutPoint {
171 let mut current = ir.nodes.get(&node_id).and_then(|node| node.parent);
172 while let Some(parent_id) = current {
173 let Some(parent) = ir.nodes.get(&parent_id) else {
174 break;
175 };
176 if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
177 let offset = scroll_map.get_offset(parent_id);
178 match direction {
179 FlexDirection::Column => point.y += offset,
180 FlexDirection::Row => point.x += offset,
181 }
182 }
183 current = parent.parent;
184 }
185 point
186}
187
188pub fn scrollbar_drag_offset_with_grab(
189 geometry: ScrollbarGeometry,
190 point: LayoutPoint,
191 pointer_to_thumb_start: f32,
192) -> f32 {
193 if geometry.track_travel <= 0.0 || geometry.max_offset <= 0.0 {
194 return 0.0;
195 }
196 let rail_start = axis_start(geometry.axis, geometry.rail_rect);
197 let pointer_axis = point_axis(geometry.axis, point);
198 let requested_thumb_start = pointer_axis - pointer_to_thumb_start;
199 let normalized = ((requested_thumb_start - rail_start) / geometry.track_travel).clamp(0.0, 1.0);
200 normalized * geometry.max_offset
201}
202
203impl ScrollbarGeometry {
204 pub fn thumb_extent(self) -> f32 {
205 axis_extent(self.axis, self.thumb_rect)
206 }
207}
208
209fn scrollbar_hit_test_recursive(
210 node_id: WidgetId,
211 ir: &CoreIR,
212 layout: &LayoutSnapshot,
213 scroll_map: &ScrollStateMap,
214 point: LayoutPoint,
215) -> Option<ScrollbarHit> {
216 let node = ir.nodes.get(&node_id)?;
217 let geom = layout.get_node_geometry(node_id)?;
218 let is_clip_container = matches!(
219 node.op,
220 Op::Layout(LayoutOp::Clip { .. }) | Op::Layout(LayoutOp::Scroll { .. })
221 );
222 if is_clip_container && !geom.rect.contains(point) {
223 return None;
224 }
225
226 if let Some(geometry) = scrollbar_geometry_for_node(ir, layout, scroll_map, node_id) {
227 let thumb_hit_rect = interaction_rect(geometry.axis, geometry.thumb_rect);
228 let rail_hit_rect = interaction_rect(geometry.axis, geometry.rail_rect);
229 if thumb_hit_rect.contains(point) {
230 return Some(ScrollbarHit {
231 geometry,
232 kind: ScrollbarHitKind::Thumb,
233 pointer_to_thumb_start: point_axis(geometry.axis, point)
234 - axis_start(geometry.axis, geometry.thumb_rect),
235 layout_point: point,
236 });
237 }
238 if rail_hit_rect.contains(point) {
239 return Some(ScrollbarHit {
240 geometry,
241 kind: ScrollbarHitKind::Rail,
242 pointer_to_thumb_start: geometry.thumb_extent() * 0.5,
243 layout_point: point,
244 });
245 }
246 }
247
248 let mut child_point = point;
249 if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
250 let offset = scroll_map.get_offset(node_id);
251 match direction {
252 FlexDirection::Column => child_point.y += offset,
253 FlexDirection::Row => child_point.x += offset,
254 }
255 }
256
257 for child_id in node.children.iter().rev() {
258 if let Some(hit) =
259 scrollbar_hit_test_recursive(*child_id, ir, layout, scroll_map, child_point)
260 {
261 return Some(hit);
262 }
263 }
264
265 None
266}
267
268fn interaction_rect(axis: ScrollbarAxis, rect: LayoutRect) -> LayoutRect {
269 match axis {
270 ScrollbarAxis::Vertical => LayoutRect::new(
271 rect.origin.x - SCROLLBAR_HIT_SLOP,
272 rect.origin.y,
273 rect.size.width + SCROLLBAR_HIT_SLOP,
274 rect.size.height,
275 ),
276 ScrollbarAxis::Horizontal => LayoutRect::new(
277 rect.origin.x,
278 rect.origin.y - SCROLLBAR_HIT_SLOP,
279 rect.size.width,
280 rect.size.height + SCROLLBAR_HIT_SLOP,
281 ),
282 }
283}
284
285fn axis_start(axis: ScrollbarAxis, rect: LayoutRect) -> f32 {
286 match axis {
287 ScrollbarAxis::Horizontal => rect.origin.x,
288 ScrollbarAxis::Vertical => rect.origin.y,
289 }
290}
291
292fn axis_extent(axis: ScrollbarAxis, rect: LayoutRect) -> f32 {
293 match axis {
294 ScrollbarAxis::Horizontal => rect.size.width,
295 ScrollbarAxis::Vertical => rect.size.height,
296 }
297}
298
299fn point_axis(axis: ScrollbarAxis, point: LayoutPoint) -> f32 {
300 match axis {
301 ScrollbarAxis::Horizontal => point.x,
302 ScrollbarAxis::Vertical => point.y,
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::{
309 scrollbar_drag_offset, scrollbar_drag_offset_with_grab, scrollbar_geometry_for_node,
310 scrollbar_hit_test, scrollbar_point_for_node, ScrollbarAxis, ScrollbarHitKind,
311 };
312 use crate::env::ScrollStateMap;
313 use fission_ir::{CompositeStyle, CoreIR, CoreNode, FlexDirection, LayoutOp, Op, WidgetId};
314 use fission_layout::{LayoutNodeGeometry, LayoutPoint, LayoutRect, LayoutSize, LayoutSnapshot};
315
316 #[test]
317 fn vertical_scrollbar_geometry_tracks_offset_inside_viewport() {
318 let (ir, mut layout, scroll) = scroll_tree();
319 layout.nodes.insert(
320 scroll,
321 LayoutNodeGeometry {
322 rect: LayoutRect::new(10.0, 20.0, 100.0, 200.0),
323 content_size: LayoutSize::new(100.0, 600.0),
324 },
325 );
326 let mut scroll_map = ScrollStateMap::default();
327 scroll_map.set_offset(scroll, 200.0);
328
329 let geometry =
330 scrollbar_geometry_for_node(&ir, &layout, &scroll_map, scroll).expect("scrollbar");
331
332 assert_eq!(geometry.axis, ScrollbarAxis::Vertical);
333 assert_eq!(geometry.rail_rect.origin.x, 102.0);
334 assert_eq!(geometry.rail_rect.origin.y, 22.0);
335 assert!((geometry.thumb_extent() - (196.0 / 3.0)).abs() <= 0.01);
336 assert!(geometry.thumb_rect.origin.y > geometry.rail_rect.origin.y);
337 assert!(geometry.thumb_rect.bottom() <= geometry.rail_rect.bottom());
338 }
339
340 #[test]
341 fn scrollbar_hit_test_prioritizes_thumb_chrome() {
342 let (ir, mut layout, scroll) = scroll_tree();
343 layout.nodes.insert(
344 scroll,
345 LayoutNodeGeometry {
346 rect: LayoutRect::new(0.0, 0.0, 100.0, 200.0),
347 content_size: LayoutSize::new(100.0, 600.0),
348 },
349 );
350
351 let hit = scrollbar_hit_test(
352 &ir,
353 &layout,
354 &ScrollStateMap::default(),
355 LayoutPoint::new(97.0, 8.0),
356 )
357 .expect("scrollbar hit");
358
359 assert_eq!(hit.kind, ScrollbarHitKind::Thumb);
360 assert_eq!(hit.geometry.node_id, scroll);
361 }
362
363 #[test]
364 fn scrollbar_drag_maps_thumb_position_to_offset() {
365 let (ir, mut layout, scroll) = scroll_tree();
366 layout.nodes.insert(
367 scroll,
368 LayoutNodeGeometry {
369 rect: LayoutRect::new(0.0, 0.0, 100.0, 200.0),
370 content_size: LayoutSize::new(100.0, 600.0),
371 },
372 );
373 let geometry =
374 scrollbar_geometry_for_node(&ir, &layout, &ScrollStateMap::default(), scroll).unwrap();
375
376 let offset = scrollbar_drag_offset_with_grab(geometry, LayoutPoint::new(97.0, 198.0), 0.0);
377
378 assert!((offset - geometry.max_offset).abs() <= 0.01);
379 }
380
381 #[test]
382 fn scrollbar_rail_click_centres_thumb_at_requested_position() {
383 let (ir, mut layout, scroll) = scroll_tree();
384 layout.nodes.insert(
385 scroll,
386 LayoutNodeGeometry {
387 rect: LayoutRect::new(0.0, 0.0, 100.0, 200.0),
388 content_size: LayoutSize::new(100.0, 600.0),
389 },
390 );
391 let geometry =
392 scrollbar_geometry_for_node(&ir, &layout, &ScrollStateMap::default(), scroll).unwrap();
393
394 let offset = scrollbar_drag_offset(geometry, LayoutPoint::new(97.0, 100.0));
395
396 assert!((offset - geometry.max_offset * 0.5).abs() <= 0.01);
397 }
398
399 #[test]
400 fn scrollbar_hit_target_extends_inward_beyond_painted_chrome() {
401 let (ir, mut layout, scroll) = scroll_tree();
402 layout.nodes.insert(
403 scroll,
404 LayoutNodeGeometry {
405 rect: LayoutRect::new(0.0, 0.0, 100.0, 200.0),
406 content_size: LayoutSize::new(100.0, 600.0),
407 },
408 );
409 let scroll_map = ScrollStateMap::default();
410 let geometry =
411 scrollbar_geometry_for_node(&ir, &layout, &scroll_map, scroll).expect("scrollbar");
412 let point = LayoutPoint::new(
413 geometry.rail_rect.x() - super::SCROLLBAR_HIT_SLOP * 0.5,
414 geometry.thumb_rect.bottom() + 4.0,
415 );
416 assert!(!geometry.rail_rect.contains(point));
417
418 let hit = scrollbar_hit_test(&ir, &layout, &scroll_map, point)
419 .expect("inward scrollbar interaction target");
420
421 assert_eq!(hit.kind, ScrollbarHitKind::Rail);
422 }
423
424 #[test]
425 fn nested_scrollbar_hit_uses_target_layout_coordinates() {
426 let parent = WidgetId::derived(71, &[0]);
427 let child = WidgetId::derived(71, &[1]);
428 let mut ir = CoreIR::new();
429 ir.add_node(
430 child,
431 Op::Layout(LayoutOp::Scroll {
432 direction: FlexDirection::Row,
433 show_scrollbar: true,
434 width: Some(100.0),
435 height: Some(50.0),
436 min_width: None,
437 max_width: None,
438 min_height: None,
439 max_height: None,
440 padding: [0.0; 4],
441 flex_grow: 0.0,
442 flex_shrink: 0.0,
443 }),
444 vec![],
445 );
446 ir.add_node(
447 parent,
448 Op::Layout(LayoutOp::Scroll {
449 direction: FlexDirection::Column,
450 show_scrollbar: true,
451 width: Some(120.0),
452 height: Some(120.0),
453 min_width: None,
454 max_width: None,
455 min_height: None,
456 max_height: None,
457 padding: [0.0; 4],
458 flex_grow: 0.0,
459 flex_shrink: 0.0,
460 }),
461 vec![child],
462 );
463 ir.set_root(parent);
464
465 let mut layout = LayoutSnapshot::new(LayoutSize::new(120.0, 120.0));
466 layout.nodes.insert(
467 parent,
468 LayoutNodeGeometry {
469 rect: LayoutRect::new(0.0, 0.0, 120.0, 120.0),
470 content_size: LayoutSize::new(120.0, 320.0),
471 },
472 );
473 layout.nodes.insert(
474 child,
475 LayoutNodeGeometry {
476 rect: LayoutRect::new(0.0, 160.0, 100.0, 50.0),
477 content_size: LayoutSize::new(300.0, 50.0),
478 },
479 );
480 let mut scroll_map = ScrollStateMap::default();
481 scroll_map.set_offset(parent, 100.0);
482
483 let visual_rail_point = LayoutPoint::new(50.0, 104.0);
484 let hit =
485 scrollbar_hit_test(&ir, &layout, &scroll_map, visual_rail_point).expect("child rail");
486
487 assert_eq!(hit.geometry.node_id, child);
488 assert_eq!(hit.kind, ScrollbarHitKind::Rail);
489 assert_eq!(
490 hit.layout_point,
491 scrollbar_point_for_node(&ir, &scroll_map, child, visual_rail_point)
492 );
493 assert!(
494 hit.geometry.rail_rect.contains(hit.layout_point),
495 "hit point must be in the target scrollbar's layout coordinate space"
496 );
497 }
498
499 fn scroll_tree() -> (CoreIR, LayoutSnapshot, WidgetId) {
500 let scroll = WidgetId::derived(70, &[1]);
501 let mut ir = CoreIR::default();
502 ir.nodes.insert(
503 scroll,
504 CoreNode {
505 id: scroll,
506 parent: None,
507 children: Vec::new(),
508 op: Op::Layout(LayoutOp::Scroll {
509 direction: FlexDirection::Column,
510 show_scrollbar: true,
511 width: Some(100.0),
512 height: Some(200.0),
513 min_width: None,
514 max_width: None,
515 min_height: None,
516 max_height: None,
517 padding: [0.0; 4],
518 flex_grow: 0.0,
519 flex_shrink: 0.0,
520 }),
521 composite: CompositeStyle::default(),
522 hash: 0,
523 },
524 );
525 ir.set_root(scroll);
526 (
527 ir,
528 LayoutSnapshot::new(LayoutSize::new(100.0, 200.0)),
529 scroll,
530 )
531 }
532}