1use editor::Editor;
2use gpui::{
3 div, prelude::*, uniform_list, AnyElement, AppContext, DismissEvent, Div, EventEmitter,
4 FocusHandle, FocusableView, Length, MouseButton, MouseDownEvent, Render, Task,
5 UniformListScrollHandle, View, ViewContext, WindowContext,
6};
7use std::{cmp, sync::Arc};
8use ui::{prelude::*, v_stack, Color, Divider, Label, ListItem, ListItemSpacing, ListSeparator};
9use workspace::ModalView;
10
11pub struct Picker<D: PickerDelegate> {
12 pub delegate: D,
13 scroll_handle: UniformListScrollHandle,
14 editor: View<Editor>,
15 pending_update_matches: Option<Task<()>>,
16 confirm_on_update: Option<bool>,
17 width: Option<Length>,
18
19 /// Whether the `Picker` is rendered as a self-contained modal.
20 ///
21 /// Set this to `false` when rendering the `Picker` as part of a larger modal.
22 is_modal: bool,
23}
24
25pub trait PickerDelegate: Sized + 'static {
26 type ListItem: IntoElement;
27 fn match_count(&self) -> usize;
28 fn selected_index(&self) -> usize;
29 fn separators_after_indices(&self) -> Vec<usize> {
30 Vec::new()
31 }
32 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>);
33
34 fn placeholder_text(&self) -> Arc<str>;
35 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
36
37 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
38 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
39
40 fn render_match(
41 &self,
42 ix: usize,
43 selected: bool,
44 cx: &mut ViewContext<Picker<Self>>,
45 ) -> Option<Self::ListItem>;
46 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
47 None
48 }
49 fn render_footer(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
50 None
51 }
52}
53
54impl<D: PickerDelegate> FocusableView for Picker<D> {
55 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
56 self.editor.focus_handle(cx)
57 }
58}
59
60impl<D: PickerDelegate> Picker<D> {
61 pub fn new(delegate: D, cx: &mut ViewContext<Self>) -> Self {
62 let editor = cx.build_view(|cx| {
63 let mut editor = Editor::single_line(cx);
64 editor.set_placeholder_text(delegate.placeholder_text(), cx);
65 editor
66 });
67 cx.subscribe(&editor, Self::on_input_editor_event).detach();
68 let mut this = Self {
69 delegate,
70 editor,
71 scroll_handle: UniformListScrollHandle::new(),
72 pending_update_matches: None,
73 confirm_on_update: None,
74 width: None,
75 is_modal: true,
76 };
77 this.update_matches("".to_string(), cx);
78 this
79 }
80
81 pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
82 self.width = Some(width.into());
83 self
84 }
85
86 pub fn modal(mut self, modal: bool) -> Self {
87 self.is_modal = modal;
88 self
89 }
90
91 pub fn focus(&self, cx: &mut WindowContext) {
92 self.editor.update(cx, |editor, cx| editor.focus(cx));
93 }
94
95 pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
96 let count = self.delegate.match_count();
97 if count > 0 {
98 let index = self.delegate.selected_index();
99 let ix = cmp::min(index + 1, count - 1);
100 self.delegate.set_selected_index(ix, cx);
101 self.scroll_handle.scroll_to_item(ix);
102 cx.notify();
103 }
104 }
105
106 fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
107 let count = self.delegate.match_count();
108 if count > 0 {
109 let index = self.delegate.selected_index();
110 let ix = index.saturating_sub(1);
111 self.delegate.set_selected_index(ix, cx);
112 self.scroll_handle.scroll_to_item(ix);
113 cx.notify();
114 }
115 }
116
117 fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
118 let count = self.delegate.match_count();
119 if count > 0 {
120 self.delegate.set_selected_index(0, cx);
121 self.scroll_handle.scroll_to_item(0);
122 cx.notify();
123 }
124 }
125
126 fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
127 let count = self.delegate.match_count();
128 if count > 0 {
129 self.delegate.set_selected_index(count - 1, cx);
130 self.scroll_handle.scroll_to_item(count - 1);
131 cx.notify();
132 }
133 }
134
135 pub fn cycle_selection(&mut self, cx: &mut ViewContext<Self>) {
136 let count = self.delegate.match_count();
137 let index = self.delegate.selected_index();
138 let new_index = if index + 1 == count { 0 } else { index + 1 };
139 self.delegate.set_selected_index(new_index, cx);
140 self.scroll_handle.scroll_to_item(new_index);
141 cx.notify();
142 }
143
144 pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
145 self.delegate.dismissed(cx);
146 cx.emit(DismissEvent);
147 }
148
149 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
150 if self.pending_update_matches.is_some() {
151 self.confirm_on_update = Some(false)
152 } else {
153 self.delegate.confirm(false, cx);
154 }
155 }
156
157 fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
158 if self.pending_update_matches.is_some() {
159 self.confirm_on_update = Some(true)
160 } else {
161 self.delegate.confirm(true, cx);
162 }
163 }
164
165 fn handle_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext<Self>) {
166 cx.stop_propagation();
167 cx.prevent_default();
168 self.delegate.set_selected_index(ix, cx);
169 self.delegate.confirm(secondary, cx);
170 }
171
172 fn on_input_editor_event(
173 &mut self,
174 _: View<Editor>,
175 event: &editor::EditorEvent,
176 cx: &mut ViewContext<Self>,
177 ) {
178 match event {
179 editor::EditorEvent::BufferEdited => {
180 let query = self.editor.read(cx).text(cx);
181 self.update_matches(query, cx);
182 }
183 editor::EditorEvent::Blurred => {
184 self.cancel(&menu::Cancel, cx);
185 }
186 _ => {}
187 }
188 }
189
190 pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
191 let query = self.editor.read(cx).text(cx);
192 self.update_matches(query, cx);
193 }
194
195 pub fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) {
196 let update = self.delegate.update_matches(query, cx);
197 self.matches_updated(cx);
198 self.pending_update_matches = Some(cx.spawn(|this, mut cx| async move {
199 update.await;
200 this.update(&mut cx, |this, cx| {
201 this.matches_updated(cx);
202 })
203 .ok();
204 }));
205 }
206
207 fn matches_updated(&mut self, cx: &mut ViewContext<Self>) {
208 let index = self.delegate.selected_index();
209 self.scroll_handle.scroll_to_item(index);
210 self.pending_update_matches = None;
211 if let Some(secondary) = self.confirm_on_update.take() {
212 self.delegate.confirm(secondary, cx);
213 }
214 cx.notify();
215 }
216
217 pub fn query(&self, cx: &AppContext) -> String {
218 self.editor.read(cx).text(cx)
219 }
220
221 pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
222 self.editor
223 .update(cx, |editor, cx| editor.set_text(query, cx));
224 }
225}
226
227impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
228impl<D: PickerDelegate> ModalView for Picker<D> {}
229
230impl<D: PickerDelegate> Render for Picker<D> {
231 type Element = Div;
232
233 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
234 let picker_editor = h_stack()
235 .overflow_hidden()
236 .flex_none()
237 .h_9()
238 .px_4()
239 .child(self.editor.clone());
240
241 div()
242 .key_context("Picker")
243 .size_full()
244 .when_some(self.width, |el, width| el.w(width))
245 .overflow_hidden()
246 // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
247 // as a part of a modal rather than the entire modal.
248 //
249 // We should revisit how the `Picker` is styled to make it more composable.
250 .when(self.is_modal, |this| this.elevation_3(cx))
251 .on_action(cx.listener(Self::select_next))
252 .on_action(cx.listener(Self::select_prev))
253 .on_action(cx.listener(Self::select_first))
254 .on_action(cx.listener(Self::select_last))
255 .on_action(cx.listener(Self::cancel))
256 .on_action(cx.listener(Self::confirm))
257 .on_action(cx.listener(Self::secondary_confirm))
258 .child(picker_editor)
259 .child(Divider::horizontal())
260 .when(self.delegate.match_count() > 0, |el| {
261 el.child(
262 v_stack()
263 .flex_grow()
264 .py_2()
265 .children(self.delegate.render_header(cx))
266 .child(
267 uniform_list(
268 cx.view().clone(),
269 "candidates",
270 self.delegate.match_count(),
271 {
272 let separators_after_indices = self.delegate.separators_after_indices();
273 let selected_index = self.delegate.selected_index();
274 move |picker, visible_range, cx| {
275 visible_range
276 .map(|ix| {
277 div()
278 .on_mouse_down(
279 MouseButton::Left,
280 cx.listener(move |this, event: &MouseDownEvent, cx| {
281 this.handle_click(
282 ix,
283 event.modifiers.command,
284 cx,
285 )
286 }),
287 )
288 .children(picker.delegate.render_match(
289 ix,
290 ix == selected_index,
291 cx,
292 )).when(separators_after_indices.contains(&ix), |picker| picker.child(ListSeparator))
293 })
294 .collect()
295 }
296 },
297 )
298 .track_scroll(self.scroll_handle.clone())
299 )
300
301 .max_h_72()
302 .overflow_hidden(),
303 )
304 })
305 .when(self.delegate.match_count() == 0, |el| {
306 el.child(
307 v_stack().flex_grow().py_2().child(
308 ListItem::new("empty_state")
309 .inset(true)
310 .spacing(ListItemSpacing::Sparse)
311 .disabled(true)
312 .child(Label::new("No matches").color(Color::Muted)),
313 ),
314 )
315 })
316 .children(self.delegate.render_footer(cx))
317 }
318}