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