1//! # settings_ui
2mod components;
3use editor::Editor;
4use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
5use gpui::{
6 App, AppContext as _, Context, Div, Entity, Global, IntoElement, ReadGlobal as _, Render,
7 TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div,
8 point, px, size, uniform_list,
9};
10use project::WorktreeId;
11use settings::{CursorShape, SaturatingBool, SettingsContent, SettingsStore};
12use std::{
13 any::{Any, TypeId, type_name},
14 cell::RefCell,
15 collections::HashMap,
16 ops::Range,
17 rc::Rc,
18 sync::Arc,
19};
20use ui::{Divider, DropdownMenu, ListItem, Switch, prelude::*};
21use util::{paths::PathStyle, rel_path::RelPath};
22
23use crate::components::SettingsEditor;
24
25#[derive(Clone, Copy)]
26struct SettingField<T: 'static> {
27 pick: fn(&SettingsContent) -> &Option<T>,
28 pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
29}
30
31trait AnySettingField {
32 fn as_any(&self) -> &dyn Any;
33 fn type_name(&self) -> &'static str;
34 fn type_id(&self) -> TypeId;
35 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile;
36}
37
38impl<T> AnySettingField for SettingField<T> {
39 fn as_any(&self) -> &dyn Any {
40 self
41 }
42
43 fn type_name(&self) -> &'static str {
44 type_name::<T>()
45 }
46
47 fn type_id(&self) -> TypeId {
48 TypeId::of::<T>()
49 }
50
51 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile {
52 let (file, _) = cx
53 .global::<SettingsStore>()
54 .get_value_from_file(file.to_settings(), self.pick);
55 return file;
56 }
57}
58
59#[derive(Default, Clone)]
60struct SettingFieldRenderer {
61 renderers: Rc<
62 RefCell<
63 HashMap<
64 TypeId,
65 Box<
66 dyn Fn(
67 &dyn AnySettingField,
68 SettingsUiFile,
69 Option<&SettingsFieldMetadata>,
70 &mut Window,
71 &mut App,
72 ) -> AnyElement,
73 >,
74 >,
75 >,
76 >,
77}
78
79impl Global for SettingFieldRenderer {}
80
81impl SettingFieldRenderer {
82 fn add_renderer<T: 'static>(
83 &mut self,
84 renderer: impl Fn(
85 &SettingField<T>,
86 SettingsUiFile,
87 Option<&SettingsFieldMetadata>,
88 &mut Window,
89 &mut App,
90 ) -> AnyElement
91 + 'static,
92 ) -> &mut Self {
93 let key = TypeId::of::<T>();
94 let renderer = Box::new(
95 move |any_setting_field: &dyn AnySettingField,
96 settings_file: SettingsUiFile,
97 metadata: Option<&SettingsFieldMetadata>,
98 window: &mut Window,
99 cx: &mut App| {
100 let field = any_setting_field
101 .as_any()
102 .downcast_ref::<SettingField<T>>()
103 .unwrap();
104 renderer(field, settings_file, metadata, window, cx)
105 },
106 );
107 self.renderers.borrow_mut().insert(key, renderer);
108 self
109 }
110
111 fn render(
112 &self,
113 any_setting_field: &dyn AnySettingField,
114 settings_file: SettingsUiFile,
115 metadata: Option<&SettingsFieldMetadata>,
116 window: &mut Window,
117 cx: &mut App,
118 ) -> AnyElement {
119 let key = any_setting_field.type_id();
120 if let Some(renderer) = self.renderers.borrow().get(&key) {
121 renderer(any_setting_field, settings_file, metadata, window, cx)
122 } else {
123 panic!(
124 "No renderer found for type: {}",
125 any_setting_field.type_name()
126 )
127 }
128 }
129}
130
131struct SettingsFieldMetadata {
132 placeholder: Option<&'static str>,
133}
134
135fn user_settings_data() -> Vec<SettingsPage> {
136 vec![
137 SettingsPage {
138 title: "General Page",
139 expanded: true,
140 items: vec![
141 SettingsPageItem::SectionHeader("General"),
142 SettingsPageItem::SettingItem(SettingItem {
143 title: "Confirm Quit",
144 description: "Whether to confirm before quitting Zed",
145 field: Box::new(SettingField {
146 pick: |settings_content| &settings_content.workspace.confirm_quit,
147 pick_mut: |settings_content| &mut settings_content.workspace.confirm_quit,
148 }),
149 metadata: None,
150 }),
151 SettingsPageItem::SettingItem(SettingItem {
152 title: "Auto Update",
153 description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
154 field: Box::new(SettingField {
155 pick: |settings_content| &settings_content.auto_update,
156 pick_mut: |settings_content| &mut settings_content.auto_update,
157 }),
158 metadata: None,
159 }),
160 SettingsPageItem::SectionHeader("Privacy"),
161 ],
162 },
163 SettingsPage {
164 title: "Project",
165 expanded: true,
166 items: vec![
167 SettingsPageItem::SectionHeader("Worktree Settings Content"),
168 SettingsPageItem::SettingItem(SettingItem {
169 title: "Project Name",
170 description: "The displayed name of this project. If not set, the root directory name",
171 field: Box::new(SettingField {
172 pick: |settings_content| &settings_content.project.worktree.project_name,
173 pick_mut: |settings_content| {
174 &mut settings_content.project.worktree.project_name
175 },
176 }),
177 metadata: Some(Box::new(SettingsFieldMetadata {
178 placeholder: Some("A new name"),
179 })),
180 }),
181 ],
182 },
183 SettingsPage {
184 title: "AI",
185 expanded: true,
186 items: vec![
187 SettingsPageItem::SectionHeader("General"),
188 SettingsPageItem::SettingItem(SettingItem {
189 title: "Disable AI",
190 description: "Whether to disable all AI features in Zed",
191 field: Box::new(SettingField {
192 pick: |settings_content| &settings_content.disable_ai,
193 pick_mut: |settings_content| &mut settings_content.disable_ai,
194 }),
195 metadata: None,
196 }),
197 ],
198 },
199 SettingsPage {
200 title: "Appearance & Behavior",
201 expanded: true,
202 items: vec![
203 SettingsPageItem::SectionHeader("Cursor"),
204 SettingsPageItem::SettingItem(SettingItem {
205 title: "Cursor Shape",
206 description: "Cursor shape for the editor",
207 field: Box::new(SettingField {
208 pick: |settings_content| &settings_content.editor.cursor_shape,
209 pick_mut: |settings_content| &mut settings_content.editor.cursor_shape,
210 }),
211 metadata: None,
212 }),
213 ],
214 },
215 ]
216}
217
218// Derive Macro, on the new ProjectSettings struct
219
220fn project_settings_data() -> Vec<SettingsPage> {
221 vec![SettingsPage {
222 title: "Project",
223 expanded: true,
224 items: vec![
225 SettingsPageItem::SectionHeader("Worktree Settings Content"),
226 SettingsPageItem::SettingItem(SettingItem {
227 title: "Project Name",
228 description: "The displayed name of this project. If not set, the root directory name",
229 field: Box::new(SettingField {
230 pick: |settings_content| &settings_content.project.worktree.project_name,
231 pick_mut: |settings_content| {
232 &mut settings_content.project.worktree.project_name
233 },
234 }),
235 metadata: Some(Box::new(SettingsFieldMetadata {
236 placeholder: Some("A new name"),
237 })),
238 }),
239 ],
240 }]
241}
242
243pub struct SettingsUiFeatureFlag;
244
245impl FeatureFlag for SettingsUiFeatureFlag {
246 const NAME: &'static str = "settings-ui";
247}
248
249actions!(
250 zed,
251 [
252 /// Opens Settings Editor.
253 OpenSettingsEditor
254 ]
255);
256
257pub fn init(cx: &mut App) {
258 init_renderers(cx);
259
260 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
261 workspace.register_action_renderer(|div, _, _, cx| {
262 let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
263 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
264 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
265 if has_flag {
266 filter.show_action_types(&settings_ui_actions);
267 } else {
268 filter.hide_action_types(&settings_ui_actions);
269 }
270 });
271 if has_flag {
272 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
273 open_settings_editor(cx).ok();
274 }))
275 } else {
276 div
277 }
278 });
279 })
280 .detach();
281}
282
283fn init_renderers(cx: &mut App) {
284 // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
285 cx.default_global::<SettingFieldRenderer>()
286 .add_renderer::<bool>(|settings_field, file, _, _, cx| {
287 render_toggle_button(*settings_field, file, cx).into_any_element()
288 })
289 .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
290 render_text_field(settings_field.clone(), file, metadata, cx)
291 })
292 .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
293 render_toggle_button(*settings_field, file, cx)
294 })
295 .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
296 render_dropdown(*settings_field, file, window, cx)
297 });
298}
299
300pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
301 cx.open_window(
302 WindowOptions {
303 titlebar: Some(TitlebarOptions {
304 title: Some("Settings Window".into()),
305 appears_transparent: true,
306 traffic_light_position: Some(point(px(12.0), px(12.0))),
307 }),
308 focus: true,
309 show: true,
310 kind: gpui::WindowKind::Normal,
311 window_background: cx.theme().window_background_appearance(),
312 window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
313 ..Default::default()
314 },
315 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
316 )
317}
318
319pub struct SettingsWindow {
320 files: Vec<SettingsUiFile>,
321 current_file: SettingsUiFile,
322 pages: Vec<SettingsPage>,
323 search: Entity<Editor>,
324 navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
325 navbar_entries: Vec<NavBarEntry>,
326 list_handle: UniformListScrollHandle,
327}
328
329#[derive(PartialEq, Debug)]
330struct NavBarEntry {
331 title: &'static str,
332 is_root: bool,
333}
334
335struct SettingsPage {
336 title: &'static str,
337 expanded: bool,
338 items: Vec<SettingsPageItem>,
339}
340
341impl SettingsPage {
342 fn section_headers(&self) -> impl Iterator<Item = &'static str> {
343 self.items.iter().filter_map(|item| match item {
344 SettingsPageItem::SectionHeader(header) => Some(*header),
345 _ => None,
346 })
347 }
348}
349
350enum SettingsPageItem {
351 SectionHeader(&'static str),
352 SettingItem(SettingItem),
353}
354
355impl SettingsPageItem {
356 fn render(&self, file: SettingsUiFile, window: &mut Window, cx: &mut App) -> AnyElement {
357 match self {
358 SettingsPageItem::SectionHeader(header) => v_flex()
359 .w_full()
360 .gap_0p5()
361 .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
362 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
363 .into_any_element(),
364 SettingsPageItem::SettingItem(setting_item) => {
365 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
366 let file_set_in =
367 SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
368
369 h_flex()
370 .id(setting_item.title)
371 .w_full()
372 .gap_2()
373 .flex_wrap()
374 .justify_between()
375 .child(
376 v_flex()
377 .max_w_1_2()
378 .flex_shrink()
379 .child(
380 h_flex()
381 .w_full()
382 .gap_4()
383 .child(
384 Label::new(SharedString::new_static(setting_item.title))
385 .size(LabelSize::Default),
386 )
387 .when_some(
388 file_set_in.filter(|file_set_in| file_set_in != &file),
389 |elem, file_set_in| {
390 elem.child(
391 Label::new(format!(
392 "set in {}",
393 file_set_in.name()
394 ))
395 .color(Color::Muted),
396 )
397 },
398 ),
399 )
400 .child(
401 Label::new(SharedString::new_static(setting_item.description))
402 .size(LabelSize::Small)
403 .color(Color::Muted),
404 ),
405 )
406 .child(renderer.render(
407 setting_item.field.as_ref(),
408 file,
409 setting_item.metadata.as_deref(),
410 window,
411 cx,
412 ))
413 .into_any_element()
414 }
415 }
416 }
417}
418
419struct SettingItem {
420 title: &'static str,
421 description: &'static str,
422 field: Box<dyn AnySettingField>,
423 metadata: Option<Box<SettingsFieldMetadata>>,
424}
425
426#[allow(unused)]
427#[derive(Clone, PartialEq)]
428enum SettingsUiFile {
429 User, // Uses all settings.
430 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
431 Server(&'static str), // Uses a special name, and the user settings
432}
433
434impl SettingsUiFile {
435 fn pages(&self) -> Vec<SettingsPage> {
436 match self {
437 SettingsUiFile::User => user_settings_data(),
438 SettingsUiFile::Local(_) => project_settings_data(),
439 SettingsUiFile::Server(_) => user_settings_data(),
440 }
441 }
442
443 fn name(&self) -> SharedString {
444 match self {
445 SettingsUiFile::User => SharedString::new_static("User"),
446 // TODO is PathStyle::local() ever not appropriate?
447 SettingsUiFile::Local((_, path)) => {
448 format!("Local ({})", path.display(PathStyle::local())).into()
449 }
450 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
451 }
452 }
453
454 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
455 Some(match file {
456 settings::SettingsFile::User => SettingsUiFile::User,
457 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
458 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
459 settings::SettingsFile::Default => return None,
460 })
461 }
462
463 fn to_settings(&self) -> settings::SettingsFile {
464 match self {
465 SettingsUiFile::User => settings::SettingsFile::User,
466 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
467 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
468 }
469 }
470}
471
472impl SettingsWindow {
473 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
474 let current_file = SettingsUiFile::User;
475 let search = cx.new(|cx| {
476 let mut editor = Editor::single_line(window, cx);
477 editor.set_placeholder_text("Search settings…", window, cx);
478 editor
479 });
480 let mut this = Self {
481 files: vec![],
482 current_file: current_file,
483 pages: vec![],
484 navbar_entries: vec![],
485 navbar_entry: 0,
486 list_handle: UniformListScrollHandle::default(),
487 search,
488 };
489 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
490 this.fetch_files(cx);
491 cx.notify();
492 })
493 .detach();
494 this.fetch_files(cx);
495
496 this.build_ui(cx);
497 this
498 }
499
500 fn toggle_navbar_entry(&mut self, ix: usize) {
501 if self.navbar_entries[ix].is_root {
502 let expanded = &mut self.page_for_navbar_index(ix).expanded;
503 *expanded = !*expanded;
504 let current_page_index = self.page_index_from_navbar_index(self.navbar_entry);
505 // if currently selected page is a child of the parent page we are folding,
506 // set the current page to the parent page
507 if current_page_index == ix {
508 self.navbar_entry = ix;
509 }
510 self.build_navbar();
511 }
512 }
513
514 fn build_navbar(&mut self) {
515 self.navbar_entries = self
516 .pages
517 .iter()
518 .flat_map(|page| {
519 std::iter::once(NavBarEntry {
520 title: page.title,
521 is_root: true,
522 })
523 .chain(
524 page.expanded
525 .then(|| {
526 page.section_headers().map(|h| NavBarEntry {
527 title: h,
528 is_root: false,
529 })
530 })
531 .into_iter()
532 .flatten(),
533 )
534 })
535 .collect();
536 }
537
538 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
539 self.pages = self.current_file.pages();
540 self.build_navbar();
541
542 cx.notify();
543 }
544
545 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
546 let settings_store = cx.global::<SettingsStore>();
547 let mut ui_files = vec![];
548 let all_files = settings_store.get_all_files();
549 for file in all_files {
550 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
551 continue;
552 };
553 ui_files.push(settings_ui_file);
554 }
555 ui_files.reverse();
556 self.files = ui_files;
557 if !self.files.contains(&self.current_file) {
558 self.change_file(0, cx);
559 }
560 }
561
562 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
563 if ix >= self.files.len() {
564 self.current_file = SettingsUiFile::User;
565 return;
566 }
567 if self.files[ix] == self.current_file {
568 return;
569 }
570 self.current_file = self.files[ix].clone();
571 self.build_ui(cx);
572 }
573
574 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
575 h_flex()
576 .gap_1()
577 .children(self.files.iter().enumerate().map(|(ix, file)| {
578 Button::new(ix, file.name())
579 .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
580 }))
581 }
582
583 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
584 h_flex()
585 .pt_1()
586 .px_1p5()
587 .gap_1p5()
588 .rounded_sm()
589 .bg(cx.theme().colors().editor_background)
590 .border_1()
591 .border_color(cx.theme().colors().border)
592 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
593 .child(self.search.clone())
594 }
595
596 fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
597 v_flex()
598 .w_64()
599 .p_2p5()
600 .pt_10()
601 .gap_3()
602 .flex_none()
603 .border_r_1()
604 .border_color(cx.theme().colors().border)
605 .bg(cx.theme().colors().panel_background)
606 .child(self.render_search(window, cx).pb_1())
607 .child(
608 uniform_list(
609 "settings-ui-nav-bar",
610 self.navbar_entries.len(),
611 cx.processor(|this, range: Range<usize>, _, cx| {
612 range
613 .into_iter()
614 .map(|ix| {
615 let entry = &this.navbar_entries[ix];
616
617 h_flex()
618 .id(("settings-ui-section", ix))
619 .w_full()
620 .pl_2p5()
621 .py_0p5()
622 .rounded_sm()
623 .border_1()
624 .border_color(cx.theme().colors().border_transparent)
625 .text_color(cx.theme().colors().text_muted)
626 .when(this.is_navbar_entry_selected(ix), |this| {
627 this.text_color(cx.theme().colors().text)
628 .bg(cx.theme().colors().element_selected.opacity(0.2))
629 .border_color(cx.theme().colors().border)
630 })
631 .child(
632 ListItem::new(("settings-ui-navbar-entry", ix))
633 .selectable(true)
634 .inset(true)
635 .indent_step_size(px(1.))
636 .indent_level(if entry.is_root { 1 } else { 3 })
637 .when(entry.is_root, |item| {
638 item.toggle(
639 this.pages
640 [this.page_index_from_navbar_index(ix)]
641 .expanded,
642 )
643 .always_show_disclosure_icon(true)
644 .on_toggle(cx.listener(move |this, _, _, cx| {
645 this.toggle_navbar_entry(ix);
646 cx.notify();
647 }))
648 })
649 .child(
650 h_flex()
651 .text_ui(cx)
652 .truncate()
653 .hover(|s| {
654 s.bg(cx.theme().colors().element_hover)
655 })
656 .child(entry.title),
657 ),
658 )
659 .on_click(cx.listener(move |this, _, _, cx| {
660 this.navbar_entry = ix;
661 cx.notify();
662 }))
663 })
664 .collect()
665 }),
666 )
667 .track_scroll(self.list_handle.clone())
668 .size_full()
669 .flex_grow(),
670 )
671 }
672
673 fn render_page(
674 &self,
675 page: &SettingsPage,
676 window: &mut Window,
677 cx: &mut Context<SettingsWindow>,
678 ) -> Div {
679 v_flex().gap_4().children(
680 page.items
681 .iter()
682 .map(|item| item.render(self.current_file.clone(), window, cx)),
683 )
684 }
685
686 fn current_page(&self) -> &SettingsPage {
687 &self.pages[self.page_index_from_navbar_index(self.navbar_entry)]
688 }
689
690 fn page_index_from_navbar_index(&self, index: usize) -> usize {
691 self.navbar_entries
692 .iter()
693 .take(index + 1)
694 .map(|entry| entry.is_root as usize)
695 .sum::<usize>()
696 - 1
697 }
698
699 fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
700 let index = self.page_index_from_navbar_index(index);
701 &mut self.pages[index]
702 }
703
704 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
705 ix == self.navbar_entry
706 }
707}
708
709impl Render for SettingsWindow {
710 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
711 div()
712 .flex()
713 .flex_row()
714 .size_full()
715 .bg(cx.theme().colors().background)
716 .text_color(cx.theme().colors().text)
717 .child(self.render_nav(window, cx))
718 .child(
719 v_flex()
720 .w_full()
721 .pt_4()
722 .px_6()
723 .gap_4()
724 .bg(cx.theme().colors().editor_background)
725 .child(self.render_files(window, cx))
726 .child(self.render_page(self.current_page(), window, cx)),
727 )
728 }
729}
730
731// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
732// let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
733// }
734
735fn render_text_field(
736 field: SettingField<String>,
737 file: SettingsUiFile,
738 metadata: Option<&SettingsFieldMetadata>,
739 cx: &mut App,
740) -> AnyElement {
741 let (_, initial_text) =
742 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
743 let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
744
745 SettingsEditor::new()
746 .when_some(initial_text, |editor, text| editor.with_initial_text(text))
747 .when_some(
748 metadata.and_then(|metadata| metadata.placeholder),
749 |editor, placeholder| editor.with_placeholder(placeholder),
750 )
751 .on_confirm(move |new_text, cx: &mut App| {
752 cx.update_global(move |store: &mut SettingsStore, cx| {
753 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
754 *(field.pick_mut)(settings) = new_text;
755 });
756 });
757 })
758 .into_any_element()
759}
760
761fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
762 field: SettingField<B>,
763 file: SettingsUiFile,
764 cx: &mut App,
765) -> AnyElement {
766 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
767
768 let toggle_state = if value.into() {
769 ui::ToggleState::Selected
770 } else {
771 ui::ToggleState::Unselected
772 };
773
774 Switch::new("toggle_button", toggle_state)
775 .on_click({
776 move |state, _window, cx| {
777 let state = *state == ui::ToggleState::Selected;
778 let field = field;
779 cx.update_global(move |store: &mut SettingsStore, cx| {
780 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
781 *(field.pick_mut)(settings) = Some(state.into());
782 });
783 });
784 }
785 })
786 .into_any_element()
787}
788
789fn render_dropdown<T>(
790 field: SettingField<T>,
791 file: SettingsUiFile,
792 window: &mut Window,
793 cx: &mut App,
794) -> AnyElement
795where
796 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
797{
798 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
799 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
800
801 let (_, ¤t_value) =
802 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
803
804 let current_value_label =
805 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
806
807 DropdownMenu::new(
808 "dropdown",
809 current_value_label,
810 ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
811 for (value, label) in variants()
812 .into_iter()
813 .copied()
814 .zip(labels().into_iter().copied())
815 {
816 menu = menu.toggleable_entry(
817 label,
818 value == current_value,
819 ui::IconPosition::Start,
820 None,
821 move |_, cx| {
822 if value == current_value {
823 return;
824 }
825 cx.update_global(move |store: &mut SettingsStore, cx| {
826 store.update_settings_file(
827 <dyn fs::Fs>::global(cx),
828 move |settings, _cx| {
829 *(field.pick_mut)(settings) = Some(value);
830 },
831 );
832 });
833 },
834 );
835 }
836 menu
837 }),
838 )
839 .into_any_element()
840}
841
842#[cfg(test)]
843mod test {
844 use super::*;
845
846 impl SettingsWindow {
847 fn navbar(&self) -> &[NavBarEntry] {
848 self.navbar_entries.as_slice()
849 }
850
851 fn navbar_entry(&self) -> usize {
852 self.navbar_entry
853 }
854 }
855
856 fn register_settings(cx: &mut App) {
857 settings::init(cx);
858 theme::init(theme::LoadThemes::JustBase, cx);
859 workspace::init_settings(cx);
860 project::Project::init_settings(cx);
861 language::init(cx);
862 editor::init(cx);
863 menu::init();
864 }
865
866 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
867 let mut pages: Vec<SettingsPage> = Vec::new();
868 let mut current_page = None;
869 let mut selected_idx = None;
870
871 for (ix, mut line) in input
872 .lines()
873 .map(|line| line.trim())
874 .filter(|line| !line.is_empty())
875 .enumerate()
876 {
877 if line.ends_with("*") {
878 assert!(
879 selected_idx.is_none(),
880 "Can only have one selected navbar entry at a time"
881 );
882 selected_idx = Some(ix);
883 line = &line[..line.len() - 1];
884 }
885
886 if line.starts_with("v") || line.starts_with(">") {
887 if let Some(current_page) = current_page.take() {
888 pages.push(current_page);
889 }
890
891 let expanded = line.starts_with("v");
892
893 current_page = Some(SettingsPage {
894 title: line.split_once(" ").unwrap().1,
895 expanded,
896 items: Vec::default(),
897 });
898 } else if line.starts_with("-") {
899 let Some(current_page) = current_page.as_mut() else {
900 panic!("Sub entries must be within a page");
901 };
902
903 current_page.items.push(SettingsPageItem::SectionHeader(
904 line.split_once(" ").unwrap().1,
905 ));
906 } else {
907 panic!(
908 "Entries must start with one of 'v', '>', or '-'\n line: {}",
909 line
910 );
911 }
912 }
913
914 if let Some(current_page) = current_page.take() {
915 pages.push(current_page);
916 }
917
918 let mut settings_window = SettingsWindow {
919 files: Vec::default(),
920 current_file: crate::SettingsUiFile::User,
921 pages,
922 search: cx.new(|cx| Editor::single_line(window, cx)),
923 navbar_entry: selected_idx.unwrap(),
924 navbar_entries: Vec::default(),
925 list_handle: UniformListScrollHandle::default(),
926 };
927
928 settings_window.build_navbar();
929 settings_window
930 }
931
932 #[track_caller]
933 fn check_navbar_toggle(
934 before: &'static str,
935 toggle_idx: usize,
936 after: &'static str,
937 window: &mut Window,
938 cx: &mut App,
939 ) {
940 let mut settings_window = parse(before, window, cx);
941 settings_window.toggle_navbar_entry(toggle_idx);
942
943 let expected_settings_window = parse(after, window, cx);
944
945 assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
946 assert_eq!(
947 settings_window.navbar_entry(),
948 expected_settings_window.navbar_entry()
949 );
950 }
951
952 macro_rules! check_navbar_toggle {
953 ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
954 #[gpui::test]
955 fn $name(cx: &mut gpui::TestAppContext) {
956 let window = cx.add_empty_window();
957 window.update(|window, cx| {
958 register_settings(cx);
959 check_navbar_toggle($before, $toggle_idx, $after, window, cx);
960 });
961 }
962 };
963 }
964
965 check_navbar_toggle!(
966 basic_open,
967 before: r"
968 v General
969 - General
970 - Privacy*
971 v Project
972 - Project Settings
973 ",
974 toggle_idx: 0,
975 after: r"
976 > General*
977 v Project
978 - Project Settings
979 "
980 );
981
982 check_navbar_toggle!(
983 basic_close,
984 before: r"
985 > General*
986 - General
987 - Privacy
988 v Project
989 - Project Settings
990 ",
991 toggle_idx: 0,
992 after: r"
993 v General*
994 - General
995 - Privacy
996 v Project
997 - Project Settings
998 "
999 );
1000
1001 check_navbar_toggle!(
1002 basic_second_root_entry_close,
1003 before: r"
1004 > General
1005 - General
1006 - Privacy
1007 v Project
1008 - Project Settings*
1009 ",
1010 toggle_idx: 1,
1011 after: r"
1012 > General
1013 > Project*
1014 "
1015 );
1016}