Skip to main content

vizia_core/views/
virtual_list.rs

1use std::{
2    collections::BTreeSet,
3    ops::{Deref, Range},
4    time::{Duration, Instant},
5};
6
7use crate::prelude::*;
8
9/// 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.
10pub struct VirtualList {
11    /// Whether the scrollbar should scroll to the cursor when pressed.
12    scroll_to_cursor: Signal<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: Signal<usize>,
17    /// The extent of each item in the list (height for vertical, width for horizontal).
18    item_height: f32,
19    /// The orientation of the list, either vertical or horizontal.
20    orientation: Signal<Orientation>,
21    /// The range of visible items in the list.
22    visible_range: Signal<Range<usize>>,
23    /// The horizontal scroll position of the list.
24    scroll_x: Signal<f32>,
25    /// The vertical scroll position of the list.
26    scroll_y: Signal<f32>,
27    /// Whether the horizontal scrollbar should be visible.
28    show_horizontal_scrollbar: Signal<bool>,
29    /// Whether the vertical scrollbar should be visible.
30    show_vertical_scrollbar: Signal<bool>,
31    /// The set of selected items in the list.
32    selection: Signal<BTreeSet<usize>>,
33    /// The selectable state of the list.
34    selectable: Signal<Selectable>,
35    /// The index of the currently focused item in the list.
36    focused: Signal<Option<usize>>,
37    /// Whether focused list items should show focus visibility.
38    focus_visibility: Signal<bool>,
39    /// Whether the selection should follow the focus.
40    selection_follows_focus: Signal<bool>,
41    /// Whether pressing Space should select the focused item.
42    space_selects_focused: Signal<bool>,
43    /// Minimum number of selected items.
44    min_selected: Signal<usize>,
45    /// Maximum number of selected items.
46    max_selected: Signal<usize>,
47    /// Whether the first item should be focused when the list gains focus with no selection.
48    focus_first_item_on_focus_in: Signal<bool>,
49    /// Callback that is called when an item is selected.
50    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
51    /// Callback called when the focused list item changes.
52    on_focus: Option<Box<dyn Fn(&mut EventContext, usize)>>,
53    /// Returns the searchable text for each item index when type-ahead is enabled.
54    type_ahead_text: Option<Box<dyn Fn(&mut EventContext, usize) -> Option<String>>>,
55    /// Buffered type-ahead query built from rapid character input.
56    type_ahead_buffer: String,
57    /// Timestamp of the last accepted type-ahead character.
58    type_ahead_last_input: Option<Instant>,
59    /// Maximum elapsed time before resetting type-ahead buffer.
60    type_ahead_timeout: Duration,
61}
62
63impl VirtualList {
64    fn set_focused_with_callback(&mut self, cx: &mut EventContext, focused: Option<usize>) {
65        let previous = self.focused.get();
66        self.focused.set(focused);
67
68        if previous != focused {
69            if let (Some(index), Some(callback)) = (focused, self.on_focus.as_ref()) {
70                callback(cx, index);
71            }
72        }
73    }
74
75    fn find_type_ahead_match(
76        &self,
77        cx: &mut EventContext,
78        query: &str,
79        start_index: usize,
80    ) -> Option<usize> {
81        let get_text = self.type_ahead_text.as_ref()?;
82        let num_items = self.num_items.get();
83        if num_items == 0 {
84            return None;
85        }
86
87        for offset in 0..num_items {
88            let index = (start_index + offset) % num_items;
89            let item_text = get_text(cx, index)
90                .map(|text| text.trim_start().to_lowercase())
91                .unwrap_or_default();
92
93            if !item_text.is_empty() && item_text.starts_with(query) {
94                return Some(index);
95            }
96        }
97
98        None
99    }
100
101    fn try_type_ahead(&mut self, cx: &mut EventContext, typed: char) -> bool {
102        if self.type_ahead_text.is_none() {
103            return false;
104        }
105
106        let num_items = self.num_items.get();
107        if num_items == 0 || typed.is_control() || typed.is_whitespace() {
108            return false;
109        }
110
111        let now = Instant::now();
112        let within_timeout = self
113            .type_ahead_last_input
114            .is_some_and(|last| now.saturating_duration_since(last) <= self.type_ahead_timeout);
115
116        let ch = typed.to_lowercase().collect::<String>();
117        let query = if within_timeout {
118            let repeated_char_cycle = !self.type_ahead_buffer.is_empty()
119                && self.type_ahead_buffer.chars().all(|c| c == typed.to_ascii_lowercase());
120
121            if repeated_char_cycle {
122                ch.clone()
123            } else {
124                format!("{}{}", self.type_ahead_buffer, ch)
125            }
126        } else {
127            ch.clone()
128        };
129
130        let start_index = self.focused.get().map(|focused| (focused + 1) % num_items).unwrap_or(0);
131
132        if let Some(index) = self.find_type_ahead_match(cx, &query, start_index) {
133            self.type_ahead_buffer = query;
134            self.type_ahead_last_input = Some(now);
135            self.focus_visibility.set(true);
136            self.set_focused_with_callback(cx, Some(index));
137
138            if self.selection_follows_focus.get() {
139                cx.emit(ListEvent::SelectFocused);
140            }
141
142            true
143        } else {
144            self.type_ahead_buffer.clear();
145            self.type_ahead_last_input = Some(now);
146            false
147        }
148    }
149
150    fn evaluate_index(index: usize, start: usize, end: usize) -> usize {
151        let len = end.saturating_sub(start);
152        if len == 0 { 0 } else { start + ((index + len - (start % len)) % len) }
153    }
154
155    /// Builds a memo that resolves the item at `index` from the backing list,
156    /// guarding against stale indices.
157    ///
158    /// Recycled items keep a captured `index` and re-evaluate whenever the
159    /// backing list changes. When the list shrinks (or empties) the item can
160    /// briefly reference an out-of-bounds index before the enclosing binding
161    /// rebuilds and removes it, so the index is clamped to the current length
162    /// and the previous value is reused while the list is transiently empty.
163    fn resolve_item<L, T>(
164        list: impl SignalWith<L> + Copy + 'static,
165        index: usize,
166        list_len: impl 'static + Fn(&L) -> usize,
167        list_index: impl 'static + Fn(&L, usize) -> T,
168    ) -> Memo<T>
169    where
170        L: 'static,
171        T: Clone + PartialEq + 'static,
172    {
173        Memo::new(move |prev| {
174            list.with(|list| {
175                let len = list_len(list);
176                if len == 0 {
177                    // The list emptied while this recycled item still references
178                    // a stale index; keep the previous value until it is removed.
179                    prev.cloned().unwrap_or_else(|| list_index(list, 0))
180                } else {
181                    list_index(list, index.min(len - 1))
182                }
183            })
184        })
185    }
186
187    fn recalc(&self, cx: &mut EventContext) {
188        let num_items = self.num_items.get();
189        if num_items == 0 {
190            self.visible_range.set_if_changed(0..0);
191            return;
192        }
193
194        let current = cx.current();
195        let current_extent = match self.orientation.get() {
196            Orientation::Horizontal => cx.cache.get_width(current),
197            Orientation::Vertical => cx.cache.get_height(current),
198        };
199        if current_extent == f32::MAX {
200            return;
201        }
202
203        let item_extent = self.item_height;
204        let total_extent = item_extent * (num_items as f32);
205        let visible_extent = current_extent / cx.scale_factor();
206
207        let mut num_visible_items = (visible_extent / item_extent).ceil();
208        num_visible_items += 1.0; // To account for partially-visible items.
209
210        let visible_items_extent = item_extent * num_visible_items;
211        let empty_extent = (total_extent - visible_items_extent).max(0.0);
212
213        // The pixel offsets within the container to the visible area.
214        let axis_scroll = match self.orientation.get() {
215            Orientation::Horizontal => self.scroll_x.get(),
216            Orientation::Vertical => self.scroll_y.get(),
217        };
218        let visible_start = empty_extent * axis_scroll;
219        let visible_end = visible_start + visible_items_extent;
220
221        // The indices of the first and last item of the visible area.
222        let mut start_index = (visible_start / item_extent).trunc() as usize;
223        let mut end_index = 1 + (visible_end / item_extent).trunc() as usize;
224
225        // Ensure we always have (num_visible_items + 1) items when possible
226        let desired_range_size = (num_visible_items as usize) + 1;
227        end_index = end_index.min(num_items);
228
229        let current_range_size = end_index.saturating_sub(start_index);
230
231        if current_range_size < desired_range_size {
232            match end_index == num_items {
233                // Try to extend backwards if we're at the end of the list
234                true => {
235                    start_index =
236                        start_index.saturating_sub(desired_range_size - current_range_size);
237                }
238                // Try to extend forwards if we have room
239                false if end_index < num_items => {
240                    end_index = (start_index + desired_range_size).min(num_items);
241                }
242                _ => {}
243            }
244        }
245
246        self.visible_range.set_if_changed(start_index..end_index);
247    }
248
249    fn selection_limits(&self) -> (usize, usize) {
250        let mut min_selected = self.min_selected.get();
251        let mut max_selected = self.max_selected.get();
252
253        match self.selectable.get() {
254            Selectable::None => {
255                min_selected = 0;
256                max_selected = 0;
257            }
258
259            Selectable::Single => {
260                min_selected = min_selected.min(1);
261                max_selected = 1;
262            }
263
264            Selectable::Multi => {}
265        }
266
267        max_selected = max_selected.min(self.num_items.get());
268        min_selected = min_selected.min(max_selected);
269
270        (min_selected, max_selected)
271    }
272
273    fn normalize_selection_state(&mut self) {
274        let (min_selected, max_selected) = self.selection_limits();
275        let num_items = self.num_items.get();
276
277        let mut selection = self.selection.get();
278        selection.retain(|index| *index < num_items);
279
280        while selection.len() > max_selected {
281            if let Some(last) = selection.iter().next_back().copied() {
282                selection.remove(&last);
283            } else {
284                break;
285            }
286        }
287
288        if selection.len() < min_selected {
289            for index in 0..num_items {
290                selection.insert(index);
291                if selection.len() >= min_selected {
292                    break;
293                }
294            }
295        }
296
297        let mut focused = self.focused.get();
298        if focused.is_some_and(|index| index >= num_items) {
299            focused = num_items.checked_sub(1);
300        }
301
302        self.selection.set(selection);
303        self.focused.set(focused);
304    }
305}
306
307impl VirtualList {
308    /// Creates a new [VirtualList] view.
309    pub fn new<V: View, S, L, T>(
310        cx: &mut Context,
311        list: S,
312        item_height: f32,
313        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
314    ) -> Handle<Self>
315    where
316        S: Res<L> + 'static,
317        L: Deref<Target = [T]> + Clone + 'static,
318        T: Clone + PartialEq + 'static,
319    {
320        Self::new_generic(
321            cx,
322            list,
323            |list| list.len(),
324            |list, index| {
325                list.get(index).cloned().unwrap_or_else(|| {
326                    list.last().cloned().expect("virtual list item requested from empty list")
327                })
328            },
329            item_height,
330            item_content,
331        )
332    }
333
334    /// Creates a new [VirtualList] view using caller-provided item views and selection state.
335    pub fn new_custom_items_with_selection<V: View, S, L, T>(
336        cx: &mut Context,
337        list: S,
338        item_height: f32,
339        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>, Memo<bool>) -> Handle<V>,
340    ) -> Handle<Self>
341    where
342        S: Res<L> + 'static,
343        L: Deref<Target = [T]> + Clone + 'static,
344        T: Clone + PartialEq + 'static,
345    {
346        Self::new_generic_custom_items_with_selection(
347            cx,
348            list,
349            |list| list.len(),
350            |list, index| {
351                list.get(index).cloned().unwrap_or_else(|| {
352                    list.last().cloned().expect("virtual list item requested from empty list")
353                })
354            },
355            item_height,
356            item_content,
357        )
358    }
359
360    /// Creates a new [VirtualList] view using caller-provided item views directly,
361    /// without an explicit selected-state memo.
362    pub fn new_custom_items<V: View, S, L, T>(
363        cx: &mut Context,
364        list: S,
365        item_height: f32,
366        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
367    ) -> Handle<Self>
368    where
369        S: Res<L> + 'static,
370        L: Deref<Target = [T]> + Clone + 'static,
371        T: Clone + PartialEq + 'static,
372    {
373        Self::new_custom_items_with_selection(cx, list, item_height, move |cx, index, item, _| {
374            item_content(cx, index, item)
375        })
376    }
377
378    /// Creates a new [VirtualList] view with a binding to the given source and a template for constructing the list items.
379    pub fn new_generic<V: View, S, L, T>(
380        cx: &mut Context,
381        list: S,
382        list_len: impl 'static + Copy + Fn(&L) -> usize,
383        list_index: impl 'static + Copy + Fn(&L, usize) -> T,
384        item_height: f32,
385        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
386    ) -> Handle<Self>
387    where
388        S: Res<L> + 'static,
389        L: Clone + 'static,
390        T: Clone + PartialEq + 'static,
391    {
392        let list = list.to_signal(cx);
393        let num_items = list.map(list_len).to_signal(cx);
394        let visible_range = Signal::new(0..0);
395        let scroll_x = Signal::new(0.0);
396        let scroll_y = Signal::new(0.0);
397        let show_horizontal_scrollbar = Signal::new(true);
398        let show_vertical_scrollbar = Signal::new(true);
399        let orientation = Signal::new(Orientation::Vertical);
400        let selection = Signal::new(BTreeSet::default());
401        let selectable = Signal::new(Selectable::None);
402        let focused = Signal::new(None);
403        let focus_visibility = Signal::new(false);
404        let selection_follows_focus = Signal::new(false);
405        let scroll_to_cursor = Signal::new(false);
406        let min_selected = Signal::new(0);
407        let max_selected = Signal::new(usize::MAX);
408        let focus_first_item_on_focus_in = Signal::new(true);
409
410        Self {
411            scroll_to_cursor,
412            on_scroll: None,
413            num_items,
414            item_height,
415            orientation,
416            visible_range,
417            scroll_x,
418            scroll_y,
419            show_horizontal_scrollbar,
420            show_vertical_scrollbar,
421            selection,
422            selectable,
423            focused,
424            focus_visibility,
425            selection_follows_focus,
426            space_selects_focused: Signal::new(true),
427            min_selected,
428            max_selected,
429            focus_first_item_on_focus_in,
430            on_select: None,
431            on_focus: None,
432            type_ahead_text: None,
433            type_ahead_buffer: String::new(),
434            type_ahead_last_input: None,
435            type_ahead_timeout: Duration::from_millis(1000),
436        }
437        .build(cx, |cx| {
438            Keymap::from(vec![
439                (
440                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
441                    KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
442                ),
443                (
444                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
445                    KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
446                ),
447                (
448                    KeyChord::new(Modifiers::empty(), Code::Home),
449                    KeymapEntry::new("Focus First", |cx| cx.emit(ListEvent::FocusFirst)),
450                ),
451                (
452                    KeyChord::new(Modifiers::empty(), Code::End),
453                    KeymapEntry::new("Focus Last", |cx| cx.emit(ListEvent::FocusLast)),
454                ),
455                (
456                    KeyChord::new(Modifiers::empty(), Code::Enter),
457                    KeymapEntry::new("Select Focused", |cx| cx.emit(ListEvent::SelectFocused)),
458                ),
459            ])
460            .build(cx);
461
462            Binding::new(cx, orientation, move |cx| {
463                let orientation = orientation.get();
464                if orientation == Orientation::Horizontal {
465                    cx.emit(KeymapEvent::RemoveAction(
466                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
467                        "Focus Next",
468                    ));
469
470                    cx.emit(KeymapEvent::RemoveAction(
471                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
472                        "Focus Previous",
473                    ));
474
475                    cx.emit(KeymapEvent::InsertAction(
476                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
477                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
478                    ));
479
480                    cx.emit(KeymapEvent::InsertAction(
481                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
482                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
483                    ));
484                } else {
485                    cx.emit(KeymapEvent::RemoveAction(
486                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
487                        "Focus Next",
488                    ));
489
490                    cx.emit(KeymapEvent::RemoveAction(
491                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
492                        "Focus Previous",
493                    ));
494
495                    cx.emit(KeymapEvent::InsertAction(
496                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
497                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
498                    ));
499
500                    cx.emit(KeymapEvent::InsertAction(
501                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
502                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
503                    ));
504                }
505            });
506
507            ScrollView::new(cx, move |cx| {
508                Binding::new(cx, orientation, move |cx| {
509                    let orientation = orientation.get();
510                    Binding::new(cx, num_items, move |cx| {
511                        let num_items = num_items.get();
512
513                        match orientation {
514                            Orientation::Horizontal => cx.emit(ScrollEvent::SetX(0.0)),
515                            Orientation::Vertical => cx.emit(ScrollEvent::SetY(0.0)),
516                        }
517
518                        let num_visible_items = visible_range.map(Range::len);
519
520                        match orientation {
521                            Orientation::Horizontal => {
522                                HStack::new(cx, |cx| {
523                                    Binding::new(cx, num_visible_items, move |cx| {
524                                        for i in 0..num_visible_items.get().min(num_items) {
525                                            let item_index = visible_range.map(move |range| {
526                                                Self::evaluate_index(i, range.start, range.end)
527                                            });
528                                            Binding::new(cx, item_index, move |cx| {
529                                                let index = item_index.get();
530                                                let item = Self::resolve_item(
531                                                    list, index, list_len, list_index,
532                                                );
533
534                                                ListItem::new(
535                                                    cx,
536                                                    index,
537                                                    item,
538                                                    selection,
539                                                    focused,
540                                                    focus_visibility,
541                                                    move |cx, index, item| {
542                                                        item_content(cx, index, item)
543                                                            .height(Percentage(100.0));
544                                                    },
545                                                )
546                                                .min_size(Auto)
547                                                .width(Pixels(item_height))
548                                                .height(Percentage(100.0))
549                                                .position_type(PositionType::Absolute)
550                                                .bind(item_index, move |handle| {
551                                                    let index = item_index.get();
552                                                    handle.left(Pixels(index as f32 * item_height));
553                                                });
554                                            });
555                                        }
556                                    })
557                                })
558                                .width(Pixels(num_items as f32 * item_height))
559                                .height(Stretch(1.0));
560                            }
561
562                            Orientation::Vertical => {
563                                VStack::new(cx, |cx| {
564                                    Binding::new(cx, num_visible_items, move |cx| {
565                                        for i in 0..num_visible_items.get().min(num_items) {
566                                            let item_index = visible_range.map(move |range| {
567                                                Self::evaluate_index(i, range.start, range.end)
568                                            });
569                                            Binding::new(cx, item_index, move |cx| {
570                                                let index = item_index.get();
571                                                let item = Self::resolve_item(
572                                                    list, index, list_len, list_index,
573                                                );
574
575                                                ListItem::new(
576                                                    cx,
577                                                    index,
578                                                    item,
579                                                    selection,
580                                                    focused,
581                                                    focus_visibility,
582                                                    move |cx, index, item| {
583                                                        item_content(cx, index, item)
584                                                            .height(Percentage(100.0));
585                                                    },
586                                                )
587                                                .min_width(Auto)
588                                                .height(Pixels(item_height))
589                                                .position_type(PositionType::Absolute)
590                                                .bind(item_index, move |handle| {
591                                                    let index = item_index.get();
592                                                    handle.top(Pixels(index as f32 * item_height));
593                                                });
594                                            });
595                                        }
596                                    })
597                                })
598                                .height(Pixels(num_items as f32 * item_height));
599                            }
600                        }
601                    })
602                })
603            })
604            .show_horizontal_scrollbar(show_horizontal_scrollbar)
605            .show_vertical_scrollbar(show_vertical_scrollbar)
606            .scroll_to_cursor(scroll_to_cursor)
607            .scroll_x(scroll_x)
608            .scroll_y(scroll_y)
609            .on_scroll(|cx, x, y| {
610                if y.is_finite() && x.is_finite() {
611                    cx.emit(ListEvent::Scroll(x, y));
612                }
613            });
614        })
615        .toggle_class("selectable", selectable.map(|s| *s != Selectable::None))
616        .multiselectable(selectable.map(|s| *s == Selectable::Multi))
617        .orientation(orientation)
618        .navigable(true)
619        .role(Role::ListBox)
620    }
621
622    /// Creates a new [VirtualList] view with custom item semantics and selected-state memo.
623    pub fn new_generic_custom_items_with_selection<V: View, S, L, T>(
624        cx: &mut Context,
625        list: S,
626        list_len: impl 'static + Fn(&L) -> usize,
627        list_index: impl 'static + Copy + Fn(&L, usize) -> T,
628        item_height: f32,
629        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>, Memo<bool>) -> Handle<V>,
630    ) -> Handle<Self>
631    where
632        S: Res<L> + 'static,
633        L: Clone + 'static,
634        T: Clone + PartialEq + 'static,
635    {
636        let list = list.to_signal(cx);
637        let num_items = list.map(list_len).to_signal(cx);
638        let visible_range = Signal::new(0..0);
639        let scroll_x = Signal::new(0.0);
640        let scroll_y = Signal::new(0.0);
641        let show_horizontal_scrollbar = Signal::new(true);
642        let show_vertical_scrollbar = Signal::new(true);
643        let orientation = Signal::new(Orientation::Vertical);
644        let selection = Signal::new(BTreeSet::default());
645        let selectable = Signal::new(Selectable::None);
646        let focused = Signal::new(None);
647        let focus_visibility = Signal::new(false);
648        let selection_follows_focus = Signal::new(false);
649        let scroll_to_cursor = Signal::new(false);
650        let min_selected = Signal::new(0);
651        let max_selected = Signal::new(usize::MAX);
652        let focus_first_item_on_focus_in = Signal::new(true);
653
654        Self {
655            scroll_to_cursor,
656            on_scroll: None,
657            num_items,
658            item_height,
659            orientation,
660            visible_range,
661            scroll_x,
662            scroll_y,
663            show_horizontal_scrollbar,
664            show_vertical_scrollbar,
665            selection,
666            selectable,
667            focused,
668            focus_visibility,
669            selection_follows_focus,
670            space_selects_focused: Signal::new(true),
671            min_selected,
672            max_selected,
673            focus_first_item_on_focus_in,
674            on_select: None,
675            on_focus: None,
676            type_ahead_text: None,
677            type_ahead_buffer: String::new(),
678            type_ahead_last_input: None,
679            type_ahead_timeout: Duration::from_millis(1000),
680        }
681        .build(cx, |cx| {
682            Keymap::from(vec![
683                (
684                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
685                    KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
686                ),
687                (
688                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
689                    KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
690                ),
691                (
692                    KeyChord::new(Modifiers::empty(), Code::Home),
693                    KeymapEntry::new("Focus First", |cx| cx.emit(ListEvent::FocusFirst)),
694                ),
695                (
696                    KeyChord::new(Modifiers::empty(), Code::End),
697                    KeymapEntry::new("Focus Last", |cx| cx.emit(ListEvent::FocusLast)),
698                ),
699                (
700                    KeyChord::new(Modifiers::empty(), Code::Enter),
701                    KeymapEntry::new("Select Focused", |cx| cx.emit(ListEvent::SelectFocused)),
702                ),
703            ])
704            .build(cx);
705
706            Binding::new(cx, orientation, move |cx| {
707                let orientation = orientation.get();
708                if orientation == Orientation::Horizontal {
709                    cx.emit(KeymapEvent::RemoveAction(
710                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
711                        "Focus Next",
712                    ));
713
714                    cx.emit(KeymapEvent::RemoveAction(
715                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
716                        "Focus Previous",
717                    ));
718
719                    cx.emit(KeymapEvent::InsertAction(
720                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
721                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
722                    ));
723
724                    cx.emit(KeymapEvent::InsertAction(
725                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
726                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
727                    ));
728                } else {
729                    cx.emit(KeymapEvent::RemoveAction(
730                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
731                        "Focus Next",
732                    ));
733
734                    cx.emit(KeymapEvent::RemoveAction(
735                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
736                        "Focus Previous",
737                    ));
738
739                    cx.emit(KeymapEvent::InsertAction(
740                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
741                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
742                    ));
743
744                    cx.emit(KeymapEvent::InsertAction(
745                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
746                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
747                    ));
748                }
749            });
750
751            ScrollView::new(cx, move |cx| {
752                Binding::new(cx, num_items, move |cx| {
753                    let num_items = num_items.get();
754                    cx.emit(ScrollEvent::SetY(0.0));
755                    VStack::new(cx, |cx| {
756                        let num_visible_items = visible_range.map(Range::len);
757                        Binding::new(cx, num_visible_items, move |cx| {
758                            for i in 0..num_visible_items.get().min(num_items) {
759                                let item_index = visible_range.map(move |range| {
760                                    Self::evaluate_index(i, range.start, range.end)
761                                });
762                                Binding::new(cx, item_index, move |cx| {
763                                    let max_index = num_items.saturating_sub(1);
764                                    let index = item_index.get().min(max_index);
765                                    let item = list.map(move |list| list_index(list, index));
766                                    let is_selected =
767                                        selection.map(move |selection| selection.contains(&index));
768                                    let is_focused = focused
769                                        .map(move |focused| focused.is_some_and(|f| f == index));
770
771                                    item_content(cx, index, item, is_selected)
772                                        .focusable(true)
773                                        .navigable(false)
774                                        .focused_with_visibility(is_focused, focus_visibility)
775                                        .on_press(move |cx| cx.emit(ListEvent::Select(index)))
776                                        .min_width(Auto)
777                                        .height(Pixels(item_height))
778                                        .position_type(PositionType::Absolute)
779                                        .bind(item_index, move |handle| {
780                                            let index = item_index.get();
781                                            handle.top(Pixels(index as f32 * item_height));
782                                        });
783                                });
784                            }
785                        })
786                    })
787                    .height(Pixels(num_items as f32 * item_height));
788                })
789            })
790            .show_horizontal_scrollbar(show_horizontal_scrollbar)
791            .show_vertical_scrollbar(show_vertical_scrollbar)
792            .scroll_to_cursor(scroll_to_cursor)
793            .scroll_x(scroll_x)
794            .scroll_y(scroll_y)
795            .on_scroll(|cx, x, y| {
796                if y.is_finite() && x.is_finite() {
797                    cx.emit(ListEvent::Scroll(x, y));
798                }
799            });
800        })
801        .toggle_class("selectable", selectable.map(|s| *s != Selectable::None))
802        .multiselectable(selectable.map(|s| *s == Selectable::Multi))
803        .orientation(orientation)
804        .navigable(true)
805        .role(Role::ListBox)
806    }
807
808    /// Creates a new [VirtualList] view with custom list accessors and caller-provided
809    /// item views directly, without an explicit selected-state memo.
810    pub fn new_generic_custom_items<V: View, S, L, T>(
811        cx: &mut Context,
812        list: S,
813        list_len: impl 'static + Fn(&L) -> usize,
814        list_index: impl 'static + Copy + Fn(&L, usize) -> T,
815        item_height: f32,
816        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
817    ) -> Handle<Self>
818    where
819        S: Res<L> + 'static,
820        L: Clone + 'static,
821        T: Clone + PartialEq + 'static,
822    {
823        Self::new_generic_custom_items_with_selection(
824            cx,
825            list,
826            list_len,
827            list_index,
828            item_height,
829            move |cx, index, item, _| item_content(cx, index, item),
830        )
831    }
832}
833
834impl View for VirtualList {
835    fn element(&self) -> Option<&'static str> {
836        Some("virtual-list")
837    }
838
839    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
840        event.take(|list_event, meta| match list_event {
841            ListEvent::Select(index) => {
842                let selectable = self.selectable.get();
843                let (min_selected, max_selected) = self.selection_limits();
844                let mut selection = self.selection.get();
845                let mut focused = self.focused.get();
846
847                match selectable {
848                    Selectable::Single => {
849                        if selection.contains(&index) {
850                            if min_selected == 0 {
851                                selection.clear();
852                                focused = None;
853                            }
854                        } else {
855                            selection.clear();
856                            selection.insert(index);
857                            focused = Some(index);
858                            if let Some(on_select) = &self.on_select {
859                                on_select(cx, index);
860                            }
861                        }
862                    }
863
864                    Selectable::Multi => {
865                        // Mirror List behavior: click target receives focus in multi-select.
866                        focused = Some(index);
867
868                        if selection.contains(&index) {
869                            if selection.len() > min_selected {
870                                selection.remove(&index);
871                                if let Some(on_select) = &self.on_select {
872                                    on_select(cx, index);
873                                }
874                            }
875                        } else if selection.len() < max_selected {
876                            selection.insert(index);
877                            if let Some(on_select) = &self.on_select {
878                                on_select(cx, index);
879                            }
880                        }
881                    }
882
883                    Selectable::None => {}
884                }
885
886                self.selection.set(selection);
887                self.set_focused_with_callback(cx, focused);
888
889                meta.consume();
890            }
891
892            ListEvent::SelectFocused => {
893                if let Some(focused) = self.focused.get() {
894                    self.focus_visibility.set(true);
895                    cx.emit(ListEvent::Select(focused))
896                }
897                meta.consume();
898            }
899
900            ListEvent::Focus(index) => {
901                if index < self.num_items.get() {
902                    self.focus_visibility.set(true);
903                    self.set_focused_with_callback(cx, Some(index));
904                }
905
906                meta.consume();
907            }
908
909            ListEvent::ClearSelection => {
910                let (min_selected, _) = self.selection_limits();
911                if min_selected == 0 {
912                    self.selection.set(BTreeSet::default());
913                }
914                meta.consume();
915            }
916
917            ListEvent::FocusNext => {
918                let mut focused = self.focused.get();
919                let num_items = self.num_items.get();
920                let mut moved_focus = false;
921                if let Some(f) = &mut focused {
922                    if *f < num_items.saturating_sub(1) {
923                        *f = f.saturating_add(1);
924                        moved_focus = true;
925                        if self.selection_follows_focus.get() {
926                            cx.emit(ListEvent::SelectFocused);
927                        }
928                    }
929                } else {
930                    focused = Some(0);
931                    moved_focus = true;
932                    if self.selection_follows_focus.get() {
933                        cx.emit(ListEvent::SelectFocused);
934                    }
935                }
936
937                if moved_focus {
938                    self.focus_visibility.set(true);
939                }
940
941                self.set_focused_with_callback(cx, focused);
942
943                meta.consume();
944            }
945
946            ListEvent::FocusPrev => {
947                let mut focused = self.focused.get();
948                let num_items = self.num_items.get();
949                let mut moved_focus = false;
950                if let Some(f) = &mut focused {
951                    if *f > 0 {
952                        *f = f.saturating_sub(1);
953                        moved_focus = true;
954                        if self.selection_follows_focus.get() {
955                            cx.emit(ListEvent::SelectFocused);
956                        }
957                    }
958                } else {
959                    focused = Some(num_items.saturating_sub(1));
960                    moved_focus = true;
961                    if self.selection_follows_focus.get() {
962                        cx.emit(ListEvent::SelectFocused);
963                    }
964                }
965
966                if moved_focus {
967                    self.focus_visibility.set(true);
968                }
969
970                self.set_focused_with_callback(cx, focused);
971
972                meta.consume();
973            }
974
975            ListEvent::FocusFirst => {
976                if self.num_items.get() > 0 {
977                    self.focus_visibility.set(true);
978                    self.set_focused_with_callback(cx, Some(0));
979                    if self.selection_follows_focus.get() {
980                        cx.emit(ListEvent::SelectFocused);
981                    }
982                }
983
984                meta.consume();
985            }
986
987            ListEvent::FocusLast => {
988                let num_items = self.num_items.get();
989                if num_items > 0 {
990                    self.focus_visibility.set(true);
991                    self.set_focused_with_callback(cx, Some(num_items.saturating_sub(1)));
992                    if self.selection_follows_focus.get() {
993                        cx.emit(ListEvent::SelectFocused);
994                    }
995                }
996
997                meta.consume();
998            }
999
1000            ListEvent::Scroll(x, y) => {
1001                self.scroll_x.set(x);
1002                self.scroll_y.set(y);
1003
1004                self.recalc(cx);
1005
1006                if let Some(callback) = &self.on_scroll {
1007                    (callback)(cx, x, y);
1008                }
1009
1010                meta.consume();
1011            }
1012        });
1013
1014        event.map(|window_event, meta| match window_event {
1015            WindowEvent::Press { mouse } => {
1016                self.focus_visibility.set(!*mouse);
1017            }
1018
1019            WindowEvent::FocusIn if meta.target == cx.current() => {
1020                if meta.origin == Entity::root() {
1021                    self.focus_visibility.set(true);
1022                }
1023
1024                let next_focused = focus_index_on_focus_in(
1025                    &self.selection.get(),
1026                    self.num_items.get(),
1027                    self.focus_first_item_on_focus_in.get(),
1028                );
1029
1030                if let Some(index) = next_focused {
1031                    if self.focused.get() == Some(index) {
1032                        self.focused.set(None);
1033                    }
1034                    self.set_focused_with_callback(cx, Some(index));
1035                }
1036            }
1037
1038            WindowEvent::CharInput(c) => {
1039                if *c == ' ' && meta.target == cx.current() && self.space_selects_focused.get() {
1040                    cx.emit(ListEvent::SelectFocused);
1041                    meta.consume();
1042                } else if self.try_type_ahead(cx, *c) {
1043                    meta.consume();
1044                }
1045            }
1046
1047            WindowEvent::GeometryChanged(geo) => {
1048                if geo.intersects(GeoChanged::WIDTH_CHANGED | GeoChanged::HEIGHT_CHANGED) {
1049                    self.recalc(cx);
1050                }
1051            }
1052
1053            _ => {}
1054        });
1055    }
1056}
1057
1058impl Handle<'_, VirtualList> {
1059    /// Sets the selected items of the list from a signal of indices.
1060    pub fn selection<R>(self, selection: impl Res<R> + 'static) -> Self
1061    where
1062        R: Deref<Target = [usize]> + Clone + 'static,
1063    {
1064        let selection = selection.to_signal(self.cx);
1065        self.bind(selection, move |handle| {
1066            selection.with(|selected_indices| {
1067                handle.modify(|list| {
1068                    let previous_focused = list.focused.get();
1069                    let mut selection = BTreeSet::default();
1070                    for idx in selected_indices.deref().iter().copied() {
1071                        selection.insert(idx);
1072                    }
1073
1074                    let focused = previous_focused
1075                        .filter(|idx| *idx < list.num_items.get())
1076                        .or_else(|| selection.iter().next_back().copied());
1077
1078                    list.selection.set(selection);
1079                    list.focused.set(focused);
1080                    list.normalize_selection_state();
1081                });
1082            });
1083        })
1084    }
1085
1086    /// Sets the focused item of the list from a signal of an optional index.
1087    pub fn focused_index(self, focused: impl Res<Option<usize>> + 'static) -> Self {
1088        let focused = focused.to_signal(self.cx);
1089        self.bind(focused, move |handle| {
1090            let focused = focused.get();
1091            handle.modify(|list| {
1092                list.focused.set(normalize_focused_index(focused, list.num_items.get()));
1093            });
1094        })
1095    }
1096
1097    /// Sets the callback triggered when a [ListItem] is selected.
1098    pub fn on_select<F>(self, callback: F) -> Self
1099    where
1100        F: 'static + Fn(&mut EventContext, usize),
1101    {
1102        self.modify(|list| list.on_select = Some(Box::new(callback)))
1103    }
1104
1105    /// Sets the callback triggered when a [ListItem] receives focus.
1106    pub fn on_focus<F>(self, callback: F) -> Self
1107    where
1108        F: 'static + Fn(&mut EventContext, usize),
1109    {
1110        self.modify(|list| list.on_focus = Some(Box::new(callback)))
1111    }
1112
1113    /// Set the selectable state of the [List].
1114    pub fn selectable<U: Into<Selectable> + Clone + 'static>(
1115        self,
1116        selectable: impl Res<U> + 'static,
1117    ) -> Self {
1118        let selectable = selectable.to_signal(self.cx);
1119        self.bind(selectable, move |handle| {
1120            let selectable = selectable.get();
1121            let s = selectable.into();
1122            handle.modify(|list| {
1123                list.selectable.set(s);
1124                list.normalize_selection_state();
1125            });
1126        })
1127    }
1128
1129    /// Sets the minimum number of selected items.
1130    pub fn min_selected(self, min_selected: impl Res<usize> + 'static) -> Self {
1131        let min_selected = min_selected.to_signal(self.cx);
1132        self.bind(min_selected, move |handle| {
1133            let min_selected = min_selected.get();
1134            handle.modify(|list| {
1135                list.min_selected.set(min_selected);
1136                list.normalize_selection_state();
1137            });
1138        })
1139    }
1140
1141    /// Sets the maximum number of selected items.
1142    pub fn max_selected(self, max_selected: impl Res<usize> + 'static) -> Self {
1143        let max_selected = max_selected.to_signal(self.cx);
1144        self.bind(max_selected, move |handle| {
1145            let max_selected = max_selected.get();
1146            handle.modify(|list| {
1147                list.max_selected.set(max_selected);
1148                list.normalize_selection_state();
1149            });
1150        })
1151    }
1152
1153    /// Sets whether the selection should follow the focus.
1154    pub fn selection_follows_focus<U: Into<bool> + Clone + 'static>(
1155        self,
1156        flag: impl Res<U> + 'static,
1157    ) -> Self {
1158        let flag = flag.to_signal(self.cx);
1159        self.bind(flag, move |handle| {
1160            let selection_follows_focus = flag.get();
1161            let s = selection_follows_focus.into();
1162            handle.modify(|list| list.selection_follows_focus.set(s));
1163        })
1164    }
1165
1166    /// Sets the orientation of the [VirtualList].
1167    pub fn horizontal<U: Into<bool> + Clone + 'static>(
1168        self,
1169        horizontal: impl Res<U> + 'static,
1170    ) -> Self {
1171        let horizontal = horizontal.to_signal(self.cx);
1172        self.bind(horizontal, move |handle| {
1173            let horizontal = horizontal.get();
1174            let horizontal = horizontal.into();
1175            handle.modify(|list| {
1176                list.orientation.set(if horizontal {
1177                    Orientation::Horizontal
1178                } else {
1179                    Orientation::Vertical
1180                });
1181            });
1182        })
1183    }
1184
1185    /// Sets whether pressing Space should select the currently focused item.
1186    pub fn space_selects_focused<U: Into<bool> + Clone + 'static>(
1187        self,
1188        flag: impl Res<U> + 'static,
1189    ) -> Self {
1190        let flag = flag.to_signal(self.cx);
1191        self.bind(flag, move |handle| {
1192            let space_selects_focused = flag.get();
1193            let s = space_selects_focused.into();
1194            handle.modify(|list| list.space_selects_focused.set(s));
1195        })
1196    }
1197
1198    /// Sets whether the scrollbar should move to the cursor when pressed.
1199    pub fn scroll_to_cursor(self, flag: bool) -> Self {
1200        self.modify(|virtual_list: &mut VirtualList| {
1201            virtual_list.scroll_to_cursor.set(flag);
1202        })
1203    }
1204
1205    /// Sets whether the first item should be focused when the list gains focus with no selection.
1206    pub fn focus_first_item_on_focus_in(self, flag: impl Res<bool> + 'static) -> Self {
1207        let flag = flag.to_signal(self.cx);
1208        self.bind(flag, move |handle| {
1209            let focus_first_item_on_focus_in = flag.get();
1210            handle.modify(|list| {
1211                list.focus_first_item_on_focus_in.set(focus_first_item_on_focus_in);
1212            });
1213        })
1214    }
1215
1216    /// Sets a callback which will be called when a scrollview is scrolled, either with the mouse wheel, touchpad, or using the scroll bars.
1217    pub fn on_scroll(
1218        self,
1219        callback: impl Fn(&mut EventContext, f32, f32) + 'static + Send + Sync,
1220    ) -> Self {
1221        self.modify(|list| list.on_scroll = Some(Box::new(callback)))
1222    }
1223
1224    /// Set the horizontal scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
1225    pub fn scroll_x(self, scrollx: impl Res<f32> + 'static) -> Self {
1226        let scrollx = scrollx.to_signal(self.cx);
1227        self.bind(scrollx, move |handle| {
1228            let sx = scrollx.get();
1229            handle.modify(|list| list.scroll_x.set(sx));
1230        })
1231    }
1232
1233    /// Set the vertical scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
1234    pub fn scroll_y(self, scrollx: impl Res<f32> + 'static) -> Self {
1235        let scrollx = scrollx.to_signal(self.cx);
1236        self.bind(scrollx, move |handle| {
1237            let sy = scrollx.get();
1238            handle.modify(|list| list.scroll_y.set(sy));
1239        })
1240    }
1241
1242    /// Sets whether the horizontal scrollbar should be visible.
1243    pub fn show_horizontal_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
1244        let flag = flag.to_signal(self.cx);
1245        self.bind(flag, move |handle| {
1246            let s = flag.get();
1247            handle.modify(|list| list.show_horizontal_scrollbar.set(s));
1248        })
1249    }
1250
1251    /// Sets whether the vertical scrollbar should be visible.
1252    pub fn show_vertical_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
1253        let flag = flag.to_signal(self.cx);
1254        self.bind(flag, move |handle| {
1255            let s = flag.get();
1256            handle.modify(|list| list.show_vertical_scrollbar.set(s));
1257        })
1258    }
1259
1260    /// Enables type-ahead navigation by providing searchable text per item index.
1261    pub fn type_ahead_text<F>(self, callback: F) -> Self
1262    where
1263        F: 'static + Fn(&mut EventContext, usize) -> Option<String>,
1264    {
1265        self.modify(|list: &mut VirtualList| list.type_ahead_text = Some(Box::new(callback)))
1266    }
1267}
1268
1269fn normalize_focused_index(focused: Option<usize>, num_items: usize) -> Option<usize> {
1270    focused.filter(|index| *index < num_items)
1271}
1272
1273fn focus_index_on_focus_in(
1274    selection: &BTreeSet<usize>,
1275    num_items: usize,
1276    focus_first_item_on_focus_in: bool,
1277) -> Option<usize> {
1278    selection
1279        .iter()
1280        .copied()
1281        .find(|index| *index < num_items)
1282        .or_else(|| focus_first_item_on_focus_in.then_some(0).filter(|_| num_items > 0))
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288
1289    fn evaluate_indices(range: Range<usize>) -> Vec<usize> {
1290        (0..range.len())
1291            .map(|index| VirtualList::evaluate_index(index, range.start, range.end))
1292            .collect()
1293    }
1294
1295    #[test]
1296    fn test_evaluate_index() {
1297        // Move forward by 0
1298        assert_eq!(evaluate_indices(0..4), [0, 1, 2, 3]);
1299        // Move forward by 1
1300        assert_eq!(evaluate_indices(1..5), [4, 1, 2, 3]);
1301        // Move forward by 2
1302        assert_eq!(evaluate_indices(2..6), [4, 5, 2, 3]);
1303        // Move forward by 3
1304        assert_eq!(evaluate_indices(3..7), [4, 5, 6, 3]);
1305        // Move forward by 4
1306        assert_eq!(evaluate_indices(4..8), [4, 5, 6, 7]);
1307        // Move forward by 5
1308        assert_eq!(evaluate_indices(5..9), [8, 5, 6, 7]);
1309        // Move forward by 6
1310        assert_eq!(evaluate_indices(6..10), [8, 9, 6, 7]);
1311        // Move forward by 7
1312        assert_eq!(evaluate_indices(7..11), [8, 9, 10, 7]);
1313        // Move forward by 8
1314        assert_eq!(evaluate_indices(8..12), [8, 9, 10, 11]);
1315        // Move forward by 9
1316        assert_eq!(evaluate_indices(9..13), [12, 9, 10, 11]);
1317    }
1318
1319    #[test]
1320    fn keeps_focused_index_when_in_range() {
1321        assert_eq!(normalize_focused_index(Some(3), 5), Some(3));
1322    }
1323
1324    #[test]
1325    fn clears_focused_index_when_out_of_range() {
1326        assert_eq!(normalize_focused_index(Some(5), 5), None);
1327    }
1328
1329    #[test]
1330    fn focuses_selected_item_on_focus_in() {
1331        let mut selection = BTreeSet::new();
1332        selection.insert(3);
1333
1334        assert_eq!(focus_index_on_focus_in(&selection, 5, false), Some(3));
1335    }
1336
1337    #[test]
1338    fn can_leave_focus_empty_when_nothing_is_selected() {
1339        let selection = BTreeSet::new();
1340
1341        assert_eq!(focus_index_on_focus_in(&selection, 5, false), None);
1342    }
1343
1344    #[test]
1345    fn falls_back_to_first_item_when_enabled() {
1346        let selection = BTreeSet::new();
1347
1348        assert_eq!(focus_index_on_focus_in(&selection, 5, true), Some(0));
1349    }
1350}