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}