#![warn(missing_docs)]
use std::collections::HashMap;
use std::ops::Range;
use vello::kurbo::{Point, Size, Vec2};
use crate::core::keyboard::{Key, KeyState, NamedKey};
use crate::core::{
AccessCtx, AccessEvent, BoxConstraints, ChildrenIds, ComposeCtx, EventCtx, KeyboardEvent,
LayoutCtx, NewWidget, PaintCtx, PointerEvent, PointerScrollEvent, PropertiesMut, PropertiesRef,
RegisterCtx, ScrollDelta, TextEvent, Update, UpdateCtx, Widget, WidgetMut, WidgetPod,
};
use crate::util::debug_panic;
#[derive(Debug)]
pub struct VirtualScrollAction {
pub old_active: Range<i64>,
pub target: Range<i64>,
}
pub struct VirtualScroll {
valid_range: Range<i64>,
active_range: Range<i64>,
action_handled: bool,
items: HashMap<i64, WidgetPod<dyn Widget>>,
anchor_index: i64,
scroll_offset_from_anchor: f64,
mean_item_height: f64,
anchor_height: f64,
warned_not_dense: bool,
missed_actions_count: u32,
}
impl std::fmt::Debug for VirtualScroll {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VirtualScroll")
.field("valid_range", &self.valid_range)
.field("active_range", &self.active_range)
.field("action_handled", &self.action_handled)
.field("missed_action_count", &self.missed_actions_count)
.field("items", &self.items.keys().collect::<Vec<_>>())
.field("anchor_index", &self.anchor_index)
.field("scroll_offset_from_anchor", &self.scroll_offset_from_anchor)
.field("mean_item_height", &self.mean_item_height)
.field("anchor_height", &self.anchor_height)
.field("warned_not_dense", &self.warned_not_dense)
.finish()
}
}
impl VirtualScroll {
pub fn new(initial_anchor: i64) -> Self {
Self {
valid_range: i64::MIN..i64::MAX,
active_range: initial_anchor..initial_anchor,
action_handled: true,
missed_actions_count: 0,
items: HashMap::default(),
anchor_index: initial_anchor,
scroll_offset_from_anchor: 0.0,
mean_item_height: DEFAULT_MEAN_ITEM_HEIGHT,
anchor_height: DEFAULT_MEAN_ITEM_HEIGHT,
warned_not_dense: false,
}
}
#[track_caller]
pub fn with_valid_range(mut self, valid_range: Range<i64>) -> Self {
self.valid_range = valid_range;
self.validate_valid_range();
self
}
#[expect(
clippy::len_without_is_empty,
reason = "The only time the VirtualScroll unloads all children is when given an empty valid range."
)]
pub fn len(&self) -> usize {
self.items.len()
}
fn validate_valid_range(&mut self) {
if self.valid_range.end < self.valid_range.start {
debug_panic!(
"Expected valid range to not have end less than its start, got {:?}",
self.valid_range
);
self.valid_range = self.valid_range.start..self.valid_range.start;
}
}
}
enum PostScrollResult {
Layout,
NoLayout,
}
impl VirtualScroll {
pub fn will_handle_action(this: &mut WidgetMut<'_, Self>, action: &VirtualScrollAction) {
if this.widget.active_range != action.old_active {
debug_panic!(
"Handling a VirtualScrollAction with the wrong range; got {:?}, expected {:?} for widget {}.\n\
Maybe this has been routed to the wrong `VirtualScroll`?",
action.old_active,
this.widget.active_range,
this.ctx.widget_id(),
);
}
this.widget.action_handled = true;
if this.widget.missed_actions_count > 0 {
this.widget.missed_actions_count = 1;
}
this.widget.active_range = action.target.clone();
this.ctx.request_layout();
}
#[track_caller]
pub fn add_child(this: &mut WidgetMut<'_, Self>, idx: i64, child: NewWidget<dyn Widget>) {
debug_assert!(
this.widget.action_handled,
"You must call `will_handle_action` before `add_child`."
);
debug_assert!(
this.widget.active_range.contains(&idx),
"`add_child` should only be called with an index requested by the controller."
);
this.ctx.children_changed();
if this.widget.items.insert(idx, child.to_pod()).is_some() {
tracing::warn!("Tried to add child {idx} twice to VirtualScroll");
};
}
#[track_caller]
pub fn remove_child(this: &mut WidgetMut<'_, Self>, idx: i64) {
debug_assert!(
this.widget.action_handled,
"You must call `will_handle_action` before `remove_child`."
);
debug_assert!(
!this.widget.active_range.contains(&idx),
"`remove_child` should only be called with an index which is not active."
);
let child = this.widget.items.remove(&idx);
if let Some(child) = child {
this.ctx.remove_child(child);
} else if !this.widget.warned_not_dense {
tracing::error!(
"Tried to remove child ({idx}) which has already been removed or was never added."
);
}
}
#[track_caller]
pub fn child_mut<'t>(this: &'t mut WidgetMut<'_, Self>, idx: i64) -> WidgetMut<'t, dyn Widget> {
let child = this.widget.items.get_mut(&idx).unwrap_or_else(|| {
panic!(
"`VirtualScroll::child_mut` called with non-present index {idx}.\n\
Active range is {:?}.",
&this.widget.active_range
)
});
this.ctx.get_mut(child)
}
pub fn set_valid_range(this: &mut WidgetMut<'_, Self>, range: Range<i64>) {
this.widget.valid_range = range;
this.widget.validate_valid_range();
this.ctx.request_layout();
}
pub fn overwrite_anchor(this: &mut WidgetMut<'_, Self>, idx: i64) {
this.widget.anchor_index = idx;
this.widget.scroll_offset_from_anchor = 0.;
this.ctx.request_layout();
}
fn post_scroll(&mut self, size: Size) -> PostScrollResult {
if self.anchor_index + 1 == self.valid_range.end {
self.cap_scroll_range_down(self.anchor_height, size.height);
}
if self.anchor_index == self.valid_range.start {
self.cap_scroll_range_up();
}
if self.scroll_offset_from_anchor < 0.
|| self.scroll_offset_from_anchor >= self.anchor_height
{
PostScrollResult::Layout
} else {
PostScrollResult::NoLayout
}
}
fn event_post_scroll(&mut self, ctx: &mut EventCtx<'_>) {
match self.post_scroll(ctx.size()) {
PostScrollResult::Layout => {
ctx.request_layout();
}
PostScrollResult::NoLayout => {}
}
ctx.request_compose();
}
fn update_post_scroll(&mut self, ctx: &mut UpdateCtx<'_>) {
match self.post_scroll(ctx.size()) {
PostScrollResult::Layout => {
ctx.request_layout();
}
PostScrollResult::NoLayout => {}
}
ctx.request_compose();
}
fn cap_scroll_range_down(&mut self, anchor_height: f64, viewport_height: f64) {
let max_scroll = (anchor_height - viewport_height / 2.).max(0.0);
self.scroll_offset_from_anchor = self.scroll_offset_from_anchor.min(max_scroll);
}
fn cap_scroll_range_up(&mut self) {
self.scroll_offset_from_anchor = self.scroll_offset_from_anchor.max(0.0);
}
}
const DEFAULT_MEAN_ITEM_HEIGHT: f64 = 60.;
impl Widget for VirtualScroll {
type Action = VirtualScrollAction;
fn on_pointer_event(
&mut self,
ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
event: &PointerEvent,
) {
match event {
PointerEvent::Scroll(PointerScrollEvent { delta, .. }) => {
let delta = match delta {
ScrollDelta::PixelDelta(p) => -p.to_logical::<f64>(ctx.get_scale_factor()).y,
ScrollDelta::LineDelta(_, y) => -y as f64 * ctx.get_scale_factor() * 120.,
_ => 0.0,
};
self.scroll_offset_from_anchor += delta;
self.event_post_scroll(ctx);
}
_ => (),
}
}
fn on_text_event(
&mut self,
ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
event: &TextEvent,
) {
const DELTA: f64 = 20000.;
let TextEvent::Keyboard(keyboard_event) = event else {
return;
};
match keyboard_event {
KeyboardEvent {
state: KeyState::Down,
key: Key::Named(NamedKey::PageDown),
..
} => {
self.scroll_offset_from_anchor += DELTA;
self.event_post_scroll(ctx);
}
KeyboardEvent {
state: KeyState::Down,
key: Key::Named(NamedKey::PageUp),
..
} => {
self.scroll_offset_from_anchor -= DELTA;
self.event_post_scroll(ctx);
}
_ => {}
}
}
fn on_access_event(
&mut self,
ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
event: &AccessEvent,
) {
if matches!(
event.action,
accesskit::Action::ScrollUp | accesskit::Action::ScrollDown
) {
let unit = if let Some(accesskit::ActionData::ScrollUnit(unit)) = &event.data {
*unit
} else {
accesskit::ScrollUnit::Item
};
let amount = match unit {
accesskit::ScrollUnit::Item => self.anchor_height,
accesskit::ScrollUnit::Page => ctx.size().height,
};
if event.action == accesskit::Action::ScrollUp {
self.scroll_offset_from_anchor -= amount;
} else {
self.scroll_offset_from_anchor += amount;
}
self.event_post_scroll(ctx);
}
}
fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) {
for child in self.items.values_mut() {
ctx.register_child(child);
}
}
fn update(&mut self, ctx: &mut UpdateCtx<'_>, _props: &mut PropertiesMut<'_>, event: &Update) {
match event {
Update::RequestPanToChild(target) => {
let new_pos_y = super::portal::compute_pan_range(
0.0..ctx.size().height,
target.min_y()..target.max_y(),
)
.start;
self.scroll_offset_from_anchor += new_pos_y;
self.update_post_scroll(ctx);
}
_ => {}
}
}
fn layout(
&mut self,
ctx: &mut LayoutCtx<'_>,
_props: &mut PropertiesMut<'_>,
bc: &BoxConstraints,
) -> Size {
let viewport_size = bc.max();
ctx.set_clip_path(viewport_size.to_rect());
let child_bc = BoxConstraints::new(
Size {
width: viewport_size.width,
height: 0.,
},
Size {
width: viewport_size.width,
height: f64::INFINITY,
},
);
let mut height_before_anchor = 0.;
let mut total_height = 0.;
let mut count = 0_u64;
let mut first_item: Option<i64> = None;
let mut last_item: Option<i64> = None;
for (idx, child) in &mut self.items {
if !self.active_range.contains(idx) {
ctx.set_stashed(child, true);
continue;
}
first_item = first_item.map(|it| it.min(*idx)).or(Some(*idx));
last_item = last_item.map(|it| it.max(*idx)).or(Some(*idx));
let child_size = ctx.run_layout(child, &child_bc);
if *idx < self.anchor_index {
height_before_anchor += child_size.height.max(0.0);
}
total_height += child_size.height.max(0.0);
count += 1;
}
let mean_item_height = if count > 0 {
total_height / count as f64
} else {
self.mean_item_height
};
let mean_item_height = if !mean_item_height.is_finite() || mean_item_height < 0.01 {
tracing::warn!(
"Got an unreasonable mean item height {mean_item_height} in virtual scrolling"
);
DEFAULT_MEAN_ITEM_HEIGHT
} else {
mean_item_height
};
self.mean_item_height = mean_item_height;
loop {
if self.scroll_offset_from_anchor < 0. {
if self.anchor_index <= self.valid_range.start {
self.cap_scroll_range_up();
break;
}
self.anchor_index -= 1;
let new_anchor_height = if self.active_range.contains(&self.anchor_index) {
let new_anchor = self.items.get(&self.anchor_index);
if let Some(new_anchor) = new_anchor {
ctx.child_size(new_anchor).height.max(0.0)
} else {
break;
}
} else {
mean_item_height
};
self.scroll_offset_from_anchor += new_anchor_height;
height_before_anchor -= new_anchor_height;
} else {
let anchor_height = if self.active_range.contains(&self.anchor_index) {
let current_anchor = self.items.get(&self.anchor_index);
if let Some(anchor_pod) = current_anchor {
ctx.child_size(anchor_pod).height.max(0.0)
} else {
break;
}
} else {
mean_item_height
};
if self.scroll_offset_from_anchor >= anchor_height {
self.anchor_index += 1;
self.scroll_offset_from_anchor -= anchor_height;
height_before_anchor += anchor_height;
} else {
break;
}
}
}
let at_valid_end = self.anchor_index + 1 >= self.valid_range.end;
if at_valid_end {
self.anchor_index = self.valid_range.end - 1;
}
if self.anchor_index < self.valid_range.start {
self.anchor_index = self.valid_range.start;
self.scroll_offset_from_anchor = 0.;
}
self.anchor_height = if let Some(anchor) = self
.items
.get(&self.anchor_index)
.filter(|_| self.active_range.contains(&self.anchor_index))
{
ctx.child_size(anchor).height.max(0.0)
} else {
mean_item_height
};
if at_valid_end {
self.scroll_offset_from_anchor = f64::INFINITY;
self.cap_scroll_range_down(self.anchor_height, viewport_size.height);
}
let cutoff_up = viewport_size.height * 1.5;
let cutoff_down = viewport_size.height * 2.5 + self.anchor_height;
let mut item_crossing_top = None;
let mut item_crossing_bottom = self.active_range.start;
let mut y = -height_before_anchor;
let mut was_dense = true;
for idx in self.active_range.clone() {
if y <= -cutoff_up {
item_crossing_top = Some(idx);
}
if y <= cutoff_down {
item_crossing_bottom = idx;
}
let item = self.items.get_mut(&idx);
if let Some(item) = item {
let size = ctx.child_size(item);
ctx.place_child(item, Point::new(0., y));
y += size.height.max(0.0);
} else {
was_dense = false;
if !self.warned_not_dense {
self.warned_not_dense = true;
tracing::error!(
"Virtual Scrolling items in {:?} ({}) not dense.\n\
Expected to be dense in {:?}, but missing {idx}",
ctx.widget_id(),
self.type_name(),
self.active_range,
);
}
}
}
if was_dense {
self.warned_not_dense = false;
}
if self.action_handled {
let target_range = if self.active_range.contains(&self.anchor_index) {
let start = if let Some(item_crossing_top) = item_crossing_top {
item_crossing_top
} else {
let number_needed =
((cutoff_up - height_before_anchor) / mean_item_height).ceil() as i64;
let start_anchor = first_item.unwrap_or(self.anchor_index);
start_anchor - number_needed
};
let end = if y >= cutoff_down {
item_crossing_bottom + 1
} else {
let number_needed = ((cutoff_down - y) / mean_item_height).ceil() as i64;
let end_anchor = last_item.unwrap_or(self.anchor_index);
end_anchor + number_needed + 1
};
start..end
} else {
let start = self.anchor_index - (cutoff_up / mean_item_height).ceil() as i64;
let end = self.anchor_index + (cutoff_down / mean_item_height).ceil() as i64;
start..end
};
let target_range = if self.valid_range.is_empty() {
self.valid_range.clone()
} else {
let start = target_range
.start
.clamp(self.valid_range.start, self.valid_range.end - 1);
let end = target_range
.end
.clamp(self.valid_range.start, self.valid_range.end);
start..end
};
if self.active_range != target_range {
let previous_active = self.active_range.clone();
ctx.submit_action::<Self::Action>(VirtualScrollAction {
old_active: previous_active,
target: target_range,
});
self.action_handled = false;
}
}
viewport_size
}
fn compose(&mut self, ctx: &mut ComposeCtx<'_>) {
let translation = Vec2 {
x: 0.,
y: -self.scroll_offset_from_anchor,
};
for idx in self.active_range.clone() {
if let Some(child) = self.items.get_mut(&idx) {
ctx.set_child_scroll_translation(child, translation);
}
}
}
fn paint(
&mut self,
_ctx: &mut PaintCtx<'_>,
_props: &PropertiesRef<'_>,
_scene: &mut vello::Scene,
) {
if !self.action_handled {
if self.missed_actions_count == 0 {
tracing::warn!(
"VirtualScroll got to painting without its action (i.e. it's request for items to be loaded) being handled.\n\
This means that there was a delay in handling its action for some reason.\n\
Maybe your driver only handles one action at a time?"
);
}
if self.missed_actions_count > 10 {
debug_panic!(
"VirtualScroll's action is being missed repeatedly being handled.\n\
Note that to handle an action, you must call `VirtualScroll::will_handle_action` with the action."
);
self.action_handled = true;
}
self.missed_actions_count += 1;
}
}
fn accessibility_role(&self) -> accesskit::Role {
accesskit::Role::ScrollView
}
fn accessibility(
&mut self,
ctx: &mut AccessCtx<'_>,
_props: &PropertiesRef<'_>,
node: &mut accesskit::Node,
) {
node.set_clips_children();
node.set_orientation(accesskit::Orientation::Vertical);
if self.valid_range.start == i64::MIN {
if self.anchor_index != i64::MIN && self.anchor_index != i64::MAX {
let y = (self.anchor_index as f64) * self.mean_item_height
+ self.scroll_offset_from_anchor;
node.set_scroll_y(y);
}
} else {
node.set_scroll_y_min(0.0);
let y = (((self.anchor_index - self.valid_range.start) as f64) * self.mean_item_height
+ self.scroll_offset_from_anchor)
.max(0.);
node.set_scroll_y(y);
if self.valid_range.end != i64::MAX {
let y_max = (((self.valid_range.end - self.valid_range.start) as f64)
* self.mean_item_height)
.max(0.);
node.set_scroll_y_max(y_max);
}
}
if self.anchor_index != self.valid_range.start || self.scroll_offset_from_anchor > 0. {
node.add_action(accesskit::Action::ScrollUp);
}
let at_end = self.anchor_index + 1 == self.valid_range.end && {
let max_scroll = (self.anchor_height - ctx.size().height / 2.).max(0.0);
self.scroll_offset_from_anchor >= max_scroll
};
if !at_end {
node.add_action(accesskit::Action::ScrollDown);
}
node.add_child_action(accesskit::Action::ScrollIntoView);
}
fn children_ids(&self) -> ChildrenIds {
let mut items = self
.items
.iter()
.map(|(index, pod)| (*index, pod.id()))
.collect::<Vec<_>>();
items.sort_unstable_by_key(|(index, _)| *index);
items.into_iter().map(|(_, id)| id).collect()
}
fn accepts_text_input(&self) -> bool {
false
}
fn accepts_focus(&self) -> bool {
true
}
fn get_debug_text(&self) -> Option<String> {
Some(format!("{self:#?}"))
}
}
#[allow(
dead_code,
reason = "Plan to expose this publicly in `VirtualScrollAction`, keep its tests around"
)]
fn opt_iter_difference(
old_range: &Range<i64>,
new_range: &Range<i64>,
) -> std::iter::Chain<Range<i64>, Range<i64>> {
(old_range.start..(new_range.start.min(old_range.end)))
.chain(new_range.end.max(old_range.start)..old_range.end)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use kurbo::{Size, Vec2};
use parley::StyleProperty;
use crate::core::{NewWidget, Widget, WidgetId, WidgetMut};
use crate::testing::{TestHarness, assert_render_snapshot};
use crate::theme::default_property_set;
use crate::vello::kurbo;
use crate::widgets::{Label, VirtualScroll, VirtualScrollAction};
use super::opt_iter_difference;
#[test]
#[expect(
clippy::reversed_empty_ranges,
reason = "Testing technically possible behaviour"
)]
fn opt_iter_difference_equiv() {
let ranges = [
5..10,
7..15,
-10..7,
20..10,
12..17,
];
for old_range in &ranges {
for new_range in &ranges {
let opt_result = opt_iter_difference(old_range, new_range).collect::<HashSet<_>>();
let mut naive_result = HashSet::new();
for idx in old_range.clone() {
if !new_range.contains(&idx) {
naive_result.insert(idx);
}
}
assert_eq!(
opt_result, naive_result,
"The optimised version of differences should be equivalent to the trivially \
correct method, but wasn't for {old_range:?} and {new_range:?}"
);
}
}
}
#[test]
fn sensible_driver() {
let widget = VirtualScroll::new(0).with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) {
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
assert_render_snapshot!(harness, "virtual_scroll_basic");
harness.edit_root_widget(|mut scroll| {
VirtualScroll::overwrite_anchor(&mut scroll, 100);
});
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
assert_render_snapshot!(harness, "virtual_scroll_moved");
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: 25. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
assert_render_snapshot!(harness, "virtual_scroll_scrolled");
}
#[test]
fn small_gaps() {
let widget = VirtualScroll::new(0).with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) && idx % 2 == 0 {
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.edit_root_widget(|mut scroll| {
VirtualScroll::overwrite_anchor(&mut scroll, 100);
});
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: 200. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
}
#[test]
fn big_gaps() {
let widget = VirtualScroll::new(0).with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) && idx % 100 == 1 {
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.edit_root_widget(|mut scroll| {
VirtualScroll::overwrite_anchor(&mut scroll, 200);
});
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: 200. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
}
#[test]
fn degenerate_driver() {
let widget = VirtualScroll::new(0).with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) && idx < 5 {
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.edit_root_widget(|mut scroll| {
VirtualScroll::overwrite_anchor(&mut scroll, 200);
});
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: 200. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
}
#[test]
fn limited_up() {
const MIN: i64 = 10;
let widget = VirtualScroll::new(0)
.with_valid_range(MIN..i64::MAX)
.with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) {
assert!(
idx >= MIN,
"Virtual Scroll controller should never request an invalid id. Requested {idx}"
);
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
let original_range;
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_eq!(
widget.anchor_index, MIN,
"Virtual Scroll controller should lock anchor to be within active range"
);
assert_eq!(
widget.scroll_offset_from_anchor, 0.0,
"Virtual Scroll controller should lock top of the first item to the top of the screen if jumping"
);
original_range = widget.active_range.clone();
}
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: -50. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_ne!(widget.anchor_index, MIN);
assert_ne!(widget.active_range, original_range);
}
harness.mouse_wheel(Vec2 { x: 0., y: 60. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_eq!(widget.anchor_index, MIN);
assert_eq!(widget.scroll_offset_from_anchor, 0.0);
}
}
#[test]
fn limited_down() {
const MAX: i64 = 10;
let widget = VirtualScroll::new(100)
.with_valid_range(i64::MIN..MAX)
.with_auto_id();
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, Size::new(100., 200.));
let virtual_scroll_id = harness.root_id();
fn driver(action: VirtualScrollAction, mut scroll: WidgetMut<'_, VirtualScroll>) {
VirtualScroll::will_handle_action(&mut scroll, &action);
for idx in action.old_active.clone() {
if !action.target.contains(&idx) {
VirtualScroll::remove_child(&mut scroll, idx);
}
}
for idx in action.target {
if !action.old_active.contains(&idx) {
assert!(
idx < MAX,
"Virtual Scroll controller should never request an invalid id. Requested {idx}"
);
VirtualScroll::add_child(
&mut scroll,
idx,
NewWidget::new(
Label::new(format!("{idx}")).with_style(StyleProperty::FontSize(30.)),
)
.erased(),
);
}
}
}
let original_range;
let original_scroll;
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_eq!(
widget.anchor_index,
MAX - 1,
"Virtual Scroll controller should lock anchor to be within active range"
);
original_scroll = widget.scroll_offset_from_anchor;
original_range = widget.active_range.clone();
assert_render_snapshot!(harness, "virtual_scroll_limited_up_bottom");
}
harness.mouse_move_to(virtual_scroll_id);
harness.mouse_wheel(Vec2 { x: 0., y: 5. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_ne!(widget.anchor_index, MAX);
assert_ne!(widget.active_range, original_range);
}
harness.mouse_wheel(Vec2 { x: 0., y: -6. });
drive_to_fixpoint(&mut harness, virtual_scroll_id, driver);
{
let widget = harness.root_widget();
assert_eq!(widget.anchor_index, MAX - 1);
assert_eq!(
widget.scroll_offset_from_anchor, original_scroll,
"Should be scrolled as far as possible (which is the same as we originally were)"
);
}
}
fn drive_to_fixpoint(
harness: &mut TestHarness<VirtualScroll>,
virtual_scroll_id: WidgetId,
mut f: impl FnMut(VirtualScrollAction, WidgetMut<'_, VirtualScroll>),
) {
let mut iteration = 0;
let mut old_active = None;
loop {
iteration += 1;
if iteration > 1000 {
panic!("Took too long to reach fixpoint");
}
let Some((action, id)) = harness.pop_action::<VirtualScrollAction>() else {
break;
};
assert_eq!(
id, virtual_scroll_id,
"Only widget in tree should give action"
);
if let Some(old_active) = old_active.take() {
assert_eq!(action.old_active, old_active);
}
old_active = Some(action.target.clone());
assert!(
action.target != action.old_active,
"Shouldn't have sent an update if tUsehe target hasn't changed"
);
harness.edit_root_widget(|scroll| {
f(action, scroll);
});
}
}
}