Skip to main content

vizia_core/views/
virtual_list.rs

1use std::{
2    collections::BTreeSet,
3    ops::{Deref, Range},
4};
5
6use crate::prelude::*;
7
8/// A view for creating a list of items from a binding to an iteratable list. Rather than creating a view for each item, items are recycled in the list.
9pub struct VirtualList {
10    /// Whether the scrollbar should scroll to the cursor when pressed.
11    scroll_to_cursor: Signal<bool>,
12    /// Callback that is called when the list is scrolled.
13    on_scroll: Option<Box<dyn Fn(&mut EventContext, f32, f32) + Send + Sync>>,
14    /// The number of items in the list.
15    num_items: Signal<usize>,
16    /// The height of each item in the list.
17    item_height: f32,
18    /// The range of visible items in the list.
19    visible_range: Signal<Range<usize>>,
20    /// The horizontal scroll position of the list.
21    scroll_x: Signal<f32>,
22    /// The vertical scroll position of the list.
23    scroll_y: Signal<f32>,
24    /// Whether the horizontal scrollbar should be visible.
25    show_horizontal_scrollbar: Signal<bool>,
26    /// Whether the vertical scrollbar should be visible.
27    show_vertical_scrollbar: Signal<bool>,
28    /// The set of selected items in the list.
29    selection: Signal<BTreeSet<usize>>,
30    /// The selectable state of the list.
31    selectable: Signal<Selectable>,
32    /// The index of the currently focused item in the list.
33    focused: Signal<Option<usize>>,
34    /// Whether the selection should follow the focus.
35    selection_follows_focus: Signal<bool>,
36    /// Callback that is called when an item is selected.
37    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
38}
39
40impl VirtualList {
41    fn evaluate_index(index: usize, start: usize, end: usize) -> usize {
42        match end - start {
43            0 => 0,
44            len => start + (len - (start % len) + index) % len,
45        }
46    }
47
48    fn recalc(&self, cx: &mut EventContext) {
49        let num_items = self.num_items.get();
50        if num_items == 0 {
51            self.visible_range.set_if_changed(0..0);
52            return;
53        }
54
55        let current = cx.current();
56        let current_height = cx.cache.get_height(current);
57        if current_height == f32::MAX {
58            return;
59        }
60
61        let item_height = self.item_height;
62        let total_height = item_height * (num_items as f32);
63        let visible_height = current_height / cx.scale_factor();
64
65        let mut num_visible_items = (visible_height / item_height).ceil();
66        num_visible_items += 1.0; // To account for partially-visible items.
67
68        let visible_items_height = item_height * num_visible_items;
69        let empty_height = (total_height - visible_items_height).max(0.0);
70
71        // The pixel offsets within the container to the visible area.
72        let visible_start = empty_height * self.scroll_y.get();
73        let visible_end = visible_start + visible_items_height;
74
75        // The indices of the first and last item of the visible area.
76        let mut start_index = (visible_start / item_height).trunc() as usize;
77        let mut end_index = 1 + (visible_end / item_height).trunc() as usize;
78
79        // Ensure we always have (num_visible_items + 1) items when possible
80        let desired_range_size = (num_visible_items as usize) + 1;
81        end_index = end_index.min(num_items);
82
83        let current_range_size = end_index.saturating_sub(start_index);
84
85        if current_range_size < desired_range_size {
86            match end_index == num_items {
87                // Try to extend backwards if we're at the end of the list
88                true => {
89                    start_index =
90                        start_index.saturating_sub(desired_range_size - current_range_size);
91                }
92                // Try to extend forwards if we have room
93                false if end_index < num_items => {
94                    end_index = (start_index + desired_range_size).min(num_items);
95                }
96                _ => {}
97            }
98        }
99
100        self.visible_range.set_if_changed(start_index..end_index);
101    }
102}
103
104impl VirtualList {
105    /// Creates a new [VirtualList] view.
106    pub fn new<V: View, S, L, T>(
107        cx: &mut Context,
108        list: S,
109        item_height: f32,
110        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
111    ) -> Handle<Self>
112    where
113        S: Res<L> + 'static,
114        L: Deref<Target = [T]> + Clone + 'static,
115        T: Clone + PartialEq + 'static,
116    {
117        Self::new_generic(
118            cx,
119            list,
120            |list| list.len(),
121            |list, index| list[index].clone(),
122            item_height,
123            item_content,
124        )
125    }
126
127    /// Creates a new [VirtualList] view with a binding to the given source and a template for constructing the list items.
128    pub fn new_generic<V: View, S, L, T>(
129        cx: &mut Context,
130        list: S,
131        list_len: impl 'static + Fn(&L) -> usize,
132        list_index: impl 'static + Copy + Fn(&L, usize) -> T,
133        item_height: f32,
134        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
135    ) -> Handle<Self>
136    where
137        S: Res<L> + 'static,
138        L: Clone + 'static,
139        T: Clone + PartialEq + 'static,
140    {
141        let list = list.to_signal(cx);
142        let num_items = list.map(list_len).to_signal(cx);
143        let visible_range = Signal::new(0..0);
144        let scroll_x = Signal::new(0.0);
145        let scroll_y = Signal::new(0.0);
146        let show_horizontal_scrollbar = Signal::new(false);
147        let show_vertical_scrollbar = Signal::new(true);
148        let selection = Signal::new(BTreeSet::default());
149        let selectable = Signal::new(Selectable::None);
150        let focused = Signal::new(None);
151        let selection_follows_focus = Signal::new(false);
152        let scroll_to_cursor = Signal::new(true);
153
154        Self {
155            scroll_to_cursor,
156            on_scroll: None,
157            num_items,
158            item_height,
159            visible_range,
160            scroll_x,
161            scroll_y,
162            show_horizontal_scrollbar,
163            show_vertical_scrollbar,
164            selection,
165            selectable,
166            focused,
167            selection_follows_focus,
168            on_select: None,
169        }
170        .build(cx, |cx| {
171            Keymap::from(vec![
172                (
173                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
174                    KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
175                ),
176                (
177                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
178                    KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
179                ),
180                (
181                    KeyChord::new(Modifiers::empty(), Code::Space),
182                    KeymapEntry::new("Select Focused", |cx| cx.emit(ListEvent::SelectFocused)),
183                ),
184                (
185                    KeyChord::new(Modifiers::empty(), Code::Enter),
186                    KeymapEntry::new("Select Focused", |cx| cx.emit(ListEvent::SelectFocused)),
187                ),
188            ])
189            .build(cx);
190
191            ScrollView::new(cx, move |cx| {
192                Binding::new(cx, num_items, move |cx| {
193                    let num_items = num_items.get();
194                    cx.emit(ScrollEvent::SetY(0.0));
195                    // The ScrollView contains a VStack which is sized to the total height
196                    // needed to fit all items. This ensures we have a correct scroll bar.
197                    VStack::new(cx, |cx| {
198                        // Within the VStack we create a view for each visible item.
199                        // This binding ensures the amount of views stay up to date.
200                        let num_visible_items = visible_range.map(Range::len);
201                        Binding::new(cx, num_visible_items, move |cx| {
202                            for i in 0..num_visible_items.get().min(num_items) {
203                                // Each item of the range maps to an index into the backing list.
204                                // As we scroll the index may change, representing an item going in/out of visibility.
205                                // Wrap `item_content` in a binding to said index, so it rebuilds only when necessary.
206                                let item_index = visible_range.map(move |range| {
207                                    Self::evaluate_index(i, range.start, range.end)
208                                });
209                                Binding::new(cx, item_index, move |cx| {
210                                    let index = item_index.get();
211                                    let item = list.map(move |list| list_index(list, index));
212
213                                    ListItem::new(
214                                        cx,
215                                        index,
216                                        item,
217                                        selection,
218                                        focused,
219                                        move |cx, index, item| {
220                                            item_content(cx, index, item).height(Percentage(100.0));
221                                        },
222                                    )
223                                    .min_width(Auto)
224                                    .height(Pixels(item_height))
225                                    .position_type(PositionType::Absolute)
226                                    .bind(
227                                        item_index,
228                                        move |handle| {
229                                            let index = item_index.get();
230                                            handle.top(Pixels(index as f32 * item_height));
231                                        },
232                                    );
233                                });
234                            }
235                        })
236                    })
237                    .height(Pixels(num_items as f32 * item_height));
238                })
239            })
240            .show_horizontal_scrollbar(show_horizontal_scrollbar)
241            .show_vertical_scrollbar(show_vertical_scrollbar)
242            .scroll_to_cursor(scroll_to_cursor)
243            .scroll_x(scroll_x)
244            .scroll_y(scroll_y)
245            .on_scroll(|cx, x, y| {
246                if y.is_finite() && x.is_finite() {
247                    cx.emit(ListEvent::Scroll(x, y));
248                }
249            });
250        })
251        .toggle_class("selectable", selectable.map(|s| *s != Selectable::None))
252        .navigable(true)
253        .role(Role::ListBox)
254    }
255}
256
257impl View for VirtualList {
258    fn element(&self) -> Option<&'static str> {
259        Some("virtual-list")
260    }
261
262    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
263        event.take(|list_event, meta| match list_event {
264            ListEvent::Select(index) => {
265                cx.focus();
266                let selectable = self.selectable.get();
267                let mut selection = self.selection.get();
268                let mut focused = self.focused.get();
269
270                match selectable {
271                    Selectable::Single => {
272                        if selection.contains(&index) {
273                            selection.clear();
274                            focused = None;
275                        } else {
276                            selection.clear();
277                            selection.insert(index);
278                            focused = Some(index);
279                            if let Some(on_select) = &self.on_select {
280                                on_select(cx, index);
281                            }
282                        }
283                    }
284
285                    Selectable::Multi => {
286                        if selection.contains(&index) {
287                            selection.remove(&index);
288                            focused = None;
289                        } else {
290                            selection.insert(index);
291                            focused = Some(index);
292                            if let Some(on_select) = &self.on_select {
293                                on_select(cx, index);
294                            }
295                        }
296                    }
297
298                    Selectable::None => {}
299                }
300
301                self.selection.set(selection);
302                self.focused.set(focused);
303
304                meta.consume();
305            }
306
307            ListEvent::SelectFocused => {
308                if let Some(focused) = self.focused.get() {
309                    cx.emit(ListEvent::Select(focused))
310                }
311                meta.consume();
312            }
313
314            ListEvent::ClearSelection => {
315                self.selection.set(BTreeSet::default());
316                meta.consume();
317            }
318
319            ListEvent::FocusNext => {
320                let mut focused = self.focused.get();
321                let num_items = self.num_items.get();
322                if let Some(f) = &mut focused {
323                    if *f < num_items.saturating_sub(1) {
324                        *f = f.saturating_add(1);
325                        if self.selection_follows_focus.get() {
326                            cx.emit(ListEvent::SelectFocused);
327                        }
328                    }
329                } else {
330                    focused = Some(0);
331                    if self.selection_follows_focus.get() {
332                        cx.emit(ListEvent::SelectFocused);
333                    }
334                }
335
336                self.focused.set(focused);
337
338                meta.consume();
339            }
340
341            ListEvent::FocusPrev => {
342                let mut focused = self.focused.get();
343                let num_items = self.num_items.get();
344                if let Some(f) = &mut focused {
345                    if *f > 0 {
346                        *f = f.saturating_sub(1);
347                        if self.selection_follows_focus.get() {
348                            cx.emit(ListEvent::SelectFocused);
349                        }
350                    }
351                } else {
352                    focused = Some(num_items.saturating_sub(1));
353                    if self.selection_follows_focus.get() {
354                        cx.emit(ListEvent::SelectFocused);
355                    }
356                }
357
358                self.focused.set(focused);
359
360                meta.consume();
361            }
362
363            ListEvent::Scroll(x, y) => {
364                self.scroll_x.set(x);
365                self.scroll_y.set(y);
366
367                self.recalc(cx);
368
369                if let Some(callback) = &self.on_scroll {
370                    (callback)(cx, x, y);
371                }
372
373                meta.consume();
374            }
375        });
376
377        event.map(|window_event, _| match window_event {
378            WindowEvent::GeometryChanged(geo) => {
379                if geo.intersects(GeoChanged::WIDTH_CHANGED | GeoChanged::HEIGHT_CHANGED) {
380                    self.recalc(cx);
381                }
382            }
383
384            _ => {}
385        });
386    }
387}
388
389impl Handle<'_, VirtualList> {
390    /// Sets the selected items of the list from a signal of indices.
391    pub fn selection<R>(self, selection: impl Res<R> + 'static) -> Self
392    where
393        R: Deref<Target = [usize]> + Clone + 'static,
394    {
395        let selection = selection.to_signal(self.cx);
396        self.bind(selection, move |handle| {
397            selection.with(|selected_indices| {
398                handle.modify(|list| {
399                    let mut selection = BTreeSet::default();
400                    let mut focused = None;
401                    for idx in selected_indices.deref().iter().copied() {
402                        selection.insert(idx);
403                        focused = Some(idx);
404                    }
405                    list.selection.set(selection);
406                    list.focused.set(focused);
407                });
408            });
409        })
410    }
411
412    /// Sets the callback triggered when a [ListItem] is selected.
413    pub fn on_select<F>(self, callback: F) -> Self
414    where
415        F: 'static + Fn(&mut EventContext, usize),
416    {
417        self.modify(|list| list.on_select = Some(Box::new(callback)))
418    }
419
420    /// Set the selectable state of the [List].
421    pub fn selectable<U: Into<Selectable> + Clone + 'static>(
422        self,
423        selectable: impl Res<U> + 'static,
424    ) -> Self {
425        let selectable = selectable.to_signal(self.cx);
426        self.bind(selectable, move |handle| {
427            let selectable = selectable.get();
428            let s = selectable.into();
429            handle.modify(|list| list.selectable.set(s));
430        })
431    }
432
433    /// Sets whether the selection should follow the focus.
434    pub fn selection_follows_focus<U: Into<bool> + Clone + 'static>(
435        self,
436        flag: impl Res<U> + 'static,
437    ) -> Self {
438        let flag = flag.to_signal(self.cx);
439        self.bind(flag, move |handle| {
440            let selection_follows_focus = flag.get();
441            let s = selection_follows_focus.into();
442            handle.modify(|list| list.selection_follows_focus.set(s));
443        })
444    }
445
446    /// Sets whether the scrollbar should move to the cursor when pressed.
447    pub fn scroll_to_cursor(self, flag: bool) -> Self {
448        self.modify(|virtual_list: &mut VirtualList| {
449            virtual_list.scroll_to_cursor.set(flag);
450        })
451    }
452
453    /// Sets a callback which will be called when a scrollview is scrolled, either with the mouse wheel, touchpad, or using the scroll bars.
454    pub fn on_scroll(
455        self,
456        callback: impl Fn(&mut EventContext, f32, f32) + 'static + Send + Sync,
457    ) -> Self {
458        self.modify(|list| list.on_scroll = Some(Box::new(callback)))
459    }
460
461    /// Set the horizontal scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
462    pub fn scroll_x(self, scrollx: impl Res<f32> + 'static) -> Self {
463        let scrollx = scrollx.to_signal(self.cx);
464        self.bind(scrollx, move |handle| {
465            let sx = scrollx.get();
466            handle.modify(|list| list.scroll_x.set(sx));
467        })
468    }
469
470    /// Set the vertical scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
471    pub fn scroll_y(self, scrollx: impl Res<f32> + 'static) -> Self {
472        let scrollx = scrollx.to_signal(self.cx);
473        self.bind(scrollx, move |handle| {
474            let sy = scrollx.get();
475            handle.modify(|list| list.scroll_y.set(sy));
476        })
477    }
478
479    /// Sets whether the horizontal scrollbar should be visible.
480    pub fn show_horizontal_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
481        let flag = flag.to_signal(self.cx);
482        self.bind(flag, move |handle| {
483            let s = flag.get();
484            handle.modify(|list| list.show_horizontal_scrollbar.set(s));
485        })
486    }
487
488    /// Sets whether the vertical scrollbar should be visible.
489    pub fn show_vertical_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
490        let flag = flag.to_signal(self.cx);
491        self.bind(flag, move |handle| {
492            let s = flag.get();
493            handle.modify(|list| list.show_vertical_scrollbar.set(s));
494        })
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn evaluate_indices(range: Range<usize>) -> Vec<usize> {
503        (0..range.len())
504            .map(|index| VirtualList::evaluate_index(index, range.start, range.end))
505            .collect()
506    }
507
508    #[test]
509    fn test_evaluate_index() {
510        // Move forward by 0
511        assert_eq!(evaluate_indices(0..4), [0, 1, 2, 3]);
512        // Move forward by 1
513        assert_eq!(evaluate_indices(1..5), [4, 1, 2, 3]);
514        // Move forward by 2
515        assert_eq!(evaluate_indices(2..6), [4, 5, 2, 3]);
516        // Move forward by 3
517        assert_eq!(evaluate_indices(3..7), [4, 5, 6, 3]);
518        // Move forward by 4
519        assert_eq!(evaluate_indices(4..8), [4, 5, 6, 7]);
520        // Move forward by 5
521        assert_eq!(evaluate_indices(5..9), [8, 5, 6, 7]);
522        // Move forward by 6
523        assert_eq!(evaluate_indices(6..10), [8, 9, 6, 7]);
524        // Move forward by 7
525        assert_eq!(evaluate_indices(7..11), [8, 9, 10, 7]);
526        // Move forward by 8
527        assert_eq!(evaluate_indices(8..12), [8, 9, 10, 11]);
528        // Move forward by 9
529        assert_eq!(evaluate_indices(9..13), [12, 9, 10, 11]);
530    }
531}