picker.rs

  1use anyhow::Result;
  2use editor::{scroll::Autoscroll, Editor};
  3use gpui::{
  4    actions, div, impl_actions, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent,
  5    DismissEvent, EventEmitter, FocusHandle, FocusableView, Length, ListState, MouseButton,
  6    MouseUpEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext,
  7};
  8use head::Head;
  9use serde::Deserialize;
 10use std::{sync::Arc, time::Duration};
 11use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
 12use workspace::ModalView;
 13
 14mod head;
 15pub mod highlighted_match_with_paths;
 16
 17enum ElementContainer {
 18    List(ListState),
 19    UniformList(UniformListScrollHandle),
 20}
 21
 22actions!(picker, [UseSelectedQuery]);
 23
 24/// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally,
 25/// performing some kind of action on it.
 26#[derive(PartialEq, Clone, Deserialize, Default)]
 27pub struct ConfirmInput {
 28    pub secondary: bool,
 29}
 30
 31impl_actions!(picker, [ConfirmInput]);
 32
 33struct PendingUpdateMatches {
 34    delegate_update_matches: Option<Task<()>>,
 35    _task: Task<Result<()>>,
 36}
 37
 38pub struct Picker<D: PickerDelegate> {
 39    pub delegate: D,
 40    element_container: ElementContainer,
 41    head: Head,
 42    pending_update_matches: Option<PendingUpdateMatches>,
 43    confirm_on_update: Option<bool>,
 44    width: Option<Length>,
 45    max_height: Option<Length>,
 46
 47    /// Whether the `Picker` is rendered as a self-contained modal.
 48    ///
 49    /// Set this to `false` when rendering the `Picker` as part of a larger modal.
 50    is_modal: bool,
 51}
 52
 53pub trait PickerDelegate: Sized + 'static {
 54    type ListItem: IntoElement;
 55
 56    fn match_count(&self) -> usize;
 57    fn selected_index(&self) -> usize;
 58    fn separators_after_indices(&self) -> Vec<usize> {
 59        Vec::new()
 60    }
 61    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>);
 62    // Allows binding some optional effect to when the selection changes.
 63    fn selected_index_changed(
 64        &self,
 65        _ix: usize,
 66        _cx: &mut ViewContext<Picker<Self>>,
 67    ) -> Option<Box<dyn Fn(&mut WindowContext) + 'static>> {
 68        None
 69    }
 70    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str>;
 71    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
 72        "No matches".into()
 73    }
 74    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
 75
 76    // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background
 77    // work for up to `duration` to try and get a result synchronously.
 78    // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes
 79    // mostly work when dismissing a palette.
 80    fn finalize_update_matches(
 81        &mut self,
 82        _query: String,
 83        _duration: Duration,
 84        _cx: &mut ViewContext<Picker<Self>>,
 85    ) -> bool {
 86        false
 87    }
 88
 89    fn confirm_update_query(&mut self, _cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
 90        None
 91    }
 92
 93    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
 94    /// Instead of interacting with currently selected entry, treats editor input literally,
 95    /// performing some kind of action on it.
 96    fn confirm_input(&mut self, _secondary: bool, _: &mut ViewContext<Picker<Self>>) {}
 97    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
 98    fn should_dismiss(&self) -> bool {
 99        true
100    }
101    fn selected_as_query(&self) -> Option<String> {
102        None
103    }
104
105    fn render_match(
106        &self,
107        ix: usize,
108        selected: bool,
109        cx: &mut ViewContext<Picker<Self>>,
110    ) -> Option<Self::ListItem>;
111    fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
112        None
113    }
114    fn render_footer(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
115        None
116    }
117}
118
119impl<D: PickerDelegate> FocusableView for Picker<D> {
120    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
121        match &self.head {
122            Head::Editor(editor) => editor.focus_handle(cx),
123            Head::Empty(head) => head.focus_handle(cx),
124        }
125    }
126}
127
128#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
129enum ContainerKind {
130    List,
131    UniformList,
132}
133
134impl<D: PickerDelegate> Picker<D> {
135    /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
136    /// The picker allows the user to perform search items by text.
137    /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
138    pub fn uniform_list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
139        let head = Head::editor(
140            delegate.placeholder_text(cx),
141            Self::on_input_editor_event,
142            cx,
143        );
144
145        Self::new(delegate, ContainerKind::UniformList, head, cx)
146    }
147
148    /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
149    /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
150    pub fn nonsearchable_uniform_list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
151        let head = Head::empty(Self::on_empty_head_blur, cx);
152
153        Self::new(delegate, ContainerKind::UniformList, head, cx)
154    }
155
156    /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
157    /// The picker allows the user to perform search items by text.
158    /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
159    pub fn list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
160        let head = Head::editor(
161            delegate.placeholder_text(cx),
162            Self::on_input_editor_event,
163            cx,
164        );
165
166        Self::new(delegate, ContainerKind::List, head, cx)
167    }
168
169    fn new(delegate: D, container: ContainerKind, head: Head, cx: &mut ViewContext<Self>) -> Self {
170        let mut this = Self {
171            delegate,
172            head,
173            element_container: Self::create_element_container(container, cx),
174            pending_update_matches: None,
175            confirm_on_update: None,
176            width: None,
177            max_height: None,
178            is_modal: true,
179        };
180        this.update_matches("".to_string(), cx);
181        // give the delegate 4ms to render the first set of suggestions.
182        this.delegate
183            .finalize_update_matches("".to_string(), Duration::from_millis(4), cx);
184        this
185    }
186
187    fn create_element_container(
188        container: ContainerKind,
189        cx: &mut ViewContext<Self>,
190    ) -> ElementContainer {
191        match container {
192            ContainerKind::UniformList => {
193                ElementContainer::UniformList(UniformListScrollHandle::new())
194            }
195            ContainerKind::List => {
196                let view = cx.view().downgrade();
197                ElementContainer::List(ListState::new(
198                    0,
199                    gpui::ListAlignment::Top,
200                    px(1000.),
201                    move |ix, cx| {
202                        view.upgrade()
203                            .map(|view| {
204                                view.update(cx, |this, cx| {
205                                    this.render_element(cx, ix).into_any_element()
206                                })
207                            })
208                            .unwrap_or_else(|| div().into_any_element())
209                    },
210                ))
211            }
212        }
213    }
214
215    pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
216        self.width = Some(width.into());
217        self
218    }
219
220    pub fn max_height(mut self, max_height: impl Into<gpui::Length>) -> Self {
221        self.max_height = Some(max_height.into());
222        self
223    }
224
225    pub fn modal(mut self, modal: bool) -> Self {
226        self.is_modal = modal;
227        self
228    }
229
230    pub fn focus(&self, cx: &mut WindowContext) {
231        self.focus_handle(cx).focus(cx);
232    }
233
234    /// Handles the selecting an index, and passing the change to the delegate.
235    /// If `scroll_to_index` is true, the new selected index will be scrolled into view.
236    ///
237    /// If some effect is bound to `selected_index_changed`, it will be executed.
238    fn set_selected_index(&mut self, ix: usize, scroll_to_index: bool, cx: &mut ViewContext<Self>) {
239        let previous_index = self.delegate.selected_index();
240        self.delegate.set_selected_index(ix, cx);
241        let current_index = self.delegate.selected_index();
242
243        if previous_index != current_index {
244            if let Some(action) = self.delegate.selected_index_changed(ix, cx) {
245                action(cx);
246            }
247            if scroll_to_index {
248                self.scroll_to_item_index(ix);
249            }
250        }
251    }
252
253    pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
254        let count = self.delegate.match_count();
255        if count > 0 {
256            let index = self.delegate.selected_index();
257            let ix = if index == count - 1 { 0 } else { index + 1 };
258            self.set_selected_index(ix, true, cx);
259            cx.notify();
260        }
261    }
262
263    fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
264        let count = self.delegate.match_count();
265        if count > 0 {
266            let index = self.delegate.selected_index();
267            let ix = if index == 0 { count - 1 } else { index - 1 };
268            self.set_selected_index(ix, true, cx);
269            cx.notify();
270        }
271    }
272
273    fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
274        let count = self.delegate.match_count();
275        if count > 0 {
276            self.set_selected_index(0, true, cx);
277            cx.notify();
278        }
279    }
280
281    fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
282        let count = self.delegate.match_count();
283        if count > 0 {
284            self.delegate.set_selected_index(count - 1, cx);
285            self.set_selected_index(count - 1, true, cx);
286            cx.notify();
287        }
288    }
289
290    pub fn cycle_selection(&mut self, cx: &mut ViewContext<Self>) {
291        let count = self.delegate.match_count();
292        let index = self.delegate.selected_index();
293        let new_index = if index + 1 == count { 0 } else { index + 1 };
294        self.set_selected_index(new_index, false, cx);
295        cx.notify();
296    }
297
298    pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
299        if self.delegate.should_dismiss() {
300            self.delegate.dismissed(cx);
301            cx.emit(DismissEvent);
302        }
303    }
304
305    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
306        if self.pending_update_matches.is_some()
307            && !self
308                .delegate
309                .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
310        {
311            self.confirm_on_update = Some(false)
312        } else {
313            self.pending_update_matches.take();
314            self.do_confirm(false, cx);
315        }
316    }
317
318    fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
319        if self.pending_update_matches.is_some()
320            && !self
321                .delegate
322                .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
323        {
324            self.confirm_on_update = Some(true)
325        } else {
326            self.do_confirm(true, cx);
327        }
328    }
329
330    fn confirm_input(&mut self, input: &ConfirmInput, cx: &mut ViewContext<Self>) {
331        self.delegate.confirm_input(input.secondary, cx);
332    }
333
334    fn use_selected_query(&mut self, _: &UseSelectedQuery, cx: &mut ViewContext<Self>) {
335        if let Some(new_query) = self.delegate.selected_as_query() {
336            self.set_query(new_query, cx);
337            cx.stop_propagation();
338        }
339    }
340
341    fn handle_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext<Self>) {
342        cx.stop_propagation();
343        cx.prevent_default();
344        self.set_selected_index(ix, false, cx);
345        self.do_confirm(secondary, cx)
346    }
347
348    fn do_confirm(&mut self, secondary: bool, cx: &mut ViewContext<Self>) {
349        if let Some(update_query) = self.delegate.confirm_update_query(cx) {
350            self.set_query(update_query, cx);
351            self.delegate.set_selected_index(0, cx);
352        } else {
353            self.delegate.confirm(secondary, cx)
354        }
355    }
356
357    fn on_input_editor_event(
358        &mut self,
359        _: View<Editor>,
360        event: &editor::EditorEvent,
361        cx: &mut ViewContext<Self>,
362    ) {
363        let Head::Editor(ref editor) = &self.head else {
364            panic!("unexpected call");
365        };
366        match event {
367            editor::EditorEvent::BufferEdited => {
368                let query = editor.read(cx).text(cx);
369                self.update_matches(query, cx);
370            }
371            editor::EditorEvent::Blurred => {
372                self.cancel(&menu::Cancel, cx);
373            }
374            _ => {}
375        }
376    }
377
378    fn on_empty_head_blur(&mut self, cx: &mut ViewContext<Self>) {
379        let Head::Empty(_) = &self.head else {
380            panic!("unexpected call");
381        };
382        self.cancel(&menu::Cancel, cx);
383    }
384
385    pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
386        let query = self.query(cx);
387        self.update_matches(query, cx);
388    }
389
390    pub fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) {
391        let delegate_pending_update_matches = self.delegate.update_matches(query, cx);
392
393        self.matches_updated(cx);
394        // This struct ensures that we can synchronously drop the task returned by the
395        // delegate's `update_matches` method and the task that the picker is spawning.
396        // If we simply capture the delegate's task into the picker's task, when the picker's
397        // task gets synchronously dropped, the delegate's task would keep running until
398        // the picker's task has a chance of being scheduled, because dropping a task happens
399        // asynchronously.
400        self.pending_update_matches = Some(PendingUpdateMatches {
401            delegate_update_matches: Some(delegate_pending_update_matches),
402            _task: cx.spawn(|this, mut cx| async move {
403                let delegate_pending_update_matches = this.update(&mut cx, |this, _| {
404                    this.pending_update_matches
405                        .as_mut()
406                        .unwrap()
407                        .delegate_update_matches
408                        .take()
409                        .unwrap()
410                })?;
411                delegate_pending_update_matches.await;
412                this.update(&mut cx, |this, cx| {
413                    this.matches_updated(cx);
414                })
415            }),
416        });
417    }
418
419    fn matches_updated(&mut self, cx: &mut ViewContext<Self>) {
420        if let ElementContainer::List(state) = &mut self.element_container {
421            state.reset(self.delegate.match_count());
422        }
423
424        let index = self.delegate.selected_index();
425        self.scroll_to_item_index(index);
426        self.pending_update_matches = None;
427        if let Some(secondary) = self.confirm_on_update.take() {
428            self.do_confirm(secondary, cx);
429        }
430        cx.notify();
431    }
432
433    pub fn query(&self, cx: &AppContext) -> String {
434        match &self.head {
435            Head::Editor(editor) => editor.read(cx).text(cx),
436            Head::Empty(_) => "".to_string(),
437        }
438    }
439
440    pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
441        if let Head::Editor(ref editor) = &self.head {
442            editor.update(cx, |editor, cx| {
443                editor.set_text(query, cx);
444                let editor_offset = editor.buffer().read(cx).len(cx);
445                editor.change_selections(Some(Autoscroll::Next), cx, |s| {
446                    s.select_ranges(Some(editor_offset..editor_offset))
447                });
448            });
449        }
450    }
451
452    fn scroll_to_item_index(&mut self, ix: usize) {
453        match &mut self.element_container {
454            ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
455            ElementContainer::UniformList(scroll_handle) => scroll_handle.scroll_to_item(ix),
456        }
457    }
458
459    fn render_element(&self, cx: &mut ViewContext<Self>, ix: usize) -> impl IntoElement {
460        div()
461            .id(("item", ix))
462            .cursor_pointer()
463            .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
464                this.handle_click(ix, event.down.modifiers.secondary(), cx)
465            }))
466            // As of this writing, GPUI intercepts `ctrl-[mouse-event]`s on macOS
467            // and produces right mouse button events. This matches platforms norms
468            // but means that UIs which depend on holding ctrl down (such as the tab
469            // switcher) can't be clicked on. Hence, this handler.
470            .on_mouse_up(
471                MouseButton::Right,
472                cx.listener(move |this, event: &MouseUpEvent, cx| {
473                    // We specficially want to use the platform key here, as
474                    // ctrl will already be held down for the tab switcher.
475                    this.handle_click(ix, event.modifiers.platform, cx)
476                }),
477            )
478            .children(
479                self.delegate
480                    .render_match(ix, ix == self.delegate.selected_index(), cx),
481            )
482            .when(
483                self.delegate.separators_after_indices().contains(&ix),
484                |picker| {
485                    picker
486                        .border_color(cx.theme().colors().border_variant)
487                        .border_b_1()
488                        .pb(px(-1.0))
489                },
490            )
491    }
492
493    fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
494        match &self.element_container {
495            ElementContainer::UniformList(scroll_handle) => uniform_list(
496                cx.view().clone(),
497                "candidates",
498                self.delegate.match_count(),
499                move |picker, visible_range, cx| {
500                    visible_range
501                        .map(|ix| picker.render_element(cx, ix))
502                        .collect()
503                },
504            )
505            .py_2()
506            .track_scroll(scroll_handle.clone())
507            .into_any_element(),
508            ElementContainer::List(state) => list(state.clone())
509                .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
510                .py_2()
511                .into_any_element(),
512        }
513    }
514}
515
516impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
517impl<D: PickerDelegate> ModalView for Picker<D> {}
518
519impl<D: PickerDelegate> Render for Picker<D> {
520    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
521        div()
522            .key_context("Picker")
523            .size_full()
524            .when_some(self.width, |el, width| el.w(width))
525            .overflow_hidden()
526            // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
527            // as a part of a modal rather than the entire modal.
528            //
529            // We should revisit how the `Picker` is styled to make it more composable.
530            .when(self.is_modal, |this| this.elevation_3(cx))
531            .on_action(cx.listener(Self::select_next))
532            .on_action(cx.listener(Self::select_prev))
533            .on_action(cx.listener(Self::select_first))
534            .on_action(cx.listener(Self::select_last))
535            .on_action(cx.listener(Self::cancel))
536            .on_action(cx.listener(Self::confirm))
537            .on_action(cx.listener(Self::secondary_confirm))
538            .on_action(cx.listener(Self::use_selected_query))
539            .on_action(cx.listener(Self::confirm_input))
540            .child(match &self.head {
541                Head::Editor(editor) => v_flex()
542                    .child(
543                        h_flex()
544                            .overflow_hidden()
545                            .flex_none()
546                            .h_9()
547                            .px_4()
548                            .child(editor.clone()),
549                    )
550                    .child(Divider::horizontal()),
551                Head::Empty(empty_head) => div().child(empty_head.clone()),
552            })
553            .when(self.delegate.match_count() > 0, |el| {
554                el.child(
555                    v_flex()
556                        .flex_grow()
557                        .max_h(self.max_height.unwrap_or(rems(18.).into()))
558                        .overflow_hidden()
559                        .children(self.delegate.render_header(cx))
560                        .child(self.render_element_container(cx)),
561                )
562            })
563            .when(self.delegate.match_count() == 0, |el| {
564                el.child(
565                    v_flex().flex_grow().py_2().child(
566                        ListItem::new("empty_state")
567                            .inset(true)
568                            .spacing(ListItemSpacing::Sparse)
569                            .disabled(true)
570                            .child(
571                                Label::new(self.delegate.no_matches_text(cx)).color(Color::Muted),
572                            ),
573                    ),
574                )
575            })
576            .children(self.delegate.render_footer(cx))
577    }
578}