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#[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 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 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 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 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#[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 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 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 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 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}