Skip to main content

damascene_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::{
7    UiState,
8    types::{SCROLL_MOMENTUM_DECAY_PER_SEC, SCROLL_MOMENTUM_STOP_VELOCITY, ScrollMomentum},
9};
10use web_time::Instant;
11
12const WHEEL_EPSILON: f32 = 0.5;
13
14#[derive(Clone, Debug)]
15pub(crate) struct ScrollStep {
16    pub scroll_id: String,
17    pub applied_delta: f32,
18}
19
20impl UiState {
21    /// Seed or read the persistent scroll offset for `id`. Use this to
22    /// pre-position a scroll viewport before [`crate::layout::layout`]
23    /// runs (call [`crate::layout::assign_ids`] first to populate the
24    /// node's `computed_id`).
25    pub fn set_scroll_offset(&mut self, id: impl Into<String>, value: f32) {
26        self.scroll.offsets.insert(id.into(), value);
27    }
28
29    /// Read the current scroll offset for `id`. Defaults to `0.0`.
30    pub fn scroll_offset(&self, id: &str) -> f32 {
31        self.scroll.offsets.get(id).copied().unwrap_or(0.0)
32    }
33
34    /// Queue programmatic scroll-to-row requests targeting virtual
35    /// lists by key. Each request is consumed during layout of the
36    /// matching list — viewport height and row heights are only known
37    /// then, especially for `virtual_list_dyn` where unmeasured rows
38    /// use the configured estimate. Hosts call this once per frame
39    /// from [`crate::event::App::drain_scroll_requests`]; apps that
40    /// own a `Runner` can also push directly for tests.
41    pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
42        self.scroll.pending_requests.extend(requests);
43    }
44
45    /// Drop any scroll requests still queued after the layout pass
46    /// completed. Called by `prepare_layout` so requests targeting a
47    /// list that wasn't in the tree this frame don't silently fire
48    /// against a re-mounted list with the same key on a later frame.
49    pub fn clear_pending_scroll_requests(&mut self) {
50        self.scroll.pending_requests.clear();
51    }
52
53    /// Iterate `(scroll_node_id, track_rect)` for every scrollable
54    /// whose visible scrollbar is currently active. Hosts use this to
55    /// drive cursor changes (e.g., a vertical-resize cursor over the
56    /// thumb), to drive screenshot tools, or to test interaction
57    /// flows. The map is rebuilt every layout pass.
58    pub fn scrollbar_tracks(&self) -> impl Iterator<Item = (&str, &Rect)> {
59        self.scroll
60            .thumb_tracks
61            .iter()
62            .map(|(id, rect)| (id.as_str(), rect))
63    }
64
65    /// Look up the scrollable whose track rect contains `(x, y)`,
66    /// returning its `computed_id`, the track rect, and the visible
67    /// thumb rect. Returns `None` if no track is currently visible at
68    /// that point. The track rect is wider than the visible thumb
69    /// (Fitts's law) and spans the full viewport height so callers
70    /// can branch on whether `y` lands inside the thumb (grab) or
71    /// above/below (click-to-page).
72    pub fn thumb_at(&self, x: f32, y: f32) -> Option<(String, Rect, Rect)> {
73        for (id, track) in &self.scroll.thumb_tracks {
74            if track.contains(x, y) {
75                let thumb = self
76                    .scroll
77                    .thumb_rects
78                    .get(id)
79                    .copied()
80                    .unwrap_or_else(|| Rect::new(track.x, track.y, track.w, 0.0));
81                return Some((id.clone(), *track, thumb));
82            }
83        }
84        None
85    }
86
87    /// Increment the scroll offset for the deepest scrollable container
88    /// under `point` that can move in `dy`'s direction. If the deepest
89    /// container is already at that edge (or has no overflow), the wheel
90    /// bubbles to the nearest scrollable ancestor that can move.
91    ///
92    /// Returns `true` if any scrollable consumed the wheel and updated
93    /// its stored offset. Hosts use this to decide whether to request a
94    /// redraw.
95    pub fn pointer_wheel(&mut self, root: &El, point: (f32, f32), dy: f32) -> bool {
96        self.scroll_by_pointer(root, point, dy).is_some()
97    }
98
99    pub(crate) fn scroll_by_pointer(
100        &mut self,
101        root: &El,
102        point: (f32, f32),
103        dy: f32,
104    ) -> Option<ScrollStep> {
105        if dy.abs() <= f32::EPSILON {
106            return None;
107        }
108        for id in scroll_targets_at(root, self, point).into_iter().rev() {
109            if let Some(step) = self.scroll_by_id(&id, dy) {
110                return Some(step);
111            }
112        }
113        None
114    }
115
116    pub(crate) fn scroll_by_id(&mut self, id: &str, dy: f32) -> Option<ScrollStep> {
117        if dy.abs() <= f32::EPSILON {
118            return None;
119        }
120        let metrics = self.scroll.metrics.get(id).copied()?;
121        if metrics.max_offset <= WHEEL_EPSILON {
122            return None;
123        }
124        let current = self
125            .scroll
126            .offsets
127            .get(id)
128            .copied()
129            .unwrap_or(0.0)
130            .clamp(0.0, metrics.max_offset);
131        let can_scroll = if dy > 0.0 {
132            current < metrics.max_offset - WHEEL_EPSILON
133        } else {
134            current > WHEEL_EPSILON
135        };
136        if !can_scroll {
137            return None;
138        }
139        let next = (current + dy).clamp(0.0, metrics.max_offset);
140        if (next - current).abs() <= f32::EPSILON {
141            return None;
142        }
143        self.scroll.offsets.insert(id.to_owned(), next);
144        Some(ScrollStep {
145            scroll_id: id.to_owned(),
146            applied_delta: next - current,
147        })
148    }
149
150    pub(crate) fn start_scroll_momentum(
151        &mut self,
152        scroll_id: Option<String>,
153        velocity: f32,
154        now: Instant,
155    ) {
156        let Some(scroll_id) = scroll_id else {
157            self.scroll.momentum = None;
158            return;
159        };
160        if velocity.abs() < super::types::SCROLL_MOMENTUM_MIN_VELOCITY {
161            self.scroll.momentum = None;
162            return;
163        }
164        self.scroll.momentum = Some(ScrollMomentum {
165            scroll_id,
166            velocity,
167            last_tick: now,
168        });
169    }
170
171    pub(crate) fn cancel_scroll_momentum(&mut self) {
172        self.scroll.momentum = None;
173    }
174
175    pub(crate) fn has_scroll_momentum(&self) -> bool {
176        self.scroll.momentum.is_some()
177    }
178
179    pub(crate) fn tick_scroll_momentum(&mut self, now: Instant) -> bool {
180        let Some(mut momentum) = self.scroll.momentum.take() else {
181            return false;
182        };
183        let dt = now
184            .duration_since(momentum.last_tick)
185            .as_secs_f32()
186            .clamp(0.0, 0.050);
187        momentum.last_tick = now;
188        if dt <= f32::EPSILON {
189            self.scroll.momentum = Some(momentum);
190            return true;
191        }
192
193        let Some(metrics) = self.scroll.metrics.get(&momentum.scroll_id).copied() else {
194            return false;
195        };
196        if metrics.max_offset <= WHEEL_EPSILON {
197            return false;
198        }
199        let current = self
200            .scroll
201            .offsets
202            .get(&momentum.scroll_id)
203            .copied()
204            .unwrap_or(0.0)
205            .clamp(0.0, metrics.max_offset);
206        let next = (current + momentum.velocity * dt).clamp(0.0, metrics.max_offset);
207        let changed = (next - current).abs() > f32::EPSILON;
208        if changed {
209            self.scroll.offsets.insert(momentum.scroll_id.clone(), next);
210        }
211
212        let hit_edge = (next <= WHEEL_EPSILON && momentum.velocity < 0.0)
213            || (next >= metrics.max_offset - WHEEL_EPSILON && momentum.velocity > 0.0);
214        momentum.velocity *= (-SCROLL_MOMENTUM_DECAY_PER_SEC * dt).exp();
215        if !hit_edge && momentum.velocity.abs() > SCROLL_MOMENTUM_STOP_VELOCITY {
216            self.scroll.momentum = Some(momentum);
217        }
218        changed || self.scroll.momentum.is_some()
219    }
220}