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