Skip to main content

dioxus_virtual_window/
lib.rs

1use dioxus::document;
2use dioxus::prelude::*;
3use std::rc::Rc;
4#[cfg(test)]
5use window_core::WindowUpdate;
6use window_core::{VirtualWindow, VirtualWindowConfig, VisibleRange, WindowEvent};
7
8const MIN_VALID_VIEWPORT_HEIGHT: f64 = 8.0;
9
10#[derive(Clone, Debug, PartialEq)]
11pub struct UseVirtualWindowConfig {
12    pub engine: VirtualWindowConfig,
13    pub scroll_sample_ms: u64,
14    pub scroll_idle_ms: u64,
15    pub viewport_id: &'static str,
16}
17
18#[derive(Clone, Copy)]
19pub struct UseVirtualWindowHandle {
20    pub range: ReadOnlySignal<VisibleRange>,
21    pub total_height: ReadOnlySignal<f64>,
22    pub scroll_top: ReadOnlySignal<f64>,
23    pub viewport_height: ReadOnlySignal<f64>,
24    pub bind_viewport: ViewportBindings,
25    pub on_item_measured: Callback<(usize, f64)>,
26    pub set_item_count: Callback<usize>,
27    pub prepend_items: Callback<usize>,
28    pub append_items: Callback<usize>,
29    pub set_stick_to_bottom: Callback<bool>,
30    pub offset_of: Callback<usize, f64>,
31}
32
33#[derive(Clone, Copy)]
34pub struct ViewportBindings {
35    pub onmounted: EventHandler<MountedEvent>,
36    pub onresize: EventHandler<ResizeEvent>,
37    pub onscroll: EventHandler<Event<ScrollData>>,
38}
39
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41struct ScrollSequencer {
42    seq: u64,
43    inflight_sample: bool,
44}
45
46impl ScrollSequencer {
47    fn begin_event(&mut self) -> Option<u64> {
48        self.seq = self.seq.wrapping_add(1);
49        if self.inflight_sample {
50            None
51        } else {
52            self.inflight_sample = true;
53            Some(self.seq)
54        }
55    }
56
57    fn current_seq(&self) -> u64 {
58        self.seq
59    }
60
61    fn is_stale(&self, ticket: u64) -> bool {
62        self.seq != ticket
63    }
64
65    fn finish_sample(&mut self) {
66        self.inflight_sample = false;
67    }
68
69    fn should_settle(&self, ticket: u64) -> bool {
70        self.seq == ticket && !self.inflight_sample
71    }
72}
73
74#[cfg(test)]
75#[derive(Clone, Copy, Debug)]
76struct AdapterSnapshot {
77    pending_scroll_to: Option<f64>,
78    range: VisibleRange,
79    total_height: f64,
80    scroll_top: f64,
81    viewport_height: f64,
82}
83
84#[cfg(test)]
85impl Default for AdapterSnapshot {
86    fn default() -> Self {
87        Self {
88            pending_scroll_to: None,
89            range: VisibleRange {
90                start: 0,
91                end: 0,
92                pad_top: 0.0,
93                pad_bottom: 0.0,
94                total_height: 0.0,
95            },
96            total_height: 0.0,
97            scroll_top: 0.0,
98            viewport_height: 0.0,
99        }
100    }
101}
102
103#[cfg(test)]
104fn apply_update_to_snapshot(snapshot: &mut AdapterSnapshot, update: WindowUpdate) {
105    snapshot.pending_scroll_to = update.scroll_to;
106    snapshot.range = update.range;
107    snapshot.total_height = update.total_height;
108    snapshot.scroll_top = update.scroll_top;
109    snapshot.viewport_height = update.viewport_height;
110}
111
112fn apply_event(
113    mut engine: Signal<VirtualWindow>,
114    mut range: Signal<VisibleRange>,
115    mut total_height: Signal<f64>,
116    mut scroll_top: Signal<f64>,
117    mut viewport_height: Signal<f64>,
118    viewport_id: &'static str,
119    event: WindowEvent,
120) {
121    let update = { engine.write().update(event) };
122    range.set(update.range);
123    total_height.set(update.total_height);
124    scroll_top.set(update.scroll_top);
125    viewport_height.set(update.viewport_height);
126
127    if let Some(target) = update.scroll_to {
128        spawn(async move {
129            set_scroll_top(viewport_id, target).await;
130        });
131    }
132}
133
134pub fn use_virtual_window(config: UseVirtualWindowConfig) -> UseVirtualWindowHandle {
135    let viewport_id = config.viewport_id;
136    let scroll_sample_ms = config.scroll_sample_ms.max(1);
137    let scroll_idle_ms = config.scroll_idle_ms.max(1);
138
139    let engine = use_signal(|| VirtualWindow::new(config.engine.clone()));
140    let range = use_signal(|| engine().visible_range());
141    let total_height = use_signal(|| engine().total_height());
142    let scroll_top = use_signal(|| 0.0);
143    let viewport_height = use_signal(|| {
144        config
145            .engine
146            .estimated_item_height
147            .max(MIN_VALID_VIEWPORT_HEIGHT)
148    });
149    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
150    let mut sequencer = use_signal(ScrollSequencer::default);
151
152    let onmounted = Callback::new(move |event: MountedEvent| async move {
153        mounted.set(Some(event.data()));
154
155        if let Ok(rect) = event.get_client_rect().await {
156            let h = rect.height();
157            if h.is_finite() && h >= MIN_VALID_VIEWPORT_HEIGHT {
158                apply_event(
159                    engine,
160                    range,
161                    total_height,
162                    scroll_top,
163                    viewport_height,
164                    viewport_id,
165                    WindowEvent::ResizeViewport { height: h },
166                );
167            }
168        }
169
170        let top = if let Ok(offset) = event.get_scroll_offset().await {
171            offset.y
172        } else {
173            get_scroll_top(viewport_id).await
174        };
175
176        apply_event(
177            engine,
178            range,
179            total_height,
180            scroll_top,
181            viewport_height,
182            viewport_id,
183            WindowEvent::Scroll { top },
184        );
185    });
186
187    let onresize = Callback::new(move |event: ResizeEvent| {
188        if let Ok(size) = event.get_content_box_size() {
189            let height = size.height;
190            if height.is_finite() && height >= MIN_VALID_VIEWPORT_HEIGHT {
191                apply_event(
192                    engine,
193                    range,
194                    total_height,
195                    scroll_top,
196                    viewport_height,
197                    viewport_id,
198                    WindowEvent::ResizeViewport { height },
199                );
200            }
201        }
202    });
203
204    let onscroll = Callback::new(move |_event: Event<ScrollData>| async move {
205        let ticket = {
206            let mut state = sequencer.write();
207            state.begin_event()
208        };
209        let Some(ticket) = ticket else {
210            return;
211        };
212
213        sleep_ms(scroll_sample_ms).await;
214
215        let top = if let Some(viewport) = mounted() {
216            if let Ok(offset) = viewport.get_scroll_offset().await {
217                offset.y
218            } else {
219                get_scroll_top(viewport_id).await
220            }
221        } else {
222            get_scroll_top(viewport_id).await
223        };
224
225        if sequencer().is_stale(ticket) {
226            sequencer.write().finish_sample();
227            return;
228        }
229
230        apply_event(
231            engine,
232            range,
233            total_height,
234            scroll_top,
235            viewport_height,
236            viewport_id,
237            WindowEvent::Scroll { top },
238        );
239
240        sequencer.write().finish_sample();
241
242        let settle_seq = sequencer().current_seq();
243        sleep_ms(scroll_idle_ms).await;
244        if sequencer().should_settle(settle_seq) {
245            apply_event(
246                engine,
247                range,
248                total_height,
249                scroll_top,
250                viewport_height,
251                viewport_id,
252                WindowEvent::Scroll { top: scroll_top() },
253            );
254        }
255    });
256
257    let on_item_measured = Callback::new(move |(index, height): (usize, f64)| {
258        apply_event(
259            engine,
260            range,
261            total_height,
262            scroll_top,
263            viewport_height,
264            viewport_id,
265            WindowEvent::MeasureItem { index, height },
266        );
267    });
268
269    let set_item_count = Callback::new(move |count: usize| {
270        apply_event(
271            engine,
272            range,
273            total_height,
274            scroll_top,
275            viewport_height,
276            viewport_id,
277            WindowEvent::SetItemCount { count },
278        );
279    });
280
281    let prepend_items = Callback::new(move |count: usize| {
282        apply_event(
283            engine,
284            range,
285            total_height,
286            scroll_top,
287            viewport_height,
288            viewport_id,
289            WindowEvent::PrependItems { count },
290        );
291    });
292
293    let append_items = Callback::new(move |count: usize| {
294        apply_event(
295            engine,
296            range,
297            total_height,
298            scroll_top,
299            viewport_height,
300            viewport_id,
301            WindowEvent::AppendItems { count },
302        );
303    });
304
305    let set_stick_to_bottom = Callback::new(move |enabled: bool| {
306        apply_event(
307            engine,
308            range,
309            total_height,
310            scroll_top,
311            viewport_height,
312            viewport_id,
313            WindowEvent::SetStickToBottom { enabled },
314        );
315    });
316
317    let offset_of = Callback::new(move |index: usize| engine().offset_of(index));
318
319    UseVirtualWindowHandle {
320        range: range.into(),
321        total_height: total_height.into(),
322        scroll_top: scroll_top.into(),
323        viewport_height: viewport_height.into(),
324        bind_viewport: ViewportBindings {
325            onmounted,
326            onresize,
327            onscroll,
328        },
329        on_item_measured,
330        set_item_count,
331        prepend_items,
332        append_items,
333        set_stick_to_bottom,
334        offset_of,
335    }
336}
337
338async fn get_scroll_top(element_id: &str) -> f64 {
339    let script = format!(
340        "const el = document.getElementById({element_id:?}); return el ? el.scrollTop : 0;"
341    );
342    document::eval(&script).join::<f64>().await.unwrap_or(0.0)
343}
344
345async fn set_scroll_top(element_id: &str, target: f64) {
346    let safe_target = target.max(0.0);
347    let script = format!(
348        "const el = document.getElementById({element_id:?}); if (el) el.scrollTop = {safe_target}; return true;"
349    );
350    let _ = document::eval(&script).join::<bool>().await;
351}
352
353async fn sleep_ms(ms: u64) {
354    let script = format!(
355        "return new Promise((resolve) => setTimeout(() => resolve(true), {}));",
356        ms
357    );
358    let _ = document::eval(&script).join::<bool>().await;
359}
360
361#[cfg(test)]
362mod tests {
363    use super::{AdapterSnapshot, ScrollSequencer, apply_update_to_snapshot};
364    use window_core::VisibleRange;
365    use window_core::WindowUpdate;
366
367    #[test]
368    fn event_sequencing_prevents_parallel_samples() {
369        let mut sequencer = ScrollSequencer::default();
370        let ticket_1 = sequencer.begin_event().expect("first event should start");
371        assert!(sequencer.begin_event().is_none());
372        sequencer.finish_sample();
373
374        let ticket_2 = sequencer.begin_event().expect("second event should start");
375        assert_ne!(ticket_1, ticket_2);
376    }
377
378    #[test]
379    fn scroll_to_effect_application_is_tracked() {
380        let mut snapshot = AdapterSnapshot::default();
381        let update = WindowUpdate {
382            range: VisibleRange {
383                start: 10,
384                end: 20,
385                pad_top: 50.0,
386                pad_bottom: 100.0,
387                total_height: 400.0,
388            },
389            total_height: 400.0,
390            scroll_top: 120.0,
391            viewport_height: 200.0,
392            distance_to_bottom: 80.0,
393            should_stick_to_bottom: false,
394            scroll_to: Some(120.0),
395            changed: true,
396        };
397
398        apply_update_to_snapshot(&mut snapshot, update);
399        assert_eq!(snapshot.pending_scroll_to, Some(120.0));
400        assert_eq!(snapshot.range.start, 10);
401        assert_eq!(snapshot.total_height, 400.0);
402    }
403
404    #[test]
405    fn idle_settle_only_after_sample_finishes() {
406        let mut sequencer = ScrollSequencer::default();
407        let ticket = sequencer.begin_event().expect("event should start");
408        assert!(!sequencer.should_settle(ticket));
409
410        sequencer.finish_sample();
411        assert!(sequencer.should_settle(ticket));
412
413        let next_ticket = sequencer.begin_event().expect("new event should start");
414        assert!(!sequencer.should_settle(ticket));
415        sequencer.finish_sample();
416        assert!(sequencer.should_settle(next_ticket));
417    }
418}