Skip to main content

fission_core/
scrollbar.rs

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;
8/// Additional inward cross-axis reach for pointer interaction.
9///
10/// Scrollbar chrome stays visually compact while remaining practical to grab
11/// on high-density displays.
12pub 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}