1mod appearance_settings_controls;
2
3use std::any::TypeId;
4use std::ops::{Not, Range};
5
6use anyhow::Context as _;
7use command_palette_hooks::CommandPaletteFilter;
8use editor::EditorSettingsControls;
9use feature_flags::{FeatureFlag, FeatureFlagViewExt};
10use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, actions};
11use settings::{SettingsStore, SettingsUiEntryVariant, SettingsUiItemSingle, SettingsValue};
12use smallvec::SmallVec;
13use ui::{NumericStepper, SwitchField, ToggleButtonGroup, ToggleButtonSimple, prelude::*};
14use workspace::{
15 Workspace,
16 item::{Item, ItemEvent},
17 with_active_or_new_workspace,
18};
19
20use crate::appearance_settings_controls::AppearanceSettingsControls;
21
22pub struct SettingsUiFeatureFlag;
23
24impl FeatureFlag for SettingsUiFeatureFlag {
25 const NAME: &'static str = "settings-ui";
26}
27
28actions!(
29 zed,
30 [
31 /// Opens the settings editor.
32 OpenSettingsEditor
33 ]
34);
35
36pub fn init(cx: &mut App) {
37 cx.on_action(|_: &OpenSettingsEditor, cx| {
38 with_active_or_new_workspace(cx, move |workspace, window, cx| {
39 let existing = workspace
40 .active_pane()
41 .read(cx)
42 .items()
43 .find_map(|item| item.downcast::<SettingsPage>());
44
45 if let Some(existing) = existing {
46 workspace.activate_item(&existing, true, true, window, cx);
47 } else {
48 let settings_page = SettingsPage::new(workspace, cx);
49 workspace.add_item_to_active_pane(Box::new(settings_page), None, true, window, cx)
50 }
51 });
52 });
53
54 cx.observe_new(|_workspace: &mut Workspace, window, cx| {
55 let Some(window) = window else {
56 return;
57 };
58
59 let settings_ui_actions = [TypeId::of::<OpenSettingsEditor>()];
60
61 CommandPaletteFilter::update_global(cx, |filter, _cx| {
62 filter.hide_action_types(&settings_ui_actions);
63 });
64
65 cx.observe_flag::<SettingsUiFeatureFlag, _>(
66 window,
67 move |is_enabled, _workspace, _, cx| {
68 if is_enabled {
69 CommandPaletteFilter::update_global(cx, |filter, _cx| {
70 filter.show_action_types(settings_ui_actions.iter());
71 });
72 } else {
73 CommandPaletteFilter::update_global(cx, |filter, _cx| {
74 filter.hide_action_types(&settings_ui_actions);
75 });
76 }
77 },
78 )
79 .detach();
80 })
81 .detach();
82}
83
84pub struct SettingsPage {
85 focus_handle: FocusHandle,
86 settings_tree: SettingsUiTree,
87}
88
89impl SettingsPage {
90 pub fn new(_workspace: &Workspace, cx: &mut Context<Workspace>) -> Entity<Self> {
91 cx.new(|cx| Self {
92 focus_handle: cx.focus_handle(),
93 settings_tree: SettingsUiTree::new(cx),
94 })
95 }
96}
97
98impl EventEmitter<ItemEvent> for SettingsPage {}
99
100impl Focusable for SettingsPage {
101 fn focus_handle(&self, _cx: &App) -> FocusHandle {
102 self.focus_handle.clone()
103 }
104}
105
106impl Item for SettingsPage {
107 type Event = ItemEvent;
108
109 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
110 Some(Icon::new(IconName::Settings))
111 }
112
113 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
114 "Settings".into()
115 }
116
117 fn show_toolbar(&self) -> bool {
118 false
119 }
120
121 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
122 f(*event)
123 }
124}
125
126// We want to iterate over the side bar with root groups
127// - this is a loop over top level groups, and if any are expanded, recursively displaying their items
128// - Should be able to get all items from a group (flatten a group)
129// - Should be able to toggle/untoggle groups in UI (at least in sidebar)
130// - Search should be available
131// - there should be an index of text -> item mappings, for using fuzzy::match
132// - Do we want to show the parent groups when a item is matched?
133
134struct UIEntry {
135 title: &'static str,
136 path: &'static str,
137 _depth: usize,
138 // a
139 // b < a descendant range < a total descendant range
140 // f | |
141 // g | |
142 // c < |
143 // d |
144 // e <
145 descendant_range: Range<usize>,
146 total_descendant_range: Range<usize>,
147 next_sibling: Option<usize>,
148 // expanded: bool,
149 render: Option<SettingsUiItemSingle>,
150}
151
152struct SettingsUiTree {
153 root_entry_indices: Vec<usize>,
154 entries: Vec<UIEntry>,
155 active_entry_index: usize,
156}
157
158fn build_tree_item(
159 tree: &mut Vec<UIEntry>,
160 group: SettingsUiEntryVariant,
161 depth: usize,
162 prev_index: Option<usize>,
163) {
164 let index = tree.len();
165 tree.push(UIEntry {
166 title: "",
167 path: "",
168 _depth: depth,
169 descendant_range: index + 1..index + 1,
170 total_descendant_range: index + 1..index + 1,
171 render: None,
172 next_sibling: None,
173 });
174 if let Some(prev_index) = prev_index {
175 tree[prev_index].next_sibling = Some(index);
176 }
177 match group {
178 SettingsUiEntryVariant::Group {
179 path,
180 title,
181 items: group_items,
182 } => {
183 tree[index].path = path;
184 tree[index].title = title;
185 for group_item in group_items {
186 let prev_index = tree[index]
187 .descendant_range
188 .is_empty()
189 .not()
190 .then_some(tree[index].descendant_range.end - 1);
191 tree[index].descendant_range.end = tree.len() + 1;
192 build_tree_item(tree, group_item.item, depth + 1, prev_index);
193 tree[index].total_descendant_range.end = tree.len();
194 }
195 }
196 SettingsUiEntryVariant::Item { path, item } => {
197 tree[index].path = path;
198 // todo(settings_ui) create title from path in macro, and use here
199 tree[index].title = path;
200 tree[index].render = Some(item);
201 }
202 SettingsUiEntryVariant::None => {
203 return;
204 }
205 }
206}
207
208impl SettingsUiTree {
209 fn new(cx: &App) -> Self {
210 let settings_store = SettingsStore::global(cx);
211 let mut tree = vec![];
212 let mut root_entry_indices = vec![];
213 for item in settings_store.settings_ui_items() {
214 if matches!(item.item, SettingsUiEntryVariant::None) {
215 continue;
216 }
217
218 assert!(
219 matches!(item.item, SettingsUiEntryVariant::Group { .. }),
220 "top level items must be groups: {:?}",
221 match item.item {
222 SettingsUiEntryVariant::Item { path, .. } => path,
223 _ => unreachable!(),
224 }
225 );
226 let prev_root_entry_index = root_entry_indices.last().copied();
227 root_entry_indices.push(tree.len());
228 build_tree_item(&mut tree, item.item, 0, prev_root_entry_index);
229 }
230
231 root_entry_indices.sort_by_key(|i| tree[*i].title);
232
233 let active_entry_index = root_entry_indices[0];
234 Self {
235 entries: tree,
236 root_entry_indices,
237 active_entry_index,
238 }
239 }
240}
241
242fn render_nav(tree: &SettingsUiTree, _window: &mut Window, cx: &mut Context<SettingsPage>) -> Div {
243 let mut nav = v_flex().p_4().gap_2();
244 for &index in &tree.root_entry_indices {
245 nav = nav.child(
246 div()
247 .id(index)
248 .on_click(cx.listener(move |settings, _, _, _| {
249 settings.settings_tree.active_entry_index = index;
250 }))
251 .child(
252 Label::new(SharedString::new_static(tree.entries[index].title))
253 .size(LabelSize::Large)
254 .when(tree.active_entry_index == index, |this| {
255 this.color(Color::Selected)
256 }),
257 ),
258 );
259 }
260 nav
261}
262
263fn render_content(
264 tree: &SettingsUiTree,
265 window: &mut Window,
266 cx: &mut Context<SettingsPage>,
267) -> impl IntoElement {
268 let Some(entry) = tree.entries.get(tree.active_entry_index) else {
269 return div()
270 .size_full()
271 .child(Label::new(SharedString::new_static("No settings found")).color(Color::Error));
272 };
273 let mut content = v_flex().size_full().gap_4();
274
275 let mut child_index = entry
276 .descendant_range
277 .is_empty()
278 .not()
279 .then_some(entry.descendant_range.start);
280 let mut path = smallvec::smallvec![entry.path];
281
282 while let Some(index) = child_index {
283 let child = &tree.entries[index];
284 child_index = child.next_sibling;
285 if child.render.is_none() {
286 // todo(settings_ui): subgroups?
287 continue;
288 }
289 path.push(child.path);
290 let settings_value = settings_value_from_settings_and_path(
291 path.clone(),
292 // PERF: how to structure this better? There feels like there's a way to avoid the clone
293 // and every value lookup
294 SettingsStore::global(cx).raw_user_settings(),
295 SettingsStore::global(cx).raw_default_settings(),
296 );
297 content = content.child(
298 div()
299 .child(
300 Label::new(SharedString::new_static(tree.entries[index].title))
301 .size(LabelSize::Large)
302 .when(tree.active_entry_index == index, |this| {
303 this.color(Color::Selected)
304 }),
305 )
306 .child(render_item_single(
307 settings_value,
308 child.render.as_ref().unwrap(),
309 window,
310 cx,
311 )),
312 );
313
314 path.pop();
315 }
316
317 return content;
318}
319
320impl Render for SettingsPage {
321 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
322 div()
323 .grid()
324 .grid_cols(16)
325 .p_4()
326 .bg(cx.theme().colors().editor_background)
327 .size_full()
328 .child(
329 div()
330 .col_span(2)
331 .h_full()
332 .child(render_nav(&self.settings_tree, window, cx)),
333 )
334 .child(div().col_span(4).h_full().child(render_content(
335 &self.settings_tree,
336 window,
337 cx,
338 )))
339 }
340}
341
342// todo(settings_ui): remove, only here as inspiration
343#[allow(dead_code)]
344fn render_old_appearance_settings(cx: &mut App) -> impl IntoElement {
345 v_flex()
346 .p_4()
347 .size_full()
348 .gap_4()
349 .child(Label::new("Settings").size(LabelSize::Large))
350 .child(
351 v_flex().gap_1().child(Label::new("Appearance")).child(
352 v_flex()
353 .elevation_2(cx)
354 .child(AppearanceSettingsControls::new()),
355 ),
356 )
357 .child(
358 v_flex().gap_1().child(Label::new("Editor")).child(
359 v_flex()
360 .elevation_2(cx)
361 .child(EditorSettingsControls::new()),
362 ),
363 )
364}
365
366fn element_id_from_path(path: &[&'static str]) -> ElementId {
367 if path.len() == 0 {
368 panic!("Path length must not be zero");
369 } else if path.len() == 1 {
370 ElementId::Name(SharedString::new_static(path[0]))
371 } else {
372 ElementId::from((
373 ElementId::from(SharedString::new_static(path[path.len() - 2])),
374 SharedString::new_static(path[path.len() - 1]),
375 ))
376 }
377}
378
379fn render_item_single(
380 settings_value: SettingsValue<serde_json::Value>,
381 item: &SettingsUiItemSingle,
382 window: &mut Window,
383 cx: &mut App,
384) -> AnyElement {
385 match item {
386 SettingsUiItemSingle::Custom(_) => div()
387 .child(format!("Item: {}", settings_value.path.join(".")))
388 .into_any_element(),
389 SettingsUiItemSingle::SwitchField => {
390 render_any_item(settings_value, render_switch_field, window, cx)
391 }
392 SettingsUiItemSingle::NumericStepper => {
393 render_any_item(settings_value, render_numeric_stepper, window, cx)
394 }
395 SettingsUiItemSingle::ToggleGroup(variants) => {
396 render_toggle_button_group(settings_value, variants, window, cx)
397 }
398 SettingsUiItemSingle::DropDown(_) => {
399 unimplemented!("This")
400 }
401 }
402}
403
404fn read_settings_value_from_path<'a>(
405 settings_contents: &'a serde_json::Value,
406 path: &[&'static str],
407) -> Option<&'a serde_json::Value> {
408 let Some((key, remaining)) = path.split_first() else {
409 return Some(settings_contents);
410 };
411 let Some(value) = settings_contents.get(key) else {
412 return None;
413 };
414
415 read_settings_value_from_path(value, remaining)
416}
417
418fn downcast_any_item<T: serde::de::DeserializeOwned>(
419 settings_value: SettingsValue<serde_json::Value>,
420) -> SettingsValue<T> {
421 let value = settings_value
422 .value
423 .map(|value| serde_json::from_value::<T>(value).expect("value is not a T"));
424 // todo(settings_ui) Create test that constructs UI tree, and asserts that all elements have default values
425 let default_value = serde_json::from_value::<T>(settings_value.default_value)
426 .expect("default value is not an Option<T>");
427 let deserialized_setting_value = SettingsValue {
428 title: settings_value.title,
429 path: settings_value.path,
430 value,
431 default_value,
432 };
433 deserialized_setting_value
434}
435
436fn render_any_item<T: serde::de::DeserializeOwned>(
437 settings_value: SettingsValue<serde_json::Value>,
438 render_fn: impl Fn(SettingsValue<T>, &mut Window, &mut App) -> AnyElement + 'static,
439 window: &mut Window,
440 cx: &mut App,
441) -> AnyElement {
442 let deserialized_setting_value = downcast_any_item(settings_value);
443 render_fn(deserialized_setting_value, window, cx)
444}
445
446fn render_numeric_stepper(
447 value: SettingsValue<u64>,
448 _window: &mut Window,
449 _cx: &mut App,
450) -> AnyElement {
451 let id = element_id_from_path(&value.path);
452 let path = value.path.clone();
453 let num = value.value.unwrap_or_else(|| value.default_value);
454
455 NumericStepper::new(
456 id,
457 num.to_string(),
458 {
459 let path = value.path.clone();
460 move |_, _, cx| {
461 let Some(number) = serde_json::Number::from_u128(num.saturating_sub(1) as u128)
462 else {
463 return;
464 };
465 let new_value = serde_json::Value::Number(number);
466 SettingsValue::write_value(&path, new_value, cx);
467 }
468 },
469 move |_, _, cx| {
470 let Some(number) = serde_json::Number::from_u128(num.saturating_add(1) as u128) else {
471 return;
472 };
473
474 let new_value = serde_json::Value::Number(number);
475
476 SettingsValue::write_value(&path, new_value, cx);
477 },
478 )
479 .style(ui::NumericStepperStyle::Outlined)
480 .into_any_element()
481}
482
483fn render_switch_field(
484 value: SettingsValue<bool>,
485 _window: &mut Window,
486 _cx: &mut App,
487) -> AnyElement {
488 let id = element_id_from_path(&value.path);
489 let path = value.path.clone();
490 SwitchField::new(
491 id,
492 SharedString::new_static(value.title),
493 None,
494 match value.read() {
495 true => ToggleState::Selected,
496 false => ToggleState::Unselected,
497 },
498 move |toggle_state, _, cx| {
499 let new_value = serde_json::Value::Bool(match toggle_state {
500 ToggleState::Indeterminate => {
501 return;
502 }
503 ToggleState::Selected => true,
504 ToggleState::Unselected => false,
505 });
506
507 SettingsValue::write_value(&path, new_value, cx);
508 },
509 )
510 .into_any_element()
511}
512
513fn render_toggle_button_group(
514 value: SettingsValue<serde_json::Value>,
515 variants: &'static [&'static str],
516 _: &mut Window,
517 _: &mut App,
518) -> AnyElement {
519 let value = downcast_any_item::<String>(value);
520
521 fn make_toggle_group<const LEN: usize>(
522 group_name: &'static str,
523 value: SettingsValue<String>,
524 variants: &'static [&'static str],
525 ) -> AnyElement {
526 let mut variants_array: [&'static str; LEN] = ["default"; LEN];
527 variants_array.copy_from_slice(variants);
528 let active_value = value.read();
529
530 let selected_idx = variants_array
531 .iter()
532 .enumerate()
533 .find_map(|(idx, variant)| {
534 if variant == &active_value {
535 Some(idx)
536 } else {
537 None
538 }
539 });
540
541 ToggleButtonGroup::single_row(
542 group_name,
543 variants_array.map(|variant| {
544 let path = value.path.clone();
545 ToggleButtonSimple::new(variant, move |_, _, cx| {
546 SettingsValue::write_value(
547 &path,
548 serde_json::Value::String(variant.to_string()),
549 cx,
550 );
551 })
552 }),
553 )
554 .when_some(selected_idx, |this, ix| this.selected_index(ix))
555 .style(ui::ToggleButtonGroupStyle::Filled)
556 .into_any_element()
557 }
558
559 macro_rules! templ_toggl_with_const_param {
560 ($len:expr) => {
561 if variants.len() == $len {
562 return make_toggle_group::<$len>(value.title, value, variants);
563 }
564 };
565 }
566 templ_toggl_with_const_param!(1);
567 templ_toggl_with_const_param!(2);
568 templ_toggl_with_const_param!(3);
569 templ_toggl_with_const_param!(4);
570 templ_toggl_with_const_param!(5);
571 templ_toggl_with_const_param!(6);
572 unreachable!("Too many variants");
573}
574
575fn settings_value_from_settings_and_path(
576 path: SmallVec<[&'static str; 1]>,
577 user_settings: &serde_json::Value,
578 default_settings: &serde_json::Value,
579) -> SettingsValue<serde_json::Value> {
580 let default_value = read_settings_value_from_path(default_settings, &path)
581 .with_context(|| format!("No default value for item at path {:?}", path.join(".")))
582 .expect("Default value set for item")
583 .clone();
584
585 let value = read_settings_value_from_path(user_settings, &path).cloned();
586 let settings_value = SettingsValue {
587 default_value,
588 value,
589 path: path.clone(),
590 // todo(settings_ui) title for items
591 title: path.last().expect("path non empty"),
592 };
593 return settings_value;
594}