repose_ui/scrollbar_repeat.rs
1//! Track-press auto-repeat for scrollbars.
2//!
3//! Compose parity (`TrackPressScroller` in
4//! `compose/foundation/.../Scrollbar.skiko.kt`): pressing the scrollbar track
5//! outside the thumb pages once immediately, then keeps paging toward the held
6//! point after a 300ms delay, every 100ms, until release.
7//!
8//! The repeat itself cannot live in the scrollbar hit closures: those run on
9//! input dispatch, but repeats must fire on time even when the pointer sits
10//! still and no input arrives. So the track-press closure registers a page
11//! callback here, and the app runtime polls it once per tick next to
12//! `poll_long_press`, with the next fire time feeding `next_wakeup_deadline`
13//! so a sleeping host still wakes up to page.
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use web_time::{Duration, Instant};
19
20use repose_core::input::{PointerId, PointerKind};
21
22/// First repeat fires this long after the track press; Compose
23/// `DelayBeforeSecondScrollOnTrackPress`.
24const FIRST_DELAY_MS: u64 = 300;
25/// Subsequent pages fire at this cadence; Compose
26/// `DelayBetweenScrollsOnTrackPress`.
27const REPEAT_MS: u64 = 100;
28
29/// Pointer key for non-touch pointers. Mouse and pen share one active repeat:
30/// there is only ever one system pointer of that kind driving a repeat.
31const MOUSE_POINTER: u64 = u64::MAX;
32
33/// Stable key for the pointer behind an event. Touch fingers keep their id so
34/// two fingers can hold two tracks; mouse and pen collapse to one key.
35pub fn pointer_key(kind: PointerKind, id: PointerId) -> u64 {
36 match kind {
37 PointerKind::Touch => id.0,
38 _ => MOUSE_POINTER,
39 }
40}
41
42/// Same key from the runtime side, which addresses pointers by touch id.
43pub fn key_for_touch(touch: Option<u64>) -> u64 {
44 touch.unwrap_or(MOUSE_POINTER)
45}
46
47struct ActiveRepeat {
48 pointer: u64,
49 next_fire: Instant,
50 page: Rc<dyn Fn()>,
51}
52
53thread_local! {
54 static ACTIVE: RefCell<Option<ActiveRepeat>> = const { RefCell::new(None) };
55}
56
57/// Start (or restart) paging for `pointer`. The caller pages once
58/// immediately; the first repeat fires [`FIRST_DELAY_MS`] later.
59pub fn begin_track_repeat(pointer: u64, page: Rc<dyn Fn()>) {
60 ACTIVE.with(|active| {
61 *active.borrow_mut() = Some(ActiveRepeat {
62 pointer,
63 next_fire: Instant::now() + Duration::from_millis(FIRST_DELAY_MS),
64 page,
65 });
66 });
67}
68
69/// Stop paging for `pointer`. No-op when another pointer holds the repeat.
70pub fn cancel_track_repeat(pointer: u64) {
71 ACTIVE.with(|active| {
72 let mut slot = active.borrow_mut();
73 if slot
74 .as_ref()
75 .is_some_and(|repeat| repeat.pointer == pointer)
76 {
77 *slot = None;
78 }
79 });
80}
81
82/// Stop every repeat. Used on global pointer cancel, where per-pointer
83/// bookkeeping may already be gone.
84pub fn cancel_all_track_repeats() {
85 ACTIVE.with(|active| {
86 *active.borrow_mut() = None;
87 });
88}
89
90/// Convenience for runtime press/release/cancel paths.
91pub fn cancel_track_repeat_for_touch(touch: Option<u64>) {
92 cancel_track_repeat(key_for_touch(touch));
93}
94
95/// Fire due repeats. Call once per tick next to the other gesture polls.
96/// Returns true when a page ran (and a frame was requested).
97pub fn poll_track_repeat() -> bool {
98 let now = Instant::now();
99 let page = ACTIVE.with(|active| {
100 let mut slot = active.borrow_mut();
101 match slot.as_mut() {
102 Some(repeat) if now >= repeat.next_fire => {
103 repeat.next_fire = now + Duration::from_millis(REPEAT_MS);
104 Some(repeat.page.clone())
105 }
106 _ => None,
107 }
108 });
109 match page {
110 Some(page) => {
111 page();
112 repose_core::request_frame();
113 true
114 }
115 None => false,
116 }
117}
118
119/// Next repeat fire time, for the host wakeup deadline.
120pub fn track_repeat_deadline() -> Option<Instant> {
121 ACTIVE.with(|active| active.borrow().as_ref().map(|repeat| repeat.next_fire))
122}