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}