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