@@ -1,19 +1,20 @@
//! # settings_ui
-use std::sync::Arc;
+use std::{ops::Range, sync::Arc};
use editor::Editor;
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
use gpui::{
- App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render, Window,
- WindowHandle, WindowOptions, actions, div, px, size,
+ App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render,
+ UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div, px, size,
+ uniform_list,
};
use project::WorktreeId;
use settings::{SettingsContent, SettingsStore};
use ui::{
ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color, Divider,
DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
- LabelCommon as _, LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _,
- Styled, Switch, h_flex, v_flex,
+ LabelCommon as _, LabelSize, ListItem, ParentElement, SharedString,
+ StatefulInteractiveElement as _, Styled, StyledTypography, Switch, h_flex, v_flex,
};
use util::{paths::PathStyle, rel_path::RelPath};
@@ -21,6 +22,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
vec![
SettingsPage {
title: "General Page",
+ expanded: true,
items: vec![
SettingsPageItem::SectionHeader("General"),
SettingsPageItem::SettingItem(SettingItem {
@@ -41,10 +43,12 @@ fn user_settings_data() -> Vec<SettingsPage> {
})
},
}),
+ SettingsPageItem::SectionHeader("Privacy"),
],
},
SettingsPage {
title: "Project",
+ expanded: true,
items: vec![
SettingsPageItem::SectionHeader("Worktree Settings Content"),
SettingsPageItem::SettingItem(SettingItem {
@@ -60,6 +64,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
},
SettingsPage {
title: "AI",
+ expanded: true,
items: vec![
SettingsPageItem::SectionHeader("General"),
SettingsPageItem::SettingItem(SettingItem {
@@ -75,6 +80,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
},
SettingsPage {
title: "Appearance & Behavior",
+ expanded: true,
items: vec![
SettingsPageItem::SectionHeader("Cursor"),
SettingsPageItem::SettingItem(SettingItem {
@@ -98,6 +104,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
fn project_settings_data() -> Vec<SettingsPage> {
vec![SettingsPage {
title: "Project",
+ expanded: true,
items: vec![
SettingsPageItem::SectionHeader("Worktree Settings Content"),
SettingsPageItem::SettingItem(SettingItem {
@@ -170,15 +177,33 @@ pub struct SettingsWindow {
current_file: SettingsFile,
pages: Vec<SettingsPage>,
search: Entity<Editor>,
- current_page: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
+ navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
+ navbar_entries: Vec<NavBarEntry>,
+ list_handle: UniformListScrollHandle,
+}
+
+#[derive(PartialEq, Debug)]
+struct NavBarEntry {
+ title: &'static str,
+ is_root: bool,
}
#[derive(Clone)]
struct SettingsPage {
title: &'static str,
+ expanded: bool,
items: Vec<SettingsPageItem>,
}
+impl SettingsPage {
+ fn section_headers(&self) -> impl Iterator<Item = &'static str> {
+ self.items.iter().filter_map(|item| match item {
+ SettingsPageItem::SectionHeader(header) => Some(*header),
+ _ => None,
+ })
+ }
+}
+
#[derive(Clone)]
enum SettingsPageItem {
SectionHeader(&'static str),
@@ -274,7 +299,9 @@ impl SettingsWindow {
files: vec![],
current_file: current_file,
pages: vec![],
- current_page: 0,
+ navbar_entries: vec![],
+ navbar_entry: 0,
+ list_handle: UniformListScrollHandle::default(),
search,
};
cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
@@ -284,15 +311,56 @@ impl SettingsWindow {
.detach();
this.fetch_files(cx);
- this.build_ui();
+ this.build_ui(cx);
this
}
- fn build_ui(&mut self) {
+ fn toggle_navbar_entry(&mut self, ix: usize) {
+ if self.navbar_entries[ix].is_root {
+ let expanded = &mut self.page_for_navbar_index(ix).expanded;
+ *expanded = !*expanded;
+ let current_page_index = self.page_index_from_navbar_index(self.navbar_entry);
+ // if currently selected page is a child of the parent page we are folding,
+ // set the current page to the parent page
+ if current_page_index == ix {
+ self.navbar_entry = ix;
+ }
+ self.build_navbar();
+ }
+ }
+
+ fn build_navbar(&mut self) {
+ self.navbar_entries = self
+ .pages
+ .iter()
+ .flat_map(|page| {
+ std::iter::once(NavBarEntry {
+ title: page.title,
+ is_root: true,
+ })
+ .chain(
+ page.expanded
+ .then(|| {
+ page.section_headers().map(|h| NavBarEntry {
+ title: h,
+ is_root: false,
+ })
+ })
+ .into_iter()
+ .flatten(),
+ )
+ })
+ .collect();
+ }
+
+ fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
self.pages = self.current_file.pages();
+ self.build_navbar();
+
+ cx.notify();
}
- fn fetch_files(&mut self, cx: &mut App) {
+ fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
let settings_store = cx.global::<SettingsStore>();
let mut ui_files = vec![];
let all_files = settings_store.get_all_files();
@@ -309,12 +377,12 @@ impl SettingsWindow {
}
ui_files.reverse();
if !ui_files.contains(&self.current_file) {
- self.change_file(0);
+ self.change_file(0, cx);
}
self.files = ui_files;
}
- fn change_file(&mut self, ix: usize) {
+ fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
if ix >= self.files.len() {
self.current_file = SettingsFile::User;
return;
@@ -323,7 +391,7 @@ impl SettingsWindow {
return;
}
self.current_file = self.files[ix].clone();
- self.build_ui();
+ self.build_ui(cx);
}
fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
@@ -333,42 +401,88 @@ impl SettingsWindow {
.gap_1()
.children(self.files.iter().enumerate().map(|(ix, file)| {
Button::new(ix, file.name())
- .on_click(cx.listener(move |this, _, _window, _cx| this.change_file(ix)))
+ .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
}))
}
fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
- div()
+ h_flex()
.child(Icon::new(IconName::MagnifyingGlass))
.child(self.search.clone())
}
fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
- let mut nav = v_flex()
- .p_4()
- .gap_2()
+ v_flex()
.bg(cx.theme().colors().panel_background)
+ .p_3()
.child(div().h_10()) // Files spacer;
- .child(self.render_search(window, cx));
-
- for (ix, page) in self.pages.iter().enumerate() {
- nav = nav.child(
- div()
- .id(page.title)
- .child(
- Label::new(page.title)
- .size(LabelSize::Large)
- .when(self.is_page_selected(ix), |this| {
- this.color(Color::Selected)
- }),
- )
- .on_click(cx.listener(move |this, _, _, cx| {
- this.current_page = ix;
- cx.notify();
- })),
- );
- }
- nav
+ .child(self.render_search(window, cx).pb_1())
+ .gap_3()
+ .child(
+ uniform_list(
+ "settings-ui-nav-bar",
+ self.navbar_entries.len(),
+ cx.processor(|this, range: Range<usize>, _, cx| {
+ range
+ .into_iter()
+ .map(|ix| {
+ let entry = &this.navbar_entries[ix];
+
+ div()
+ .id(("settings-ui-section", ix))
+ .child(
+ ListItem::new(("settings-ui-navbar-entry", ix))
+ .selectable(true)
+ .indent_step_size(px(10.))
+ .indent_level(if entry.is_root { 1 } else { 3 })
+ .when(entry.is_root, |item| {
+ item.toggle(
+ this.pages
+ [this.page_index_from_navbar_index(ix)]
+ .expanded,
+ )
+ .always_show_disclosure_icon(true)
+ .on_toggle(cx.listener(move |this, _, _, cx| {
+ this.toggle_navbar_entry(ix);
+ cx.notify();
+ }))
+ })
+ .child(
+ div()
+ .text_ui(cx)
+ .size_full()
+ .child(entry.title)
+ .hover(|style| {
+ style.bg(cx.theme().colors().element_hover)
+ })
+ .when(!entry.is_root, |this| {
+ this.text_color(
+ cx.theme().colors().text_muted,
+ )
+ })
+ .when(
+ this.is_navbar_entry_selected(ix),
+ |this| {
+ this.text_color(
+ Color::Selected.color(cx),
+ )
+ },
+ ),
+ ),
+ )
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.navbar_entry = ix;
+ cx.notify();
+ }))
+ })
+ .collect()
+ }),
+ )
+ .track_scroll(self.list_handle.clone())
+ .gap_1_5()
+ .size_full()
+ .flex_grow(),
+ )
}
fn render_page(
@@ -385,11 +499,25 @@ impl SettingsWindow {
}
fn current_page(&self) -> &SettingsPage {
- &self.pages[self.current_page]
+ &self.pages[self.page_index_from_navbar_index(self.navbar_entry)]
+ }
+
+ fn page_index_from_navbar_index(&self, index: usize) -> usize {
+ self.navbar_entries
+ .iter()
+ .take(index + 1)
+ .map(|entry| entry.is_root as usize)
+ .sum::<usize>()
+ - 1
}
- fn is_page_selected(&self, ix: usize) -> bool {
- ix == self.current_page
+ fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
+ let index = self.page_index_from_navbar_index(index);
+ &mut self.pages[index]
+ }
+
+ fn is_navbar_entry_selected(&self, ix: usize) -> bool {
+ ix == self.navbar_entry
}
}
@@ -577,3 +705,179 @@ where
)
.into_any_element()
}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ impl SettingsWindow {
+ fn navbar(&self) -> &[NavBarEntry] {
+ self.navbar_entries.as_slice()
+ }
+
+ fn navbar_entry(&self) -> usize {
+ self.navbar_entry
+ }
+ }
+
+ fn register_settings(cx: &mut App) {
+ settings::init(cx);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ workspace::init_settings(cx);
+ project::Project::init_settings(cx);
+ language::init(cx);
+ editor::init(cx);
+ menu::init();
+ }
+
+ fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
+ let mut pages: Vec<SettingsPage> = Vec::new();
+ let mut current_page = None;
+ let mut selected_idx = None;
+
+ for (ix, mut line) in input
+ .lines()
+ .map(|line| line.trim())
+ .filter(|line| !line.is_empty())
+ .enumerate()
+ {
+ if line.ends_with("*") {
+ assert!(
+ selected_idx.is_none(),
+ "Can only have one selected navbar entry at a time"
+ );
+ selected_idx = Some(ix);
+ line = &line[..line.len() - 1];
+ }
+
+ if line.starts_with("v") || line.starts_with(">") {
+ if let Some(current_page) = current_page.take() {
+ pages.push(current_page);
+ }
+
+ let expanded = line.starts_with("v");
+
+ current_page = Some(SettingsPage {
+ title: line.split_once(" ").unwrap().1,
+ expanded,
+ items: Vec::default(),
+ });
+ } else if line.starts_with("-") {
+ let Some(current_page) = current_page.as_mut() else {
+ panic!("Sub entries must be within a page");
+ };
+
+ current_page.items.push(SettingsPageItem::SectionHeader(
+ line.split_once(" ").unwrap().1,
+ ));
+ } else {
+ panic!(
+ "Entries must start with one of 'v', '>', or '-'\n line: {}",
+ line
+ );
+ }
+ }
+
+ if let Some(current_page) = current_page.take() {
+ pages.push(current_page);
+ }
+
+ let mut settings_window = SettingsWindow {
+ files: Vec::default(),
+ current_file: crate::SettingsFile::User,
+ pages,
+ search: cx.new(|cx| Editor::single_line(window, cx)),
+ navbar_entry: selected_idx.unwrap(),
+ navbar_entries: Vec::default(),
+ list_handle: UniformListScrollHandle::default(),
+ };
+
+ settings_window.build_navbar();
+ settings_window
+ }
+
+ #[track_caller]
+ fn check_navbar_toggle(
+ before: &'static str,
+ toggle_idx: usize,
+ after: &'static str,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let mut settings_window = parse(before, window, cx);
+ settings_window.toggle_navbar_entry(toggle_idx);
+
+ let expected_settings_window = parse(after, window, cx);
+
+ assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
+ assert_eq!(
+ settings_window.navbar_entry(),
+ expected_settings_window.navbar_entry()
+ );
+ }
+
+ macro_rules! check_navbar_toggle {
+ ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
+ #[gpui::test]
+ fn $name(cx: &mut gpui::TestAppContext) {
+ let window = cx.add_empty_window();
+ window.update(|window, cx| {
+ register_settings(cx);
+ check_navbar_toggle($before, $toggle_idx, $after, window, cx);
+ });
+ }
+ };
+ }
+
+ check_navbar_toggle!(
+ basic_open,
+ before: r"
+ v General
+ - General
+ - Privacy*
+ v Project
+ - Project Settings
+ ",
+ toggle_idx: 0,
+ after: r"
+ > General*
+ v Project
+ - Project Settings
+ "
+ );
+
+ check_navbar_toggle!(
+ basic_close,
+ before: r"
+ > General*
+ - General
+ - Privacy
+ v Project
+ - Project Settings
+ ",
+ toggle_idx: 0,
+ after: r"
+ v General*
+ - General
+ - Privacy
+ v Project
+ - Project Settings
+ "
+ );
+
+ check_navbar_toggle!(
+ basic_second_root_entry_close,
+ before: r"
+ > General
+ - General
+ - Privacy
+ v Project
+ - Project Settings*
+ ",
+ toggle_idx: 1,
+ after: r"
+ > General
+ > Project*
+ "
+ );
+}