From 4f742cd18bb56bfe89124751b896dddc0d0ce395 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Fri, 25 Oct 2024 22:22:15 +0200 Subject: [PATCH] add simple virtual list widget --- examples/virtual-list.rs | 36 ++ guide/guide-examples/examples/virtual-list.rs | 40 +++ guide/src/SUMMARY.md | 1 + guide/src/widgets/controls/virtual-list.md | 19 ++ src/widgets.rs | 1 + src/widgets/scroll.rs | 2 +- src/widgets/virtual_list.rs | 309 ++++++++++++++++++ 7 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 examples/virtual-list.rs create mode 100644 guide/guide-examples/examples/virtual-list.rs create mode 100644 guide/src/widgets/controls/virtual-list.md create mode 100644 src/widgets/virtual_list.rs diff --git a/examples/virtual-list.rs b/examples/virtual-list.rs new file mode 100644 index 0000000..35c9637 --- /dev/null +++ b/examples/virtual-list.rs @@ -0,0 +1,36 @@ +use cushy::styles::Dimension; +use cushy::widget::MakeWidget; +use cushy::widgets::virtual_list::{VirtualList, VirtualListContent}; +use cushy::Run; +use figures::units::Lp; + +#[derive(Debug)] +struct TestVirtualList; + +impl VirtualListContent for TestVirtualList { + fn item_count(&self) -> impl cushy::value::IntoValue { + 50 + } + fn item_height(&self) -> impl cushy::value::IntoValue { + 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 { + Dimension::Lp(Lp::inches_f(10.)) + } +} + +fn list() -> impl MakeWidget { + VirtualList::new(TestVirtualList).expand() +} + +fn main() -> cushy::Result { + list().run() +} + +#[test] +fn runs() { + cushy::example!(list).untested_still_frame(); +} diff --git a/guide/guide-examples/examples/virtual-list.rs b/guide/guide-examples/examples/virtual-list.rs new file mode 100644 index 0000000..3781511 --- /dev/null +++ b/guide/guide-examples/examples/virtual-list.rs @@ -0,0 +1,40 @@ +use cushy::figures::units::Lp; +use cushy::styles::Dimension; +use cushy::widget::MakeWidget; +use cushy::widgets::virtual_list::{VirtualList, VirtualListContent}; +use cushy::Run; + +// ANCHOR: implementation +#[derive(Debug)] +struct TestVirtualList; + +impl VirtualListContent for TestVirtualList { + fn item_count(&self) -> impl cushy::value::IntoValue { + 50 + } + fn item_height(&self) -> impl cushy::value::IntoValue { + 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 { + Dimension::Lp(Lp::inches_f(10.)) + } +} +// ANCHOR_END: implementation + +// ANCHOR: list +fn list() -> impl MakeWidget { + VirtualList::new(TestVirtualList).expand() +} +// ANCHOR_END: list + +fn main() -> cushy::Result { + list().run() +} + +#[test] +fn runs() { + cushy::example!(list).untested_still_frame(); +} diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index 73c2528..288a0f5 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -42,6 +42,7 @@ - [Switcher](./widgets/controls/switcher.md) - [TileMap](./widgets/controls/tilemap.md) - [Validated](./widgets/controls/validated.md) + - [VirtualList](./widgets/controls/virtual-list.md) - [Utility Widgets](./widgets/utility.md) - [Custom](./widgets/utility/custom.md) - [Data](./widgets/utility/data.md) diff --git a/guide/src/widgets/controls/virtual-list.md b/guide/src/widgets/controls/virtual-list.md new file mode 100644 index 0000000..b7f069f --- /dev/null +++ b/guide/src/widgets/controls/virtual-list.md @@ -0,0 +1,19 @@ +# Virtual List + +The [VirtualList] widget allows efficient rendering of long lists of items. +It currently only supports the simplest form - a known width, equal heights of each item and a known item count, though all of the values are reactive, as is the custom in cushy. + +You can create a widget by implementing the [VirtualListContent] trait: + +```rust,no_run,no_playground +{{#include ../../../guide-examples/examples/virtual-list.rs:implementation}} +``` + +And then using it as any other widget in your functions + +```rust,no_run,no_playground +{{#include ../../../guide-examples/examples/virtual-list.rs:list}} +``` + +[VirtualList]: <{{ docs }}/widgets/virtual_list/struct.VirtualList.html> +[VirtualListContent]: <{{ docs }}/widgets/virtual_list/struct.VirtualListContent.html> diff --git a/src/widgets.rs b/src/widgets.rs index 91f77e0..054c870 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -25,6 +25,7 @@ pub mod progress; pub mod radio; mod resize; pub mod scroll; +pub mod virtual_list; pub mod select; pub mod shortcuts; pub mod slider; diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index 0e312dc..bfbec92 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -25,7 +25,7 @@ use crate::ConstraintLimit; // TODO is this useful enough to make public? #[derive(Debug)] -struct OwnedWidget(OwnedWidgetState); +pub(crate) struct OwnedWidget(OwnedWidgetState); #[derive(Debug)] enum OwnedWidgetState { diff --git a/src/widgets/virtual_list.rs b/src/widgets/virtual_list.rs new file mode 100644 index 0000000..b7bd93c --- /dev/null +++ b/src/widgets/virtual_list.rs @@ -0,0 +1,309 @@ +use std::{collections::VecDeque, fmt::Debug, 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 super::scroll::OwnedWidget; + +/// 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; + /// Width of the items + fn width(&self) -> impl IntoValue; + /// Number of items + fn item_count(&self) -> impl IntoValue; + /// 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 VirtualListItem { + index: usize, + mounted: MountedWidget, +} + +#[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 { + virtual_list: T, + vertical_scroll: OwnedWidget, + items: VecDeque, + content_size: Dynamic>, + /// Maximum scroll value - max_scroll.y + control_size.height should be the height of the content. + pub max_scroll: DynamicReader>, + /// Current scroll value. The x value is always 0. Change the value to scroll the widget programmatically. + pub scroll: Dynamic>, + control_size: Dynamic>, + + /// Height of an item. Based on [VirtualListContent::item_height]. + pub item_height: DynamicReader, + /// Width of the items. Based on [VirtualListContent::width]. + pub width: DynamicReader, + /// Number of items. Based on [VirtualListContent::item_count]. + pub item_count: DynamicReader, + + visible_range: Dynamic> +} + +impl VirtualList { + /// Creates a new [VirtualList] based on the given [VirtualListContent]. + pub fn new(virtual_list: T) -> Self { + let scroll = Dynamic::>::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 content_size = Dynamic::new(Size::default()); + + let y = scroll.map_each_cloned(|scroll| scroll.y); + y.for_each_cloned({ + let scroll = scroll.clone(); + move |y| { + if let Ok(mut scroll) = scroll.try_lock() { + if scroll.y != y { + scroll.y = y; + } + } + } + }) + .persist(); + 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, + vertical_scroll: OwnedWidget::new(vertical), + items: VecDeque::new(), + control_size: Dynamic::new(Size::default()), + content_size, + max_scroll, + scroll, + + item_height, + width, + item_count, + visible_range: Default::default() + } + } + + /// Returns a reader for the maximum scroll value. + /// + /// This represents the maximum amount that the scroll can be moved by. + #[must_use] + pub const fn max_scroll(&self) -> &DynamicReader> { + &self.max_scroll + } + + /// Returns a reader for the size of the scrollable area. + #[must_use] + pub fn content_size(&self) -> DynamicReader> { + self.content_size.create_reader() + } + + /// Returns a reader for the size of this Scroll widget. + #[must_use] + pub fn control_size(&self) -> DynamicReader> { + self.control_size.create_reader() + } + + /// Returns a reader for number of visible items. 0 indexed. + #[must_use] + pub fn visible_range(&self) -> DynamicReader> { + self.visible_range.create_reader() + } + + fn show_scrollbars(&mut self, context: &mut EventContext<'_>) { + let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock(); + vertical + .downcast_mut::() + .expect("a ScrollBar") + .show(context); + } + + fn hide_scrollbars(&mut self, context: &mut EventContext<'_>) { + let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock(); + vertical + .downcast_mut::() + .expect("a ScrollBar") + .hide(context); + } +} + +impl Widget for VirtualList { + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_>) -> bool { + true + } + + fn hover( + &mut self, + _location: Point, + context: &mut EventContext<'_>, + ) -> Option { + self.show_scrollbars(context); + + None + } + + fn unhover(&mut self, context: &mut EventContext<'_>) { + self.hide_scrollbars(context); + } + + fn mounted(&mut self, context: &mut EventContext<'_>) { + for child in &mut self.items { + child.mounted.remount_if_needed(context); + } + } + + fn redraw(&mut self, context: &mut cushy::context::GraphicsContext<'_, '_, '_, '_>) { + for child in &mut self.items { + context.for_other(&child.mounted).redraw(); + } + let vertical = self + .vertical_scroll + .expect_made_mut() + .mounted(&mut context.as_event_context()); + context.for_other(&vertical).redraw(); + } + + fn layout( + &mut self, + available_space: Size, + context: &mut cushy::context::LayoutContext<'_, '_, '_, '_>, + ) -> Size { + let item_height = self.item_height.get_tracking_invalidate(context); + let item_height_upx = item_height.into_upx(context.gfx.scale()); + 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)); + } + + 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 + } + + fn mouse_wheel( + &mut self, + _device_id: DeviceId, + delta: MouseScrollDelta, + _phase: TouchPhase, + context: &mut EventContext<'_>, + ) -> EventHandling { + let mut handled = false; + { + let mut vertical = self.vertical_scroll.expect_made().widget().lock(); + handled |= vertical + .downcast_mut::() + .expect("a ScrollBar") + .mouse_wheel(delta, context) + .is_break(); + } + if handled { + self.show_scrollbars(context); + context.set_needs_redraw(); + + HANDLED + } else { + IGNORED + } + } +} + +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), + } +} \ No newline at end of file