Skip to main content

fret_ui/scroll/
mod.rs

1use std::{cell::RefCell, ops::Deref, rc::Rc};
2
3use fret_core::{FrameId, Point, Px, Size};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ScrollStrategy {
7    Start,
8    Center,
9    End,
10    Nearest,
11}
12
13#[derive(Debug, Default)]
14struct ScrollHandleState {
15    offset: Point,
16    viewport: Size,
17    content: Size,
18    revision: u64,
19}
20
21/// A lightweight imperative handle for driving scroll state.
22///
23/// This is intentionally small and allocation-free to clone, so component-layer code can store it
24/// and pass it back into declarative elements each frame.
25#[derive(Debug, Default, Clone)]
26pub struct ScrollHandle {
27    state: Rc<RefCell<ScrollHandleState>>,
28}
29
30impl ScrollHandle {
31    pub(crate) fn binding_key(&self) -> usize {
32        Rc::as_ptr(&self.state) as usize
33    }
34
35    pub(crate) fn bump_revision(&self) {
36        let mut state = self.state.borrow_mut();
37        state.revision = state.revision.saturating_add(1);
38    }
39
40    pub fn offset(&self) -> Point {
41        self.state.borrow().offset
42    }
43
44    /// Monotonic revision counter that increments when the scroll offset changes via
45    /// [`ScrollHandle::set_offset`] (or helpers that call it).
46    ///
47    /// The declarative runtime uses this to detect out-of-band scroll changes (e.g.
48    /// component-driven "scroll into view") and invalidate the bound scroll nodes, even when the
49    /// element instances themselves did not change.
50    pub fn revision(&self) -> u64 {
51        self.state.borrow().revision
52    }
53
54    pub fn max_offset(&self) -> Point {
55        let state = self.state.borrow();
56        Point::new(
57            Px((state.content.width.0 - state.viewport.width.0).max(0.0)),
58            Px((state.content.height.0 - state.viewport.height.0).max(0.0)),
59        )
60    }
61
62    pub fn clamp_offset(&self, offset: Point) -> Point {
63        let state = self.state.borrow();
64        let max_x = (state.content.width.0 - state.viewport.width.0).max(0.0);
65        let max_y = (state.content.height.0 - state.viewport.height.0).max(0.0);
66        let clamp_x = state.viewport.width.0 > 0.0 && state.content.width.0 > 0.0;
67        let clamp_y = state.viewport.height.0 > 0.0 && state.content.height.0 > 0.0;
68
69        let x = offset.x.0.max(0.0);
70        let y = offset.y.0.max(0.0);
71        Point::new(
72            Px(if clamp_x { x.min(max_x) } else { x }),
73            Px(if clamp_y { y.min(max_y) } else { y }),
74        )
75    }
76
77    #[track_caller]
78    pub fn set_offset(&self, offset: Point) {
79        let clamped = self.clamp_offset(offset);
80        let mut state = self.state.borrow_mut();
81        if (state.offset.x.0 - clamped.x.0).abs() <= 0.01
82            && (state.offset.y.0 - clamped.y.0).abs() <= 0.01
83        {
84            return;
85        }
86        if crate::runtime_config::ui_runtime_config().debug_scroll_handle_set_offset
87            && state.offset.y.0 > 0.01
88            && clamped.y.0 <= 0.01
89        {
90            let loc = std::panic::Location::caller();
91            eprintln!(
92                "scroll_handle.set_offset -> clamped_to_top handle_key={} prev=({:.3},{:.3}) next=({:.3},{:.3}) caller={}::{}:{}",
93                self.binding_key(),
94                state.offset.x.0,
95                state.offset.y.0,
96                clamped.x.0,
97                clamped.y.0,
98                loc.file(),
99                loc.line(),
100                loc.column(),
101            );
102        }
103        state.offset = clamped;
104        state.revision = state.revision.saturating_add(1);
105    }
106
107    /// Internal offset setter used by the runtime during layout passes.
108    ///
109    /// Unlike [`ScrollHandle::set_offset`], this does **not** bump the handle revision because the
110    /// runtime is already invalidating and recomputing layout/paint in the same frame.
111    pub(crate) fn set_offset_internal(&self, offset: Point) {
112        let clamped = self.clamp_offset(offset);
113        self.state.borrow_mut().offset = clamped;
114    }
115
116    pub fn scroll_to_offset(&self, offset: Point) {
117        self.set_offset(offset);
118    }
119
120    pub fn viewport_size(&self) -> Size {
121        self.state.borrow().viewport
122    }
123
124    pub fn set_viewport_size(&self, viewport: Size) {
125        let mut state = self.state.borrow_mut();
126        let next = Size::new(
127            Px(viewport.width.0.max(0.0)),
128            Px(viewport.height.0.max(0.0)),
129        );
130        let mut changed = false;
131        if state.viewport != next {
132            state.viewport = next;
133            changed = true;
134        }
135
136        let clamped = {
137            let max_x = (state.content.width.0 - state.viewport.width.0).max(0.0);
138            let max_y = (state.content.height.0 - state.viewport.height.0).max(0.0);
139            let clamp_x = state.viewport.width.0 > 0.0 && state.content.width.0 > 0.0;
140            let clamp_y = state.viewport.height.0 > 0.0 && state.content.height.0 > 0.0;
141
142            let x = state.offset.x.0.max(0.0);
143            let y = state.offset.y.0.max(0.0);
144            Point::new(
145                Px(if clamp_x { x.min(max_x) } else { x }),
146                Px(if clamp_y { y.min(max_y) } else { y }),
147            )
148        };
149        if (state.offset.x.0 - clamped.x.0).abs() > 0.01
150            || (state.offset.y.0 - clamped.y.0).abs() > 0.01
151        {
152            state.offset = clamped;
153            changed = true;
154        }
155
156        if changed {
157            state.revision = state.revision.saturating_add(1);
158        }
159    }
160
161    /// Internal viewport setter used by the runtime during layout passes.
162    ///
163    /// Unlike [`ScrollHandle::set_viewport_size`], this does **not** bump the handle revision
164    /// because the runtime is already invalidating and recomputing layout/paint in the same frame.
165    pub(crate) fn set_viewport_size_internal(&self, viewport: Size) {
166        let mut state = self.state.borrow_mut();
167        state.viewport = Size::new(
168            Px(viewport.width.0.max(0.0)),
169            Px(viewport.height.0.max(0.0)),
170        );
171        let max_x = (state.content.width.0 - state.viewport.width.0).max(0.0);
172        let max_y = (state.content.height.0 - state.viewport.height.0).max(0.0);
173        let clamp_x = state.viewport.width.0 > 0.0 && state.content.width.0 > 0.0;
174        let clamp_y = state.viewport.height.0 > 0.0 && state.content.height.0 > 0.0;
175
176        let x = state.offset.x.0.max(0.0);
177        let y = state.offset.y.0.max(0.0);
178        state.offset = Point::new(
179            Px(if clamp_x { x.min(max_x) } else { x }),
180            Px(if clamp_y { y.min(max_y) } else { y }),
181        );
182    }
183
184    pub fn content_size(&self) -> Size {
185        self.state.borrow().content
186    }
187
188    pub fn set_content_size(&self, content: Size) {
189        let mut state = self.state.borrow_mut();
190        let next = Size::new(Px(content.width.0.max(0.0)), Px(content.height.0.max(0.0)));
191        let mut changed = false;
192        if state.content != next {
193            state.content = next;
194            changed = true;
195        }
196
197        let clamped = {
198            let max_x = (state.content.width.0 - state.viewport.width.0).max(0.0);
199            let max_y = (state.content.height.0 - state.viewport.height.0).max(0.0);
200            let clamp_x = state.viewport.width.0 > 0.0 && state.content.width.0 > 0.0;
201            let clamp_y = state.viewport.height.0 > 0.0 && state.content.height.0 > 0.0;
202
203            let x = state.offset.x.0.max(0.0);
204            let y = state.offset.y.0.max(0.0);
205            Point::new(
206                Px(if clamp_x { x.min(max_x) } else { x }),
207                Px(if clamp_y { y.min(max_y) } else { y }),
208            )
209        };
210        if (state.offset.x.0 - clamped.x.0).abs() > 0.01
211            || (state.offset.y.0 - clamped.y.0).abs() > 0.01
212        {
213            state.offset = clamped;
214            changed = true;
215        }
216
217        if changed {
218            state.revision = state.revision.saturating_add(1);
219        }
220    }
221
222    /// Internal content-size setter used by the runtime during layout passes.
223    ///
224    /// Unlike [`ScrollHandle::set_content_size`], this does **not** bump the handle revision
225    /// because the runtime is already invalidating and recomputing layout/paint in the same frame.
226    pub(crate) fn set_content_size_internal(&self, content: Size) {
227        let mut state = self.state.borrow_mut();
228        state.content = Size::new(Px(content.width.0.max(0.0)), Px(content.height.0.max(0.0)));
229        let max_x = (state.content.width.0 - state.viewport.width.0).max(0.0);
230        let max_y = (state.content.height.0 - state.viewport.height.0).max(0.0);
231        let clamp_x = state.viewport.width.0 > 0.0 && state.content.width.0 > 0.0;
232        let clamp_y = state.viewport.height.0 > 0.0 && state.content.height.0 > 0.0;
233
234        let x = state.offset.x.0.max(0.0);
235        let y = state.offset.y.0.max(0.0);
236        state.offset = Point::new(
237            Px(if clamp_x { x.min(max_x) } else { x }),
238            Px(if clamp_y { y.min(max_y) } else { y }),
239        );
240    }
241
242    pub fn scroll_to_range_y(&self, start_y: Px, end_y: Px, strategy: ScrollStrategy) {
243        let start_y = Px(start_y.0.max(0.0));
244        let end_y = Px(end_y.0.max(start_y.0));
245
246        let viewport_h = Px(self.viewport_size().height.0.max(0.0));
247        if viewport_h.0 <= 0.0 {
248            return;
249        }
250
251        let prev = self.offset();
252        let view_top = prev.y;
253        let view_bottom = Px(view_top.0 + viewport_h.0);
254
255        let next_y = match strategy {
256            ScrollStrategy::Start => start_y,
257            ScrollStrategy::End => Px(end_y.0 - viewport_h.0),
258            ScrollStrategy::Center => {
259                let center = 0.5 * (start_y.0 + end_y.0);
260                Px(center - 0.5 * viewport_h.0)
261            }
262            ScrollStrategy::Nearest => {
263                if start_y.0 < view_top.0 {
264                    start_y
265                } else if end_y.0 > view_bottom.0 {
266                    Px(end_y.0 - viewport_h.0)
267                } else {
268                    view_top
269                }
270            }
271        };
272
273        self.set_offset(Point::new(prev.x, next_y));
274    }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278struct DeferredScrollToItem {
279    index: usize,
280    strategy: ScrollStrategy,
281}
282
283#[derive(Debug, Default)]
284struct VirtualListScrollHandleState {
285    items_count: usize,
286    deferred: Option<DeferredScrollToItem>,
287    last_consumed: Option<DeferredScrollToItem>,
288    last_consumed_revision: u64,
289    last_consumed_frame_id: FrameId,
290}
291
292/// A scroll handle with VirtualList-specific helpers (scroll-to-item).
293#[derive(Debug, Default, Clone)]
294pub struct VirtualListScrollHandle {
295    state: Rc<RefCell<VirtualListScrollHandleState>>,
296    base_handle: ScrollHandle,
297}
298
299impl Deref for VirtualListScrollHandle {
300    type Target = ScrollHandle;
301
302    fn deref(&self) -> &Self::Target {
303        &self.base_handle
304    }
305}
306
307impl VirtualListScrollHandle {
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    pub fn base_handle(&self) -> &ScrollHandle {
313        &self.base_handle
314    }
315
316    pub fn scroll_to_item(&self, index: usize, strategy: ScrollStrategy) {
317        let current_revision = self.base_handle.revision();
318        let mut state = self.state.borrow_mut();
319        let next = DeferredScrollToItem { index, strategy };
320        if state.deferred == Some(next) {
321            return;
322        }
323        if state.deferred.is_none()
324            && state.last_consumed == Some(next)
325            && state.last_consumed_revision == current_revision
326        {
327            return;
328        }
329        state.deferred = Some(next);
330        self.base_handle.bump_revision();
331    }
332
333    pub fn scroll_to_index(&self, index: usize, strategy: ScrollStrategy) {
334        self.scroll_to_item(index, strategy);
335    }
336
337    pub fn scroll_to_bottom(&self) {
338        // Avoid relying on the runtime-driven `items_count` bookkeeping for "scroll to end".
339        // The layout pass will clamp the requested index against the current list count.
340        self.scroll_to_item(usize::MAX, ScrollStrategy::End);
341    }
342
343    pub(crate) fn set_items_count(&self, items_count: usize) {
344        self.state.borrow_mut().items_count = items_count;
345    }
346
347    pub(crate) fn deferred_scroll_to_item(&self) -> Option<(usize, ScrollStrategy)> {
348        self.state.borrow().deferred.map(|d| (d.index, d.strategy))
349    }
350
351    pub(crate) fn clear_deferred_scroll_to_item(&self, frame_id: FrameId) {
352        let mut state = self.state.borrow_mut();
353        if let Some(deferred) = state.deferred {
354            state.last_consumed = Some(deferred);
355            state.last_consumed_revision = self.base_handle.revision();
356            state.last_consumed_frame_id = frame_id;
357        }
358        state.deferred = None;
359    }
360
361    pub(crate) fn scroll_to_item_consumed_in_frame(&self, frame_id: FrameId) -> bool {
362        let state = self.state.borrow();
363        state.last_consumed.is_some() && state.last_consumed_frame_id == frame_id
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn scroll_handle_clamps_offset_to_content_bounds() {
373        let handle = ScrollHandle::default();
374        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
375        handle.set_content_size(Size::new(Px(20.0), Px(30.0)));
376
377        handle.set_offset(Point::new(Px(-5.0), Px(999.0)));
378        assert_eq!(handle.offset(), Point::new(Px(0.0), Px(20.0)));
379    }
380
381    #[test]
382    fn scroll_handle_clamps_offset_when_content_shrinks_via_set_content_size() {
383        let handle = ScrollHandle::default();
384        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
385        handle.set_content_size(Size::new(Px(10.0), Px(100.0)));
386        handle.set_offset(Point::new(Px(0.0), Px(90.0)));
387
388        handle.set_content_size(Size::new(Px(10.0), Px(50.0)));
389        assert_eq!(handle.offset(), Point::new(Px(0.0), Px(40.0)));
390    }
391
392    #[test]
393    fn scroll_handle_clamps_offset_when_viewport_grows_via_set_viewport_size() {
394        let handle = ScrollHandle::default();
395        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
396        handle.set_content_size(Size::new(Px(10.0), Px(100.0)));
397        handle.set_offset(Point::new(Px(0.0), Px(90.0)));
398
399        handle.set_viewport_size(Size::new(Px(10.0), Px(50.0)));
400        assert_eq!(handle.offset(), Point::new(Px(0.0), Px(50.0)));
401    }
402
403    #[test]
404    fn scroll_handle_internal_setters_do_not_bump_revision() {
405        let handle = ScrollHandle::default();
406        let rev0 = handle.revision();
407
408        handle.set_viewport_size_internal(Size::new(Px(10.0), Px(10.0)));
409        handle.set_content_size_internal(Size::new(Px(20.0), Px(30.0)));
410        handle.set_offset_internal(Point::new(Px(0.0), Px(5.0)));
411        assert_eq!(handle.revision(), rev0);
412
413        handle.set_viewport_size(Size::new(Px(11.0), Px(10.0)));
414        assert_eq!(handle.revision(), rev0.saturating_add(1));
415    }
416
417    #[test]
418    fn scroll_handle_revision_bumps_on_user_visible_clamps() {
419        let handle = ScrollHandle::default();
420        let rev0 = handle.revision();
421
422        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
423        let rev1 = handle.revision();
424        assert!(rev1 > rev0);
425
426        handle.set_content_size(Size::new(Px(10.0), Px(100.0)));
427        let rev2 = handle.revision();
428        assert!(rev2 > rev1);
429
430        handle.set_offset(Point::new(Px(0.0), Px(90.0)));
431        let rev3 = handle.revision();
432        assert!(rev3 > rev2);
433
434        // Clamp via shrink should bump the revision (observable offset change).
435        handle.set_content_size(Size::new(Px(10.0), Px(50.0)));
436        let rev4 = handle.revision();
437        assert!(rev4 > rev3);
438    }
439
440    #[test]
441    fn virtual_list_scroll_to_bottom_requests_end_sentinel() {
442        let handle = VirtualListScrollHandle::default();
443        handle.scroll_to_bottom();
444        assert_eq!(
445            handle.deferred_scroll_to_item(),
446            Some((usize::MAX, ScrollStrategy::End))
447        );
448    }
449
450    #[test]
451    fn scroll_handle_scroll_to_range_nearest_keeps_range_visible() {
452        let handle = ScrollHandle::default();
453        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
454        handle.set_content_size(Size::new(Px(10.0), Px(100.0)));
455
456        handle.set_offset(Point::new(Px(0.0), Px(20.0)));
457        handle.scroll_to_range_y(Px(25.0), Px(28.0), ScrollStrategy::Nearest);
458        assert_eq!(handle.offset().y, Px(20.0));
459
460        handle.scroll_to_range_y(Px(5.0), Px(8.0), ScrollStrategy::Nearest);
461        assert_eq!(handle.offset().y, Px(5.0));
462
463        handle.scroll_to_range_y(Px(95.0), Px(99.0), ScrollStrategy::Nearest);
464        assert_eq!(handle.offset().y, Px(89.0));
465    }
466
467    #[test]
468    fn virtual_list_scroll_to_item_does_not_bump_revision_when_reissued_without_context_change() {
469        let handle = VirtualListScrollHandle::new();
470        handle.set_viewport_size(Size::new(Px(10.0), Px(10.0)));
471        handle.set_content_size(Size::new(Px(10.0), Px(100.0)));
472
473        let initial_revision = handle.revision();
474        handle.scroll_to_item(5, ScrollStrategy::Nearest);
475        let first_rev = handle.revision();
476        assert!(first_rev > initial_revision);
477
478        // Simulate runtime consumption: the request was consumed, but did not necessarily change
479        // the offset. Reissuing the same request should not keep bumping the revision forever.
480        handle.clear_deferred_scroll_to_item(FrameId(1));
481        handle.scroll_to_item(5, ScrollStrategy::Nearest);
482        assert_eq!(handle.revision(), first_rev);
483
484        // A context change (e.g. content size change) should allow the same request to be issued
485        // again, because it may become meaningful after layout changes.
486        handle.set_content_size(Size::new(Px(10.0), Px(120.0)));
487        let after_context = handle.revision();
488        handle.scroll_to_item(5, ScrollStrategy::Nearest);
489        assert!(handle.revision() > after_context);
490    }
491}