settings ui: Implement dynamic navbar based on pages section headers (#38915)
Release Notes: - N/A --------- Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
co-authored by
Ben Kunkle
parent
ecc35fcd9a
commit
daebc4052d
@@ -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) {
|
||||
self.pages = self.current_file.pages();
|
||||
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 fetch_files(&mut self, cx: &mut App) {
|
||||
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 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));
|
||||
.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];
|
||||
|
||||
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
|
||||
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 is_page_selected(&self, ix: usize) -> bool {
|
||||
ix == self.current_page
|
||||
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 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*
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user