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