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    /// Callback that is called when an item is selected.
42    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
43    /// Returns the searchable text for each item index when type-ahead is enabled.
44    type_ahead_text: Option<Box<dyn Fn(&mut EventContext, usize) -> Option<String>>>,
45    /// Buffered type-ahead query built from rapid character input.
46    type_ahead_buffer: String,
47    /// Timestamp of the last accepted type-ahead character.
48    type_ahead_last_input: Option<Instant>,
49    /// Maximum elapsed time before resetting type-ahead buffer.
50    type_ahead_timeout: Duration,
51}
52
53impl VirtualList {
54    fn find_type_ahead_match(
55        &self,
56        cx: &mut EventContext,
57        query: &str,
58        start_index: usize,
59    ) -> Option<usize> {
60        let get_text = self.type_ahead_text.as_ref()?;
61        let num_items = self.num_items.get();
62        if num_items == 0 {
63            return None;
64        }
65
66        for offset in 0..num_items {
67            let index = (start_index + offset) % num_items;
68            let item_text = get_text(cx, index)
69                .map(|text| text.trim_start().to_lowercase())
70                .unwrap_or_default();
71
72            if !item_text.is_empty() && item_text.starts_with(query) {
73                return Some(index);
74            }
75        }
76
77        None
78    }
79
80    fn try_type_ahead(&mut self, cx: &mut EventContext, typed: char) -> bool {
81        if self.type_ahead_text.is_none() {
82            return false;
83        }
84
85        let num_items = self.num_items.get();
86        if num_items == 0 || typed.is_control() || typed.is_whitespace() {
87            return false;
88        }
89
90        let now = Instant::now();
91        let within_timeout = self
92            .type_ahead_last_input
93            .is_some_and(|last| now.saturating_duration_since(last) <= self.type_ahead_timeout);
94
95        let ch = typed.to_lowercase().collect::<String>();
96        let query = if within_timeout {
97            let repeated_char_cycle = !self.type_ahead_buffer.is_empty()
98                && self.type_ahead_buffer.chars().all(|c| c == typed.to_ascii_lowercase());
99
100            if repeated_char_cycle {
101                ch.clone()
102            } else {
103                format!("{}{}", self.type_ahead_buffer, ch)
104            }
105        } else {
106            ch.clone()
107        };
108
109        let start_index = self.focused.get().map(|focused| (focused + 1) % num_items).unwrap_or(0);
110
111        if let Some(index) = self.find_type_ahead_match(cx, &query, start_index) {
112            self.type_ahead_buffer = query;
113            self.type_ahead_last_input = Some(now);
114            self.focus_visibility.set(true);
115            self.focused.set(Some(index));
116
117            if self.selection_follows_focus.get() {
118                cx.emit(ListEvent::SelectFocused);
119            }
120
121            true
122        } else {
123            self.type_ahead_buffer.clear();
124            self.type_ahead_last_input = Some(now);
125            false
126        }
127    }
128
129    fn evaluate_index(index: usize, start: usize, end: usize) -> usize {
130        match end - start {
131            0 => 0,
132            len => start + (len - (start % len) + index) % len,
133        }
134    }
135
136    /// Builds a memo that resolves the item at `index` from the backing list,
137    /// guarding against stale indices.
138    ///
139    /// Recycled items keep a captured `index` and re-evaluate whenever the
140    /// backing list changes. When the list shrinks (or empties) the item can
141    /// briefly reference an out-of-bounds index before the enclosing binding
142    /// rebuilds and removes it, so the index is clamped to the current length
143    /// and the previous value is reused while the list is transiently empty.
144    fn resolve_item<L, T>(
145        list: impl SignalWith<L> + Copy + 'static,
146        index: usize,
147        list_len: impl 'static + Fn(&L) -> usize,
148        list_index: impl 'static + Fn(&L, usize) -> T,
149    ) -> Memo<T>
150    where
151        L: 'static,
152        T: Clone + PartialEq + 'static,
153    {
154        Memo::new(move |prev| {
155            list.with(|list| {
156                let len = list_len(list);
157                if len == 0 {
158                    // The list emptied while this recycled item still references
159                    // a stale index; keep the previous value until it is removed.
160                    prev.cloned().unwrap_or_else(|| list_index(list, 0))
161                } else {
162                    list_index(list, index.min(len - 1))
163                }
164            })
165        })
166    }
167
168    fn recalc(&self, cx: &mut EventContext) {
169        let num_items = self.num_items.get();
170        if num_items == 0 {
171            self.visible_range.set_if_changed(0..0);
172            return;
173        }
174
175        let current = cx.current();
176        let current_extent = match self.orientation.get() {
177            Orientation::Horizontal => cx.cache.get_width(current),
178            Orientation::Vertical => cx.cache.get_height(current),
179        };
180        if current_extent == f32::MAX {
181            return;
182        }
183
184        let item_extent = self.item_height;
185        let total_extent = item_extent * (num_items as f32);
186        let visible_extent = current_extent / cx.scale_factor();
187
188        let mut num_visible_items = (visible_extent / item_extent).ceil();
189        num_visible_items += 1.0; // To account for partially-visible items.
190
191        let visible_items_extent = item_extent * num_visible_items;
192        let empty_extent = (total_extent - visible_items_extent).max(0.0);
193
194        // The pixel offsets within the container to the visible area.
195        let axis_scroll = match self.orientation.get() {
196            Orientation::Horizontal => self.scroll_x.get(),
197            Orientation::Vertical => self.scroll_y.get(),
198        };
199        let visible_start = empty_extent * axis_scroll;
200        let visible_end = visible_start + visible_items_extent;
201
202        // The indices of the first and last item of the visible area.
203        let mut start_index = (visible_start / item_extent).trunc() as usize;
204        let mut end_index = 1 + (visible_end / item_extent).trunc() as usize;
205
206        // Ensure we always have (num_visible_items + 1) items when possible
207        let desired_range_size = (num_visible_items as usize) + 1;
208        end_index = end_index.min(num_items);
209
210        let current_range_size = end_index.saturating_sub(start_index);
211
212        if current_range_size < desired_range_size {
213            match end_index == num_items {
214                // Try to extend backwards if we're at the end of the list
215                true => {
216                    start_index =
217                        start_index.saturating_sub(desired_range_size - current_range_size);
218                }
219                // Try to extend forwards if we have room
220                false if end_index < num_items => {
221                    end_index = (start_index + desired_range_size).min(num_items);
222                }
223                _ => {}
224            }
225        }
226
227        self.visible_range.set_if_changed(start_index..end_index);
228    }
229}
230
231impl VirtualList {
232    /// Creates a new [VirtualList] view.
233    pub fn new<V: View, S, L, T>(
234        cx: &mut Context,
235        list: S,
236        item_height: f32,
237        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
238    ) -> Handle<Self>
239    where
240        S: Res<L> + 'static,
241        L: Deref<Target = [T]> + Clone + 'static,
242        T: Clone + PartialEq + 'static,
243    {
244        Self::new_generic(
245            cx,
246            list,
247            |list| list.len(),
248            |list, index| list[index].clone(),
249            item_height,
250            item_content,
251        )
252    }
253
254    /// Creates a new [VirtualList] view with a binding to the given source and a template for constructing the list items.
255    pub fn new_generic<V: View, S, L, T>(
256        cx: &mut Context,
257        list: S,
258        list_len: impl 'static + Copy + Fn(&L) -> usize,
259        list_index: impl 'static + Copy + Fn(&L, usize) -> T,
260        item_height: f32,
261        item_content: impl 'static + Copy + Fn(&mut Context, usize, Memo<T>) -> Handle<V>,
262    ) -> Handle<Self>
263    where
264        S: Res<L> + 'static,
265        L: Clone + 'static,
266        T: Clone + PartialEq + 'static,
267    {
268        let list = list.to_signal(cx);
269        let num_items = list.map(list_len).to_signal(cx);
270        let visible_range = Signal::new(0..0);
271        let scroll_x = Signal::new(0.0);
272        let scroll_y = Signal::new(0.0);
273        let show_horizontal_scrollbar = Signal::new(false);
274        let show_vertical_scrollbar = Signal::new(true);
275        let orientation = Signal::new(Orientation::Vertical);
276        let selection = Signal::new(BTreeSet::default());
277        let selectable = Signal::new(Selectable::None);
278        let focused = Signal::new(None);
279        let focus_visibility = Signal::new(false);
280        let selection_follows_focus = Signal::new(false);
281        let scroll_to_cursor = Signal::new(true);
282
283        Self {
284            scroll_to_cursor,
285            on_scroll: None,
286            num_items,
287            item_height,
288            orientation,
289            visible_range,
290            scroll_x,
291            scroll_y,
292            show_horizontal_scrollbar,
293            show_vertical_scrollbar,
294            selection,
295            selectable,
296            focused,
297            focus_visibility,
298            selection_follows_focus,
299            on_select: None,
300            type_ahead_text: None,
301            type_ahead_buffer: String::new(),
302            type_ahead_last_input: None,
303            type_ahead_timeout: Duration::from_millis(1000),
304        }
305        .build(cx, |cx| {
306            Keymap::from(vec![
307                (
308                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
309                    KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
310                ),
311                (
312                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
313                    KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
314                ),
315                (
316                    KeyChord::new(Modifiers::empty(), Code::Home),
317                    KeymapEntry::new("Focus First", |cx| cx.emit(ListEvent::FocusFirst)),
318                ),
319                (
320                    KeyChord::new(Modifiers::empty(), Code::End),
321                    KeymapEntry::new("Focus Last", |cx| cx.emit(ListEvent::FocusLast)),
322                ),
323                (
324                    KeyChord::new(Modifiers::empty(), Code::Enter),
325                    KeymapEntry::new("Select Focused", |cx| cx.emit(ListEvent::SelectFocused)),
326                ),
327            ])
328            .build(cx);
329
330            Binding::new(cx, orientation, move |cx| {
331                let orientation = orientation.get();
332                if orientation == Orientation::Horizontal {
333                    cx.emit(KeymapEvent::RemoveAction(
334                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
335                        "Focus Next",
336                    ));
337
338                    cx.emit(KeymapEvent::RemoveAction(
339                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
340                        "Focus Previous",
341                    ));
342
343                    cx.emit(KeymapEvent::InsertAction(
344                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
345                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
346                    ));
347
348                    cx.emit(KeymapEvent::InsertAction(
349                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
350                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
351                    ));
352                } else {
353                    cx.emit(KeymapEvent::RemoveAction(
354                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
355                        "Focus Next",
356                    ));
357
358                    cx.emit(KeymapEvent::RemoveAction(
359                        KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
360                        "Focus Previous",
361                    ));
362
363                    cx.emit(KeymapEvent::InsertAction(
364                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
365                        KeymapEntry::new("Focus Next", |cx| cx.emit(ListEvent::FocusNext)),
366                    ));
367
368                    cx.emit(KeymapEvent::InsertAction(
369                        KeyChord::new(Modifiers::empty(), Code::ArrowUp),
370                        KeymapEntry::new("Focus Previous", |cx| cx.emit(ListEvent::FocusPrev)),
371                    ));
372                }
373            });
374
375            ScrollView::new(cx, move |cx| {
376                Binding::new(cx, orientation, move |cx| {
377                    let orientation = orientation.get();
378                    Binding::new(cx, num_items, move |cx| {
379                        let num_items = num_items.get();
380
381                        match orientation {
382                            Orientation::Horizontal => cx.emit(ScrollEvent::SetX(0.0)),
383                            Orientation::Vertical => cx.emit(ScrollEvent::SetY(0.0)),
384                        }
385
386                        let num_visible_items = visible_range.map(Range::len);
387
388                        match orientation {
389                            Orientation::Horizontal => {
390                                HStack::new(cx, |cx| {
391                                    Binding::new(cx, num_visible_items, move |cx| {
392                                        for i in 0..num_visible_items.get().min(num_items) {
393                                            let item_index = visible_range.map(move |range| {
394                                                Self::evaluate_index(i, range.start, range.end)
395                                            });
396                                            Binding::new(cx, item_index, move |cx| {
397                                                let index = item_index.get();
398                                                let item = Self::resolve_item(
399                                                    list, index, list_len, list_index,
400                                                );
401
402                                                ListItem::new(
403                                                    cx,
404                                                    index,
405                                                    item,
406                                                    selection,
407                                                    focused,
408                                                    focus_visibility,
409                                                    move |cx, index, item| {
410                                                        item_content(cx, index, item)
411                                                            .height(Percentage(100.0));
412                                                    },
413                                                )
414                                                .min_size(Auto)
415                                                .width(Pixels(item_height))
416                                                .height(Percentage(100.0))
417                                                .position_type(PositionType::Absolute)
418                                                .bind(item_index, move |handle| {
419                                                    let index = item_index.get();
420                                                    handle.left(Pixels(index as f32 * item_height));
421                                                });
422                                            });
423                                        }
424                                    })
425                                })
426                                .width(Pixels(num_items as f32 * item_height))
427                                .height(Stretch(1.0));
428                            }
429
430                            Orientation::Vertical => {
431                                VStack::new(cx, |cx| {
432                                    Binding::new(cx, num_visible_items, move |cx| {
433                                        for i in 0..num_visible_items.get().min(num_items) {
434                                            let item_index = visible_range.map(move |range| {
435                                                Self::evaluate_index(i, range.start, range.end)
436                                            });
437                                            Binding::new(cx, item_index, move |cx| {
438                                                let index = item_index.get();
439                                                let item = Self::resolve_item(
440                                                    list, index, list_len, list_index,
441                                                );
442
443                                                ListItem::new(
444                                                    cx,
445                                                    index,
446                                                    item,
447                                                    selection,
448                                                    focused,
449                                                    focus_visibility,
450                                                    move |cx, index, item| {
451                                                        item_content(cx, index, item)
452                                                            .height(Percentage(100.0));
453                                                    },
454                                                )
455                                                .min_width(Auto)
456                                                .height(Pixels(item_height))
457                                                .position_type(PositionType::Absolute)
458                                                .bind(item_index, move |handle| {
459                                                    let index = item_index.get();
460                                                    handle.top(Pixels(index as f32 * item_height));
461                                                });
462                                            });
463                                        }
464                                    })
465                                })
466                                .height(Pixels(num_items as f32 * item_height));
467                            }
468                        }
469                    })
470                })
471            })
472            .show_horizontal_scrollbar(show_horizontal_scrollbar)
473            .show_vertical_scrollbar(show_vertical_scrollbar)
474            .scroll_to_cursor(scroll_to_cursor)
475            .scroll_x(scroll_x)
476            .scroll_y(scroll_y)
477            .on_scroll(|cx, x, y| {
478                if y.is_finite() && x.is_finite() {
479                    cx.emit(ListEvent::Scroll(x, y));
480                }
481            });
482        })
483        .toggle_class("selectable", selectable.map(|s| *s != Selectable::None))
484        .orientation(orientation)
485        .navigable(true)
486        .role(Role::ListBox)
487    }
488}
489
490impl View for VirtualList {
491    fn element(&self) -> Option<&'static str> {
492        Some("virtual-list")
493    }
494
495    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
496        event.take(|list_event, meta| match list_event {
497            ListEvent::Select(index) => {
498                let selectable = self.selectable.get();
499                let mut selection = self.selection.get();
500                let mut focused = self.focused.get();
501
502                match selectable {
503                    Selectable::Single => {
504                        if selection.contains(&index) {
505                            selection.clear();
506                            focused = None;
507                        } else {
508                            selection.clear();
509                            selection.insert(index);
510                            focused = Some(index);
511                            if let Some(on_select) = &self.on_select {
512                                on_select(cx, index);
513                            }
514                        }
515                    }
516
517                    Selectable::Multi => {
518                        if selection.contains(&index) {
519                            selection.remove(&index);
520                            focused = None;
521                        } else {
522                            selection.insert(index);
523                            focused = Some(index);
524                            if let Some(on_select) = &self.on_select {
525                                on_select(cx, index);
526                            }
527                        }
528                    }
529
530                    Selectable::None => {}
531                }
532
533                self.selection.set(selection);
534                self.focused.set(focused);
535
536                meta.consume();
537            }
538
539            ListEvent::SelectFocused => {
540                if let Some(focused) = self.focused.get() {
541                    self.focus_visibility.set(true);
542                    cx.emit(ListEvent::Select(focused))
543                }
544                meta.consume();
545            }
546
547            ListEvent::ClearSelection => {
548                self.selection.set(BTreeSet::default());
549                meta.consume();
550            }
551
552            ListEvent::FocusNext => {
553                let mut focused = self.focused.get();
554                let num_items = self.num_items.get();
555                if let Some(f) = &mut focused {
556                    if *f < num_items.saturating_sub(1) {
557                        *f = f.saturating_add(1);
558                        if self.selection_follows_focus.get() {
559                            self.focus_visibility.set(true);
560                            cx.emit(ListEvent::SelectFocused);
561                        }
562                    }
563                } else {
564                    focused = Some(0);
565                    if self.selection_follows_focus.get() {
566                        self.focus_visibility.set(true);
567                        cx.emit(ListEvent::SelectFocused);
568                    }
569                }
570
571                self.focused.set(focused);
572
573                meta.consume();
574            }
575
576            ListEvent::FocusPrev => {
577                let mut focused = self.focused.get();
578                let num_items = self.num_items.get();
579                if let Some(f) = &mut focused {
580                    if *f > 0 {
581                        *f = f.saturating_sub(1);
582                        if self.selection_follows_focus.get() {
583                            self.focus_visibility.set(true);
584                            cx.emit(ListEvent::SelectFocused);
585                        }
586                    }
587                } else {
588                    focused = Some(num_items.saturating_sub(1));
589                    if self.selection_follows_focus.get() {
590                        self.focus_visibility.set(true);
591                        cx.emit(ListEvent::SelectFocused);
592                    }
593                }
594
595                self.focused.set(focused);
596
597                meta.consume();
598            }
599
600            ListEvent::FocusFirst => {
601                if self.num_items.get() > 0 {
602                    self.focus_visibility.set(true);
603                    self.focused.set(Some(0));
604                    if self.selection_follows_focus.get() {
605                        cx.emit(ListEvent::SelectFocused);
606                    }
607                }
608
609                meta.consume();
610            }
611
612            ListEvent::FocusLast => {
613                let num_items = self.num_items.get();
614                if num_items > 0 {
615                    self.focus_visibility.set(true);
616                    self.focused.set(Some(num_items.saturating_sub(1)));
617                    if self.selection_follows_focus.get() {
618                        cx.emit(ListEvent::SelectFocused);
619                    }
620                }
621
622                meta.consume();
623            }
624
625            ListEvent::Scroll(x, y) => {
626                self.scroll_x.set(x);
627                self.scroll_y.set(y);
628
629                self.recalc(cx);
630
631                if let Some(callback) = &self.on_scroll {
632                    (callback)(cx, x, y);
633                }
634
635                meta.consume();
636            }
637        });
638
639        event.map(|window_event, meta| match window_event {
640            WindowEvent::Press { mouse } => {
641                self.focus_visibility.set(!*mouse);
642            }
643
644            WindowEvent::CharInput(c) => {
645                if *c == ' ' && meta.target == cx.current() {
646                    cx.emit(ListEvent::SelectFocused);
647                    meta.consume();
648                } else if self.try_type_ahead(cx, *c) {
649                    meta.consume();
650                }
651            }
652
653            WindowEvent::GeometryChanged(geo) => {
654                if geo.intersects(GeoChanged::WIDTH_CHANGED | GeoChanged::HEIGHT_CHANGED) {
655                    self.recalc(cx);
656                }
657            }
658
659            _ => {}
660        });
661    }
662}
663
664impl Handle<'_, VirtualList> {
665    /// Sets the selected items of the list from a signal of indices.
666    pub fn selection<R>(self, selection: impl Res<R> + 'static) -> Self
667    where
668        R: Deref<Target = [usize]> + Clone + 'static,
669    {
670        let selection = selection.to_signal(self.cx);
671        self.bind(selection, move |handle| {
672            selection.with(|selected_indices| {
673                handle.modify(|list| {
674                    let mut selection = BTreeSet::default();
675                    let mut focused = None;
676                    for idx in selected_indices.deref().iter().copied() {
677                        selection.insert(idx);
678                        focused = Some(idx);
679                    }
680                    list.selection.set(selection);
681                    list.focused.set(focused);
682                });
683            });
684        })
685    }
686
687    /// Sets the callback triggered when a [ListItem] is selected.
688    pub fn on_select<F>(self, callback: F) -> Self
689    where
690        F: 'static + Fn(&mut EventContext, usize),
691    {
692        self.modify(|list| list.on_select = Some(Box::new(callback)))
693    }
694
695    /// Set the selectable state of the [List].
696    pub fn selectable<U: Into<Selectable> + Clone + 'static>(
697        self,
698        selectable: impl Res<U> + 'static,
699    ) -> Self {
700        let selectable = selectable.to_signal(self.cx);
701        self.bind(selectable, move |handle| {
702            let selectable = selectable.get();
703            let s = selectable.into();
704            handle.modify(|list| list.selectable.set(s));
705        })
706    }
707
708    /// Sets whether the selection should follow the focus.
709    pub fn selection_follows_focus<U: Into<bool> + Clone + 'static>(
710        self,
711        flag: impl Res<U> + 'static,
712    ) -> Self {
713        let flag = flag.to_signal(self.cx);
714        self.bind(flag, move |handle| {
715            let selection_follows_focus = flag.get();
716            let s = selection_follows_focus.into();
717            handle.modify(|list| list.selection_follows_focus.set(s));
718        })
719    }
720
721    /// Sets the orientation of the [VirtualList].
722    pub fn horizontal<U: Into<bool> + Clone + 'static>(
723        self,
724        horizontal: impl Res<U> + 'static,
725    ) -> Self {
726        let horizontal = horizontal.to_signal(self.cx);
727        self.bind(horizontal, move |handle| {
728            let horizontal = horizontal.get();
729            let horizontal = horizontal.into();
730            handle.modify(|list| {
731                list.orientation.set(if horizontal {
732                    Orientation::Horizontal
733                } else {
734                    Orientation::Vertical
735                });
736            });
737        })
738    }
739
740    /// Sets whether the scrollbar should move to the cursor when pressed.
741    pub fn scroll_to_cursor(self, flag: bool) -> Self {
742        self.modify(|virtual_list: &mut VirtualList| {
743            virtual_list.scroll_to_cursor.set(flag);
744        })
745    }
746
747    /// Sets a callback which will be called when a scrollview is scrolled, either with the mouse wheel, touchpad, or using the scroll bars.
748    pub fn on_scroll(
749        self,
750        callback: impl Fn(&mut EventContext, f32, f32) + 'static + Send + Sync,
751    ) -> Self {
752        self.modify(|list| list.on_scroll = Some(Box::new(callback)))
753    }
754
755    /// Set the horizontal scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
756    pub fn scroll_x(self, scrollx: impl Res<f32> + 'static) -> Self {
757        let scrollx = scrollx.to_signal(self.cx);
758        self.bind(scrollx, move |handle| {
759            let sx = scrollx.get();
760            handle.modify(|list| list.scroll_x.set(sx));
761        })
762    }
763
764    /// Set the vertical scroll position of the [ScrollView]. Accepts a value or lens to an 'f32' between 0 and 1.
765    pub fn scroll_y(self, scrollx: impl Res<f32> + 'static) -> Self {
766        let scrollx = scrollx.to_signal(self.cx);
767        self.bind(scrollx, move |handle| {
768            let sy = scrollx.get();
769            handle.modify(|list| list.scroll_y.set(sy));
770        })
771    }
772
773    /// Sets whether the horizontal scrollbar should be visible.
774    pub fn show_horizontal_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
775        let flag = flag.to_signal(self.cx);
776        self.bind(flag, move |handle| {
777            let s = flag.get();
778            handle.modify(|list| list.show_horizontal_scrollbar.set(s));
779        })
780    }
781
782    /// Sets whether the vertical scrollbar should be visible.
783    pub fn show_vertical_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
784        let flag = flag.to_signal(self.cx);
785        self.bind(flag, move |handle| {
786            let s = flag.get();
787            handle.modify(|list| list.show_vertical_scrollbar.set(s));
788        })
789    }
790
791    /// Enables type-ahead navigation by providing searchable text per item index.
792    pub fn type_ahead_text<F>(self, callback: F) -> Self
793    where
794        F: 'static + Fn(&mut EventContext, usize) -> Option<String>,
795    {
796        self.modify(|list: &mut VirtualList| list.type_ahead_text = Some(Box::new(callback)))
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    fn evaluate_indices(range: Range<usize>) -> Vec<usize> {
805        (0..range.len())
806            .map(|index| VirtualList::evaluate_index(index, range.start, range.end))
807            .collect()
808    }
809
810    #[test]
811    fn test_evaluate_index() {
812        // Move forward by 0
813        assert_eq!(evaluate_indices(0..4), [0, 1, 2, 3]);
814        // Move forward by 1
815        assert_eq!(evaluate_indices(1..5), [4, 1, 2, 3]);
816        // Move forward by 2
817        assert_eq!(evaluate_indices(2..6), [4, 5, 2, 3]);
818        // Move forward by 3
819        assert_eq!(evaluate_indices(3..7), [4, 5, 6, 3]);
820        // Move forward by 4
821        assert_eq!(evaluate_indices(4..8), [4, 5, 6, 7]);
822        // Move forward by 5
823        assert_eq!(evaluate_indices(5..9), [8, 5, 6, 7]);
824        // Move forward by 6
825        assert_eq!(evaluate_indices(6..10), [8, 9, 6, 7]);
826        // Move forward by 7
827        assert_eq!(evaluate_indices(7..11), [8, 9, 10, 7]);
828        // Move forward by 8
829        assert_eq!(evaluate_indices(8..12), [8, 9, 10, 11]);
830        // Move forward by 9
831        assert_eq!(evaluate_indices(9..13), [12, 9, 10, 11]);
832    }
833}