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