1use editor::Editor;
2use gpui::{
3 div, list, prelude::*, uniform_list, AnyElement, AppContext, DismissEvent, EventEmitter,
4 FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
5 UniformListScrollHandle, View, ViewContext, WindowContext,
6};
7use std::sync::Arc;
8use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
9use workspace::ModalView;
10
11enum ElementContainer {
12 List(ListState),
13 UniformList(UniformListScrollHandle),
14}
15
16pub struct Picker<D: PickerDelegate> {
17 pub delegate: D,
18 element_container: ElementContainer,
19 editor: View<Editor>,
20 pending_update_matches: Option<Task<()>>,
21 confirm_on_update: Option<bool>,
22 width: Option<Length>,
23 max_height: Option<Length>,
24
25 /// Whether the `Picker` is rendered as a self-contained modal.
26 ///
27 /// Set this to `false` when rendering the `Picker` as part of a larger modal.
28 is_modal: bool,
29}
30
31pub trait PickerDelegate: Sized + 'static {
32 type ListItem: IntoElement;
33 fn match_count(&self) -> usize;
34 fn selected_index(&self) -> usize;
35 fn separators_after_indices(&self) -> Vec<usize> {
36 Vec::new()
37 }
38 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>);
39
40 fn placeholder_text(&self) -> Arc<str>;
41 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
42
43 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
44 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
45
46 fn render_match(
47 &self,
48 ix: usize,
49 selected: bool,
50 cx: &mut ViewContext<Picker<Self>>,
51 ) -> Option<Self::ListItem>;
52 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
53 None
54 }
55 fn render_footer(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
56 None
57 }
58}
59
60impl<D: PickerDelegate> FocusableView for Picker<D> {
61 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
62 self.editor.focus_handle(cx)
63 }
64}
65
66fn create_editor(placeholder: Arc<str>, cx: &mut WindowContext<'_>) -> View<Editor> {
67 cx.new_view(|cx| {
68 let mut editor = Editor::single_line(cx);
69 editor.set_placeholder_text(placeholder, cx);
70 editor
71 })
72}
73
74impl<D: PickerDelegate> Picker<D> {
75 /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
76 /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
77 pub fn uniform_list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
78 Self::new(delegate, cx, true)
79 }
80
81 /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
82 /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
83 pub fn list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
84 Self::new(delegate, cx, false)
85 }
86
87 fn new(delegate: D, cx: &mut ViewContext<Self>, is_uniform: bool) -> Self {
88 let editor = create_editor(delegate.placeholder_text(), cx);
89 cx.subscribe(&editor, Self::on_input_editor_event).detach();
90 let mut this = Self {
91 delegate,
92 editor,
93 element_container: Self::crate_element_container(is_uniform, cx),
94 pending_update_matches: None,
95 confirm_on_update: None,
96 width: None,
97 max_height: None,
98 is_modal: true,
99 };
100 this.update_matches("".to_string(), cx);
101 this
102 }
103
104 fn crate_element_container(is_uniform: bool, cx: &mut ViewContext<Self>) -> ElementContainer {
105 if is_uniform {
106 ElementContainer::UniformList(UniformListScrollHandle::new())
107 } else {
108 let view = cx.view().downgrade();
109 ElementContainer::List(ListState::new(
110 0,
111 gpui::ListAlignment::Top,
112 px(1000.),
113 move |ix, cx| {
114 view.upgrade()
115 .map(|view| {
116 view.update(cx, |this, cx| {
117 this.render_element(cx, ix).into_any_element()
118 })
119 })
120 .unwrap_or_else(|| div().into_any_element())
121 },
122 ))
123 }
124 }
125
126 pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
127 self.width = Some(width.into());
128 self
129 }
130
131 pub fn max_height(mut self, max_height: impl Into<gpui::Length>) -> Self {
132 self.max_height = Some(max_height.into());
133 self
134 }
135
136 pub fn modal(mut self, modal: bool) -> Self {
137 self.is_modal = modal;
138 self
139 }
140
141 pub fn focus(&self, cx: &mut WindowContext) {
142 self.editor.update(cx, |editor, cx| editor.focus(cx));
143 }
144
145 pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
146 let count = self.delegate.match_count();
147 if count > 0 {
148 let index = self.delegate.selected_index();
149 let ix = if index == count - 1 { 0 } else { index + 1 };
150 self.delegate.set_selected_index(ix, cx);
151 self.scroll_to_item_index(ix);
152 cx.notify();
153 }
154 }
155
156 fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
157 let count = self.delegate.match_count();
158 if count > 0 {
159 let index = self.delegate.selected_index();
160 let ix = if index == 0 { count - 1 } else { index - 1 };
161 self.delegate.set_selected_index(ix, cx);
162 self.scroll_to_item_index(ix);
163 cx.notify();
164 }
165 }
166
167 fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
168 let count = self.delegate.match_count();
169 if count > 0 {
170 self.delegate.set_selected_index(0, cx);
171 self.scroll_to_item_index(0);
172 cx.notify();
173 }
174 }
175
176 fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
177 let count = self.delegate.match_count();
178 if count > 0 {
179 self.delegate.set_selected_index(count - 1, cx);
180 self.scroll_to_item_index(count - 1);
181 cx.notify();
182 }
183 }
184
185 pub fn cycle_selection(&mut self, cx: &mut ViewContext<Self>) {
186 let count = self.delegate.match_count();
187 let index = self.delegate.selected_index();
188 let new_index = if index + 1 == count { 0 } else { index + 1 };
189 self.delegate.set_selected_index(new_index, cx);
190 self.scroll_to_item_index(new_index);
191 cx.notify();
192 }
193
194 pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
195 self.delegate.dismissed(cx);
196 cx.emit(DismissEvent);
197 }
198
199 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
200 if self.pending_update_matches.is_some() {
201 self.confirm_on_update = Some(false)
202 } else {
203 self.delegate.confirm(false, cx);
204 }
205 }
206
207 fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
208 if self.pending_update_matches.is_some() {
209 self.confirm_on_update = Some(true)
210 } else {
211 self.delegate.confirm(true, cx);
212 }
213 }
214
215 fn handle_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext<Self>) {
216 cx.stop_propagation();
217 cx.prevent_default();
218 self.delegate.set_selected_index(ix, cx);
219 self.delegate.confirm(secondary, cx);
220 }
221
222 fn on_input_editor_event(
223 &mut self,
224 _: View<Editor>,
225 event: &editor::EditorEvent,
226 cx: &mut ViewContext<Self>,
227 ) {
228 match event {
229 editor::EditorEvent::BufferEdited => {
230 let query = self.editor.read(cx).text(cx);
231 self.update_matches(query, cx);
232 }
233 editor::EditorEvent::Blurred => {
234 self.cancel(&menu::Cancel, cx);
235 }
236 _ => {}
237 }
238 }
239
240 pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
241 let query = self.editor.read(cx).text(cx);
242 self.update_matches(query, cx);
243 }
244
245 pub fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) {
246 let update = self.delegate.update_matches(query, cx);
247 self.matches_updated(cx);
248 self.pending_update_matches = Some(cx.spawn(|this, mut cx| async move {
249 update.await;
250 this.update(&mut cx, |this, cx| {
251 this.matches_updated(cx);
252 })
253 .ok();
254 }));
255 }
256
257 fn matches_updated(&mut self, cx: &mut ViewContext<Self>) {
258 if let ElementContainer::List(state) = &mut self.element_container {
259 state.reset(self.delegate.match_count());
260 }
261
262 let index = self.delegate.selected_index();
263 self.scroll_to_item_index(index);
264 self.pending_update_matches = None;
265 if let Some(secondary) = self.confirm_on_update.take() {
266 self.delegate.confirm(secondary, cx);
267 }
268 cx.notify();
269 }
270
271 pub fn query(&self, cx: &AppContext) -> String {
272 self.editor.read(cx).text(cx)
273 }
274
275 pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
276 self.editor
277 .update(cx, |editor, cx| editor.set_text(query, cx));
278 }
279
280 fn scroll_to_item_index(&mut self, ix: usize) {
281 match &mut self.element_container {
282 ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
283 ElementContainer::UniformList(scroll_handle) => scroll_handle.scroll_to_item(ix),
284 }
285 }
286
287 fn render_element(&self, cx: &mut ViewContext<Self>, ix: usize) -> impl IntoElement {
288 div()
289 .on_mouse_down(
290 MouseButton::Left,
291 cx.listener(move |this, event: &MouseDownEvent, cx| {
292 this.handle_click(ix, event.modifiers.command, cx)
293 }),
294 )
295 .children(
296 self.delegate
297 .render_match(ix, ix == self.delegate.selected_index(), cx),
298 )
299 .when(
300 self.delegate.separators_after_indices().contains(&ix),
301 |picker| {
302 picker
303 .border_color(cx.theme().colors().border_variant)
304 .border_b_1()
305 .pb(px(-1.0))
306 },
307 )
308 }
309
310 fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
311 match &self.element_container {
312 ElementContainer::UniformList(scroll_handle) => uniform_list(
313 cx.view().clone(),
314 "candidates",
315 self.delegate.match_count(),
316 move |picker, visible_range, cx| {
317 visible_range
318 .map(|ix| picker.render_element(cx, ix))
319 .collect()
320 },
321 )
322 .py_2()
323 .track_scroll(scroll_handle.clone())
324 .into_any_element(),
325 ElementContainer::List(state) => list(state.clone())
326 .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
327 .py_2()
328 .into_any_element(),
329 }
330 }
331}
332
333impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
334impl<D: PickerDelegate> ModalView for Picker<D> {}
335
336impl<D: PickerDelegate> Render for Picker<D> {
337 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
338 let picker_editor = h_flex()
339 .overflow_hidden()
340 .flex_none()
341 .h_9()
342 .px_4()
343 .child(self.editor.clone());
344
345 div()
346 .key_context("Picker")
347 .size_full()
348 .when_some(self.width, |el, width| el.w(width))
349 .overflow_hidden()
350 // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
351 // as a part of a modal rather than the entire modal.
352 //
353 // We should revisit how the `Picker` is styled to make it more composable.
354 .when(self.is_modal, |this| this.elevation_3(cx))
355 .on_action(cx.listener(Self::select_next))
356 .on_action(cx.listener(Self::select_prev))
357 .on_action(cx.listener(Self::select_first))
358 .on_action(cx.listener(Self::select_last))
359 .on_action(cx.listener(Self::cancel))
360 .on_action(cx.listener(Self::confirm))
361 .on_action(cx.listener(Self::secondary_confirm))
362 .child(picker_editor)
363 .child(Divider::horizontal())
364 .when(self.delegate.match_count() > 0, |el| {
365 el.child(
366 v_flex()
367 .flex_grow()
368 .max_h(self.max_height.unwrap_or(rems(18.).into()))
369 .overflow_hidden()
370 .children(self.delegate.render_header(cx))
371 .child(self.render_element_container(cx)),
372 )
373 })
374 .when(self.delegate.match_count() == 0, |el| {
375 el.child(
376 v_flex().flex_grow().py_2().child(
377 ListItem::new("empty_state")
378 .inset(true)
379 .spacing(ListItemSpacing::Sparse)
380 .disabled(true)
381 .child(Label::new("No matches").color(Color::Muted)),
382 ),
383 )
384 })
385 .children(self.delegate.render_footer(cx))
386 }
387}