settings_ui.rs

  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}