1mod head;
2pub mod highlighted_match_with_paths;
3pub mod popover_menu;
4
5use anyhow::Result;
6
7use gpui::{
8 Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle,
9 Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, Render,
10 ScrollStrategy, Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*,
11 uniform_list,
12};
13use head::Head;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use std::{
17 cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration,
18};
19use theme_settings::ThemeSettings;
20use ui::{
21 Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing,
22 ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex,
23};
24use ui_input::{ErasedEditor, ErasedEditorEvent};
25use workspace::{ModalView, item::Settings};
26use zed_actions::editor::{MoveDown, MoveUp};
27
28enum ElementContainer {
29 List(ListState),
30 UniformList(UniformListScrollHandle),
31}
32
33pub enum Direction {
34 Up,
35 Down,
36}
37
38actions!(
39 picker,
40 [
41 /// Confirms the selected completion in the picker.
42 ConfirmCompletion
43 ]
44);
45
46/// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally,
47/// performing some kind of action on it.
48#[derive(Clone, PartialEq, Deserialize, JsonSchema, Default, Action)]
49#[action(namespace = picker)]
50#[serde(deny_unknown_fields)]
51pub struct ConfirmInput {
52 pub secondary: bool,
53}
54
55struct PendingUpdateMatches {
56 delegate_update_matches: Option<Task<()>>,
57 _task: Task<Result<()>>,
58}
59
60pub struct Picker<D: PickerDelegate> {
61 pub delegate: D,
62 element_container: ElementContainer,
63 head: Head,
64 pending_update_matches: Option<PendingUpdateMatches>,
65 confirm_on_update: Option<bool>,
66 width: Option<Length>,
67 widest_item: Option<usize>,
68 max_height: Option<Length>,
69 /// An external control to display a scrollbar in the `Picker`.
70 show_scrollbar: bool,
71 /// Whether the `Picker` is rendered as a self-contained modal.
72 ///
73 /// Set this to `false` when rendering the `Picker` as part of a larger modal.
74 is_modal: bool,
75 /// Bounds tracking for the picker container (for aside positioning)
76 picker_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
77 /// Bounds tracking for items (for aside positioning) - maps item index to bounds
78 item_bounds: Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>,
79}
80
81#[derive(Debug, Default, Clone, Copy, PartialEq)]
82pub enum PickerEditorPosition {
83 #[default]
84 /// Render the editor at the start of the picker. Usually the top
85 Start,
86 /// Render the editor at the end of the picker. Usually the bottom
87 End,
88}
89
90pub trait PickerDelegate: Sized + 'static {
91 type ListItem: IntoElement;
92
93 fn match_count(&self) -> usize;
94 fn selected_index(&self) -> usize;
95 fn separators_after_indices(&self) -> Vec<usize> {
96 Vec::new()
97 }
98 fn set_selected_index(
99 &mut self,
100 ix: usize,
101 window: &mut Window,
102 cx: &mut Context<Picker<Self>>,
103 );
104
105 /// Called before the picker handles `SelectPrevious` or `SelectNext`. Return `Some(query)` to
106 /// set a new query and prevent the default selection behavior.
107 fn select_history(
108 &mut self,
109 _direction: Direction,
110 _query: &str,
111 _window: &mut Window,
112 _cx: &mut App,
113 ) -> Option<String> {
114 None
115 }
116 fn can_select(
117 &self,
118 _ix: usize,
119 _window: &mut Window,
120 _cx: &mut Context<Picker<Self>>,
121 ) -> bool {
122 true
123 }
124 fn select_on_hover(&self) -> bool {
125 true
126 }
127
128 // Allows binding some optional effect to when the selection changes.
129 fn selected_index_changed(
130 &self,
131 _ix: usize,
132 _window: &mut Window,
133 _cx: &mut Context<Picker<Self>>,
134 ) -> Option<Box<dyn Fn(&mut Window, &mut App) + 'static>> {
135 None
136 }
137 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str>;
138 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
139 Some("No matches".into())
140 }
141 fn update_matches(
142 &mut self,
143 query: String,
144 window: &mut Window,
145 cx: &mut Context<Picker<Self>>,
146 ) -> Task<()>;
147
148 // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background
149 // work for up to `duration` to try and get a result synchronously.
150 // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes
151 // mostly work when dismissing a palette.
152 fn finalize_update_matches(
153 &mut self,
154 _query: String,
155 _duration: Duration,
156 _window: &mut Window,
157 _cx: &mut Context<Picker<Self>>,
158 ) -> bool {
159 false
160 }
161
162 /// Override if you want to have <enter> update the query instead of confirming.
163 fn confirm_update_query(
164 &mut self,
165 _window: &mut Window,
166 _cx: &mut Context<Picker<Self>>,
167 ) -> Option<String> {
168 None
169 }
170 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>);
171 /// Instead of interacting with currently selected entry, treats editor input literally,
172 /// performing some kind of action on it.
173 fn confirm_input(
174 &mut self,
175 _secondary: bool,
176 _window: &mut Window,
177 _: &mut Context<Picker<Self>>,
178 ) {
179 }
180 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>);
181 fn should_dismiss(&self) -> bool {
182 true
183 }
184 fn confirm_completion(
185 &mut self,
186 _query: String,
187 _window: &mut Window,
188 _: &mut Context<Picker<Self>>,
189 ) -> Option<String> {
190 None
191 }
192
193 fn editor_position(&self) -> PickerEditorPosition {
194 PickerEditorPosition::default()
195 }
196
197 fn render_editor(
198 &self,
199 editor: &Arc<dyn ErasedEditor>,
200 window: &mut Window,
201 cx: &mut Context<Picker<Self>>,
202 ) -> Div {
203 v_flex()
204 .when(
205 self.editor_position() == PickerEditorPosition::End,
206 |this| this.child(Divider::horizontal()),
207 )
208 .child(
209 h_flex()
210 .overflow_hidden()
211 .flex_none()
212 .h_9()
213 .px_2p5()
214 .child(editor.render(window, cx)),
215 )
216 .when(
217 self.editor_position() == PickerEditorPosition::Start,
218 |this| this.child(Divider::horizontal()),
219 )
220 }
221
222 fn render_match(
223 &self,
224 ix: usize,
225 selected: bool,
226 window: &mut Window,
227 cx: &mut Context<Picker<Self>>,
228 ) -> Option<Self::ListItem>;
229
230 fn render_header(
231 &self,
232 _window: &mut Window,
233 _: &mut Context<Picker<Self>>,
234 ) -> Option<AnyElement> {
235 None
236 }
237
238 fn render_footer(
239 &self,
240 _window: &mut Window,
241 _: &mut Context<Picker<Self>>,
242 ) -> Option<AnyElement> {
243 None
244 }
245
246 fn documentation_aside(
247 &self,
248 _window: &mut Window,
249 _cx: &mut Context<Picker<Self>>,
250 ) -> Option<DocumentationAside> {
251 None
252 }
253
254 /// Returns the index of the item whose documentation aside should be shown.
255 /// This is used to position the aside relative to that item.
256 /// Typically this is the hovered item, not necessarily the selected item.
257 fn documentation_aside_index(&self) -> Option<usize> {
258 None
259 }
260}
261
262impl<D: PickerDelegate> Focusable for Picker<D> {
263 fn focus_handle(&self, cx: &App) -> FocusHandle {
264 match &self.head {
265 Head::Editor(editor) => editor.focus_handle(cx),
266 Head::Empty(head) => head.focus_handle(cx),
267 }
268 }
269}
270
271#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
272enum ContainerKind {
273 List,
274 UniformList,
275}
276
277impl<D: PickerDelegate> Picker<D> {
278 /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
279 /// The picker allows the user to perform search items by text.
280 /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
281 pub fn uniform_list(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
282 let head = Head::editor(
283 delegate.placeholder_text(window, cx),
284 Self::on_input_editor_event,
285 window,
286 cx,
287 );
288
289 Self::new(delegate, ContainerKind::UniformList, head, window, cx)
290 }
291
292 /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
293 /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
294 pub fn nonsearchable_uniform_list(
295 delegate: D,
296 window: &mut Window,
297 cx: &mut Context<Self>,
298 ) -> Self {
299 let head = Head::empty(Self::on_empty_head_blur, window, cx);
300
301 Self::new(delegate, ContainerKind::UniformList, head, window, cx)
302 }
303
304 /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
305 /// The picker allows the user to perform search items by text.
306 /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
307 pub fn nonsearchable_list(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
308 let head = Head::empty(Self::on_empty_head_blur, window, cx);
309
310 Self::new(delegate, ContainerKind::List, head, window, cx)
311 }
312
313 /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
314 /// The picker allows the user to perform search items by text.
315 /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
316 pub fn list(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
317 let head = Head::editor(
318 delegate.placeholder_text(window, cx),
319 Self::on_input_editor_event,
320 window,
321 cx,
322 );
323
324 Self::new(delegate, ContainerKind::List, head, window, cx)
325 }
326
327 fn new(
328 delegate: D,
329 container: ContainerKind,
330 head: Head,
331 window: &mut Window,
332 cx: &mut Context<Self>,
333 ) -> Self {
334 let element_container = Self::create_element_container(container);
335 let mut this = Self {
336 delegate,
337 head,
338 element_container,
339 pending_update_matches: None,
340 confirm_on_update: None,
341 width: None,
342 widest_item: None,
343 max_height: Some(rems(24.).into()),
344 show_scrollbar: false,
345 is_modal: true,
346 picker_bounds: Rc::new(Cell::new(None)),
347 item_bounds: Rc::new(RefCell::new(HashMap::default())),
348 };
349 this.update_matches("".to_string(), window, cx);
350 // give the delegate 4ms to render the first set of suggestions.
351 this.delegate
352 .finalize_update_matches("".to_string(), Duration::from_millis(4), window, cx);
353 this
354 }
355
356 fn create_element_container(container: ContainerKind) -> ElementContainer {
357 match container {
358 ContainerKind::UniformList => {
359 ElementContainer::UniformList(UniformListScrollHandle::new())
360 }
361 ContainerKind::List => {
362 ElementContainer::List(ListState::new(0, gpui::ListAlignment::Top, px(1000.)))
363 }
364 }
365 }
366
367 pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
368 self.width = Some(width.into());
369 self
370 }
371
372 pub fn widest_item(mut self, ix: Option<usize>) -> Self {
373 self.widest_item = ix;
374 self
375 }
376
377 pub fn max_height(mut self, max_height: Option<gpui::Length>) -> Self {
378 self.max_height = max_height;
379 self
380 }
381
382 pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
383 self.show_scrollbar = show_scrollbar;
384 self
385 }
386
387 pub fn modal(mut self, modal: bool) -> Self {
388 self.is_modal = modal;
389 self
390 }
391
392 pub fn list_measure_all(mut self) -> Self {
393 match self.element_container {
394 ElementContainer::List(state) => {
395 self.element_container = ElementContainer::List(state.measure_all());
396 }
397 _ => {}
398 }
399 self
400 }
401
402 pub fn focus(&self, window: &mut Window, cx: &mut App) {
403 self.focus_handle(cx).focus(window, cx);
404 }
405
406 /// Handles the selecting an index, and passing the change to the delegate.
407 /// If `fallback_direction` is set to `None`, the index will not be selected
408 /// if the element at that index cannot be selected.
409 /// If `fallback_direction` is set to
410 /// `Some(..)`, the next selectable element will be selected in the
411 /// specified direction (Down or Up), cycling through all elements until
412 /// finding one that can be selected or returning if there are no selectable elements.
413 /// If `scroll_to_index` is true, the new selected index will be scrolled into
414 /// view.
415 ///
416 /// If some effect is bound to `selected_index_changed`, it will be executed.
417 pub fn set_selected_index(
418 &mut self,
419 mut ix: usize,
420 fallback_direction: Option<Direction>,
421 scroll_to_index: bool,
422 window: &mut Window,
423 cx: &mut Context<Self>,
424 ) {
425 let match_count = self.delegate.match_count();
426 if match_count == 0 {
427 return;
428 }
429
430 if let Some(bias) = fallback_direction {
431 let mut curr_ix = ix;
432 while !self.delegate.can_select(curr_ix, window, cx) {
433 curr_ix = match bias {
434 Direction::Down => {
435 if curr_ix == match_count - 1 {
436 0
437 } else {
438 curr_ix + 1
439 }
440 }
441 Direction::Up => {
442 if curr_ix == 0 {
443 match_count - 1
444 } else {
445 curr_ix - 1
446 }
447 }
448 };
449 // There is no item that can be selected
450 if ix == curr_ix {
451 return;
452 }
453 }
454 ix = curr_ix;
455 } else if !self.delegate.can_select(ix, window, cx) {
456 return;
457 }
458
459 let previous_index = self.delegate.selected_index();
460 self.delegate.set_selected_index(ix, window, cx);
461 let current_index = self.delegate.selected_index();
462
463 if previous_index != current_index {
464 if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) {
465 action(window, cx);
466 }
467 if scroll_to_index {
468 self.scroll_to_item_index(ix);
469 }
470 }
471 }
472
473 pub fn select_next(
474 &mut self,
475 _: &menu::SelectNext,
476 window: &mut Window,
477 cx: &mut Context<Self>,
478 ) {
479 let query = self.query(cx);
480 if let Some(query) = self
481 .delegate
482 .select_history(Direction::Down, &query, window, cx)
483 {
484 self.set_query(&query, window, cx);
485 return;
486 }
487 let count = self.delegate.match_count();
488 if count > 0 {
489 let index = self.delegate.selected_index();
490 let ix = if index == count - 1 { 0 } else { index + 1 };
491 self.set_selected_index(ix, Some(Direction::Down), true, window, cx);
492 cx.notify();
493 }
494 }
495
496 pub fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
497 self.select_previous(&Default::default(), window, cx);
498 }
499
500 fn select_previous(
501 &mut self,
502 _: &menu::SelectPrevious,
503 window: &mut Window,
504 cx: &mut Context<Self>,
505 ) {
506 let query = self.query(cx);
507 if let Some(query) = self
508 .delegate
509 .select_history(Direction::Up, &query, window, cx)
510 {
511 self.set_query(&query, window, cx);
512 return;
513 }
514 let count = self.delegate.match_count();
515 if count > 0 {
516 let index = self.delegate.selected_index();
517 let ix = if index == 0 { count - 1 } else { index - 1 };
518 self.set_selected_index(ix, Some(Direction::Up), true, window, cx);
519 cx.notify();
520 }
521 }
522
523 pub fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
524 self.select_next(&Default::default(), window, cx);
525 }
526
527 pub fn select_first(
528 &mut self,
529 _: &menu::SelectFirst,
530 window: &mut Window,
531 cx: &mut Context<Self>,
532 ) {
533 let count = self.delegate.match_count();
534 if count > 0 {
535 self.set_selected_index(0, Some(Direction::Down), true, window, cx);
536 cx.notify();
537 }
538 }
539
540 fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
541 let count = self.delegate.match_count();
542 if count > 0 {
543 self.set_selected_index(count - 1, Some(Direction::Up), true, window, cx);
544 cx.notify();
545 }
546 }
547
548 pub fn cycle_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
549 let count = self.delegate.match_count();
550 let index = self.delegate.selected_index();
551 let new_index = if index + 1 == count { 0 } else { index + 1 };
552 self.set_selected_index(new_index, Some(Direction::Down), true, window, cx);
553 cx.notify();
554 }
555
556 pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
557 if self.delegate.should_dismiss() {
558 self.delegate.dismissed(window, cx);
559 cx.emit(DismissEvent);
560 }
561 }
562
563 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
564 if self.pending_update_matches.is_some()
565 && !self.delegate.finalize_update_matches(
566 self.query(cx),
567 Duration::from_millis(16),
568 window,
569 cx,
570 )
571 {
572 self.confirm_on_update = Some(false)
573 } else {
574 self.pending_update_matches.take();
575 self.do_confirm(false, window, cx);
576 }
577 }
578
579 fn secondary_confirm(
580 &mut self,
581 _: &menu::SecondaryConfirm,
582 window: &mut Window,
583 cx: &mut Context<Self>,
584 ) {
585 if self.pending_update_matches.is_some()
586 && !self.delegate.finalize_update_matches(
587 self.query(cx),
588 Duration::from_millis(16),
589 window,
590 cx,
591 )
592 {
593 self.confirm_on_update = Some(true)
594 } else {
595 self.do_confirm(true, window, cx);
596 }
597 }
598
599 fn confirm_input(&mut self, input: &ConfirmInput, window: &mut Window, cx: &mut Context<Self>) {
600 self.delegate.confirm_input(input.secondary, window, cx);
601 }
602
603 fn confirm_completion(
604 &mut self,
605 _: &ConfirmCompletion,
606 window: &mut Window,
607 cx: &mut Context<Self>,
608 ) {
609 if let Some(new_query) = self.delegate.confirm_completion(self.query(cx), window, cx) {
610 self.set_query(&new_query, window, cx);
611 } else {
612 cx.propagate()
613 }
614 }
615
616 fn handle_click(
617 &mut self,
618 ix: usize,
619 secondary: bool,
620 window: &mut Window,
621 cx: &mut Context<Self>,
622 ) {
623 cx.stop_propagation();
624 window.prevent_default();
625 if !self.delegate.can_select(ix, window, cx) {
626 return;
627 }
628 self.set_selected_index(ix, None, false, window, cx);
629 self.do_confirm(secondary, window, cx)
630 }
631
632 fn do_confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Self>) {
633 if let Some(update_query) = self.delegate.confirm_update_query(window, cx) {
634 self.set_query(&update_query, window, cx);
635 self.set_selected_index(0, Some(Direction::Down), false, window, cx);
636 } else {
637 self.delegate.confirm(secondary, window, cx)
638 }
639 }
640
641 fn on_input_editor_event(
642 &mut self,
643 event: &ErasedEditorEvent,
644 window: &mut Window,
645 cx: &mut Context<Self>,
646 ) {
647 let Head::Editor(editor) = &self.head else {
648 panic!("unexpected call");
649 };
650 match event {
651 ErasedEditorEvent::BufferEdited => {
652 let query = editor.text(cx);
653 self.update_matches(query, window, cx);
654 }
655 ErasedEditorEvent::Blurred => {
656 if self.is_modal && window.is_window_active() {
657 self.cancel(&menu::Cancel, window, cx);
658 }
659 }
660 }
661 }
662
663 fn on_empty_head_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
664 let Head::Empty(_) = &self.head else {
665 panic!("unexpected call");
666 };
667 if window.is_window_active() {
668 self.cancel(&menu::Cancel, window, cx);
669 }
670 }
671
672 pub fn refresh_placeholder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
673 match &self.head {
674 Head::Editor(editor) => {
675 let placeholder = self.delegate.placeholder_text(window, cx);
676
677 editor.set_placeholder_text(placeholder.as_ref(), window, cx);
678 cx.notify();
679 }
680 Head::Empty(_) => {}
681 }
682 }
683
684 pub fn refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
685 let query = self.query(cx);
686 self.update_matches(query, window, cx);
687 }
688
689 pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context<Self>) {
690 let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx);
691
692 self.matches_updated(window, cx);
693 // This struct ensures that we can synchronously drop the task returned by the
694 // delegate's `update_matches` method and the task that the picker is spawning.
695 // If we simply capture the delegate's task into the picker's task, when the picker's
696 // task gets synchronously dropped, the delegate's task would keep running until
697 // the picker's task has a chance of being scheduled, because dropping a task happens
698 // asynchronously.
699 self.pending_update_matches = Some(PendingUpdateMatches {
700 delegate_update_matches: Some(delegate_pending_update_matches),
701 _task: cx.spawn_in(window, async move |this, cx| {
702 let delegate_pending_update_matches = this.update(cx, |this, _| {
703 this.pending_update_matches
704 .as_mut()
705 .unwrap()
706 .delegate_update_matches
707 .take()
708 .unwrap()
709 })?;
710 delegate_pending_update_matches.await;
711 this.update_in(cx, |this, window, cx| {
712 this.matches_updated(window, cx);
713 })
714 }),
715 });
716 }
717
718 fn matches_updated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
719 if let ElementContainer::List(state) = &mut self.element_container {
720 state.reset(self.delegate.match_count());
721 }
722
723 let index = self.delegate.selected_index();
724 self.scroll_to_item_index(index);
725 self.pending_update_matches = None;
726 if let Some(secondary) = self.confirm_on_update.take() {
727 self.do_confirm(secondary, window, cx);
728 }
729 cx.notify();
730 }
731
732 pub fn query(&self, cx: &App) -> String {
733 match &self.head {
734 Head::Editor(editor) => editor.text(cx),
735 Head::Empty(_) => "".to_string(),
736 }
737 }
738
739 pub fn set_query(&self, query: &str, window: &mut Window, cx: &mut App) {
740 if let Head::Editor(editor) = &self.head {
741 editor.set_text(query, window, cx);
742 editor.move_selection_to_end(window, cx);
743 }
744 }
745
746 fn scroll_to_item_index(&mut self, ix: usize) {
747 match &mut self.element_container {
748 ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
749 ElementContainer::UniformList(scroll_handle) => {
750 scroll_handle.scroll_to_item(ix, ScrollStrategy::Nearest)
751 }
752 }
753 }
754
755 fn render_element(
756 &self,
757 window: &mut Window,
758 cx: &mut Context<Self>,
759 ix: usize,
760 ) -> impl IntoElement + use<D> {
761 let item_bounds = self.item_bounds.clone();
762 let selectable = self.delegate.can_select(ix, window, cx);
763
764 div()
765 .id(("item", ix))
766 .when(selectable, |this| this.cursor_pointer())
767 .child(
768 canvas(
769 move |bounds, _window, _cx| {
770 item_bounds.borrow_mut().insert(ix, bounds);
771 },
772 |_bounds, _state, _window, _cx| {},
773 )
774 .size_full()
775 .absolute()
776 .top_0()
777 .left_0(),
778 )
779 .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
780 this.handle_click(ix, event.modifiers().secondary(), window, cx)
781 }))
782 // As of this writing, GPUI intercepts `ctrl-[mouse-event]`s on macOS
783 // and produces right mouse button events. This matches platforms norms
784 // but means that UIs which depend on holding ctrl down (such as the tab
785 // switcher) can't be clicked on. Hence, this handler.
786 .on_mouse_up(
787 MouseButton::Right,
788 cx.listener(move |this, event: &MouseUpEvent, window, cx| {
789 // We specifically want to use the platform key here, as
790 // ctrl will already be held down for the tab switcher.
791 this.handle_click(ix, event.modifiers.platform, window, cx)
792 }),
793 )
794 .when(self.delegate.select_on_hover(), |this| {
795 this.on_hover(cx.listener(move |this, hovered: &bool, window, cx| {
796 if *hovered {
797 this.set_selected_index(ix, None, false, window, cx);
798 cx.notify();
799 }
800 }))
801 })
802 .children(self.delegate.render_match(
803 ix,
804 ix == self.delegate.selected_index(),
805 window,
806 cx,
807 ))
808 .when(
809 self.delegate.separators_after_indices().contains(&ix),
810 |picker| {
811 picker
812 .border_color(cx.theme().colors().border_variant)
813 .border_b_1()
814 .py(px(-1.0))
815 },
816 )
817 }
818
819 fn render_element_container(&self, cx: &mut Context<Self>) -> impl IntoElement {
820 let sizing_behavior = if self.max_height.is_some() {
821 ListSizingBehavior::Infer
822 } else {
823 ListSizingBehavior::Auto
824 };
825
826 match &self.element_container {
827 ElementContainer::UniformList(scroll_handle) => uniform_list(
828 "candidates",
829 self.delegate.match_count(),
830 cx.processor(move |picker, visible_range: Range<usize>, window, cx| {
831 visible_range
832 .map(|ix| picker.render_element(window, cx, ix))
833 .collect()
834 }),
835 )
836 .with_sizing_behavior(sizing_behavior)
837 .when_some(self.widest_item, |el, widest_item| {
838 el.with_width_from_item(Some(widest_item))
839 })
840 .flex_grow()
841 .py_1()
842 .track_scroll(&scroll_handle)
843 .into_any_element(),
844 ElementContainer::List(state) => list(
845 state.clone(),
846 cx.processor(|this, ix, window, cx| {
847 this.render_element(window, cx, ix).into_any_element()
848 }),
849 )
850 .with_sizing_behavior(sizing_behavior)
851 .flex_grow()
852 .py_2()
853 .into_any_element(),
854 }
855 }
856
857 #[cfg(any(test, feature = "test-support"))]
858 pub fn logical_scroll_top_index(&self) -> usize {
859 match &self.element_container {
860 ElementContainer::List(state) => state.logical_scroll_top().item_ix,
861 ElementContainer::UniformList(scroll_handle) => {
862 scroll_handle.logical_scroll_top_index()
863 }
864 }
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use gpui::TestAppContext;
872 use std::cell::Cell;
873
874 struct TestDelegate {
875 items: Vec<bool>,
876 selected_index: usize,
877 confirmed_index: Rc<Cell<Option<usize>>>,
878 }
879
880 impl TestDelegate {
881 fn new(items: Vec<bool>) -> Self {
882 Self {
883 items,
884 selected_index: 0,
885 confirmed_index: Rc::new(Cell::new(None)),
886 }
887 }
888 }
889
890 impl PickerDelegate for TestDelegate {
891 type ListItem = ui::ListItem;
892
893 fn match_count(&self) -> usize {
894 self.items.len()
895 }
896
897 fn selected_index(&self) -> usize {
898 self.selected_index
899 }
900
901 fn set_selected_index(
902 &mut self,
903 ix: usize,
904 _window: &mut Window,
905 _cx: &mut Context<Picker<Self>>,
906 ) {
907 self.selected_index = ix;
908 }
909
910 fn can_select(
911 &self,
912 ix: usize,
913 _window: &mut Window,
914 _cx: &mut Context<Picker<Self>>,
915 ) -> bool {
916 self.items.get(ix).copied().unwrap_or(false)
917 }
918
919 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
920 "Test".into()
921 }
922
923 fn update_matches(
924 &mut self,
925 _query: String,
926 _window: &mut Window,
927 _cx: &mut Context<Picker<Self>>,
928 ) -> Task<()> {
929 Task::ready(())
930 }
931
932 fn confirm(
933 &mut self,
934 _secondary: bool,
935 _window: &mut Window,
936 _cx: &mut Context<Picker<Self>>,
937 ) {
938 self.confirmed_index.set(Some(self.selected_index));
939 }
940
941 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
942
943 fn render_match(
944 &self,
945 ix: usize,
946 selected: bool,
947 _window: &mut Window,
948 _cx: &mut Context<Picker<Self>>,
949 ) -> Option<Self::ListItem> {
950 Some(
951 ui::ListItem::new(ix)
952 .inset(true)
953 .toggle_state(selected)
954 .child(ui::Label::new(format!("Item {ix}"))),
955 )
956 }
957 }
958
959 fn init_test(cx: &mut TestAppContext) {
960 cx.update(|cx| {
961 let store = settings::SettingsStore::test(cx);
962 cx.set_global(store);
963 theme_settings::init(theme::LoadThemes::JustBase, cx);
964 editor::init(cx);
965 });
966 }
967
968 #[gpui::test]
969 async fn test_clicking_non_selectable_item_does_not_confirm(cx: &mut TestAppContext) {
970 init_test(cx);
971
972 let confirmed_index = Rc::new(Cell::new(None));
973 let (picker, cx) = cx.add_window_view(|window, cx| {
974 let mut delegate = TestDelegate::new(vec![true, false, true]);
975 delegate.confirmed_index = confirmed_index.clone();
976 Picker::uniform_list(delegate, window, cx)
977 });
978
979 picker.update(cx, |picker, _cx| {
980 assert_eq!(picker.delegate.selected_index(), 0);
981 });
982
983 picker.update_in(cx, |picker, window, cx| {
984 picker.handle_click(1, false, window, cx);
985 });
986 assert!(
987 confirmed_index.get().is_none(),
988 "clicking a non-selectable item should not confirm"
989 );
990
991 picker.update_in(cx, |picker, window, cx| {
992 picker.handle_click(0, false, window, cx);
993 });
994 assert_eq!(
995 confirmed_index.get(),
996 Some(0),
997 "clicking a selectable item should confirm"
998 );
999 }
1000
1001 #[gpui::test]
1002 async fn test_keyboard_navigation_skips_non_selectable_items(cx: &mut TestAppContext) {
1003 init_test(cx);
1004
1005 let (picker, cx) = cx.add_window_view(|window, cx| {
1006 Picker::uniform_list(TestDelegate::new(vec![true, false, true]), window, cx)
1007 });
1008
1009 picker.update(cx, |picker, _cx| {
1010 assert_eq!(picker.delegate.selected_index(), 0);
1011 });
1012
1013 picker.update_in(cx, |picker, window, cx| {
1014 picker.select_next(&menu::SelectNext, window, cx);
1015 });
1016 picker.update(cx, |picker, _cx| {
1017 assert_eq!(
1018 picker.delegate.selected_index(),
1019 2,
1020 "select_next should skip non-selectable item at index 1"
1021 );
1022 });
1023
1024 picker.update_in(cx, |picker, window, cx| {
1025 picker.select_previous(&menu::SelectPrevious, window, cx);
1026 });
1027 picker.update(cx, |picker, _cx| {
1028 assert_eq!(
1029 picker.delegate.selected_index(),
1030 0,
1031 "select_previous should skip non-selectable item at index 1"
1032 );
1033 });
1034 }
1035}
1036
1037impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
1038impl<D: PickerDelegate> ModalView for Picker<D> {}
1039
1040impl<D: PickerDelegate> Render for Picker<D> {
1041 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1042 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1043 let window_size = window.viewport_size();
1044 let rem_size = window.rem_size();
1045 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1046
1047 let aside = self.delegate.documentation_aside(window, cx);
1048
1049 let editor_position = self.delegate.editor_position();
1050 let picker_bounds = self.picker_bounds.clone();
1051 let menu = v_flex()
1052 .key_context("Picker")
1053 .size_full()
1054 .when_some(self.width, |el, width| el.w(width))
1055 .overflow_hidden()
1056 .child(
1057 canvas(
1058 move |bounds, _window, _cx| {
1059 picker_bounds.set(Some(bounds));
1060 },
1061 |_bounds, _state, _window, _cx| {},
1062 )
1063 .size_full()
1064 .absolute()
1065 .top_0()
1066 .left_0(),
1067 )
1068 // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
1069 // as a part of a modal rather than the entire modal.
1070 //
1071 // We should revisit how the `Picker` is styled to make it more composable.
1072 .when(self.is_modal, |this| this.elevation_3(cx))
1073 .on_action(cx.listener(Self::select_next))
1074 .on_action(cx.listener(Self::select_previous))
1075 .on_action(cx.listener(Self::editor_move_down))
1076 .on_action(cx.listener(Self::editor_move_up))
1077 .on_action(cx.listener(Self::select_first))
1078 .on_action(cx.listener(Self::select_last))
1079 .on_action(cx.listener(Self::cancel))
1080 .on_action(cx.listener(Self::confirm))
1081 .on_action(cx.listener(Self::secondary_confirm))
1082 .on_action(cx.listener(Self::confirm_completion))
1083 .on_action(cx.listener(Self::confirm_input))
1084 .children(match &self.head {
1085 Head::Editor(editor) => {
1086 if editor_position == PickerEditorPosition::Start {
1087 Some(self.delegate.render_editor(&editor.clone(), window, cx))
1088 } else {
1089 None
1090 }
1091 }
1092 Head::Empty(empty_head) => Some(div().child(empty_head.clone())),
1093 })
1094 .when(self.delegate.match_count() > 0, |el| {
1095 el.child(
1096 v_flex()
1097 .id("element-container")
1098 .relative()
1099 .flex_grow()
1100 .when_some(self.max_height, |div, max_h| div.max_h(max_h))
1101 .overflow_hidden()
1102 .children(self.delegate.render_header(window, cx))
1103 .child(self.render_element_container(cx))
1104 .when(self.show_scrollbar, |this| {
1105 let base_scrollbar_config =
1106 Scrollbars::new(ScrollAxes::Vertical).width_sm();
1107
1108 this.map(|this| match &self.element_container {
1109 ElementContainer::List(state) => this.custom_scrollbars(
1110 base_scrollbar_config.tracked_scroll_handle(state),
1111 window,
1112 cx,
1113 ),
1114 ElementContainer::UniformList(state) => this.custom_scrollbars(
1115 base_scrollbar_config.tracked_scroll_handle(state),
1116 window,
1117 cx,
1118 ),
1119 })
1120 }),
1121 )
1122 })
1123 .when(self.delegate.match_count() == 0, |el| {
1124 el.when_some(self.delegate.no_matches_text(window, cx), |el, text| {
1125 el.child(
1126 v_flex().flex_grow().py_2().child(
1127 ListItem::new("empty_state")
1128 .inset(true)
1129 .spacing(ListItemSpacing::Sparse)
1130 .disabled(true)
1131 .child(Label::new(text).color(Color::Muted)),
1132 ),
1133 )
1134 })
1135 })
1136 .children(self.delegate.render_footer(window, cx))
1137 .children(match &self.head {
1138 Head::Editor(editor) => {
1139 if editor_position == PickerEditorPosition::End {
1140 Some(self.delegate.render_editor(&editor.clone(), window, cx))
1141 } else {
1142 None
1143 }
1144 }
1145 Head::Empty(empty_head) => Some(div().child(empty_head.clone())),
1146 });
1147
1148 let Some(aside) = aside else {
1149 return menu;
1150 };
1151
1152 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1153 WithRemSize::new(ui_font_size)
1154 .occlude()
1155 .elevation_2(cx)
1156 .w_full()
1157 .p_2()
1158 .overflow_hidden()
1159 .when(is_wide_window, |this| this.max_w_96())
1160 .when(!is_wide_window, |this| this.max_w_48())
1161 .child((aside.render)(cx))
1162 };
1163
1164 if is_wide_window {
1165 let aside_index = self.delegate.documentation_aside_index();
1166 let picker_bounds = self.picker_bounds.get();
1167 let item_bounds =
1168 aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied());
1169
1170 let item_position = match (picker_bounds, item_bounds) {
1171 (Some(picker_bounds), Some(item_bounds)) => {
1172 let relative_top = item_bounds.origin.y - picker_bounds.origin.y;
1173 let height = item_bounds.size.height;
1174 Some((relative_top, height))
1175 }
1176 _ => None,
1177 };
1178
1179 div()
1180 .relative()
1181 .child(menu)
1182 // Only render the aside once we have bounds to avoid flicker
1183 .when_some(item_position, |this, (top, height)| {
1184 this.child(
1185 h_flex()
1186 .absolute()
1187 .when(aside.side == DocumentationSide::Left, |el| {
1188 el.right_full().mr_1()
1189 })
1190 .when(aside.side == DocumentationSide::Right, |el| {
1191 el.left_full().ml_1()
1192 })
1193 .top(top)
1194 .h(height)
1195 .child(render_aside(aside, cx)),
1196 )
1197 })
1198 } else {
1199 v_flex()
1200 .w_full()
1201 .gap_1()
1202 .justify_end()
1203 .child(render_aside(aside, cx))
1204 .child(menu)
1205 }
1206 }
1207}