gpui_component/list/
list.rs

1use std::ops::Range;
2use std::time::Duration;
3
4use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
5use crate::input::InputState;
6use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
7use crate::{
8    ActiveTheme, IconName, Size,
9    input::{Input, InputEvent},
10    scroll::Scrollbar,
11    v_flex,
12};
13use crate::{Icon, IndexPath, Selectable, Sizable, StyledExt};
14use crate::{VirtualListScrollHandle, list::ListDelegate, v_virtual_list};
15use gpui::{
16    App, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, EventEmitter,
17    ListSizingBehavior, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement,
18    StyleRefinement, Subscription, px, size,
19};
20use gpui::{
21    AppContext, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
22    Length, MouseButton, ParentElement, Render, Styled, Task, Window, div, prelude::FluentBuilder,
23};
24use rust_i18n::t;
25use smol::Timer;
26
27pub(crate) fn init(cx: &mut App) {
28    let context: Option<&str> = Some("List");
29    cx.bind_keys([
30        KeyBinding::new("escape", Cancel, context),
31        KeyBinding::new("enter", Confirm { secondary: false }, context),
32        KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
33        KeyBinding::new("up", SelectUp, context),
34        KeyBinding::new("down", SelectDown, context),
35    ]);
36}
37
38#[derive(Clone)]
39pub enum ListEvent {
40    /// Move to select item.
41    Select(IndexPath),
42    /// Click on item or pressed Enter.
43    Confirm(IndexPath),
44    /// Pressed ESC to deselect the item.
45    Cancel,
46}
47
48struct ListOptions {
49    size: Size,
50    scrollbar_visible: bool,
51    search_placeholder: Option<SharedString>,
52    max_height: Option<Length>,
53    paddings: EdgesRefinement<DefiniteLength>,
54}
55
56impl Default for ListOptions {
57    fn default() -> Self {
58        Self {
59            size: Size::default(),
60            scrollbar_visible: true,
61            max_height: None,
62            search_placeholder: None,
63            paddings: EdgesRefinement::default(),
64        }
65    }
66}
67
68/// The state for List.
69///
70/// List required all items has the same height.
71pub struct ListState<D: ListDelegate> {
72    pub(crate) focus_handle: FocusHandle,
73    pub(crate) query_input: Entity<InputState>,
74    options: ListOptions,
75    delegate: D,
76    last_query: Option<String>,
77    scroll_handle: VirtualListScrollHandle,
78    rows_cache: RowsCache,
79    selected_index: Option<IndexPath>,
80    item_to_measure_index: IndexPath,
81    deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>,
82    mouse_right_clicked_index: Option<IndexPath>,
83    reset_on_cancel: bool,
84    searchable: bool,
85    selectable: bool,
86    _search_task: Task<()>,
87    _load_more_task: Task<()>,
88    _query_input_subscription: Subscription,
89}
90
91impl<D> ListState<D>
92where
93    D: ListDelegate,
94{
95    pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
96        let query_input =
97            cx.new(|cx| InputState::new(window, cx).placeholder(t!("List.search_placeholder")));
98
99        let _query_input_subscription =
100            cx.subscribe_in(&query_input, window, Self::on_query_input_event);
101
102        Self {
103            focus_handle: cx.focus_handle(),
104            options: ListOptions::default(),
105            delegate,
106            rows_cache: RowsCache::default(),
107            query_input,
108            last_query: None,
109            selected_index: None,
110            selectable: true,
111            searchable: false,
112            item_to_measure_index: IndexPath::default(),
113            deferred_scroll_to_index: None,
114            mouse_right_clicked_index: None,
115            scroll_handle: VirtualListScrollHandle::new(),
116            reset_on_cancel: true,
117            _search_task: Task::ready(()),
118            _load_more_task: Task::ready(()),
119            _query_input_subscription,
120        }
121    }
122
123    /// Sets whether the list is searchable, default is `false`.
124    ///
125    /// When `true`, there will be a search input at the top of the list.
126    pub fn searchable(mut self, searchable: bool) -> Self {
127        self.searchable = searchable;
128        self
129    }
130
131    pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
132        self.searchable = searchable;
133        cx.notify();
134    }
135
136    /// Sets whether the list is selectable, default is true.
137    pub fn selectable(mut self, selectable: bool) -> Self {
138        self.selectable = selectable;
139        self
140    }
141
142    /// Sets whether the list is selectable, default is true.
143    pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
144        self.selectable = selectable;
145        cx.notify();
146    }
147
148    pub fn delegate(&self) -> &D {
149        &self.delegate
150    }
151
152    pub fn delegate_mut(&mut self) -> &mut D {
153        &mut self.delegate
154    }
155
156    /// Focus the list, if the list is searchable, focus the search input.
157    pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
158        self.focus_handle(cx).focus(window);
159    }
160
161    /// Return true if either the list or the search input is focused.
162    pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool {
163        self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window)
164    }
165
166    /// Set the selected index of the list,
167    /// this will also scroll to the selected item.
168    pub(crate) fn _set_selected_index(
169        &mut self,
170        ix: Option<IndexPath>,
171        window: &mut Window,
172        cx: &mut Context<Self>,
173    ) {
174        if !self.selectable {
175            return;
176        }
177
178        self.selected_index = ix;
179        self.delegate.set_selected_index(ix, window, cx);
180        self.scroll_to_selected_item(window, cx);
181    }
182
183    /// Set the selected index of the list,
184    /// this method will not scroll to the selected item.
185    pub fn set_selected_index(
186        &mut self,
187        ix: Option<IndexPath>,
188        window: &mut Window,
189        cx: &mut Context<Self>,
190    ) {
191        self.selected_index = ix;
192        self.delegate.set_selected_index(ix, window, cx);
193    }
194
195    pub fn selected_index(&self) -> Option<IndexPath> {
196        self.selected_index
197    }
198
199    /// Set a specific list item for measurement.
200    pub fn set_item_to_measure_index(
201        &mut self,
202        ix: IndexPath,
203        _: &mut Window,
204        cx: &mut Context<Self>,
205    ) {
206        self.item_to_measure_index = ix;
207        cx.notify();
208    }
209
210    /// Scroll to the item at the given index.
211    pub fn scroll_to_item(
212        &mut self,
213        ix: IndexPath,
214        strategy: ScrollStrategy,
215        _: &mut Window,
216        cx: &mut Context<Self>,
217    ) {
218        if ix.section == 0 && ix.row == 0 {
219            // If the item is the first item, scroll to the top.
220            let mut offset = self.scroll_handle.base_handle().offset();
221            offset.y = px(0.);
222            self.scroll_handle.base_handle().set_offset(offset);
223            cx.notify();
224            return;
225        }
226        self.deferred_scroll_to_index = Some((ix, strategy));
227        cx.notify();
228    }
229
230    /// Get scroll handle
231    pub fn scroll_handle(&self) -> &VirtualListScrollHandle {
232        &self.scroll_handle
233    }
234
235    pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context<Self>) {
236        if let Some(ix) = self.selected_index {
237            self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top));
238            cx.notify();
239        }
240    }
241
242    fn on_query_input_event(
243        &mut self,
244        state: &Entity<InputState>,
245        event: &InputEvent,
246        window: &mut Window,
247        cx: &mut Context<Self>,
248    ) {
249        match event {
250            InputEvent::Change => {
251                let text = state.read(cx).value();
252                let text = text.trim().to_string();
253                if Some(&text) == self.last_query.as_ref() {
254                    return;
255                }
256
257                self.set_searching(true, window, cx);
258                let search = self.delegate.perform_search(&text, window, cx);
259
260                if self.rows_cache.len() > 0 {
261                    self._set_selected_index(Some(IndexPath::default()), window, cx);
262                } else {
263                    self._set_selected_index(None, window, cx);
264                }
265
266                self._search_task = cx.spawn_in(window, async move |this, window| {
267                    search.await;
268
269                    _ = this.update_in(window, |this, _, _| {
270                        this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
271                        this.last_query = Some(text);
272                    });
273
274                    // Always wait 100ms to avoid flicker
275                    Timer::after(Duration::from_millis(100)).await;
276                    _ = this.update_in(window, |this, window, cx| {
277                        this.set_searching(false, window, cx);
278                    });
279                });
280            }
281            InputEvent::PressEnter { secondary } => self.on_action_confirm(
282                &Confirm {
283                    secondary: *secondary,
284                },
285                window,
286                cx,
287            ),
288            _ => {}
289        }
290    }
291
292    fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context<Self>) {
293        self.query_input
294            .update(cx, |input, cx| input.set_loading(searching, window, cx));
295    }
296
297    /// Dispatch delegate's `load_more` method when the
298    /// visible range is near the end.
299    fn load_more_if_need(
300        &mut self,
301        entities_count: usize,
302        visible_end: usize,
303        window: &mut Window,
304        cx: &mut Context<Self>,
305    ) {
306        // FIXME: Here need void sections items count.
307
308        let threshold = self.delegate.load_more_threshold();
309        // Securely handle subtract logic to prevent attempt
310        // to subtract with overflow
311        if visible_end >= entities_count.saturating_sub(threshold) {
312            if !self.delegate.is_eof(cx) {
313                return;
314            }
315
316            self._load_more_task = cx.spawn_in(window, async move |view, cx| {
317                _ = view.update_in(cx, |view, window, cx| {
318                    view.delegate.load_more(window, cx);
319                });
320            });
321        }
322    }
323
324    pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self {
325        self.reset_on_cancel = reset;
326        self
327    }
328
329    fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
330        cx.propagate();
331        if self.reset_on_cancel {
332            self._set_selected_index(None, window, cx);
333        }
334
335        self.delegate.cancel(window, cx);
336        cx.emit(ListEvent::Cancel);
337        cx.notify();
338    }
339
340    fn on_action_confirm(
341        &mut self,
342        confirm: &Confirm,
343        window: &mut Window,
344        cx: &mut Context<Self>,
345    ) {
346        if self.rows_cache.len() == 0 {
347            return;
348        }
349
350        let Some(ix) = self.selected_index else {
351            return;
352        };
353
354        self.delegate
355            .set_selected_index(self.selected_index, window, cx);
356        self.delegate.confirm(confirm.secondary, window, cx);
357        cx.emit(ListEvent::Confirm(ix));
358        cx.notify();
359    }
360
361    fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context<Self>) {
362        if !self.selectable {
363            return;
364        }
365
366        self.selected_index = Some(ix);
367        self.delegate.set_selected_index(Some(ix), window, cx);
368        self.scroll_to_selected_item(window, cx);
369        cx.emit(ListEvent::Select(ix));
370        cx.notify();
371    }
372
373    pub(crate) fn on_action_select_prev(
374        &mut self,
375        _: &SelectUp,
376        window: &mut Window,
377        cx: &mut Context<Self>,
378    ) {
379        if self.rows_cache.len() == 0 {
380            return;
381        }
382
383        let prev_ix = self.rows_cache.prev(self.selected_index);
384        self.select_item(prev_ix, window, cx);
385    }
386
387    pub(crate) fn on_action_select_next(
388        &mut self,
389        _: &SelectDown,
390        window: &mut Window,
391        cx: &mut Context<Self>,
392    ) {
393        if self.rows_cache.len() == 0 {
394            return;
395        }
396
397        let next_ix = self.rows_cache.next(self.selected_index);
398        self.select_item(next_ix, window, cx);
399    }
400
401    fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
402        let sections_count = self.delegate.sections_count(cx);
403
404        let mut measured_size = MeasuredEntrySize::default();
405
406        // Measure the item_height and section header/footer height.
407        let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
408        measured_size.item_size = self
409            .render_list_item(self.item_to_measure_index, window, cx)
410            .into_any_element()
411            .layout_as_root(available_space, window, cx);
412
413        if let Some(mut el) = self
414            .delegate
415            .render_section_header(0, window, cx)
416            .map(|r| r.into_any_element())
417        {
418            measured_size.section_header_size = el.layout_as_root(available_space, window, cx);
419        }
420        if let Some(mut el) = self
421            .delegate
422            .render_section_footer(0, window, cx)
423            .map(|r| r.into_any_element())
424        {
425            measured_size.section_footer_size = el.layout_as_root(available_space, window, cx);
426        }
427
428        self.rows_cache
429            .prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| {
430                self.delegate.items_count(section_ix, cx)
431            });
432    }
433
434    fn render_list_item(
435        &mut self,
436        ix: IndexPath,
437        window: &mut Window,
438        cx: &mut Context<Self>,
439    ) -> impl IntoElement {
440        let selectable = self.selectable;
441        let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false);
442        let mouse_right_clicked = self
443            .mouse_right_clicked_index
444            .map(|s| s.eq_row(ix))
445            .unwrap_or(false);
446        let id = SharedString::from(format!("list-item-{}", ix));
447
448        div()
449            .id(id)
450            .w_full()
451            .relative()
452            .overflow_hidden()
453            .children(self.delegate.render_item(ix, window, cx).map(|item| {
454                item.selected(selected)
455                    .secondary_selected(mouse_right_clicked)
456            }))
457            .when(selectable, |this| {
458                this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
459                    this.mouse_right_clicked_index = None;
460                    this.selected_index = Some(ix);
461                    this.on_action_confirm(
462                        &Confirm {
463                            secondary: e.modifiers().secondary(),
464                        },
465                        window,
466                        cx,
467                    );
468                }))
469                .on_mouse_down(
470                    MouseButton::Right,
471                    cx.listener(move |this, _, _, cx| {
472                        this.mouse_right_clicked_index = Some(ix);
473                        cx.notify();
474                    }),
475                )
476            })
477    }
478
479    fn render_items(
480        &mut self,
481        items_count: usize,
482        entities_count: usize,
483        window: &mut Window,
484        cx: &mut Context<Self>,
485    ) -> impl IntoElement {
486        let rows_cache = self.rows_cache.clone();
487        let scrollbar_visible = self.options.scrollbar_visible;
488        let scroll_handle = self.scroll_handle.clone();
489
490        v_flex()
491            .flex_grow()
492            .relative()
493            .size_full()
494            .when_some(self.options.max_height, |this, h| this.max_h(h))
495            .overflow_hidden()
496            .when(items_count == 0, |this| {
497                this.child(self.delegate.render_empty(window, cx))
498            })
499            .when(items_count > 0, {
500                |this| {
501                    this.child(
502                        v_virtual_list(
503                            cx.entity(),
504                            "virtual-list",
505                            rows_cache.entries_sizes.clone(),
506                            move |list, visible_range: Range<usize>, window, cx| {
507                                list.load_more_if_need(
508                                    entities_count,
509                                    visible_range.end,
510                                    window,
511                                    cx,
512                                );
513
514                                // NOTE: Here the v_virtual_list would not able to have gap_y,
515                                // because the section header, footer is always have rendered as a empty child item,
516                                // even the delegate give a None result.
517
518                                visible_range
519                                    .map(|ix| {
520                                        let Some(entry) = rows_cache.get(ix) else {
521                                            return div();
522                                        };
523
524                                        div().children(match entry {
525                                            RowEntry::Entry(index) => Some(
526                                                list.render_list_item(index, window, cx)
527                                                    .into_any_element(),
528                                            ),
529                                            RowEntry::SectionHeader(section_ix) => list
530                                                .delegate_mut()
531                                                .render_section_header(section_ix, window, cx)
532                                                .map(|r| r.into_any_element()),
533                                            RowEntry::SectionFooter(section_ix) => list
534                                                .delegate_mut()
535                                                .render_section_footer(section_ix, window, cx)
536                                                .map(|r| r.into_any_element()),
537                                        })
538                                    })
539                                    .collect::<Vec<_>>()
540                            },
541                        )
542                        .paddings(self.options.paddings.clone())
543                        .when(self.options.max_height.is_some(), |this| {
544                            this.with_sizing_behavior(ListSizingBehavior::Infer)
545                        })
546                        .track_scroll(&scroll_handle)
547                        .into_any_element(),
548                    )
549                }
550            })
551            .when(scrollbar_visible, |this| {
552                this.child(Scrollbar::vertical(&scroll_handle))
553            })
554    }
555}
556
557impl<D> Focusable for ListState<D>
558where
559    D: ListDelegate,
560{
561    fn focus_handle(&self, cx: &App) -> FocusHandle {
562        if self.searchable {
563            self.query_input.focus_handle(cx)
564        } else {
565            self.focus_handle.clone()
566        }
567    }
568}
569impl<D> EventEmitter<ListEvent> for ListState<D> where D: ListDelegate {}
570impl<D> Render for ListState<D>
571where
572    D: ListDelegate,
573{
574    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
575        self.prepare_items_if_needed(window, cx);
576
577        // Scroll to the selected item if it is set.
578        if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() {
579            if let Some(item_ix) = self.rows_cache.position_of(&ix) {
580                self.scroll_handle.scroll_to_item(item_ix, strategy);
581            }
582        }
583
584        let loading = self.delegate().loading(cx);
585        let query_input = if self.searchable {
586            // sync placeholder
587            if let Some(placeholder) = &self.options.search_placeholder {
588                self.query_input.update(cx, |input, cx| {
589                    input.set_placeholder(placeholder.clone(), window, cx);
590                });
591            }
592            Some(self.query_input.clone())
593        } else {
594            None
595        };
596
597        let loading_view = if loading {
598            Some(self.delegate.render_loading(window, cx).into_any_element())
599        } else {
600            None
601        };
602        let initial_view = if let Some(input) = &query_input {
603            if input.read(cx).value().is_empty() {
604                self.delegate.render_initial(window, cx)
605            } else {
606                None
607            }
608        } else {
609            None
610        };
611        let items_count = self.rows_cache.items_count();
612        let entities_count = self.rows_cache.len();
613        let mouse_right_clicked_index = self.mouse_right_clicked_index;
614
615        v_flex()
616            .key_context("List")
617            .id("list-state")
618            .track_focus(&self.focus_handle)
619            .size_full()
620            .relative()
621            .overflow_hidden()
622            .when_some(query_input, |this, input| {
623                this.child(
624                    div()
625                        .map(|this| match self.options.size {
626                            Size::Small => this.px_1p5(),
627                            _ => this.px_2(),
628                        })
629                        .border_b_1()
630                        .border_color(cx.theme().border)
631                        .child(
632                            Input::new(&input)
633                                .with_size(self.options.size)
634                                .prefix(
635                                    Icon::new(IconName::Search)
636                                        .text_color(cx.theme().muted_foreground),
637                                )
638                                .cleanable(true)
639                                .p_0()
640                                .appearance(false),
641                        ),
642                )
643            })
644            .when(!loading, |this| {
645                this.on_action(cx.listener(Self::on_action_cancel))
646                    .on_action(cx.listener(Self::on_action_confirm))
647                    .on_action(cx.listener(Self::on_action_select_next))
648                    .on_action(cx.listener(Self::on_action_select_prev))
649                    .map(|this| {
650                        if let Some(view) = initial_view {
651                            this.child(view)
652                        } else {
653                            this.child(self.render_items(items_count, entities_count, window, cx))
654                        }
655                    })
656                    // Click out to cancel right clicked row
657                    .when(mouse_right_clicked_index.is_some(), |this| {
658                        this.on_mouse_down_out(cx.listener(|this, _, _, cx| {
659                            this.mouse_right_clicked_index = None;
660                            cx.notify();
661                        }))
662                    })
663            })
664            .children(loading_view)
665    }
666}
667
668/// The List element.
669#[derive(IntoElement)]
670pub struct List<D: ListDelegate + 'static> {
671    state: Entity<ListState<D>>,
672    style: StyleRefinement,
673    options: ListOptions,
674}
675
676impl<D> List<D>
677where
678    D: ListDelegate + 'static,
679{
680    /// Create a new List element with the given ListState entity.
681    pub fn new(state: &Entity<ListState<D>>) -> Self {
682        Self {
683            state: state.clone(),
684            style: StyleRefinement::default(),
685            options: ListOptions::default(),
686        }
687    }
688
689    /// Set whether the scrollbar is visible, default is `true`.
690    pub fn scrollbar_visible(mut self, visible: bool) -> Self {
691        self.options.scrollbar_visible = visible;
692        self
693    }
694
695    /// Sets the placeholder text for the search input.
696    pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
697        self.options.search_placeholder = Some(placeholder.into());
698        self
699    }
700}
701
702impl<D> Styled for List<D>
703where
704    D: ListDelegate + 'static,
705{
706    fn style(&mut self) -> &mut StyleRefinement {
707        &mut self.style
708    }
709}
710
711impl<D> Sizable for List<D>
712where
713    D: ListDelegate + 'static,
714{
715    fn with_size(mut self, size: impl Into<Size>) -> Self {
716        self.options.size = size.into();
717        self
718    }
719}
720
721impl<D> RenderOnce for List<D>
722where
723    D: ListDelegate + 'static,
724{
725    fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
726        // Take paddings, max_height to options, and clear them from style,
727        // because they would be applied to the inner virtual list.
728        self.options.paddings = self.style.padding.clone();
729        self.options.max_height = self.style.max_size.height;
730        self.style.padding = EdgesRefinement::default();
731        self.style.max_size.height = None;
732
733        self.state.update(cx, |state, _| {
734            state.options = self.options;
735        });
736
737        div()
738            .id("list")
739            .size_full()
740            .refine_style(&self.style)
741            .child(self.state.clone())
742    }
743}