picker.rs

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