use dioxus::document;
use dioxus::prelude::*;
use std::rc::Rc;
#[cfg(test)]
use window_core::WindowUpdate;
use window_core::{VirtualWindow, VirtualWindowConfig, VisibleRange, WindowEvent};
const MIN_VALID_VIEWPORT_HEIGHT: f64 = 8.0;
#[derive(Clone, Debug, PartialEq)]
pub struct UseVirtualWindowConfig {
pub engine: VirtualWindowConfig,
pub scroll_sample_ms: u64,
pub scroll_idle_ms: u64,
pub viewport_id: &'static str,
}
#[derive(Clone, Copy)]
pub struct UseVirtualWindowHandle {
pub range: ReadOnlySignal<VisibleRange>,
pub total_height: ReadOnlySignal<f64>,
pub scroll_top: ReadOnlySignal<f64>,
pub viewport_height: ReadOnlySignal<f64>,
pub bind_viewport: ViewportBindings,
pub on_item_measured: Callback<(usize, f64)>,
pub set_item_count: Callback<usize>,
pub prepend_items: Callback<usize>,
pub append_items: Callback<usize>,
pub set_stick_to_bottom: Callback<bool>,
pub offset_of: Callback<usize, f64>,
}
#[derive(Clone, Copy)]
pub struct ViewportBindings {
pub onmounted: EventHandler<MountedEvent>,
pub onresize: EventHandler<ResizeEvent>,
pub onscroll: EventHandler<Event<ScrollData>>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ScrollSequencer {
seq: u64,
inflight_sample: bool,
}
impl ScrollSequencer {
fn begin_event(&mut self) -> Option<u64> {
self.seq = self.seq.wrapping_add(1);
if self.inflight_sample {
None
} else {
self.inflight_sample = true;
Some(self.seq)
}
}
fn current_seq(&self) -> u64 {
self.seq
}
fn is_stale(&self, ticket: u64) -> bool {
self.seq != ticket
}
fn finish_sample(&mut self) {
self.inflight_sample = false;
}
fn should_settle(&self, ticket: u64) -> bool {
self.seq == ticket && !self.inflight_sample
}
}
#[cfg(test)]
#[derive(Clone, Copy, Debug)]
struct AdapterSnapshot {
pending_scroll_to: Option<f64>,
range: VisibleRange,
total_height: f64,
scroll_top: f64,
viewport_height: f64,
}
#[cfg(test)]
impl Default for AdapterSnapshot {
fn default() -> Self {
Self {
pending_scroll_to: None,
range: VisibleRange {
start: 0,
end: 0,
pad_top: 0.0,
pad_bottom: 0.0,
total_height: 0.0,
},
total_height: 0.0,
scroll_top: 0.0,
viewport_height: 0.0,
}
}
}
#[cfg(test)]
fn apply_update_to_snapshot(snapshot: &mut AdapterSnapshot, update: WindowUpdate) {
snapshot.pending_scroll_to = update.scroll_to;
snapshot.range = update.range;
snapshot.total_height = update.total_height;
snapshot.scroll_top = update.scroll_top;
snapshot.viewport_height = update.viewport_height;
}
fn apply_event(
mut engine: Signal<VirtualWindow>,
mut range: Signal<VisibleRange>,
mut total_height: Signal<f64>,
mut scroll_top: Signal<f64>,
mut viewport_height: Signal<f64>,
viewport_id: &'static str,
event: WindowEvent,
) {
let update = { engine.write().update(event) };
range.set(update.range);
total_height.set(update.total_height);
scroll_top.set(update.scroll_top);
viewport_height.set(update.viewport_height);
if let Some(target) = update.scroll_to {
spawn(async move {
set_scroll_top(viewport_id, target).await;
});
}
}
pub fn use_virtual_window(config: UseVirtualWindowConfig) -> UseVirtualWindowHandle {
let viewport_id = config.viewport_id;
let scroll_sample_ms = config.scroll_sample_ms.max(1);
let scroll_idle_ms = config.scroll_idle_ms.max(1);
let engine = use_signal(|| VirtualWindow::new(config.engine.clone()));
let range = use_signal(|| engine().visible_range());
let total_height = use_signal(|| engine().total_height());
let scroll_top = use_signal(|| 0.0);
let viewport_height = use_signal(|| {
config
.engine
.estimated_item_height
.max(MIN_VALID_VIEWPORT_HEIGHT)
});
let mut mounted = use_signal(|| None::<Rc<MountedData>>);
let mut sequencer = use_signal(ScrollSequencer::default);
let onmounted = Callback::new(move |event: MountedEvent| async move {
mounted.set(Some(event.data()));
if let Ok(rect) = event.get_client_rect().await {
let h = rect.height();
if h.is_finite() && h >= MIN_VALID_VIEWPORT_HEIGHT {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::ResizeViewport { height: h },
);
}
}
let top = if let Ok(offset) = event.get_scroll_offset().await {
offset.y
} else {
get_scroll_top(viewport_id).await
};
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::Scroll { top },
);
});
let onresize = Callback::new(move |event: ResizeEvent| {
if let Ok(size) = event.get_content_box_size() {
let height = size.height;
if height.is_finite() && height >= MIN_VALID_VIEWPORT_HEIGHT {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::ResizeViewport { height },
);
}
}
});
let onscroll = Callback::new(move |_event: Event<ScrollData>| async move {
let ticket = {
let mut state = sequencer.write();
state.begin_event()
};
let Some(ticket) = ticket else {
return;
};
sleep_ms(scroll_sample_ms).await;
let top = if let Some(viewport) = mounted() {
if let Ok(offset) = viewport.get_scroll_offset().await {
offset.y
} else {
get_scroll_top(viewport_id).await
}
} else {
get_scroll_top(viewport_id).await
};
if sequencer().is_stale(ticket) {
sequencer.write().finish_sample();
return;
}
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::Scroll { top },
);
sequencer.write().finish_sample();
let settle_seq = sequencer().current_seq();
sleep_ms(scroll_idle_ms).await;
if sequencer().should_settle(settle_seq) {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::Scroll { top: scroll_top() },
);
}
});
let on_item_measured = Callback::new(move |(index, height): (usize, f64)| {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::MeasureItem { index, height },
);
});
let set_item_count = Callback::new(move |count: usize| {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::SetItemCount { count },
);
});
let prepend_items = Callback::new(move |count: usize| {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::PrependItems { count },
);
});
let append_items = Callback::new(move |count: usize| {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::AppendItems { count },
);
});
let set_stick_to_bottom = Callback::new(move |enabled: bool| {
apply_event(
engine,
range,
total_height,
scroll_top,
viewport_height,
viewport_id,
WindowEvent::SetStickToBottom { enabled },
);
});
let offset_of = Callback::new(move |index: usize| engine().offset_of(index));
UseVirtualWindowHandle {
range: range.into(),
total_height: total_height.into(),
scroll_top: scroll_top.into(),
viewport_height: viewport_height.into(),
bind_viewport: ViewportBindings {
onmounted,
onresize,
onscroll,
},
on_item_measured,
set_item_count,
prepend_items,
append_items,
set_stick_to_bottom,
offset_of,
}
}
async fn get_scroll_top(element_id: &str) -> f64 {
let script = format!(
"const el = document.getElementById({element_id:?}); return el ? el.scrollTop : 0;"
);
document::eval(&script).join::<f64>().await.unwrap_or(0.0)
}
async fn set_scroll_top(element_id: &str, target: f64) {
let safe_target = target.max(0.0);
let script = format!(
"const el = document.getElementById({element_id:?}); if (el) el.scrollTop = {safe_target}; return true;"
);
let _ = document::eval(&script).join::<bool>().await;
}
async fn sleep_ms(ms: u64) {
let script = format!(
"return new Promise((resolve) => setTimeout(() => resolve(true), {}));",
ms
);
let _ = document::eval(&script).join::<bool>().await;
}
#[cfg(test)]
mod tests {
use super::{AdapterSnapshot, ScrollSequencer, apply_update_to_snapshot};
use window_core::VisibleRange;
use window_core::WindowUpdate;
#[test]
fn event_sequencing_prevents_parallel_samples() {
let mut sequencer = ScrollSequencer::default();
let ticket_1 = sequencer.begin_event().expect("first event should start");
assert!(sequencer.begin_event().is_none());
sequencer.finish_sample();
let ticket_2 = sequencer.begin_event().expect("second event should start");
assert_ne!(ticket_1, ticket_2);
}
#[test]
fn scroll_to_effect_application_is_tracked() {
let mut snapshot = AdapterSnapshot::default();
let update = WindowUpdate {
range: VisibleRange {
start: 10,
end: 20,
pad_top: 50.0,
pad_bottom: 100.0,
total_height: 400.0,
},
total_height: 400.0,
scroll_top: 120.0,
viewport_height: 200.0,
distance_to_bottom: 80.0,
should_stick_to_bottom: false,
scroll_to: Some(120.0),
changed: true,
};
apply_update_to_snapshot(&mut snapshot, update);
assert_eq!(snapshot.pending_scroll_to, Some(120.0));
assert_eq!(snapshot.range.start, 10);
assert_eq!(snapshot.total_height, 400.0);
}
#[test]
fn idle_settle_only_after_sample_finishes() {
let mut sequencer = ScrollSequencer::default();
let ticket = sequencer.begin_event().expect("event should start");
assert!(!sequencer.should_settle(ticket));
sequencer.finish_sample();
assert!(sequencer.should_settle(ticket));
let next_ticket = sequencer.begin_event().expect("new event should start");
assert!(!sequencer.should_settle(ticket));
sequencer.finish_sample();
assert!(sequencer.should_settle(next_ticket));
}
}