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