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}