Refactored VirtualList

These set of changes attempt to resolve a few complexities from the
original implementation: sizing and how to dynamically update the
content in the list.

On the sizing front, manually specifying the width and height of the
rows felt like it was more complex than measuring the first widget and
using that for all other widgets. This allows a user who wants to force
an explicit size to use the Resize widget, while also supporting
SizeToFit flows. Additionally, this paves the way for us to add
horizontal scrolling to this list, but this commit was already complex
enough I held off on that change for now.

One workflow I wanted to see supported was going from 0 rows to 50 rows.
When the item count comes from a trait, it was pretty complicated to
determine how to tell the list to ask for a new row count. By having the
user provide a Value<usize>, they can provide a `Dynamic<usize>` that
can be updated with a new row count whenever the application determines
there is new data. We still need to figure out a way to force a refresh
of the data even if the row count doesn't change.

Ultimately changing this allowed removing the trait and seemingly
simplified the basic usage in addition to adding more flexibility.
This commit is contained in:
Jonathan Johnson 2024-11-07 14:27:08 -08:00
parent 4f742cd18b
commit 4bc3f5a884
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
9 changed files with 633 additions and 368 deletions

567
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,9 @@
use cushy::styles::Dimension;
use cushy::widget::MakeWidget;
use cushy::widgets::virtual_list::{VirtualList, VirtualListContent};
use cushy::widgets::VirtualList;
use cushy::Run;
use figures::units::Lp;
#[derive(Debug)]
struct TestVirtualList;
impl VirtualListContent for TestVirtualList {
fn item_count(&self) -> impl cushy::value::IntoValue<usize> {
50
}
fn item_height(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
cushy::styles::Dimension::Lp(Lp::inches_f(0.5))
}
fn widget_at(&self, index: usize) -> impl MakeWidget {
format!("Item {}", index)
}
fn width(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
Dimension::Lp(Lp::inches_f(10.))
}
}
fn list() -> impl MakeWidget {
VirtualList::new(TestVirtualList).expand()
VirtualList::new(50, |index| format!("Item {}", index)).expand()
}
fn main() -> cushy::Result {

View file

@ -1,32 +1,10 @@
use cushy::figures::units::Lp;
use cushy::styles::Dimension;
use cushy::widget::MakeWidget;
use cushy::widgets::virtual_list::{VirtualList, VirtualListContent};
use cushy::widgets::VirtualList;
use cushy::Run;
// ANCHOR: implementation
#[derive(Debug)]
struct TestVirtualList;
impl VirtualListContent for TestVirtualList {
fn item_count(&self) -> impl cushy::value::IntoValue<usize> {
50
}
fn item_height(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
cushy::styles::Dimension::Lp(Lp::inches_f(0.5))
}
fn widget_at(&self, index: usize) -> impl MakeWidget {
format!("Item {}", index)
}
fn width(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
Dimension::Lp(Lp::inches_f(10.))
}
}
// ANCHOR_END: implementation
// ANCHOR: list
fn list() -> impl MakeWidget {
VirtualList::new(TestVirtualList).expand()
VirtualList::new(50, |index| format!("Item {}", index)).expand()
}
// ANCHOR_END: list

View file

@ -195,6 +195,19 @@ impl ConstraintLimit {
ConstraintLimit::SizeToFit(_) => measured.into_unsigned(),
}
}
/// When `self` is `SizeToFit`, the smallest of the constraint and
/// `measured` will be returned. When `self` is `Fill`, the fill size will
/// be returned.
pub fn fill_or_fit<Unit>(self, measured: Unit) -> UPx
where
Unit: IntoUnsigned<Unsigned = UPx>,
{
match self {
ConstraintLimit::Fill(size) => size,
ConstraintLimit::SizeToFit(size) => size.min(measured.into_unsigned()),
}
}
}
/// An extension trait for `Size<ConstraintLimit>`.

View file

@ -1348,7 +1348,7 @@ pub trait MakeWidget: Sized {
/// Creates a [`WidgetRef`] for use as child widget.
#[must_use]
fn widget_ref(self) -> WidgetRef {
fn into_ref(self) -> WidgetRef {
WidgetRef::new(self)
}

View file

@ -25,7 +25,6 @@ pub mod progress;
pub mod radio;
mod resize;
pub mod scroll;
pub mod virtual_list;
pub mod select;
pub mod shortcuts;
pub mod slider;
@ -36,6 +35,7 @@ mod switcher;
mod themed;
mod tilemap;
pub mod validated;
mod virtual_list;
pub mod wrap;
pub use self::align::Align;
@ -70,4 +70,5 @@ pub use self::switcher::Switcher;
pub use self::themed::Themed;
pub use self::tilemap::TileMap;
pub use self::validated::Validated;
pub use self::virtual_list::VirtualList;
pub use self::wrap::Wrap;

View file

@ -137,7 +137,7 @@ impl Button {
/// Returns a new button with the provided label.
pub fn new(content: impl MakeWidget) -> Self {
Self {
content: content.widget_ref(),
content: content.into_ref(),
on_click: None,
per_window: WindowLocal::default(),
kind: Value::Constant(ButtonKind::default()),

View file

@ -107,7 +107,17 @@ impl WrapperWidget for Expand {
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> WrappedLayout {
let available_space = available_space.map(|lim| ConstraintLimit::Fill(lim.max()));
let available_space = match &self.kind {
ExpandKind::Weighted(_) => available_space.map(|lim| ConstraintLimit::Fill(lim.max())),
ExpandKind::Horizontal => Size::new(
ConstraintLimit::Fill(available_space.width.max()),
ConstraintLimit::SizeToFit(available_space.height.max()),
),
ExpandKind::Vertical => Size::new(
ConstraintLimit::SizeToFit(available_space.width.max()),
ConstraintLimit::Fill(available_space.height.max()),
),
};
let child = self.child.mounted(&mut context.as_event_context());
let size = context.for_other(&child).layout(available_space);

View file

@ -1,22 +1,38 @@
use std::{collections::VecDeque, fmt::Debug, ops::Range};
use std::collections::VecDeque;
use std::fmt::Debug;
use std::ops::Range;
use crate::{context::{AsEventContext, EventContext}, figures::{units::{Px, UPx}, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}, kludgine::app::winit::{event::{ MouseScrollDelta, TouchPhase}, window::CursorIcon}, styles::Dimension, value::{Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, Source}, widget::{EventHandling, MakeWidget, MountedWidget, Widget, HANDLED, IGNORED}, widgets::scroll::ScrollBar, window::DeviceId, ConstraintLimit};
use cushy::context::LayoutContext;
use cushy::ConstraintLimit;
use figures::UnscaledUnit;
use super::scroll::OwnedWidget;
use crate::context::{AsEventContext, EventContext};
use crate::figures::units::{Px, UPx};
use crate::figures::{IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero};
use crate::kludgine::app::winit::event::{MouseScrollDelta, TouchPhase};
use crate::kludgine::app::winit::window::CursorIcon;
use crate::value::{Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, Source};
use crate::widget::{
Callback, EventHandling, MakeWidget, MountedWidget, Widget, WidgetInstance, HANDLED, IGNORED,
};
use crate::widgets::scroll::ScrollBar;
use crate::window::DeviceId;
/// A virtual list contents.
/// This simple virtual list assumes that all items have the same height, width and that the item count is known.
/// All the values are dynamic, so the list will update when the values change.
pub trait VirtualListContent: Debug {
/// Single item height
fn item_height(&self) -> impl IntoValue<Dimension>;
/// Width of the items
fn width(&self) -> impl IntoValue<Dimension>;
/// Number of items
fn item_count(&self) -> impl IntoValue<usize>;
/// Create a widget for the item at the given index.
/// This is called when the widget comes into view. The widget may be removed at any moment (by scrolling it out of view) and recreated later.
fn widget_at(&self, index: usize) -> impl MakeWidget;
#[derive(Debug)]
struct RowMaker(Callback<usize, WidgetInstance>);
impl RowMaker {
fn make_row(
&mut self,
index: usize,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> VirtualListItem {
VirtualListItem {
index,
mounted: context.push_child(self.0.invoke(index)),
}
}
}
#[derive(Debug)]
@ -26,37 +42,47 @@ struct VirtualListItem {
}
#[derive(Debug)]
/// A virtual list widget.
/// Requires a [VirtualListContent] trait implementation to render the items.
/// Items are lazily recreated as they go in and out of view.
pub struct VirtualList<T: VirtualListContent + Send + 'static> {
virtual_list: T,
/// A virtuallized list view
///
/// This widget allows scrolling a list of rows by lazily loading only the rows
/// that are currently being displayed to the screen.
pub struct VirtualList {
make_row: RowMaker,
vertical_scroll: OwnedWidget<ScrollBar>,
items: VecDeque<VirtualListItem>,
content_size: Dynamic<Size<UPx>>,
/// Maximum scroll value - max_scroll.y + control_size.height should be the height of the content.
/// Maximum scroll value - `max_scroll.y` + `control_size.height` should be the height of the content.
pub max_scroll: DynamicReader<Point<UPx>>,
/// Current scroll value. The x value is always 0. Change the value to scroll the widget programmatically.
/// Current scroll value. Changes to this dynamic will scroll the list
/// programmatically.
pub scroll: Dynamic<Point<UPx>>,
control_size: Dynamic<Size<UPx>>,
/// Height of an item. Based on [VirtualListContent::item_height].
pub item_height: DynamicReader<Dimension>,
/// Width of the items. Based on [VirtualListContent::width].
pub width: DynamicReader<Dimension>,
/// Number of items. Based on [VirtualListContent::item_count].
pub item_count: DynamicReader<usize>,
item_count: DynamicReader<usize>,
item_size: Dynamic<Size<UPx>>,
visible_range: Dynamic<Range<usize>>
visible_range: Dynamic<Range<usize>>,
}
impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
/// Creates a new [VirtualList] based on the given [VirtualListContent].
pub fn new(virtual_list: T) -> Self {
impl VirtualList {
/// Creates a new [`VirtualList`] that displays `item_count` rows, loading
/// each row as needed by invoking `make_row`.
///
/// `make_row` will be called each time a new row becomes visible. As rows
/// are no longer visible, they will be freed, ensuring a minimum number of
/// widgets is kept in memory at any given time.
///
/// Each row will be sized to match the first visible row. To ensure all
/// rows have a consistent size, use the [`Resize`](../Resize) widget.
pub fn new<MakeRow, Row>(item_count: impl IntoValue<usize>, mut make_row: MakeRow) -> Self
where
MakeRow: FnMut(usize) -> Row + Send + 'static,
Row: MakeWidget,
{
let make_row = RowMaker(Callback::new(move |row| make_row(row).make_widget()));
let scroll = Dynamic::<Point<UPx>>::default();
let item_height = virtual_list.item_height().into_value().into_dynamic().create_reader();
let width = virtual_list.width().into_value().into_dynamic().create_reader();
let item_count = virtual_list.item_count().into_value().into_dynamic().create_reader();
let item_size = Dynamic::new(Size::ZERO);
let item_count = item_count.into_value().into_dynamic().into_reader();
let content_size = Dynamic::new(Size::default());
let y = scroll.map_each_cloned(|scroll| scroll.y);
@ -71,14 +97,14 @@ impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
}
})
.persist();
let vertical =
ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true);
let max_scroll = (&vertical.max_scroll())
let vertical = ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true);
let max_scroll = vertical
.max_scroll()
.map_each_cloned(|y| Point::new(UPx::ZERO, y))
.into_reader();
Self {
virtual_list,
make_row,
vertical_scroll: OwnedWidget::new(vertical),
items: VecDeque::new(),
control_size: Dynamic::new(Size::default()),
@ -86,10 +112,9 @@ impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
max_scroll,
scroll,
item_height,
width,
item_size,
item_count,
visible_range: Default::default()
visible_range: Dynamic::default(),
}
}
@ -134,9 +159,140 @@ impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
.expect("a ScrollBar")
.hide(context);
}
fn layout_rows(
&mut self,
item_count: usize,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
let mut item_size = self.calculate_item_size(available_space, context).ceil();
let content_height = item_size.height * u32::try_from(item_count).unwrap_or(u32::MAX);
let content_height = content_height.into_upx(context.gfx.scale());
let new_control_size = Size::new(
available_space.width.fill_or_fit(item_size.width),
available_space.height.fill_or_fit(content_height),
)
.ceil();
if item_size.width < new_control_size.width {
item_size.width = new_control_size.width;
}
let vertical = self
.vertical_scroll
.make_if_needed()
.mounted(&mut context.as_event_context());
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
context.set_child_layout(
&vertical,
Rect::new(
Point::new(
available_space
.width
.fit_measured(new_control_size.width)
.saturating_sub(scrollbar_layout.width)
.into_signed(),
Px::ZERO,
),
scrollbar_layout.into_signed(),
),
);
let scroll = self.scroll.get_tracking_invalidate(context);
let start_item = (scroll.y.floor() / item_size.height).floor().get() as usize;
let end_item = ((scroll.y.ceil() + new_control_size.height) / item_size.height)
.ceil()
.get() as usize;
let end_item = end_item.min(item_count - 1);
self.visible_range.set(start_item..end_item);
let first = self.items.front().map(|t| t.index);
let last = self.items.back().map(|t| t.index);
if self.items.is_empty() || first.unwrap() > end_item || last.unwrap() < start_item {
for item in self.items.drain(..) {
context.remove_child(&item.mounted);
}
self.items.extend(
(start_item..=end_item).map(|index| self.make_row.make_row(index, context)),
);
} else {
let first = first.expect("List is not empty");
let last = last.expect("List is not empty");
while self
.items
.front()
.map_or(false, |item| item.index < start_item)
{
context.remove_child(&self.items.pop_front().expect("at least one item").mounted);
}
while self
.items
.back()
.map_or(false, |item| item.index > end_item)
{
self.items.pop_back();
}
// no extend front :(
for item in (start_item..first).rev() {
self.items.push_front(self.make_row.make_row(item, context));
}
self.items.extend(
((last + 1)..=end_item).map(|index| self.make_row.make_row(index, context)),
);
}
// TODO add % to Figures
let mut y =
-UPx::from_unscaled(scroll.y.into_unscaled() % item_size.height.into_unscaled())
.into_signed();
let constraint = item_size.map(ConstraintLimit::SizeToFit);
for item in &self.items {
let child_size = context.for_other(&item.mounted).layout(constraint);
context.set_child_layout(
&item.mounted,
Rect::new(
Point::new(Px::ZERO, y),
item_size.min(child_size).into_signed(),
),
);
y += item_size.height.into_signed();
}
self.control_size.set(new_control_size);
self.content_size
.set(Size::new(item_size.width, content_height));
self.item_size.set(item_size);
new_control_size
}
fn calculate_item_size(
&mut self,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
if self.items.is_empty() {
self.items.push_front(self.make_row.make_row(0, context));
}
context
.for_other(
&self
.items
.front()
.expect("at least one mounted item")
.mounted,
)
.layout(available_space.map(|space| ConstraintLimit::SizeToFit(space.max())))
}
}
impl<T: VirtualListContent + Send + 'static> Widget for VirtualList<T> {
impl Widget for VirtualList {
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
true
}
@ -173,104 +329,16 @@ impl<T: VirtualListContent + Send + 'static> Widget for VirtualList<T> {
}
fn layout(
&mut self,
available_space: Size<cushy::ConstraintLimit>,
context: &mut cushy::context::LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
let item_height = self.item_height.get_tracking_invalidate(context);
let item_height_upx = item_height.into_upx(context.gfx.scale());
&mut self,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
let item_count = self.item_count.get_tracking_invalidate(context);
let content_height = item_height * item_count as i32;
let content_height = content_height.into_upx(context.gfx.scale());
let width = self.width.get_tracking_invalidate(context);
let width = width.into_upx(context.gfx.scale());
let new_control_size = Size::new(
width,
constrain_child(available_space.height, content_height),
);
let vertical = self
.vertical_scroll
.make_if_needed()
.mounted(&mut context.as_event_context());
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
context.set_child_layout(
&vertical,
Rect::new(
Point::new(
available_space.width
.fit_measured(new_control_size.width)
.saturating_sub(scrollbar_layout.width)
.into_signed(),
Px::ZERO,
),
scrollbar_layout.into_signed(),
),
);
let scroll = self.scroll.get_tracking_invalidate(context);
let start_item = (scroll.y / item_height_upx).floor().get() as usize;
let end_item = ((scroll.y + new_control_size.height) / item_height_upx).ceil().get() as usize;
let end_item = end_item.min(item_count-1);
self.visible_range.set(start_item..end_item);
let first = self.items.front().map(|t| t.index);
let last = self.items.back().map(|t| t.index);
let mut closure = |index| {
let widget = self.virtual_list.widget_at(index);
let mut widget = widget.widget_ref();
let mounted = widget.mounted(&mut context.as_event_context());
VirtualListItem { index, mounted }
};
if self.items.is_empty() || first.unwrap() > end_item || last.unwrap() < start_item {
self.items.clear();
self.items.extend((start_item..=end_item).map(closure));
} else {
let first = first.expect("List is not empty");
let last = last.expect("List is not empty");
if first < start_item {
while self.items.front().is_some() && self.items.front().expect("Checked is some").index < start_item {
self.items.pop_front();
}
}
if last > end_item {
while self.items.back().is_some() && self.items.back().expect("Checked is some").index > end_item {
self.items.pop_back();
}
}
// no extend front :(
for item in (start_item..first).rev() {
self.items.push_front(closure(item));
}
self.items.extend(((last+1)..=end_item).map(closure));
if item_count == 0 {
return available_space.map(ConstraintLimit::min);
}
let item_size = Size::new(width, item_height_upx);
let constraint = item_size.map(ConstraintLimit::Fill);
for item in &self.items {
context.for_other(&item.mounted).layout(constraint);
}
let item_size = item_size.into_signed();
let scroll = self.scroll.get_tracking_invalidate(context).into_signed();
for item in &self.items {
context.set_child_layout(
&item.mounted,
Rect::new(
Point::new(Px::ZERO, (item_height_upx * item.index as f32).into_signed() - scroll.y),
item_size,
)
);
}
self.control_size.set(new_control_size);
self.content_size.set(Size::new(width, content_height));
new_control_size
self.layout_rows(item_count, available_space, context)
}
fn mouse_wheel(
@ -299,11 +367,3 @@ impl<T: VirtualListContent + Send + 'static> Widget for VirtualList<T> {
}
}
}
fn constrain_child(constraint: ConstraintLimit, measured: UPx) -> UPx {
match constraint {
ConstraintLimit::Fill(size) => size.min(measured),
// change from Scroll widget: returning just measured here would break the functionality (render too many items)
ConstraintLimit::SizeToFit(size) => size.min(measured),
}
}