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