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