Skip to main content

fission_core/
scrollbar.rs

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