picker.rs

  1use editor::Editor;
  2use gpui::{
  3    div, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent, DismissEvent,
  4    EventEmitter, FocusHandle, FocusableView, Length, ListState, Render, Task,
  5    UniformListScrollHandle, View, ViewContext, WindowContext,
  6};
  7use std::{sync::Arc, time::Duration};
  8use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
  9use workspace::ModalView;
 10
 11pub mod highlighted_match_with_paths;
 12
 13enum ElementContainer {
 14    List(ListState),
 15    UniformList(UniformListScrollHandle),
 16}
 17
 18pub struct Picker<D: PickerDelegate> {
 19    pub delegate: D,
 20    element_container: ElementContainer,
 21    editor: View<Editor>,
 22    pending_update_matches: Option<Task<()>>,
 23    confirm_on_update: Option<bool>,
 24    width: Option<Length>,
 25    max_height: Option<Length>,
 26
 27    /// Whether the `Picker` is rendered as a self-contained modal.
 28    ///
 29    /// Set this to `false` when rendering the `Picker` as part of a larger modal.
 30    is_modal: bool,
 31}
 32
 33pub trait PickerDelegate: Sized + 'static {
 34    type ListItem: IntoElement;
 35
 36    fn match_count(&self) -> usize;
 37    fn selected_index(&self) -> usize;
 38    fn separators_after_indices(&self) -> Vec<usize> {
 39        Vec::new()
 40    }
 41    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>);
 42
 43    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str>;
 44    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
 45
 46    // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background
 47    // work for up to `duration` to try and get a result synchronously.
 48    // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes
 49    // mostly work when dismissing a palette.
 50    fn finalize_update_matches(
 51        &mut self,
 52        _query: String,
 53        _duration: Duration,
 54        _cx: &mut ViewContext<Picker<Self>>,
 55    ) -> bool {
 56        false
 57    }
 58
 59    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
 60    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
 61    fn selected_as_query(&self) -> Option<String> {
 62        None
 63    }
 64
 65    fn render_match(
 66        &self,
 67        ix: usize,
 68        selected: bool,
 69        cx: &mut ViewContext<Picker<Self>>,
 70    ) -> Option<Self::ListItem>;
 71    fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
 72        None
 73    }
 74    fn render_footer(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
 75        None
 76    }
 77}
 78
 79impl<D: PickerDelegate> FocusableView for Picker<D> {
 80    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 81        self.editor.focus_handle(cx)
 82    }
 83}
 84
 85fn create_editor(placeholder: Arc<str>, cx: &mut WindowContext<'_>) -> View<Editor> {
 86    cx.new_view(|cx| {
 87        let mut editor = Editor::single_line(cx);
 88        editor.set_placeholder_text(placeholder, cx);
 89        editor
 90    })
 91}
 92
 93impl<D: PickerDelegate> Picker<D> {
 94    /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
 95    /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
 96    pub fn uniform_list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
 97        Self::new(delegate, cx, true)
 98    }
 99
100    /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
101    /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
102    pub fn list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
103        Self::new(delegate, cx, false)
104    }
105
106    fn new(delegate: D, cx: &mut ViewContext<Self>, is_uniform: bool) -> Self {
107        let editor = create_editor(delegate.placeholder_text(cx), cx);
108        cx.subscribe(&editor, Self::on_input_editor_event).detach();
109        let mut this = Self {
110            delegate,
111            editor,
112            element_container: Self::create_element_container(is_uniform, cx),
113            pending_update_matches: None,
114            confirm_on_update: None,
115            width: None,
116            max_height: None,
117            is_modal: true,
118        };
119        this.update_matches("".to_string(), cx);
120        // give the delegate 4ms to renderthe first set of suggestions.
121        this.delegate
122            .finalize_update_matches("".to_string(), Duration::from_millis(4), cx);
123        this
124    }
125
126    fn create_element_container(is_uniform: bool, cx: &mut ViewContext<Self>) -> ElementContainer {
127        if is_uniform {
128            ElementContainer::UniformList(UniformListScrollHandle::new())
129        } else {
130            let view = cx.view().downgrade();
131            ElementContainer::List(ListState::new(
132                0,
133                gpui::ListAlignment::Top,
134                px(1000.),
135                move |ix, cx| {
136                    view.upgrade()
137                        .map(|view| {
138                            view.update(cx, |this, cx| {
139                                this.render_element(cx, ix).into_any_element()
140                            })
141                        })
142                        .unwrap_or_else(|| div().into_any_element())
143                },
144            ))
145        }
146    }
147
148    pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
149        self.width = Some(width.into());
150        self
151    }
152
153    pub fn max_height(mut self, max_height: impl Into<gpui::Length>) -> Self {
154        self.max_height = Some(max_height.into());
155        self
156    }
157
158    pub fn modal(mut self, modal: bool) -> Self {
159        self.is_modal = modal;
160        self
161    }
162
163    pub fn focus(&self, cx: &mut WindowContext) {
164        self.editor.update(cx, |editor, cx| editor.focus(cx));
165    }
166
167    pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
168        let count = self.delegate.match_count();
169        if count > 0 {
170            let index = self.delegate.selected_index();
171            let ix = if index == count - 1 { 0 } else { index + 1 };
172            self.delegate.set_selected_index(ix, cx);
173            self.scroll_to_item_index(ix);
174            cx.notify();
175        }
176    }
177
178    fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
179        let count = self.delegate.match_count();
180        if count > 0 {
181            let index = self.delegate.selected_index();
182            let ix = if index == 0 { count - 1 } else { index - 1 };
183            self.delegate.set_selected_index(ix, cx);
184            self.scroll_to_item_index(ix);
185            cx.notify();
186        }
187    }
188
189    fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
190        let count = self.delegate.match_count();
191        if count > 0 {
192            self.delegate.set_selected_index(0, cx);
193            self.scroll_to_item_index(0);
194            cx.notify();
195        }
196    }
197
198    fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
199        let count = self.delegate.match_count();
200        if count > 0 {
201            self.delegate.set_selected_index(count - 1, cx);
202            self.scroll_to_item_index(count - 1);
203            cx.notify();
204        }
205    }
206
207    pub fn cycle_selection(&mut self, cx: &mut ViewContext<Self>) {
208        let count = self.delegate.match_count();
209        let index = self.delegate.selected_index();
210        let new_index = if index + 1 == count { 0 } else { index + 1 };
211        self.delegate.set_selected_index(new_index, cx);
212        self.scroll_to_item_index(new_index);
213        cx.notify();
214    }
215
216    pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
217        self.delegate.dismissed(cx);
218        cx.emit(DismissEvent);
219    }
220
221    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
222        if self.pending_update_matches.is_some()
223            && !self
224                .delegate
225                .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
226        {
227            self.confirm_on_update = Some(false)
228        } else {
229            self.pending_update_matches.take();
230            self.delegate.confirm(false, cx);
231        }
232    }
233
234    fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
235        if self.pending_update_matches.is_some()
236            && !self
237                .delegate
238                .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
239        {
240            self.confirm_on_update = Some(true)
241        } else {
242            self.delegate.confirm(true, cx);
243        }
244    }
245
246    fn use_selected_query(&mut self, _: &menu::UseSelectedQuery, cx: &mut ViewContext<Self>) {
247        if let Some(new_query) = self.delegate.selected_as_query() {
248            self.set_query(new_query, cx);
249            cx.stop_propagation();
250        }
251    }
252
253    fn handle_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext<Self>) {
254        cx.stop_propagation();
255        cx.prevent_default();
256        self.delegate.set_selected_index(ix, cx);
257        self.delegate.confirm(secondary, cx);
258    }
259
260    fn on_input_editor_event(
261        &mut self,
262        _: View<Editor>,
263        event: &editor::EditorEvent,
264        cx: &mut ViewContext<Self>,
265    ) {
266        match event {
267            editor::EditorEvent::BufferEdited => {
268                let query = self.editor.read(cx).text(cx);
269                self.update_matches(query, cx);
270            }
271            editor::EditorEvent::Blurred => {
272                self.cancel(&menu::Cancel, cx);
273            }
274            _ => {}
275        }
276    }
277
278    pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
279        let query = self.editor.read(cx).text(cx);
280        self.update_matches(query, cx);
281    }
282
283    pub fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) {
284        let update = self.delegate.update_matches(query, cx);
285        self.matches_updated(cx);
286        self.pending_update_matches = Some(cx.spawn(|this, mut cx| async move {
287            update.await;
288            this.update(&mut cx, |this, cx| {
289                this.matches_updated(cx);
290            })
291            .ok();
292        }));
293    }
294
295    fn matches_updated(&mut self, cx: &mut ViewContext<Self>) {
296        if let ElementContainer::List(state) = &mut self.element_container {
297            state.reset(self.delegate.match_count());
298        }
299
300        let index = self.delegate.selected_index();
301        self.scroll_to_item_index(index);
302        self.pending_update_matches = None;
303        if let Some(secondary) = self.confirm_on_update.take() {
304            self.delegate.confirm(secondary, cx);
305        }
306        cx.notify();
307    }
308
309    pub fn query(&self, cx: &AppContext) -> String {
310        self.editor.read(cx).text(cx)
311    }
312
313    pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
314        self.editor
315            .update(cx, |editor, cx| editor.set_text(query, cx));
316    }
317
318    fn scroll_to_item_index(&mut self, ix: usize) {
319        match &mut self.element_container {
320            ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
321            ElementContainer::UniformList(scroll_handle) => scroll_handle.scroll_to_item(ix),
322        }
323    }
324
325    fn render_element(&self, cx: &mut ViewContext<Self>, ix: usize) -> impl IntoElement {
326        div()
327            .id(("item", ix))
328            .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
329                this.handle_click(ix, event.down.modifiers.command, cx)
330            }))
331            .children(
332                self.delegate
333                    .render_match(ix, ix == self.delegate.selected_index(), cx),
334            )
335            .when(
336                self.delegate.separators_after_indices().contains(&ix),
337                |picker| {
338                    picker
339                        .border_color(cx.theme().colors().border_variant)
340                        .border_b_1()
341                        .pb(px(-1.0))
342                },
343            )
344    }
345
346    fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
347        match &self.element_container {
348            ElementContainer::UniformList(scroll_handle) => uniform_list(
349                cx.view().clone(),
350                "candidates",
351                self.delegate.match_count(),
352                move |picker, visible_range, cx| {
353                    visible_range
354                        .map(|ix| picker.render_element(cx, ix))
355                        .collect()
356                },
357            )
358            .py_2()
359            .track_scroll(scroll_handle.clone())
360            .into_any_element(),
361            ElementContainer::List(state) => list(state.clone())
362                .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
363                .py_2()
364                .into_any_element(),
365        }
366    }
367}
368
369impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
370impl<D: PickerDelegate> ModalView for Picker<D> {}
371
372impl<D: PickerDelegate> Render for Picker<D> {
373    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
374        let picker_editor = h_flex()
375            .overflow_hidden()
376            .flex_none()
377            .h_9()
378            .px_4()
379            .child(self.editor.clone());
380
381        div()
382            .key_context("Picker")
383            .size_full()
384            .when_some(self.width, |el, width| el.w(width))
385            .overflow_hidden()
386            // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
387            // as a part of a modal rather than the entire modal.
388            //
389            // We should revisit how the `Picker` is styled to make it more composable.
390            .when(self.is_modal, |this| this.elevation_3(cx))
391            .on_action(cx.listener(Self::select_next))
392            .on_action(cx.listener(Self::select_prev))
393            .on_action(cx.listener(Self::select_first))
394            .on_action(cx.listener(Self::select_last))
395            .on_action(cx.listener(Self::cancel))
396            .on_action(cx.listener(Self::confirm))
397            .on_action(cx.listener(Self::secondary_confirm))
398            .on_action(cx.listener(Self::use_selected_query))
399            .child(picker_editor)
400            .child(Divider::horizontal())
401            .when(self.delegate.match_count() > 0, |el| {
402                el.child(
403                    v_flex()
404                        .flex_grow()
405                        .max_h(self.max_height.unwrap_or(rems(18.).into()))
406                        .overflow_hidden()
407                        .children(self.delegate.render_header(cx))
408                        .child(self.render_element_container(cx)),
409                )
410            })
411            .when(self.delegate.match_count() == 0, |el| {
412                el.child(
413                    v_flex().flex_grow().py_2().child(
414                        ListItem::new("empty_state")
415                            .inset(true)
416                            .spacing(ListItemSpacing::Sparse)
417                            .disabled(true)
418                            .child(Label::new("No matches").color(Color::Muted)),
419                    ),
420                )
421            })
422            .children(self.delegate.render_footer(cx))
423    }
424}