Skip to main content

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}