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}
337
338struct SettingsPage {
339 title: &'static str,
340 expanded: bool,
341 items: Vec<SettingsPageItem>,
342}
343
344#[derive(PartialEq)]
345enum SettingsPageItem {
346 SectionHeader(&'static str),
347 SettingItem(SettingItem),
348}
349
350impl std::fmt::Debug for SettingsPageItem {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 match self {
353 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
354 SettingsPageItem::SettingItem(setting_item) => {
355 write!(f, "SettingItem({})", setting_item.title)
356 }
357 }
358 }
359}
360
361impl SettingsPageItem {
362 fn render(&self, file: SettingsUiFile, window: &mut Window, cx: &mut App) -> AnyElement {
363 match self {
364 SettingsPageItem::SectionHeader(header) => v_flex()
365 .w_full()
366 .gap_0p5()
367 .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
368 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
369 .into_any_element(),
370 SettingsPageItem::SettingItem(setting_item) => {
371 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
372 let file_set_in =
373 SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
374
375 h_flex()
376 .id(setting_item.title)
377 .w_full()
378 .gap_2()
379 .flex_wrap()
380 .justify_between()
381 .child(
382 v_flex()
383 .max_w_1_2()
384 .flex_shrink()
385 .child(
386 h_flex()
387 .w_full()
388 .gap_4()
389 .child(
390 Label::new(SharedString::new_static(setting_item.title))
391 .size(LabelSize::Default),
392 )
393 .when_some(
394 file_set_in.filter(|file_set_in| file_set_in != &file),
395 |elem, file_set_in| {
396 elem.child(
397 Label::new(format!(
398 "set in {}",
399 file_set_in.name()
400 ))
401 .color(Color::Muted),
402 )
403 },
404 ),
405 )
406 .child(
407 Label::new(SharedString::new_static(setting_item.description))
408 .size(LabelSize::Small)
409 .color(Color::Muted),
410 ),
411 )
412 .child(renderer.render(
413 setting_item.field.as_ref(),
414 file,
415 setting_item.metadata.as_deref(),
416 window,
417 cx,
418 ))
419 .into_any_element()
420 }
421 }
422 }
423}
424
425struct SettingItem {
426 title: &'static str,
427 description: &'static str,
428 field: Box<dyn AnySettingField>,
429 metadata: Option<Box<SettingsFieldMetadata>>,
430}
431
432impl PartialEq for SettingItem {
433 fn eq(&self, other: &Self) -> bool {
434 self.title == other.title
435 && self.description == other.description
436 && (match (&self.metadata, &other.metadata) {
437 (None, None) => true,
438 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
439 _ => false,
440 })
441 }
442}
443
444#[allow(unused)]
445#[derive(Clone, PartialEq)]
446enum SettingsUiFile {
447 User, // Uses all settings.
448 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
449 Server(&'static str), // Uses a special name, and the user settings
450}
451
452impl SettingsUiFile {
453 fn pages(&self) -> Vec<SettingsPage> {
454 match self {
455 SettingsUiFile::User => user_settings_data(),
456 SettingsUiFile::Local(_) => project_settings_data(),
457 SettingsUiFile::Server(_) => user_settings_data(),
458 }
459 }
460
461 fn name(&self) -> SharedString {
462 match self {
463 SettingsUiFile::User => SharedString::new_static("User"),
464 // TODO is PathStyle::local() ever not appropriate?
465 SettingsUiFile::Local((_, path)) => {
466 format!("Local ({})", path.display(PathStyle::local())).into()
467 }
468 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
469 }
470 }
471
472 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
473 Some(match file {
474 settings::SettingsFile::User => SettingsUiFile::User,
475 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
476 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
477 settings::SettingsFile::Default => return None,
478 })
479 }
480
481 fn to_settings(&self) -> settings::SettingsFile {
482 match self {
483 SettingsUiFile::User => settings::SettingsFile::User,
484 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
485 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
486 }
487 }
488}
489
490impl SettingsWindow {
491 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
492 let current_file = SettingsUiFile::User;
493 let search_bar = cx.new(|cx| {
494 let mut editor = Editor::single_line(window, cx);
495 editor.set_placeholder_text("Search settings…", window, cx);
496 editor
497 });
498
499 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
500 let EditorEvent::Edited { transaction_id: _ } = event else {
501 return;
502 };
503
504 this.update_matches(cx);
505 })
506 .detach();
507
508 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
509 this.fetch_files(cx);
510 cx.notify();
511 })
512 .detach();
513
514 let mut this = Self {
515 files: vec![],
516 current_file: current_file,
517 pages: vec![],
518 navbar_entries: vec![],
519 navbar_entry: 0,
520 list_handle: UniformListScrollHandle::default(),
521 search_bar,
522 search_task: None,
523 search_matches: vec![],
524 };
525
526 this.fetch_files(cx);
527 this.build_ui(cx);
528
529 this
530 }
531
532 fn toggle_navbar_entry(&mut self, ix: usize) {
533 // We can only toggle root entries
534 if !self.navbar_entries[ix].is_root {
535 return;
536 }
537
538 let toggle_page_index = self.page_index_from_navbar_index(ix);
539 let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
540
541 let expanded = &mut self.page_for_navbar_index(ix).expanded;
542 *expanded = !*expanded;
543 let expanded = *expanded;
544 // if currently selected page is a child of the parent page we are folding,
545 // set the current page to the parent page
546 if selected_page_index == toggle_page_index {
547 self.navbar_entry = ix;
548 } else if selected_page_index > toggle_page_index {
549 let sub_items_count = self.pages[toggle_page_index]
550 .items
551 .iter()
552 .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
553 .count();
554 if expanded {
555 self.navbar_entry += sub_items_count;
556 } else {
557 self.navbar_entry -= sub_items_count;
558 }
559 }
560
561 self.build_navbar();
562 }
563
564 fn build_navbar(&mut self) {
565 let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
566 for (page_index, page) in self.pages.iter().enumerate() {
567 if !self.search_matches[page_index]
568 .iter()
569 .any(|is_match| *is_match)
570 && !self.search_matches[page_index].is_empty()
571 {
572 continue;
573 }
574 navbar_entries.push(NavBarEntry {
575 title: page.title,
576 is_root: true,
577 });
578 if !page.expanded {
579 continue;
580 }
581
582 for (item_index, item) in page.items.iter().enumerate() {
583 let SettingsPageItem::SectionHeader(title) = item else {
584 continue;
585 };
586 if !self.search_matches[page_index][item_index] {
587 continue;
588 }
589
590 navbar_entries.push(NavBarEntry {
591 title,
592 is_root: false,
593 });
594 }
595 }
596 self.navbar_entries = navbar_entries;
597 }
598
599 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
600 self.search_task.take();
601 let query = self.search_bar.read(cx).text(cx);
602 if query.is_empty() {
603 for page in &mut self.search_matches {
604 page.fill(true);
605 }
606 self.build_navbar();
607 cx.notify();
608 return;
609 }
610
611 struct ItemKey {
612 page_index: usize,
613 header_index: usize,
614 item_index: usize,
615 }
616 let mut key_lut: Vec<ItemKey> = vec![];
617 let mut candidates = Vec::default();
618
619 for (page_index, page) in self.pages.iter().enumerate() {
620 let mut header_index = 0;
621 for (item_index, item) in page.items.iter().enumerate() {
622 let key_index = key_lut.len();
623 match item {
624 SettingsPageItem::SettingItem(item) => {
625 candidates.push(StringMatchCandidate::new(key_index, item.title));
626 candidates.push(StringMatchCandidate::new(key_index, item.description));
627 }
628 SettingsPageItem::SectionHeader(header) => {
629 candidates.push(StringMatchCandidate::new(key_index, header));
630 header_index = item_index;
631 }
632 }
633 key_lut.push(ItemKey {
634 page_index,
635 header_index,
636 item_index,
637 });
638 }
639 }
640 let atomic_bool = AtomicBool::new(false);
641
642 self.search_task = Some(cx.spawn(async move |this, cx| {
643 let string_matches = fuzzy::match_strings(
644 candidates.as_slice(),
645 &query,
646 false,
647 false,
648 candidates.len(),
649 &atomic_bool,
650 cx.background_executor().clone(),
651 );
652 let string_matches = string_matches.await;
653
654 this.update(cx, |this, cx| {
655 for page in &mut this.search_matches {
656 page.fill(false);
657 }
658
659 for string_match in string_matches {
660 let ItemKey {
661 page_index,
662 header_index,
663 item_index,
664 } = key_lut[string_match.candidate_id];
665 let page = &mut this.search_matches[page_index];
666 page[header_index] = true;
667 page[item_index] = true;
668 }
669 this.build_navbar();
670 this.navbar_entry = 0;
671 cx.notify()
672 })
673 .ok();
674 }));
675 }
676
677 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
678 self.pages = self.current_file.pages();
679 self.search_matches = self
680 .pages
681 .iter()
682 .map(|page| vec![true; page.items.len()])
683 .collect::<Vec<_>>();
684 self.build_navbar();
685
686 if !self.search_bar.read(cx).is_empty(cx) {
687 self.update_matches(cx);
688 }
689
690 cx.notify();
691 }
692
693 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
694 let settings_store = cx.global::<SettingsStore>();
695 let mut ui_files = vec![];
696 let all_files = settings_store.get_all_files();
697 for file in all_files {
698 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
699 continue;
700 };
701 ui_files.push(settings_ui_file);
702 }
703 ui_files.reverse();
704 self.files = ui_files;
705 if !self.files.contains(&self.current_file) {
706 self.change_file(0, cx);
707 }
708 }
709
710 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
711 if ix >= self.files.len() {
712 self.current_file = SettingsUiFile::User;
713 return;
714 }
715 if self.files[ix] == self.current_file {
716 return;
717 }
718 self.current_file = self.files[ix].clone();
719 self.build_ui(cx);
720 }
721
722 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
723 h_flex()
724 .gap_1()
725 .children(self.files.iter().enumerate().map(|(ix, file)| {
726 Button::new(ix, file.name())
727 .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
728 }))
729 }
730
731 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
732 h_flex()
733 .pt_1()
734 .px_1p5()
735 .gap_1p5()
736 .rounded_sm()
737 .bg(cx.theme().colors().editor_background)
738 .border_1()
739 .border_color(cx.theme().colors().border)
740 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
741 .child(self.search_bar.clone())
742 }
743
744 fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
745 v_flex()
746 .w_64()
747 .p_2p5()
748 .pt_10()
749 .gap_3()
750 .flex_none()
751 .border_r_1()
752 .border_color(cx.theme().colors().border)
753 .bg(cx.theme().colors().panel_background)
754 .child(self.render_search(window, cx).pb_1())
755 .child(
756 uniform_list(
757 "settings-ui-nav-bar",
758 self.navbar_entries.len(),
759 cx.processor(|this, range: Range<usize>, _, cx| {
760 range
761 .into_iter()
762 .map(|ix| {
763 let entry = &this.navbar_entries[ix];
764
765 h_flex()
766 .id(("settings-ui-section", ix))
767 .w_full()
768 .pl_2p5()
769 .py_0p5()
770 .rounded_sm()
771 .border_1()
772 .border_color(cx.theme().colors().border_transparent)
773 .text_color(cx.theme().colors().text_muted)
774 .when(this.is_navbar_entry_selected(ix), |this| {
775 this.text_color(cx.theme().colors().text)
776 .bg(cx.theme().colors().element_selected.opacity(0.2))
777 .border_color(cx.theme().colors().border)
778 })
779 .child(
780 ListItem::new(("settings-ui-navbar-entry", ix))
781 .selectable(true)
782 .inset(true)
783 .indent_step_size(px(1.))
784 .indent_level(if entry.is_root { 1 } else { 3 })
785 .when(entry.is_root, |item| {
786 item.toggle(
787 this.pages
788 [this.page_index_from_navbar_index(ix)]
789 .expanded,
790 )
791 .always_show_disclosure_icon(true)
792 .on_toggle(cx.listener(move |this, _, _, cx| {
793 this.toggle_navbar_entry(ix);
794 cx.notify();
795 }))
796 })
797 .child(
798 h_flex()
799 .text_ui(cx)
800 .truncate()
801 .hover(|s| {
802 s.bg(cx.theme().colors().element_hover)
803 })
804 .child(entry.title),
805 ),
806 )
807 .on_click(cx.listener(move |this, _, _, cx| {
808 this.navbar_entry = ix;
809 cx.notify();
810 }))
811 })
812 .collect()
813 }),
814 )
815 .track_scroll(self.list_handle.clone())
816 .size_full()
817 .flex_grow(),
818 )
819 }
820
821 fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
822 let page_idx = self.current_page_index();
823
824 self.current_page()
825 .items
826 .iter()
827 .enumerate()
828 .filter_map(move |(item_index, item)| {
829 self.search_matches[page_idx][item_index].then_some(item)
830 })
831 }
832
833 fn render_page(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
834 v_flex().gap_4().children(
835 self.page_items()
836 .map(|item| item.render(self.current_file.clone(), window, cx)),
837 )
838 }
839
840 fn current_page_index(&self) -> usize {
841 self.page_index_from_navbar_index(self.navbar_entry)
842 }
843
844 fn current_page(&self) -> &SettingsPage {
845 &self.pages[self.current_page_index()]
846 }
847
848 fn page_index_from_navbar_index(&self, index: usize) -> usize {
849 if self.navbar_entries.is_empty() {
850 return 0;
851 }
852
853 self.navbar_entries
854 .iter()
855 .take(index + 1)
856 .map(|entry| entry.is_root as usize)
857 .sum::<usize>()
858 - 1
859 }
860
861 fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
862 let index = self.page_index_from_navbar_index(index);
863 &mut self.pages[index]
864 }
865
866 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
867 ix == self.navbar_entry
868 }
869}
870
871impl Render for SettingsWindow {
872 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
873 div()
874 .flex()
875 .flex_row()
876 .size_full()
877 .bg(cx.theme().colors().background)
878 .text_color(cx.theme().colors().text)
879 .child(self.render_nav(window, cx))
880 .child(
881 v_flex()
882 .w_full()
883 .pt_4()
884 .px_6()
885 .gap_4()
886 .bg(cx.theme().colors().editor_background)
887 .child(self.render_files(window, cx))
888 .child(self.render_page(window, cx)),
889 )
890 }
891}
892
893// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
894// let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
895// }
896
897fn render_text_field(
898 field: SettingField<String>,
899 file: SettingsUiFile,
900 metadata: Option<&SettingsFieldMetadata>,
901 cx: &mut App,
902) -> AnyElement {
903 let (_, initial_text) =
904 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
905 let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
906
907 SettingsEditor::new()
908 .when_some(initial_text, |editor, text| editor.with_initial_text(text))
909 .when_some(
910 metadata.and_then(|metadata| metadata.placeholder),
911 |editor, placeholder| editor.with_placeholder(placeholder),
912 )
913 .on_confirm(move |new_text, cx: &mut App| {
914 cx.update_global(move |store: &mut SettingsStore, cx| {
915 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
916 *(field.pick_mut)(settings) = new_text;
917 });
918 });
919 })
920 .into_any_element()
921}
922
923fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
924 field: SettingField<B>,
925 file: SettingsUiFile,
926 cx: &mut App,
927) -> AnyElement {
928 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
929
930 let toggle_state = if value.into() {
931 ui::ToggleState::Selected
932 } else {
933 ui::ToggleState::Unselected
934 };
935
936 Switch::new("toggle_button", toggle_state)
937 .on_click({
938 move |state, _window, cx| {
939 let state = *state == ui::ToggleState::Selected;
940 let field = field;
941 cx.update_global(move |store: &mut SettingsStore, cx| {
942 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
943 *(field.pick_mut)(settings) = Some(state.into());
944 });
945 });
946 }
947 })
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 ui::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 ui::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 .into_any_element()
1002}
1003
1004#[cfg(test)]
1005mod test {
1006
1007 use super::*;
1008
1009 impl SettingsWindow {
1010 fn navbar(&self) -> &[NavBarEntry] {
1011 self.navbar_entries.as_slice()
1012 }
1013
1014 fn navbar_entry(&self) -> usize {
1015 self.navbar_entry
1016 }
1017
1018 fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1019 let mut this = Self::new(window, cx);
1020 this.navbar_entries.clear();
1021 this.pages.clear();
1022 this
1023 }
1024
1025 fn build(mut self) -> Self {
1026 self.build_navbar();
1027 self
1028 }
1029
1030 fn add_page(
1031 mut self,
1032 title: &'static str,
1033 build_page: impl Fn(SettingsPage) -> SettingsPage,
1034 ) -> Self {
1035 let page = SettingsPage {
1036 title,
1037 expanded: false,
1038 items: Vec::default(),
1039 };
1040
1041 self.pages.push(build_page(page));
1042 self
1043 }
1044
1045 fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1046 self.search_task.take();
1047 self.search_bar.update(cx, |editor, cx| {
1048 editor.set_text(search_query, window, cx);
1049 });
1050 self.update_matches(cx);
1051 }
1052
1053 fn assert_search_results(&self, other: &Self) {
1054 assert_eq!(self.navbar_entries, other.navbar_entries);
1055 assert_eq!(
1056 self.current_page().items.iter().collect::<Vec<_>>(),
1057 other.page_items().collect::<Vec<_>>()
1058 );
1059 }
1060 }
1061
1062 impl SettingsPage {
1063 fn item(mut self, item: SettingsPageItem) -> Self {
1064 self.items.push(item);
1065 self
1066 }
1067 }
1068
1069 fn register_settings(cx: &mut App) {
1070 settings::init(cx);
1071 theme::init(theme::LoadThemes::JustBase, cx);
1072 workspace::init_settings(cx);
1073 project::Project::init_settings(cx);
1074 language::init(cx);
1075 editor::init(cx);
1076 menu::init();
1077 }
1078
1079 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1080 let mut pages: Vec<SettingsPage> = Vec::new();
1081 let mut current_page = None;
1082 let mut selected_idx = None;
1083 let mut ix = 0;
1084 let mut in_closed_subentry = false;
1085
1086 for mut line in input
1087 .lines()
1088 .map(|line| line.trim())
1089 .filter(|line| !line.is_empty())
1090 {
1091 let mut is_selected = false;
1092 if line.ends_with("*") {
1093 assert!(
1094 selected_idx.is_none(),
1095 "Can only have one selected navbar entry at a time"
1096 );
1097 selected_idx = Some(ix);
1098 line = &line[..line.len() - 1];
1099 is_selected = true;
1100 }
1101
1102 if line.starts_with("v") || line.starts_with(">") {
1103 if let Some(current_page) = current_page.take() {
1104 pages.push(current_page);
1105 }
1106
1107 let expanded = line.starts_with("v");
1108 in_closed_subentry = !expanded;
1109 ix += 1;
1110
1111 current_page = Some(SettingsPage {
1112 title: line.split_once(" ").unwrap().1,
1113 expanded,
1114 items: Vec::default(),
1115 });
1116 } else if line.starts_with("-") {
1117 if !in_closed_subentry {
1118 ix += 1;
1119 } else if is_selected && in_closed_subentry {
1120 panic!("Can't select sub entry if it's parent is closed");
1121 }
1122
1123 let Some(current_page) = current_page.as_mut() else {
1124 panic!("Sub entries must be within a page");
1125 };
1126
1127 current_page.items.push(SettingsPageItem::SectionHeader(
1128 line.split_once(" ").unwrap().1,
1129 ));
1130 } else {
1131 panic!(
1132 "Entries must start with one of 'v', '>', or '-'\n line: {}",
1133 line
1134 );
1135 }
1136 }
1137
1138 if let Some(current_page) = current_page.take() {
1139 pages.push(current_page);
1140 }
1141
1142 let search_matches = pages
1143 .iter()
1144 .map(|page| vec![true; page.items.len()])
1145 .collect::<Vec<_>>();
1146
1147 let mut settings_window = SettingsWindow {
1148 files: Vec::default(),
1149 current_file: crate::SettingsUiFile::User,
1150 pages,
1151 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1152 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1153 navbar_entries: Vec::default(),
1154 list_handle: UniformListScrollHandle::default(),
1155 search_matches,
1156 search_task: None,
1157 };
1158
1159 settings_window.build_navbar();
1160 settings_window
1161 }
1162
1163 #[track_caller]
1164 fn check_navbar_toggle(
1165 before: &'static str,
1166 toggle_idx: usize,
1167 after: &'static str,
1168 window: &mut Window,
1169 cx: &mut App,
1170 ) {
1171 let mut settings_window = parse(before, window, cx);
1172 settings_window.toggle_navbar_entry(toggle_idx);
1173
1174 let expected_settings_window = parse(after, window, cx);
1175
1176 assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
1177 assert_eq!(
1178 settings_window.navbar_entry(),
1179 expected_settings_window.navbar_entry()
1180 );
1181 }
1182
1183 macro_rules! check_navbar_toggle {
1184 ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
1185 #[gpui::test]
1186 fn $name(cx: &mut gpui::TestAppContext) {
1187 let window = cx.add_empty_window();
1188 window.update(|window, cx| {
1189 register_settings(cx);
1190 check_navbar_toggle($before, $toggle_idx, $after, window, cx);
1191 });
1192 }
1193 };
1194 }
1195
1196 check_navbar_toggle!(
1197 navbar_basic_open,
1198 before: r"
1199 v General
1200 - General
1201 - Privacy*
1202 v Project
1203 - Project Settings
1204 ",
1205 toggle_idx: 0,
1206 after: r"
1207 > General*
1208 v Project
1209 - Project Settings
1210 "
1211 );
1212
1213 check_navbar_toggle!(
1214 navbar_basic_close,
1215 before: r"
1216 > General*
1217 - General
1218 - Privacy
1219 v Project
1220 - Project Settings
1221 ",
1222 toggle_idx: 0,
1223 after: r"
1224 v General*
1225 - General
1226 - Privacy
1227 v Project
1228 - Project Settings
1229 "
1230 );
1231
1232 check_navbar_toggle!(
1233 navbar_basic_second_root_entry_close,
1234 before: r"
1235 > General
1236 - General
1237 - Privacy
1238 v Project
1239 - Project Settings*
1240 ",
1241 toggle_idx: 1,
1242 after: r"
1243 > General
1244 > Project*
1245 "
1246 );
1247
1248 check_navbar_toggle!(
1249 navbar_toggle_subroot,
1250 before: r"
1251 v General Page
1252 - General
1253 - Privacy
1254 v Project
1255 - Worktree Settings Content*
1256 v AI
1257 - General
1258 > Appearance & Behavior
1259 ",
1260 toggle_idx: 3,
1261 after: r"
1262 v General Page
1263 - General
1264 - Privacy
1265 > Project*
1266 v AI
1267 - General
1268 > Appearance & Behavior
1269 "
1270 );
1271
1272 check_navbar_toggle!(
1273 navbar_toggle_close_propagates_selected_index,
1274 before: r"
1275 v General Page
1276 - General
1277 - Privacy
1278 v Project
1279 - Worktree Settings Content
1280 v AI
1281 - General*
1282 > Appearance & Behavior
1283 ",
1284 toggle_idx: 0,
1285 after: r"
1286 > General Page
1287 v Project
1288 - Worktree Settings Content
1289 v AI
1290 - General*
1291 > Appearance & Behavior
1292 "
1293 );
1294
1295 check_navbar_toggle!(
1296 navbar_toggle_expand_propagates_selected_index,
1297 before: r"
1298 > 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 v General Page
1310 - General
1311 - Privacy
1312 v Project
1313 - Worktree Settings Content
1314 v AI
1315 - General*
1316 > Appearance & Behavior
1317 "
1318 );
1319
1320 check_navbar_toggle!(
1321 navbar_toggle_sub_entry_does_nothing,
1322 before: r"
1323 > General Page
1324 - General
1325 - Privacy
1326 v Project
1327 - Worktree Settings Content
1328 v AI
1329 - General*
1330 > Appearance & Behavior
1331 ",
1332 toggle_idx: 4,
1333 after: r"
1334 > General Page
1335 - General
1336 - Privacy
1337 v Project
1338 - Worktree Settings Content
1339 v AI
1340 - General*
1341 > Appearance & Behavior
1342 "
1343 );
1344
1345 #[gpui::test]
1346 fn test_basic_search(cx: &mut gpui::TestAppContext) {
1347 let cx = cx.add_empty_window();
1348 let (actual, expected) = cx.update(|window, cx| {
1349 register_settings(cx);
1350
1351 let expected = cx.new(|cx| {
1352 SettingsWindow::new_builder(window, cx)
1353 .add_page("General", |page| {
1354 page.item(SettingsPageItem::SectionHeader("General settings"))
1355 .item(SettingsPageItem::SettingItem(SettingItem {
1356 title: "test title",
1357 description: "General test",
1358 field: Box::new(SettingField {
1359 pick: |settings_content| {
1360 &settings_content.workspace.confirm_quit
1361 },
1362 pick_mut: |settings_content| {
1363 &mut settings_content.workspace.confirm_quit
1364 },
1365 }),
1366 metadata: None,
1367 }))
1368 })
1369 .build()
1370 });
1371
1372 let actual = cx.new(|cx| {
1373 SettingsWindow::new_builder(window, cx)
1374 .add_page("General", |page| {
1375 page.item(SettingsPageItem::SectionHeader("General settings"))
1376 .item(SettingsPageItem::SettingItem(SettingItem {
1377 title: "test title",
1378 description: "General test",
1379 field: Box::new(SettingField {
1380 pick: |settings_content| {
1381 &settings_content.workspace.confirm_quit
1382 },
1383 pick_mut: |settings_content| {
1384 &mut settings_content.workspace.confirm_quit
1385 },
1386 }),
1387 metadata: None,
1388 }))
1389 })
1390 .add_page("Theme", |page| {
1391 page.item(SettingsPageItem::SectionHeader("Theme settings"))
1392 })
1393 .build()
1394 });
1395
1396 actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1397
1398 (actual, expected)
1399 });
1400
1401 cx.cx.run_until_parked();
1402
1403 cx.update(|_window, cx| {
1404 let expected = expected.read(cx);
1405 let actual = actual.read(cx);
1406 expected.assert_search_results(&actual);
1407 })
1408 }
1409}