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 client;
287    use editor;
288    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
289    use language;
290    use menu::{Cancel, Confirm, SelectNext, SelectPrevious};
291    use project::{FakeFs, Project};
292    use serde_json::json;
293    use settings::Settings;
294    use theme::{self, ThemeSettings};
295    use workspace::{self, AppState};
296    use zed_actions::settings_profile_selector;
297
298    async fn init_test(
299        profiles_json: serde_json::Value,
300        cx: &mut TestAppContext,
301    ) -> (Entity<Workspace>, &mut VisualTestContext) {
302        cx.update(|cx| {
303            let state = AppState::test(cx);
304            let settings_store = SettingsStore::test(cx);
305            cx.set_global(settings_store);
306            settings::init(cx);
307            theme::init(theme::LoadThemes::JustBase, cx);
308            ThemeSettings::register(cx);
309            client::init_settings(cx);
310            language::init(cx);
311            super::init(cx);
312            editor::init(cx);
313            workspace::init_settings(cx);
314            Project::init_settings(cx);
315            state
316        });
317
318        cx.update(|cx| {
319            SettingsStore::update_global(cx, |store, cx| {
320                let settings_json = json!({
321                    "buffer_font_size": 10.0,
322                    "profiles": profiles_json,
323                });
324
325                store
326                    .set_user_settings(&settings_json.to_string(), cx)
327                    .unwrap();
328            });
329        });
330
331        let fs = FakeFs::new(cx.executor());
332        let project = Project::test(fs, ["/test".as_ref()], cx).await;
333        let (workspace, cx) =
334            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
335
336        cx.update(|_, cx| {
337            assert!(!cx.has_global::<ActiveSettingsProfileName>());
338            let theme_settings = ThemeSettings::get_global(cx);
339            assert_eq!(theme_settings.buffer_font_size(cx).0, 10.0);
340        });
341
342        (workspace, cx)
343    }
344
345    #[track_caller]
346    fn active_settings_profile_picker(
347        workspace: &Entity<Workspace>,
348        cx: &mut VisualTestContext,
349    ) -> Entity<Picker<SettingsProfileSelectorDelegate>> {
350        workspace.update(cx, |workspace, cx| {
351            workspace
352                .active_modal::<SettingsProfileSelector>(cx)
353                .expect("settings profile selector is not open")
354                .read(cx)
355                .picker
356                .clone()
357        })
358    }
359
360    #[gpui::test]
361    async fn test_settings_profile_selector_state(cx: &mut TestAppContext) {
362        let demo_videos_profile_name = "Demo Videos".to_string();
363        let classroom_and_streaming_profile_name = "Classroom / Streaming".to_string();
364
365        let profiles_json = json!({
366            demo_videos_profile_name.clone(): {
367                "buffer_font_size": 15.0
368            },
369            classroom_and_streaming_profile_name.clone(): {
370                "buffer_font_size": 20.0,
371            }
372        });
373        let (workspace, cx) = init_test(profiles_json.clone(), cx).await;
374
375        cx.dispatch_action(settings_profile_selector::Toggle);
376        let picker = active_settings_profile_picker(&workspace, cx);
377
378        picker.read_with(cx, |picker, cx| {
379            assert_eq!(picker.delegate.matches.len(), 3);
380            assert_eq!(picker.delegate.matches[0].string, display_name(&None));
381            assert_eq!(
382                picker.delegate.matches[1].string,
383                classroom_and_streaming_profile_name
384            );
385            assert_eq!(picker.delegate.matches[2].string, demo_videos_profile_name);
386            assert_eq!(picker.delegate.matches.get(3), None);
387
388            assert_eq!(picker.delegate.selected_index, 0);
389            assert_eq!(picker.delegate.selected_profile_name, None);
390
391            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
392            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 10.0);
393        });
394
395        cx.dispatch_action(Confirm);
396
397        cx.update(|_, cx| {
398            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
399        });
400
401        cx.dispatch_action(settings_profile_selector::Toggle);
402        let picker = active_settings_profile_picker(&workspace, cx);
403        cx.dispatch_action(SelectNext);
404
405        picker.read_with(cx, |picker, cx| {
406            assert_eq!(picker.delegate.selected_index, 1);
407            assert_eq!(
408                picker.delegate.selected_profile_name,
409                Some(classroom_and_streaming_profile_name.clone())
410            );
411
412            assert_eq!(
413                cx.try_global::<ActiveSettingsProfileName>()
414                    .map(|p| p.0.clone()),
415                Some(classroom_and_streaming_profile_name.clone())
416            );
417
418            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 20.0);
419        });
420
421        cx.dispatch_action(Cancel);
422
423        cx.update(|_, cx| {
424            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
425            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 10.0);
426        });
427
428        cx.dispatch_action(settings_profile_selector::Toggle);
429        let picker = active_settings_profile_picker(&workspace, cx);
430
431        cx.dispatch_action(SelectNext);
432
433        picker.read_with(cx, |picker, cx| {
434            assert_eq!(picker.delegate.selected_index, 1);
435            assert_eq!(
436                picker.delegate.selected_profile_name,
437                Some(classroom_and_streaming_profile_name.clone())
438            );
439
440            assert_eq!(
441                cx.try_global::<ActiveSettingsProfileName>()
442                    .map(|p| p.0.clone()),
443                Some(classroom_and_streaming_profile_name.clone())
444            );
445
446            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 20.0);
447        });
448
449        cx.dispatch_action(SelectNext);
450
451        picker.read_with(cx, |picker, cx| {
452            assert_eq!(picker.delegate.selected_index, 2);
453            assert_eq!(
454                picker.delegate.selected_profile_name,
455                Some(demo_videos_profile_name.clone())
456            );
457
458            assert_eq!(
459                cx.try_global::<ActiveSettingsProfileName>()
460                    .map(|p| p.0.clone()),
461                Some(demo_videos_profile_name.clone())
462            );
463
464            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 15.0);
465        });
466
467        cx.dispatch_action(Confirm);
468
469        cx.update(|_, cx| {
470            assert_eq!(
471                cx.try_global::<ActiveSettingsProfileName>()
472                    .map(|p| p.0.clone()),
473                Some(demo_videos_profile_name.clone())
474            );
475            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 15.0);
476        });
477
478        cx.dispatch_action(settings_profile_selector::Toggle);
479        let picker = active_settings_profile_picker(&workspace, cx);
480
481        picker.read_with(cx, |picker, cx| {
482            assert_eq!(picker.delegate.selected_index, 2);
483            assert_eq!(
484                picker.delegate.selected_profile_name,
485                Some(demo_videos_profile_name.clone())
486            );
487
488            assert_eq!(
489                cx.try_global::<ActiveSettingsProfileName>()
490                    .map(|p| p.0.clone()),
491                Some(demo_videos_profile_name.clone())
492            );
493            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 15.0);
494        });
495
496        cx.dispatch_action(SelectPrevious);
497
498        picker.read_with(cx, |picker, cx| {
499            assert_eq!(picker.delegate.selected_index, 1);
500            assert_eq!(
501                picker.delegate.selected_profile_name,
502                Some(classroom_and_streaming_profile_name.clone())
503            );
504
505            assert_eq!(
506                cx.try_global::<ActiveSettingsProfileName>()
507                    .map(|p| p.0.clone()),
508                Some(classroom_and_streaming_profile_name.clone())
509            );
510
511            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 20.0);
512        });
513
514        cx.dispatch_action(Cancel);
515
516        cx.update(|_, cx| {
517            assert_eq!(
518                cx.try_global::<ActiveSettingsProfileName>()
519                    .map(|p| p.0.clone()),
520                Some(demo_videos_profile_name.clone())
521            );
522
523            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 15.0);
524        });
525
526        cx.dispatch_action(settings_profile_selector::Toggle);
527        let picker = active_settings_profile_picker(&workspace, cx);
528
529        picker.read_with(cx, |picker, cx| {
530            assert_eq!(picker.delegate.selected_index, 2);
531            assert_eq!(
532                picker.delegate.selected_profile_name,
533                Some(demo_videos_profile_name.clone())
534            );
535
536            assert_eq!(
537                cx.try_global::<ActiveSettingsProfileName>()
538                    .map(|p| p.0.clone()),
539                Some(demo_videos_profile_name)
540            );
541
542            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 15.0);
543        });
544
545        cx.dispatch_action(SelectPrevious);
546
547        picker.read_with(cx, |picker, cx| {
548            assert_eq!(picker.delegate.selected_index, 1);
549            assert_eq!(
550                picker.delegate.selected_profile_name,
551                Some(classroom_and_streaming_profile_name.clone())
552            );
553
554            assert_eq!(
555                cx.try_global::<ActiveSettingsProfileName>()
556                    .map(|p| p.0.clone()),
557                Some(classroom_and_streaming_profile_name)
558            );
559
560            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 20.0);
561        });
562
563        cx.dispatch_action(SelectPrevious);
564
565        picker.read_with(cx, |picker, cx| {
566            assert_eq!(picker.delegate.selected_index, 0);
567            assert_eq!(picker.delegate.selected_profile_name, None);
568
569            assert_eq!(
570                cx.try_global::<ActiveSettingsProfileName>()
571                    .map(|p| p.0.clone()),
572                None
573            );
574
575            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 10.0);
576        });
577
578        cx.dispatch_action(Confirm);
579
580        cx.update(|_, cx| {
581            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
582            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx).0, 10.0);
583        });
584    }
585}