settings_profile_selector.rs

  1use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
  2use gpui::{
  3    App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, WeakEntity, Window,
  4};
  5use picker::{Picker, PickerDelegate};
  6use settings::{ActiveSettingsProfileName, SettingsStore};
  7use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
  8use workspace::{ModalView, Workspace};
  9
 10pub fn init(cx: &mut App) {
 11    cx.on_action(|_: &zed_actions::settings_profile_selector::Toggle, cx| {
 12        workspace::with_active_or_new_workspace(cx, |workspace, window, cx| {
 13            toggle_settings_profile_selector(workspace, window, cx);
 14        });
 15    });
 16}
 17
 18fn toggle_settings_profile_selector(
 19    workspace: &mut Workspace,
 20    window: &mut Window,
 21    cx: &mut Context<Workspace>,
 22) {
 23    workspace.toggle_modal(window, cx, |window, cx| {
 24        let delegate = SettingsProfileSelectorDelegate::new(cx.entity().downgrade(), window, cx);
 25        SettingsProfileSelector::new(delegate, window, cx)
 26    });
 27}
 28
 29pub struct SettingsProfileSelector {
 30    picker: Entity<Picker<SettingsProfileSelectorDelegate>>,
 31}
 32
 33impl ModalView for SettingsProfileSelector {}
 34
 35impl EventEmitter<DismissEvent> for SettingsProfileSelector {}
 36
 37impl Focusable for SettingsProfileSelector {
 38    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
 39        self.picker.focus_handle(cx)
 40    }
 41}
 42
 43impl Render for SettingsProfileSelector {
 44    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
 45        v_flex().w(rems(34.)).child(self.picker.clone())
 46    }
 47}
 48
 49impl SettingsProfileSelector {
 50    pub fn new(
 51        delegate: SettingsProfileSelectorDelegate,
 52        window: &mut Window,
 53        cx: &mut Context<Self>,
 54    ) -> Self {
 55        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
 56        Self { picker }
 57    }
 58}
 59
 60pub struct SettingsProfileSelectorDelegate {
 61    matches: Vec<StringMatch>,
 62    profile_names: Vec<Option<String>>,
 63    original_profile_name: Option<String>,
 64    selected_profile_name: Option<String>,
 65    selected_index: usize,
 66    selection_completed: bool,
 67    selector: WeakEntity<SettingsProfileSelector>,
 68}
 69
 70impl SettingsProfileSelectorDelegate {
 71    fn new(
 72        selector: WeakEntity<SettingsProfileSelector>,
 73        _: &mut Window,
 74        cx: &mut Context<SettingsProfileSelector>,
 75    ) -> Self {
 76        let settings_store = cx.global::<SettingsStore>();
 77        let mut profile_names: Vec<String> = settings_store
 78            .configured_settings_profiles()
 79            .map(|s| s.to_string())
 80            .collect();
 81
 82        profile_names.sort();
 83        let mut profile_names: Vec<_> = profile_names.into_iter().map(Some).collect();
 84        profile_names.insert(0, None);
 85
 86        let matches = profile_names
 87            .iter()
 88            .enumerate()
 89            .map(|(ix, profile_name)| StringMatch {
 90                candidate_id: ix,
 91                score: 0.0,
 92                positions: Default::default(),
 93                string: display_name(profile_name),
 94            })
 95            .collect();
 96
 97        let profile_name = cx
 98            .try_global::<ActiveSettingsProfileName>()
 99            .map(|p| p.0.clone());
100
101        let mut this = Self {
102            matches,
103            profile_names,
104            original_profile_name: profile_name.clone(),
105            selected_profile_name: None,
106            selected_index: 0,
107            selection_completed: false,
108            selector,
109        };
110
111        if let Some(profile_name) = profile_name {
112            this.select_if_matching(&profile_name);
113        }
114
115        this
116    }
117
118    fn select_if_matching(&mut self, profile_name: &str) {
119        self.selected_index = self
120            .matches
121            .iter()
122            .position(|mat| mat.string == profile_name)
123            .unwrap_or(self.selected_index);
124    }
125
126    fn set_selected_profile(
127        &self,
128        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
129    ) -> Option<String> {
130        let mat = self.matches.get(self.selected_index)?;
131        let profile_name = self.profile_names.get(mat.candidate_id)?;
132        return Self::update_active_profile_name_global(profile_name.clone(), cx);
133    }
134
135    fn update_active_profile_name_global(
136        profile_name: Option<String>,
137        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
138    ) -> Option<String> {
139        if let Some(profile_name) = profile_name {
140            cx.set_global(ActiveSettingsProfileName(profile_name.clone()));
141            return Some(profile_name.clone());
142        }
143
144        if cx.has_global::<ActiveSettingsProfileName>() {
145            cx.remove_global::<ActiveSettingsProfileName>();
146        }
147
148        None
149    }
150}
151
152impl PickerDelegate for SettingsProfileSelectorDelegate {
153    type ListItem = ListItem;
154
155    fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc<str> {
156        "Select a settings profile...".into()
157    }
158
159    fn match_count(&self) -> usize {
160        self.matches.len()
161    }
162
163    fn selected_index(&self) -> usize {
164        self.selected_index
165    }
166
167    fn set_selected_index(
168        &mut self,
169        ix: usize,
170        _: &mut Window,
171        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
172    ) {
173        self.selected_index = ix;
174        self.selected_profile_name = self.set_selected_profile(cx);
175    }
176
177    fn update_matches(
178        &mut self,
179        query: String,
180        window: &mut Window,
181        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
182    ) -> Task<()> {
183        let background = cx.background_executor().clone();
184        let candidates = self
185            .profile_names
186            .iter()
187            .enumerate()
188            .map(|(id, profile_name)| StringMatchCandidate::new(id, &display_name(profile_name)))
189            .collect::<Vec<_>>();
190
191        cx.spawn_in(window, async move |this, cx| {
192            let matches = if query.is_empty() {
193                candidates
194                    .into_iter()
195                    .enumerate()
196                    .map(|(index, candidate)| StringMatch {
197                        candidate_id: index,
198                        string: candidate.string,
199                        positions: Vec::new(),
200                        score: 0.0,
201                    })
202                    .collect()
203            } else {
204                match_strings(
205                    &candidates,
206                    &query,
207                    false,
208                    true,
209                    100,
210                    &Default::default(),
211                    background,
212                )
213                .await
214            };
215
216            this.update_in(cx, |this, _, cx| {
217                this.delegate.matches = matches;
218                this.delegate.selected_index = this
219                    .delegate
220                    .selected_index
221                    .min(this.delegate.matches.len().saturating_sub(1));
222                this.delegate.selected_profile_name = this.delegate.set_selected_profile(cx);
223            })
224            .ok();
225        })
226    }
227
228    fn confirm(
229        &mut self,
230        _: bool,
231        _: &mut Window,
232        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
233    ) {
234        self.selection_completed = true;
235        self.selector
236            .update(cx, |_, cx| {
237                cx.emit(DismissEvent);
238            })
239            .ok();
240    }
241
242    fn dismissed(
243        &mut self,
244        _: &mut Window,
245        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
246    ) {
247        if !self.selection_completed {
248            SettingsProfileSelectorDelegate::update_active_profile_name_global(
249                self.original_profile_name.clone(),
250                cx,
251            );
252        }
253        self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
254    }
255
256    fn render_match(
257        &self,
258        ix: usize,
259        selected: bool,
260        _: &mut Window,
261        _: &mut Context<Picker<Self>>,
262    ) -> Option<Self::ListItem> {
263        let mat = &self.matches[ix];
264        let profile_name = &self.profile_names[mat.candidate_id];
265
266        Some(
267            ListItem::new(ix)
268                .inset(true)
269                .spacing(ListItemSpacing::Sparse)
270                .toggle_state(selected)
271                .child(HighlightedLabel::new(
272                    display_name(profile_name),
273                    mat.positions.clone(),
274                )),
275        )
276    }
277}
278
279fn display_name(profile_name: &Option<String>) -> String {
280    profile_name.clone().unwrap_or("Disabled".into())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use editor;
287    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
288    use language;
289    use menu::{Cancel, Confirm, SelectNext, SelectPrevious};
290    use project::{FakeFs, Project};
291    use serde_json::json;
292    use workspace::{self, AppState};
293    use zed_actions::settings_profile_selector;
294
295    async fn init_test(
296        profiles_json: serde_json::Value,
297        cx: &mut TestAppContext,
298    ) -> (Entity<Workspace>, &mut VisualTestContext) {
299        cx.update(|cx| {
300            let state = AppState::test(cx);
301            language::init(cx);
302            super::init(cx);
303            editor::init(cx);
304            workspace::init_settings(cx);
305            Project::init_settings(cx);
306            state
307        });
308
309        cx.update(|cx| {
310            SettingsStore::update_global(cx, |store, cx| {
311                let settings_json = json!({
312                    "profiles": profiles_json
313                });
314
315                store
316                    .set_user_settings(&settings_json.to_string(), cx)
317                    .unwrap();
318            });
319        });
320
321        let fs = FakeFs::new(cx.executor());
322        let project = Project::test(fs, ["/test".as_ref()], cx).await;
323        let (workspace, cx) =
324            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
325
326        cx.update(|_, cx| {
327            assert!(!cx.has_global::<ActiveSettingsProfileName>());
328        });
329
330        (workspace, cx)
331    }
332
333    #[track_caller]
334    fn active_settings_profile_picker(
335        workspace: &Entity<Workspace>,
336        cx: &mut VisualTestContext,
337    ) -> Entity<Picker<SettingsProfileSelectorDelegate>> {
338        workspace.update(cx, |workspace, cx| {
339            workspace
340                .active_modal::<SettingsProfileSelector>(cx)
341                .expect("settings profile selector is not open")
342                .read(cx)
343                .picker
344                .clone()
345        })
346    }
347
348    #[gpui::test]
349    async fn test_settings_profile_selector_state(cx: &mut TestAppContext) {
350        let profiles_json = json!({
351            "Demo Videos": {
352                "buffer_font_size": 14
353            },
354            "Classroom / Streaming": {
355                "buffer_font_size": 16,
356                "vim_mode": true
357            }
358        });
359        let (workspace, cx) = init_test(profiles_json.clone(), cx).await;
360
361        cx.dispatch_action(settings_profile_selector::Toggle);
362
363        let picker = active_settings_profile_picker(&workspace, cx);
364
365        picker.read_with(cx, |picker, cx| {
366            assert_eq!(picker.delegate.matches.len(), 3);
367            assert_eq!(picker.delegate.matches[0].string, "Disabled");
368            assert_eq!(picker.delegate.matches[1].string, "Classroom / Streaming");
369            assert_eq!(picker.delegate.matches[2].string, "Demo Videos");
370            assert_eq!(picker.delegate.matches.get(3), None);
371
372            assert_eq!(picker.delegate.selected_index, 0);
373            assert_eq!(picker.delegate.selected_profile_name, None);
374
375            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
376        });
377
378        cx.dispatch_action(Confirm);
379
380        cx.update(|_, cx| {
381            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
382        });
383
384        cx.dispatch_action(settings_profile_selector::Toggle);
385        let picker = active_settings_profile_picker(&workspace, cx);
386        cx.dispatch_action(SelectNext);
387
388        picker.read_with(cx, |picker, cx| {
389            assert_eq!(picker.delegate.selected_index, 1);
390            assert_eq!(
391                picker.delegate.selected_profile_name,
392                Some("Classroom / Streaming".to_string())
393            );
394
395            assert_eq!(
396                cx.try_global::<ActiveSettingsProfileName>()
397                    .map(|p| p.0.clone()),
398                Some("Classroom / Streaming".to_string())
399            );
400        });
401
402        cx.dispatch_action(Cancel);
403
404        cx.update(|_, cx| {
405            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
406        });
407
408        cx.dispatch_action(settings_profile_selector::Toggle);
409        let picker = active_settings_profile_picker(&workspace, cx);
410
411        cx.dispatch_action(SelectNext);
412
413        picker.read_with(cx, |picker, cx| {
414            assert_eq!(picker.delegate.selected_index, 1);
415            assert_eq!(
416                picker.delegate.selected_profile_name,
417                Some("Classroom / Streaming".to_string())
418            );
419
420            assert_eq!(
421                cx.try_global::<ActiveSettingsProfileName>()
422                    .map(|p| p.0.clone()),
423                Some("Classroom / Streaming".to_string())
424            );
425        });
426
427        cx.dispatch_action(SelectNext);
428
429        picker.read_with(cx, |picker, cx| {
430            assert_eq!(picker.delegate.selected_index, 2);
431            assert_eq!(
432                picker.delegate.selected_profile_name,
433                Some("Demo Videos".to_string())
434            );
435
436            assert_eq!(
437                cx.try_global::<ActiveSettingsProfileName>()
438                    .map(|p| p.0.clone()),
439                Some("Demo Videos".to_string())
440            );
441        });
442
443        cx.dispatch_action(Confirm);
444
445        cx.update(|_, cx| {
446            assert_eq!(
447                cx.try_global::<ActiveSettingsProfileName>()
448                    .map(|p| p.0.clone()),
449                Some("Demo Videos".to_string())
450            );
451        });
452
453        cx.dispatch_action(settings_profile_selector::Toggle);
454        let picker = active_settings_profile_picker(&workspace, cx);
455
456        picker.read_with(cx, |picker, cx| {
457            assert_eq!(picker.delegate.selected_index, 2);
458            assert_eq!(
459                picker.delegate.selected_profile_name,
460                Some("Demo Videos".to_string())
461            );
462
463            assert_eq!(
464                cx.try_global::<ActiveSettingsProfileName>()
465                    .map(|p| p.0.clone()),
466                Some("Demo Videos".to_string())
467            );
468        });
469
470        cx.dispatch_action(SelectPrevious);
471
472        picker.read_with(cx, |picker, cx| {
473            assert_eq!(picker.delegate.selected_index, 1);
474            assert_eq!(
475                picker.delegate.selected_profile_name,
476                Some("Classroom / Streaming".to_string())
477            );
478
479            assert_eq!(
480                cx.try_global::<ActiveSettingsProfileName>()
481                    .map(|p| p.0.clone()),
482                Some("Classroom / Streaming".to_string())
483            );
484        });
485
486        cx.dispatch_action(Cancel);
487
488        cx.update(|_, cx| {
489            assert_eq!(
490                cx.try_global::<ActiveSettingsProfileName>()
491                    .map(|p| p.0.clone()),
492                Some("Demo Videos".to_string())
493            );
494        });
495
496        cx.dispatch_action(settings_profile_selector::Toggle);
497        let picker = active_settings_profile_picker(&workspace, cx);
498
499        picker.read_with(cx, |picker, cx| {
500            assert_eq!(picker.delegate.selected_index, 2);
501            assert_eq!(
502                picker.delegate.selected_profile_name,
503                Some("Demo Videos".to_string())
504            );
505
506            assert_eq!(
507                cx.try_global::<ActiveSettingsProfileName>()
508                    .map(|p| p.0.clone()),
509                Some("Demo Videos".to_string())
510            );
511        });
512
513        cx.dispatch_action(SelectPrevious);
514
515        picker.read_with(cx, |picker, cx| {
516            assert_eq!(picker.delegate.selected_index, 1);
517            assert_eq!(
518                picker.delegate.selected_profile_name,
519                Some("Classroom / Streaming".to_string())
520            );
521
522            assert_eq!(
523                cx.try_global::<ActiveSettingsProfileName>()
524                    .map(|p| p.0.clone()),
525                Some("Classroom / Streaming".to_string())
526            );
527        });
528
529        cx.dispatch_action(SelectPrevious);
530
531        picker.read_with(cx, |picker, cx| {
532            assert_eq!(picker.delegate.selected_index, 0);
533            assert_eq!(picker.delegate.selected_profile_name, None);
534
535            assert_eq!(
536                cx.try_global::<ActiveSettingsProfileName>()
537                    .map(|p| p.0.clone()),
538                None
539            );
540        });
541
542        cx.dispatch_action(Confirm);
543
544        cx.update(|_, cx| {
545            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
546        });
547    }
548}