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}