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}