aetna_core/state/scroll.rs
1//! Scroll offset, scrollbar, and wheel helpers for [`UiState`](super::UiState).
2
3use crate::hit_test::scroll_targets_at;
4use crate::tree::{El, Rect};
5
6use super::UiState;
7
8const WHEEL_EPSILON: f32 = 0.5;
9
10impl UiState {
11 /// Seed or read the persistent scroll offset for `id`. Use this to
12 /// pre-position a scroll viewport before [`crate::layout::layout`]
13 /// runs (call [`crate::layout::assign_ids`] first to populate the
14 /// node's `computed_id`).
15 pub fn set_scroll_offset(&mut self, id: impl Into<String>, value: f32) {
16 self.scroll.offsets.insert(id.into(), value);
17 }
18
19 /// Read the current scroll offset for `id`. Defaults to `0.0`.
20 pub fn scroll_offset(&self, id: &str) -> f32 {
21 self.scroll.offsets.get(id).copied().unwrap_or(0.0)
22 }
23
24 /// Queue programmatic scroll-to-row requests targeting virtual
25 /// lists by key. Each request is consumed during layout of the
26 /// matching list — viewport height and row heights are only known
27 /// then, especially for `virtual_list_dyn` where unmeasured rows
28 /// use the configured estimate. Hosts call this once per frame
29 /// from [`crate::event::App::drain_scroll_requests`]; apps that
30 /// own a `Runner` can also push directly for tests.
31 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
32 self.scroll.pending_requests.extend(requests);
33 }
34
35 /// Drop any scroll requests still queued after the layout pass
36 /// completed. Called by `prepare_layout` so requests targeting a
37 /// list that wasn't in the tree this frame don't silently fire
38 /// against a re-mounted list with the same key on a later frame.
39 pub fn clear_pending_scroll_requests(&mut self) {
40 self.scroll.pending_requests.clear();
41 }
42
43 /// Iterate `(scroll_node_id, track_rect)` for every scrollable
44 /// whose visible scrollbar is currently active. Hosts use this to
45 /// drive cursor changes (e.g., a vertical-resize cursor over the
46 /// thumb), to drive screenshot tools, or to test interaction
47 /// flows. The map is rebuilt every layout pass.
48 pub fn scrollbar_tracks(&self) -> impl Iterator<Item = (&str, &Rect)> {
49 self.scroll
50 .thumb_tracks
51 .iter()
52 .map(|(id, rect)| (id.as_str(), rect))
53 }
54
55 /// Look up the scrollable whose track rect contains `(x, y)`,
56 /// returning its `computed_id`, the track rect, and the visible
57 /// thumb rect. Returns `None` if no track is currently visible at
58 /// that point. The track rect is wider than the visible thumb
59 /// (Fitts's law) and spans the full viewport height so callers
60 /// can branch on whether `y` lands inside the thumb (grab) or
61 /// above/below (click-to-page).
62 pub fn thumb_at(&self, x: f32, y: f32) -> Option<(String, Rect, Rect)> {
63 for (id, track) in &self.scroll.thumb_tracks {
64 if track.contains(x, y) {
65 let thumb = self
66 .scroll
67 .thumb_rects
68 .get(id)
69 .copied()
70 .unwrap_or_else(|| Rect::new(track.x, track.y, track.w, 0.0));
71 return Some((id.clone(), *track, thumb));
72 }
73 }
74 None
75 }
76
77 /// Increment the scroll offset for the deepest scrollable container
78 /// under `point` that can move in `dy`'s direction. If the deepest
79 /// container is already at that edge (or has no overflow), the wheel
80 /// bubbles to the nearest scrollable ancestor that can move.
81 ///
82 /// Returns `true` if any scrollable consumed the wheel and updated
83 /// its stored offset. Hosts use this to decide whether to request a
84 /// redraw.
85 pub fn pointer_wheel(&mut self, root: &El, point: (f32, f32), dy: f32) -> bool {
86 if dy.abs() <= f32::EPSILON {
87 return false;
88 }
89 let targets = scroll_targets_at(root, self, point);
90 for id in targets.into_iter().rev() {
91 let Some(metrics) = self.scroll.metrics.get(&id).copied() else {
92 continue;
93 };
94 if metrics.max_offset <= WHEEL_EPSILON {
95 continue;
96 }
97 let current = self
98 .scroll
99 .offsets
100 .get(&id)
101 .copied()
102 .unwrap_or(0.0)
103 .clamp(0.0, metrics.max_offset);
104 let can_scroll = if dy > 0.0 {
105 current < metrics.max_offset - WHEEL_EPSILON
106 } else {
107 current > WHEEL_EPSILON
108 };
109 if !can_scroll {
110 continue;
111 }
112 let next = (current + dy).clamp(0.0, metrics.max_offset);
113 if (next - current).abs() <= f32::EPSILON {
114 continue;
115 }
116 self.scroll.offsets.insert(id, next);
117 return true;
118 }
119 false
120 }
121}