1//! # settings_ui
2mod components;
3use editor::{Editor, EditorEvent};
4use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
5use fuzzy::StringMatchCandidate;
6use gpui::{
7 App, Div, Entity, Global, ReadGlobal as _, Task, TitlebarOptions, UniformListScrollHandle,
8 Window, WindowHandle, WindowOptions, actions, div, 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, atomic::AtomicBool},
19};
20use ui::{
21 ContextMenu, Divider, DropdownMenu, DropdownStyle, Switch, SwitchColor, TreeViewItem,
22 prelude::*,
23};
24use util::{paths::PathStyle, rel_path::RelPath};
25
26use crate::components::SettingsEditor;
27
28#[derive(Clone, Copy)]
29struct SettingField<T: 'static> {
30 pick: fn(&SettingsContent) -> &Option<T>,
31 pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
32}
33
34trait AnySettingField {
35 fn as_any(&self) -> &dyn Any;
36 fn type_name(&self) -> &'static str;
37 fn type_id(&self) -> TypeId;
38 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile;
39}
40
41impl<T> AnySettingField for SettingField<T> {
42 fn as_any(&self) -> &dyn Any {
43 self
44 }
45
46 fn type_name(&self) -> &'static str {
47 type_name::<T>()
48 }
49
50 fn type_id(&self) -> TypeId {
51 TypeId::of::<T>()
52 }
53
54 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile {
55 let (file, _) = cx
56 .global::<SettingsStore>()
57 .get_value_from_file(file.to_settings(), self.pick);
58 return file;
59 }
60}
61
62#[derive(Default, Clone)]
63struct SettingFieldRenderer {
64 renderers: Rc<
65 RefCell<
66 HashMap<
67 TypeId,
68 Box<
69 dyn Fn(
70 &dyn AnySettingField,
71 SettingsUiFile,
72 Option<&SettingsFieldMetadata>,
73 &mut Window,
74 &mut App,
75 ) -> AnyElement,
76 >,
77 >,
78 >,
79 >,
80}
81
82impl Global for SettingFieldRenderer {}
83
84impl SettingFieldRenderer {
85 fn add_renderer<T: 'static>(
86 &mut self,
87 renderer: impl Fn(
88 &SettingField<T>,
89 SettingsUiFile,
90 Option<&SettingsFieldMetadata>,
91 &mut Window,
92 &mut App,
93 ) -> AnyElement
94 + 'static,
95 ) -> &mut Self {
96 let key = TypeId::of::<T>();
97 let renderer = Box::new(
98 move |any_setting_field: &dyn AnySettingField,
99 settings_file: SettingsUiFile,
100 metadata: Option<&SettingsFieldMetadata>,
101 window: &mut Window,
102 cx: &mut App| {
103 let field = any_setting_field
104 .as_any()
105 .downcast_ref::<SettingField<T>>()
106 .unwrap();
107 renderer(field, settings_file, metadata, window, cx)
108 },
109 );
110 self.renderers.borrow_mut().insert(key, renderer);
111 self
112 }
113
114 fn render(
115 &self,
116 any_setting_field: &dyn AnySettingField,
117 settings_file: SettingsUiFile,
118 metadata: Option<&SettingsFieldMetadata>,
119 window: &mut Window,
120 cx: &mut App,
121 ) -> AnyElement {
122 let key = any_setting_field.type_id();
123 if let Some(renderer) = self.renderers.borrow().get(&key) {
124 renderer(any_setting_field, settings_file, metadata, window, cx)
125 } else {
126 panic!(
127 "No renderer found for type: {}",
128 any_setting_field.type_name()
129 )
130 }
131 }
132}
133
134struct SettingsFieldMetadata {
135 placeholder: Option<&'static str>,
136}
137
138fn user_settings_data() -> Vec<SettingsPage> {
139 vec![
140 SettingsPage {
141 title: "General Page",
142 expanded: true,
143 items: vec![
144 SettingsPageItem::SectionHeader("General"),
145 SettingsPageItem::SettingItem(SettingItem {
146 title: "Confirm Quit",
147 description: "Whether to confirm before quitting Zed",
148 field: Box::new(SettingField {
149 pick: |settings_content| &settings_content.workspace.confirm_quit,
150 pick_mut: |settings_content| &mut settings_content.workspace.confirm_quit,
151 }),
152 metadata: None,
153 }),
154 SettingsPageItem::SettingItem(SettingItem {
155 title: "Auto Update",
156 description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
157 field: Box::new(SettingField {
158 pick: |settings_content| &settings_content.auto_update,
159 pick_mut: |settings_content| &mut settings_content.auto_update,
160 }),
161 metadata: None,
162 }),
163 SettingsPageItem::SectionHeader("Privacy"),
164 ],
165 },
166 SettingsPage {
167 title: "Project",
168 expanded: true,
169 items: vec![
170 SettingsPageItem::SectionHeader("Worktree Settings Content"),
171 SettingsPageItem::SettingItem(SettingItem {
172 title: "Project Name",
173 description: "The displayed name of this project. If not set, the root directory name",
174 field: Box::new(SettingField {
175 pick: |settings_content| &settings_content.project.worktree.project_name,
176 pick_mut: |settings_content| {
177 &mut settings_content.project.worktree.project_name
178 },
179 }),
180 metadata: Some(Box::new(SettingsFieldMetadata {
181 placeholder: Some("A new name"),
182 })),
183 }),
184 ],
185 },
186 SettingsPage {
187 title: "AI",
188 expanded: true,
189 items: vec![
190 SettingsPageItem::SectionHeader("General"),
191 SettingsPageItem::SettingItem(SettingItem {
192 title: "Disable AI",
193 description: "Whether to disable all AI features in Zed",
194 field: Box::new(SettingField {
195 pick: |settings_content| &settings_content.disable_ai,
196 pick_mut: |settings_content| &mut settings_content.disable_ai,
197 }),
198 metadata: None,
199 }),
200 ],
201 },
202 SettingsPage {
203 title: "Appearance & Behavior",
204 expanded: true,
205 items: vec![
206 SettingsPageItem::SectionHeader("Cursor"),
207 SettingsPageItem::SettingItem(SettingItem {
208 title: "Cursor Shape",
209 description: "Cursor shape for the editor",
210 field: Box::new(SettingField {
211 pick: |settings_content| &settings_content.editor.cursor_shape,
212 pick_mut: |settings_content| &mut settings_content.editor.cursor_shape,
213 }),
214 metadata: None,
215 }),
216 ],
217 },
218 ]
219}
220
221// Derive Macro, on the new ProjectSettings struct
222
223fn project_settings_data() -> Vec<SettingsPage> {
224 vec![SettingsPage {
225 title: "Project",
226 expanded: true,
227 items: vec![
228 SettingsPageItem::SectionHeader("Worktree Settings Content"),
229 SettingsPageItem::SettingItem(SettingItem {
230 title: "Project Name",
231 description: "The displayed name of this project. If not set, the root directory name",
232 field: Box::new(SettingField {
233 pick: |settings_content| &settings_content.project.worktree.project_name,
234 pick_mut: |settings_content| {
235 &mut settings_content.project.worktree.project_name
236 },
237 }),
238 metadata: Some(Box::new(SettingsFieldMetadata {
239 placeholder: Some("A new name"),
240 })),
241 }),
242 ],
243 }]
244}
245
246pub struct SettingsUiFeatureFlag;
247
248impl FeatureFlag for SettingsUiFeatureFlag {
249 const NAME: &'static str = "settings-ui";
250}
251
252actions!(
253 zed,
254 [
255 /// Opens Settings Editor.
256 OpenSettingsEditor
257 ]
258);
259
260pub fn init(cx: &mut App) {
261 init_renderers(cx);
262
263 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
264 workspace.register_action_renderer(|div, _, _, cx| {
265 let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
266 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
267 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
268 if has_flag {
269 filter.show_action_types(&settings_ui_actions);
270 } else {
271 filter.hide_action_types(&settings_ui_actions);
272 }
273 });
274 if has_flag {
275 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
276 open_settings_editor(cx).ok();
277 }))
278 } else {
279 div
280 }
281 });
282 })
283 .detach();
284}
285
286fn init_renderers(cx: &mut App) {
287 // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
288 cx.default_global::<SettingFieldRenderer>()
289 .add_renderer::<bool>(|settings_field, file, _, _, cx| {
290 render_toggle_button(*settings_field, file, cx).into_any_element()
291 })
292 .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
293 render_text_field(settings_field.clone(), file, metadata, cx)
294 })
295 .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
296 render_toggle_button(*settings_field, file, cx)
297 })
298 .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
299 render_dropdown(*settings_field, file, window, cx)
300 });
301}
302
303pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
304 cx.open_window(
305 WindowOptions {
306 titlebar: Some(TitlebarOptions {
307 title: Some("Settings Window".into()),
308 appears_transparent: true,
309 traffic_light_position: Some(point(px(12.0), px(12.0))),
310 }),
311 focus: true,
312 show: true,
313 kind: gpui::WindowKind::Normal,
314 window_background: cx.theme().window_background_appearance(),
315 window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
316 ..Default::default()
317 },
318 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
319 )
320}
321
322pub struct SettingsWindow {
323 files: Vec<SettingsUiFile>,
324 current_file: SettingsUiFile,
325 pages: Vec<SettingsPage>,
326 search_bar: Entity<Editor>,
327 search_task: Option<Task<()>>,
328 navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
329 navbar_entries: Vec<NavBarEntry>,
330 list_handle: UniformListScrollHandle,
331 search_matches: Vec<Vec<bool>>,
332}
333
334#[derive(PartialEq, Debug)]
335struct NavBarEntry {
336 title: &'static str,
337 is_root: bool,
338 page_index: usize,
339}
340
341struct SettingsPage {
342 title: &'static str,
343 expanded: bool,
344 items: Vec<SettingsPageItem>,
345}
346
347#[derive(PartialEq)]
348enum SettingsPageItem {
349 SectionHeader(&'static str),
350 SettingItem(SettingItem),
351}
352
353impl std::fmt::Debug for SettingsPageItem {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 match self {
356 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
357 SettingsPageItem::SettingItem(setting_item) => {
358 write!(f, "SettingItem({})", setting_item.title)
359 }
360 }
361 }
362}
363
364impl SettingsPageItem {
365 fn render(
366 &self,
367 file: SettingsUiFile,
368 is_last: bool,
369 window: &mut Window,
370 cx: &mut App,
371 ) -> AnyElement {
372 match self {
373 SettingsPageItem::SectionHeader(header) => v_flex()
374 .w_full()
375 .gap_1()
376 .child(
377 Label::new(SharedString::new_static(header))
378 .size(LabelSize::XSmall)
379 .color(Color::Muted)
380 .buffer_font(cx),
381 )
382 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
383 .into_any_element(),
384 SettingsPageItem::SettingItem(setting_item) => {
385 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
386 let file_set_in =
387 SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
388
389 h_flex()
390 .id(setting_item.title)
391 .w_full()
392 .gap_2()
393 .flex_wrap()
394 .justify_between()
395 .when(!is_last, |this| {
396 this.pb_4()
397 .border_b_1()
398 .border_color(cx.theme().colors().border_variant)
399 })
400 .child(
401 v_flex()
402 .max_w_1_2()
403 .flex_shrink()
404 .child(
405 h_flex()
406 .w_full()
407 .gap_4()
408 .child(
409 Label::new(SharedString::new_static(setting_item.title))
410 .size(LabelSize::Default),
411 )
412 .when_some(
413 file_set_in.filter(|file_set_in| file_set_in != &file),
414 |elem, file_set_in| {
415 elem.child(
416 Label::new(format!(
417 "set in {}",
418 file_set_in.name()
419 ))
420 .color(Color::Muted),
421 )
422 },
423 ),
424 )
425 .child(
426 Label::new(SharedString::new_static(setting_item.description))
427 .size(LabelSize::Small)
428 .color(Color::Muted),
429 ),
430 )
431 .child(renderer.render(
432 setting_item.field.as_ref(),
433 file,
434 setting_item.metadata.as_deref(),
435 window,
436 cx,
437 ))
438 .into_any_element()
439 }
440 }
441 }
442}
443
444struct SettingItem {
445 title: &'static str,
446 description: &'static str,
447 field: Box<dyn AnySettingField>,
448 metadata: Option<Box<SettingsFieldMetadata>>,
449}
450
451impl PartialEq for SettingItem {
452 fn eq(&self, other: &Self) -> bool {
453 self.title == other.title
454 && self.description == other.description
455 && (match (&self.metadata, &other.metadata) {
456 (None, None) => true,
457 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
458 _ => false,
459 })
460 }
461}
462
463#[allow(unused)]
464#[derive(Clone, PartialEq)]
465enum SettingsUiFile {
466 User, // Uses all settings.
467 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
468 Server(&'static str), // Uses a special name, and the user settings
469}
470
471impl SettingsUiFile {
472 fn pages(&self) -> Vec<SettingsPage> {
473 match self {
474 SettingsUiFile::User => user_settings_data(),
475 SettingsUiFile::Local(_) => project_settings_data(),
476 SettingsUiFile::Server(_) => user_settings_data(),
477 }
478 }
479
480 fn name(&self) -> SharedString {
481 match self {
482 SettingsUiFile::User => SharedString::new_static("User"),
483 // TODO is PathStyle::local() ever not appropriate?
484 SettingsUiFile::Local((_, path)) => {
485 format!("Local ({})", path.display(PathStyle::local())).into()
486 }
487 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
488 }
489 }
490
491 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
492 Some(match file {
493 settings::SettingsFile::User => SettingsUiFile::User,
494 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
495 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
496 settings::SettingsFile::Default => return None,
497 })
498 }
499
500 fn to_settings(&self) -> settings::SettingsFile {
501 match self {
502 SettingsUiFile::User => settings::SettingsFile::User,
503 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
504 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
505 }
506 }
507}
508
509impl SettingsWindow {
510 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
511 let current_file = SettingsUiFile::User;
512 let search_bar = cx.new(|cx| {
513 let mut editor = Editor::single_line(window, cx);
514 editor.set_placeholder_text("Search settings…", window, cx);
515 editor
516 });
517
518 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
519 let EditorEvent::Edited { transaction_id: _ } = event else {
520 return;
521 };
522
523 this.update_matches(cx);
524 })
525 .detach();
526
527 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
528 this.fetch_files(cx);
529 cx.notify();
530 })
531 .detach();
532
533 let mut this = Self {
534 files: vec![],
535 current_file: current_file,
536 pages: vec![],
537 navbar_entries: vec![],
538 navbar_entry: 0,
539 list_handle: UniformListScrollHandle::default(),
540 search_bar,
541 search_task: None,
542 search_matches: vec![],
543 };
544
545 this.fetch_files(cx);
546 this.build_ui(cx);
547
548 this
549 }
550
551 fn toggle_navbar_entry(&mut self, ix: usize) {
552 // We can only toggle root entries
553 if !self.navbar_entries[ix].is_root {
554 return;
555 }
556
557 let toggle_page_index = self.page_index_from_navbar_index(ix);
558 let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
559
560 let expanded = &mut self.page_for_navbar_index(ix).expanded;
561 *expanded = !*expanded;
562 let expanded = *expanded;
563 // if currently selected page is a child of the parent page we are folding,
564 // set the current page to the parent page
565 if selected_page_index == toggle_page_index {
566 self.navbar_entry = ix;
567 } else if selected_page_index > toggle_page_index {
568 let sub_items_count = self.pages[toggle_page_index]
569 .items
570 .iter()
571 .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
572 .count();
573 if expanded {
574 self.navbar_entry += sub_items_count;
575 } else {
576 self.navbar_entry -= sub_items_count;
577 }
578 }
579
580 self.build_navbar();
581 }
582
583 fn build_navbar(&mut self) {
584 let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
585 for (page_index, page) in self.pages.iter().enumerate() {
586 if !self.search_matches[page_index]
587 .iter()
588 .any(|is_match| *is_match)
589 && !self.search_matches[page_index].is_empty()
590 {
591 continue;
592 }
593 navbar_entries.push(NavBarEntry {
594 title: page.title,
595 is_root: true,
596 page_index,
597 });
598 if !page.expanded {
599 continue;
600 }
601
602 for (item_index, item) in page.items.iter().enumerate() {
603 let SettingsPageItem::SectionHeader(title) = item else {
604 continue;
605 };
606 if !self.search_matches[page_index][item_index] {
607 continue;
608 }
609
610 navbar_entries.push(NavBarEntry {
611 title,
612 is_root: false,
613 page_index,
614 });
615 }
616 }
617 self.navbar_entries = navbar_entries;
618 }
619
620 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
621 self.search_task.take();
622 let query = self.search_bar.read(cx).text(cx);
623 if query.is_empty() {
624 for page in &mut self.search_matches {
625 page.fill(true);
626 }
627 self.build_navbar();
628 cx.notify();
629 return;
630 }
631
632 struct ItemKey {
633 page_index: usize,
634 header_index: usize,
635 item_index: usize,
636 }
637 let mut key_lut: Vec<ItemKey> = vec![];
638 let mut candidates = Vec::default();
639
640 for (page_index, page) in self.pages.iter().enumerate() {
641 let mut header_index = 0;
642 for (item_index, item) in page.items.iter().enumerate() {
643 let key_index = key_lut.len();
644 match item {
645 SettingsPageItem::SettingItem(item) => {
646 candidates.push(StringMatchCandidate::new(key_index, item.title));
647 candidates.push(StringMatchCandidate::new(key_index, item.description));
648 }
649 SettingsPageItem::SectionHeader(header) => {
650 candidates.push(StringMatchCandidate::new(key_index, header));
651 header_index = item_index;
652 }
653 }
654 key_lut.push(ItemKey {
655 page_index,
656 header_index,
657 item_index,
658 });
659 }
660 }
661 let atomic_bool = AtomicBool::new(false);
662
663 self.search_task = Some(cx.spawn(async move |this, cx| {
664 let string_matches = fuzzy::match_strings(
665 candidates.as_slice(),
666 &query,
667 false,
668 false,
669 candidates.len(),
670 &atomic_bool,
671 cx.background_executor().clone(),
672 );
673 let string_matches = string_matches.await;
674
675 this.update(cx, |this, cx| {
676 for page in &mut this.search_matches {
677 page.fill(false);
678 }
679
680 for string_match in string_matches {
681 let ItemKey {
682 page_index,
683 header_index,
684 item_index,
685 } = key_lut[string_match.candidate_id];
686 let page = &mut this.search_matches[page_index];
687 page[header_index] = true;
688 page[item_index] = true;
689 }
690 this.build_navbar();
691 this.navbar_entry = 0;
692 cx.notify();
693 })
694 .ok();
695 }));
696 }
697
698 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
699 self.pages = self.current_file.pages();
700 self.search_matches = self
701 .pages
702 .iter()
703 .map(|page| vec![true; page.items.len()])
704 .collect::<Vec<_>>();
705 self.build_navbar();
706
707 if !self.search_bar.read(cx).is_empty(cx) {
708 self.update_matches(cx);
709 }
710
711 cx.notify();
712 }
713
714 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
715 let settings_store = cx.global::<SettingsStore>();
716 let mut ui_files = vec![];
717 let all_files = settings_store.get_all_files();
718 for file in all_files {
719 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
720 continue;
721 };
722 ui_files.push(settings_ui_file);
723 }
724 ui_files.reverse();
725 self.files = ui_files;
726 if !self.files.contains(&self.current_file) {
727 self.change_file(0, cx);
728 }
729 }
730
731 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
732 if ix >= self.files.len() {
733 self.current_file = SettingsUiFile::User;
734 return;
735 }
736 if self.files[ix] == self.current_file {
737 return;
738 }
739 self.current_file = self.files[ix].clone();
740 self.navbar_entry = 0;
741 self.build_ui(cx);
742 }
743
744 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
745 h_flex()
746 .gap_1()
747 .children(self.files.iter().enumerate().map(|(ix, file)| {
748 Button::new(ix, file.name())
749 .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
750 }))
751 }
752
753 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
754 h_flex()
755 .pt_1()
756 .px_1p5()
757 .gap_1p5()
758 .rounded_sm()
759 .bg(cx.theme().colors().editor_background)
760 .border_1()
761 .border_color(cx.theme().colors().border)
762 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
763 .child(self.search_bar.clone())
764 }
765
766 fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
767 v_flex()
768 .w_64()
769 .p_2p5()
770 .pt_10()
771 .gap_3()
772 .flex_none()
773 .border_r_1()
774 .border_color(cx.theme().colors().border)
775 .bg(cx.theme().colors().panel_background)
776 .child(self.render_search(window, cx).pb_1())
777 .child(
778 uniform_list(
779 "settings-ui-nav-bar",
780 self.navbar_entries.len(),
781 cx.processor(|this, range: Range<usize>, _, cx| {
782 range
783 .into_iter()
784 .map(|ix| {
785 let entry = &this.navbar_entries[ix];
786
787 TreeViewItem::new(("settings-ui-navbar-entry", ix), entry.title)
788 .root_item(entry.is_root)
789 .toggle_state(this.is_navbar_entry_selected(ix))
790 .when(entry.is_root, |item| {
791 item.toggle(
792 this.pages[this.page_index_from_navbar_index(ix)]
793 .expanded,
794 )
795 .on_toggle(
796 cx.listener(move |this, _, _, cx| {
797 this.toggle_navbar_entry(ix);
798 cx.notify();
799 }),
800 )
801 })
802 .on_click(cx.listener(move |this, _, _, cx| {
803 this.navbar_entry = ix;
804 cx.notify();
805 }))
806 .into_any_element()
807 })
808 .collect()
809 }),
810 )
811 .track_scroll(self.list_handle.clone())
812 .size_full()
813 .flex_grow(),
814 )
815 }
816
817 fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
818 let page_idx = self.current_page_index();
819
820 self.current_page()
821 .items
822 .iter()
823 .enumerate()
824 .filter_map(move |(item_index, item)| {
825 self.search_matches[page_idx][item_index].then_some(item)
826 })
827 }
828
829 fn render_page(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
830 let items: Vec<_> = self.page_items().collect();
831 let items_len = items.len();
832
833 v_flex()
834 .gap_4()
835 .children(items.into_iter().enumerate().map(|(index, item)| {
836 let is_last = index == items_len - 1;
837 item.render(self.current_file.clone(), is_last, window, cx)
838 }))
839 }
840
841 fn current_page_index(&self) -> usize {
842 self.page_index_from_navbar_index(self.navbar_entry)
843 }
844
845 fn current_page(&self) -> &SettingsPage {
846 &self.pages[self.current_page_index()]
847 }
848
849 fn page_index_from_navbar_index(&self, index: usize) -> usize {
850 if self.navbar_entries.is_empty() {
851 return 0;
852 }
853
854 self.navbar_entries[index].page_index
855 }
856
857 fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
858 let index = self.page_index_from_navbar_index(index);
859 &mut self.pages[index]
860 }
861
862 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
863 ix == self.navbar_entry
864 }
865}
866
867impl Render for SettingsWindow {
868 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
869 let ui_font = theme::setup_ui_font(window, cx);
870
871 div()
872 .flex()
873 .flex_row()
874 .size_full()
875 .font(ui_font)
876 .bg(cx.theme().colors().background)
877 .text_color(cx.theme().colors().text)
878 .child(self.render_nav(window, cx))
879 .child(
880 v_flex()
881 .w_full()
882 .pt_4()
883 .px_6()
884 .gap_4()
885 .bg(cx.theme().colors().editor_background)
886 .child(self.render_files(window, cx))
887 .child(self.render_page(window, cx)),
888 )
889 }
890}
891
892// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
893// let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
894// }
895
896fn render_text_field(
897 field: SettingField<String>,
898 file: SettingsUiFile,
899 metadata: Option<&SettingsFieldMetadata>,
900 cx: &mut App,
901) -> AnyElement {
902 let (_, initial_text) =
903 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
904 let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
905
906 SettingsEditor::new()
907 .when_some(initial_text, |editor, text| editor.with_initial_text(text))
908 .when_some(
909 metadata.and_then(|metadata| metadata.placeholder),
910 |editor, placeholder| editor.with_placeholder(placeholder),
911 )
912 .on_confirm(move |new_text, cx: &mut App| {
913 cx.update_global(move |store: &mut SettingsStore, cx| {
914 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
915 *(field.pick_mut)(settings) = new_text;
916 });
917 });
918 })
919 .into_any_element()
920}
921
922fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
923 field: SettingField<B>,
924 file: SettingsUiFile,
925 cx: &mut App,
926) -> AnyElement {
927 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
928
929 let toggle_state = if value.into() {
930 ToggleState::Selected
931 } else {
932 ToggleState::Unselected
933 };
934
935 Switch::new("toggle_button", toggle_state)
936 .on_click({
937 move |state, _window, cx| {
938 let state = *state == ui::ToggleState::Selected;
939 let field = field;
940 cx.update_global(move |store: &mut SettingsStore, cx| {
941 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
942 *(field.pick_mut)(settings) = Some(state.into());
943 });
944 });
945 }
946 })
947 .color(SwitchColor::Accent)
948 .into_any_element()
949}
950
951fn render_dropdown<T>(
952 field: SettingField<T>,
953 file: SettingsUiFile,
954 window: &mut Window,
955 cx: &mut App,
956) -> AnyElement
957where
958 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
959{
960 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
961 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
962
963 let (_, ¤t_value) =
964 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
965
966 let current_value_label =
967 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
968
969 DropdownMenu::new(
970 "dropdown",
971 current_value_label,
972 ContextMenu::build(window, cx, move |mut menu, _, _| {
973 for (value, label) in variants()
974 .into_iter()
975 .copied()
976 .zip(labels().into_iter().copied())
977 {
978 menu = menu.toggleable_entry(
979 label,
980 value == current_value,
981 IconPosition::Start,
982 None,
983 move |_, cx| {
984 if value == current_value {
985 return;
986 }
987 cx.update_global(move |store: &mut SettingsStore, cx| {
988 store.update_settings_file(
989 <dyn fs::Fs>::global(cx),
990 move |settings, _cx| {
991 *(field.pick_mut)(settings) = Some(value);
992 },
993 );
994 });
995 },
996 );
997 }
998 menu
999 }),
1000 )
1001 .style(DropdownStyle::Outlined)
1002 .into_any_element()
1003}
1004
1005#[cfg(test)]
1006mod test {
1007
1008 use super::*;
1009
1010 impl SettingsWindow {
1011 fn navbar(&self) -> &[NavBarEntry] {
1012 self.navbar_entries.as_slice()
1013 }
1014
1015 fn navbar_entry(&self) -> usize {
1016 self.navbar_entry
1017 }
1018
1019 fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1020 let mut this = Self::new(window, cx);
1021 this.navbar_entries.clear();
1022 this.pages.clear();
1023 this
1024 }
1025
1026 fn build(mut self) -> Self {
1027 self.build_navbar();
1028 self
1029 }
1030
1031 fn add_page(
1032 mut self,
1033 title: &'static str,
1034 build_page: impl Fn(SettingsPage) -> SettingsPage,
1035 ) -> Self {
1036 let page = SettingsPage {
1037 title,
1038 expanded: false,
1039 items: Vec::default(),
1040 };
1041
1042 self.pages.push(build_page(page));
1043 self
1044 }
1045
1046 fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1047 self.search_task.take();
1048 self.search_bar.update(cx, |editor, cx| {
1049 editor.set_text(search_query, window, cx);
1050 });
1051 self.update_matches(cx);
1052 }
1053
1054 fn assert_search_results(&self, other: &Self) {
1055 // page index could be different because of filtered out pages
1056 assert!(
1057 self.navbar_entries
1058 .iter()
1059 .zip(other.navbar_entries.iter())
1060 .all(|(entry, other)| {
1061 entry.is_root == other.is_root && entry.title == other.title
1062 })
1063 );
1064 assert_eq!(
1065 self.current_page().items.iter().collect::<Vec<_>>(),
1066 other.page_items().collect::<Vec<_>>()
1067 );
1068 }
1069 }
1070
1071 impl SettingsPage {
1072 fn item(mut self, item: SettingsPageItem) -> Self {
1073 self.items.push(item);
1074 self
1075 }
1076 }
1077
1078 impl SettingsPageItem {
1079 fn basic_item(title: &'static str, description: &'static str) -> Self {
1080 SettingsPageItem::SettingItem(SettingItem {
1081 title,
1082 description,
1083 field: Box::new(SettingField {
1084 pick: |settings_content| &settings_content.auto_update,
1085 pick_mut: |settings_content| &mut settings_content.auto_update,
1086 }),
1087 metadata: None,
1088 })
1089 }
1090 }
1091
1092 fn register_settings(cx: &mut App) {
1093 settings::init(cx);
1094 theme::init(theme::LoadThemes::JustBase, cx);
1095 workspace::init_settings(cx);
1096 project::Project::init_settings(cx);
1097 language::init(cx);
1098 editor::init(cx);
1099 menu::init();
1100 }
1101
1102 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1103 let mut pages: Vec<SettingsPage> = Vec::new();
1104 let mut current_page = None;
1105 let mut selected_idx = None;
1106 let mut ix = 0;
1107 let mut in_closed_subentry = false;
1108
1109 for mut line in input
1110 .lines()
1111 .map(|line| line.trim())
1112 .filter(|line| !line.is_empty())
1113 {
1114 let mut is_selected = false;
1115 if line.ends_with("*") {
1116 assert!(
1117 selected_idx.is_none(),
1118 "Can only have one selected navbar entry at a time"
1119 );
1120 selected_idx = Some(ix);
1121 line = &line[..line.len() - 1];
1122 is_selected = true;
1123 }
1124
1125 if line.starts_with("v") || line.starts_with(">") {
1126 if let Some(current_page) = current_page.take() {
1127 pages.push(current_page);
1128 }
1129
1130 let expanded = line.starts_with("v");
1131 in_closed_subentry = !expanded;
1132 ix += 1;
1133
1134 current_page = Some(SettingsPage {
1135 title: line.split_once(" ").unwrap().1,
1136 expanded,
1137 items: Vec::default(),
1138 });
1139 } else if line.starts_with("-") {
1140 if !in_closed_subentry {
1141 ix += 1;
1142 } else if is_selected && in_closed_subentry {
1143 panic!("Can't select sub entry if it's parent is closed");
1144 }
1145
1146 let Some(current_page) = current_page.as_mut() else {
1147 panic!("Sub entries must be within a page");
1148 };
1149
1150 current_page.items.push(SettingsPageItem::SectionHeader(
1151 line.split_once(" ").unwrap().1,
1152 ));
1153 } else {
1154 panic!(
1155 "Entries must start with one of 'v', '>', or '-'\n line: {}",
1156 line
1157 );
1158 }
1159 }
1160
1161 if let Some(current_page) = current_page.take() {
1162 pages.push(current_page);
1163 }
1164
1165 let search_matches = pages
1166 .iter()
1167 .map(|page| vec![true; page.items.len()])
1168 .collect::<Vec<_>>();
1169
1170 let mut settings_window = SettingsWindow {
1171 files: Vec::default(),
1172 current_file: crate::SettingsUiFile::User,
1173 pages,
1174 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1175 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1176 navbar_entries: Vec::default(),
1177 list_handle: UniformListScrollHandle::default(),
1178 search_matches,
1179 search_task: None,
1180 };
1181
1182 settings_window.build_navbar();
1183 settings_window
1184 }
1185
1186 #[track_caller]
1187 fn check_navbar_toggle(
1188 before: &'static str,
1189 toggle_idx: usize,
1190 after: &'static str,
1191 window: &mut Window,
1192 cx: &mut App,
1193 ) {
1194 let mut settings_window = parse(before, window, cx);
1195 settings_window.toggle_navbar_entry(toggle_idx);
1196
1197 let expected_settings_window = parse(after, window, cx);
1198
1199 assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
1200 assert_eq!(
1201 settings_window.navbar_entry(),
1202 expected_settings_window.navbar_entry()
1203 );
1204 }
1205
1206 macro_rules! check_navbar_toggle {
1207 ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
1208 #[gpui::test]
1209 fn $name(cx: &mut gpui::TestAppContext) {
1210 let window = cx.add_empty_window();
1211 window.update(|window, cx| {
1212 register_settings(cx);
1213 check_navbar_toggle($before, $toggle_idx, $after, window, cx);
1214 });
1215 }
1216 };
1217 }
1218
1219 check_navbar_toggle!(
1220 navbar_basic_open,
1221 before: r"
1222 v General
1223 - General
1224 - Privacy*
1225 v Project
1226 - Project Settings
1227 ",
1228 toggle_idx: 0,
1229 after: r"
1230 > General*
1231 v Project
1232 - Project Settings
1233 "
1234 );
1235
1236 check_navbar_toggle!(
1237 navbar_basic_close,
1238 before: r"
1239 > General*
1240 - General
1241 - Privacy
1242 v Project
1243 - Project Settings
1244 ",
1245 toggle_idx: 0,
1246 after: r"
1247 v General*
1248 - General
1249 - Privacy
1250 v Project
1251 - Project Settings
1252 "
1253 );
1254
1255 check_navbar_toggle!(
1256 navbar_basic_second_root_entry_close,
1257 before: r"
1258 > General
1259 - General
1260 - Privacy
1261 v Project
1262 - Project Settings*
1263 ",
1264 toggle_idx: 1,
1265 after: r"
1266 > General
1267 > Project*
1268 "
1269 );
1270
1271 check_navbar_toggle!(
1272 navbar_toggle_subroot,
1273 before: r"
1274 v General Page
1275 - General
1276 - Privacy
1277 v Project
1278 - Worktree Settings Content*
1279 v AI
1280 - General
1281 > Appearance & Behavior
1282 ",
1283 toggle_idx: 3,
1284 after: r"
1285 v General Page
1286 - General
1287 - Privacy
1288 > Project*
1289 v AI
1290 - General
1291 > Appearance & Behavior
1292 "
1293 );
1294
1295 check_navbar_toggle!(
1296 navbar_toggle_close_propagates_selected_index,
1297 before: r"
1298 v General Page
1299 - General
1300 - Privacy
1301 v Project
1302 - Worktree Settings Content
1303 v AI
1304 - General*
1305 > Appearance & Behavior
1306 ",
1307 toggle_idx: 0,
1308 after: r"
1309 > General Page
1310 v Project
1311 - Worktree Settings Content
1312 v AI
1313 - General*
1314 > Appearance & Behavior
1315 "
1316 );
1317
1318 check_navbar_toggle!(
1319 navbar_toggle_expand_propagates_selected_index,
1320 before: r"
1321 > General Page
1322 - General
1323 - Privacy
1324 v Project
1325 - Worktree Settings Content
1326 v AI
1327 - General*
1328 > Appearance & Behavior
1329 ",
1330 toggle_idx: 0,
1331 after: r"
1332 v General Page
1333 - General
1334 - Privacy
1335 v Project
1336 - Worktree Settings Content
1337 v AI
1338 - General*
1339 > Appearance & Behavior
1340 "
1341 );
1342
1343 check_navbar_toggle!(
1344 navbar_toggle_sub_entry_does_nothing,
1345 before: r"
1346 > General Page
1347 - General
1348 - Privacy
1349 v Project
1350 - Worktree Settings Content
1351 v AI
1352 - General*
1353 > Appearance & Behavior
1354 ",
1355 toggle_idx: 4,
1356 after: r"
1357 > General Page
1358 - General
1359 - Privacy
1360 v Project
1361 - Worktree Settings Content
1362 v AI
1363 - General*
1364 > Appearance & Behavior
1365 "
1366 );
1367
1368 #[gpui::test]
1369 fn test_basic_search(cx: &mut gpui::TestAppContext) {
1370 let cx = cx.add_empty_window();
1371 let (actual, expected) = cx.update(|window, cx| {
1372 register_settings(cx);
1373
1374 let expected = cx.new(|cx| {
1375 SettingsWindow::new_builder(window, cx)
1376 .add_page("General", |page| {
1377 page.item(SettingsPageItem::SectionHeader("General settings"))
1378 .item(SettingsPageItem::basic_item("test title", "General test"))
1379 })
1380 .build()
1381 });
1382
1383 let actual = cx.new(|cx| {
1384 SettingsWindow::new_builder(window, cx)
1385 .add_page("General", |page| {
1386 page.item(SettingsPageItem::SectionHeader("General settings"))
1387 .item(SettingsPageItem::basic_item("test title", "General test"))
1388 })
1389 .add_page("Theme", |page| {
1390 page.item(SettingsPageItem::SectionHeader("Theme settings"))
1391 })
1392 .build()
1393 });
1394
1395 actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1396
1397 (actual, expected)
1398 });
1399
1400 cx.cx.run_until_parked();
1401
1402 cx.update(|_window, cx| {
1403 let expected = expected.read(cx);
1404 let actual = actual.read(cx);
1405 expected.assert_search_results(&actual);
1406 })
1407 }
1408
1409 #[gpui::test]
1410 fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
1411 let cx = cx.add_empty_window();
1412 let (actual, expected) = cx.update(|window, cx| {
1413 register_settings(cx);
1414
1415 let actual = cx.new(|cx| {
1416 SettingsWindow::new_builder(window, cx)
1417 .add_page("General", |page| {
1418 page.item(SettingsPageItem::SectionHeader("General settings"))
1419 .item(SettingsPageItem::basic_item(
1420 "Confirm Quit",
1421 "Whether to confirm before quitting Zed",
1422 ))
1423 .item(SettingsPageItem::basic_item(
1424 "Auto Update",
1425 "Automatically update Zed",
1426 ))
1427 })
1428 .add_page("AI", |page| {
1429 page.item(SettingsPageItem::basic_item(
1430 "Disable AI",
1431 "Whether to disable all AI features in Zed",
1432 ))
1433 })
1434 .add_page("Appearance & Behavior", |page| {
1435 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1436 SettingsPageItem::basic_item(
1437 "Cursor Shape",
1438 "Cursor shape for the editor",
1439 ),
1440 )
1441 })
1442 .build()
1443 });
1444
1445 let expected = cx.new(|cx| {
1446 SettingsWindow::new_builder(window, cx)
1447 .add_page("Appearance & Behavior", |page| {
1448 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1449 SettingsPageItem::basic_item(
1450 "Cursor Shape",
1451 "Cursor shape for the editor",
1452 ),
1453 )
1454 })
1455 .build()
1456 });
1457
1458 actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
1459
1460 (actual, expected)
1461 });
1462
1463 cx.cx.run_until_parked();
1464
1465 cx.update(|_window, cx| {
1466 let expected = expected.read(cx);
1467 let actual = actual.read(cx);
1468 expected.assert_search_results(&actual);
1469 })
1470 }
1471}