gpui_component/setting/
settings.rs

1use crate::{
2    IconName, Sizable, Size, StyledExt,
3    group_box::GroupBoxVariant,
4    input::{Input, InputState},
5    resizable::{h_resizable, resizable_panel},
6    setting::{SettingGroup, SettingPage},
7    sidebar::{Sidebar, SidebarMenu, SidebarMenuItem},
8};
9use gpui::{
10    App, AppContext as _, Axis, ElementId, Entity, IntoElement, ParentElement as _, Pixels,
11    RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px, relative,
12};
13use rust_i18n::t;
14
15/// The settings structure containing multiple pages for app settings.
16///
17/// The hierarchy of settings is as follows:
18///
19/// ```ignore
20/// Settings
21///   SettingPage     <- The single active page displayed
22///     SettingGroup
23///       SettingItem
24///         Label
25///         SettingField (e.g., Switch, Dropdown, Input)
26/// ```
27#[derive(IntoElement)]
28pub struct Settings {
29    id: ElementId,
30    pages: Vec<SettingPage>,
31    group_variant: GroupBoxVariant,
32    size: Size,
33    sidebar_width: Pixels,
34    sidebar_style: StyleRefinement,
35}
36
37impl Settings {
38    /// Create a new settings with the given ID.
39    pub fn new(id: impl Into<ElementId>) -> Self {
40        Self {
41            id: id.into(),
42            pages: vec![],
43            group_variant: GroupBoxVariant::default(),
44            size: Size::default(),
45            sidebar_width: px(250.0),
46            sidebar_style: StyleRefinement::default(),
47        }
48    }
49
50    /// Set the width of the sidebar, default is `250px`.
51    pub fn sidebar_width(mut self, width: impl Into<Pixels>) -> Self {
52        self.sidebar_width = width.into();
53        self
54    }
55
56    /// Add a page to the settings.
57    pub fn page(mut self, page: SettingPage) -> Self {
58        self.pages.push(page);
59        self
60    }
61
62    /// Add pages to the settings.
63    pub fn pages(mut self, pages: impl IntoIterator<Item = SettingPage>) -> Self {
64        self.pages.extend(pages);
65        self
66    }
67
68    /// Set the default variant for all setting groups.
69    ///
70    /// All setting groups will use this variant unless overridden individually.
71    pub fn with_group_variant(mut self, variant: GroupBoxVariant) -> Self {
72        self.group_variant = variant;
73        self
74    }
75
76    /// Set the style refinement for the sidebar.
77    pub fn sidebar_style(mut self, style: &StyleRefinement) -> Self {
78        self.sidebar_style = style.clone();
79        self
80    }
81
82    fn filtered_pages(&self, query: &str) -> Vec<SettingPage> {
83        self.pages
84            .iter()
85            .filter_map(|page| {
86                let filtered_groups: Vec<SettingGroup> = page
87                    .groups
88                    .iter()
89                    .filter_map(|group| {
90                        let mut group = group.clone();
91                        group.items = group
92                            .items
93                            .iter()
94                            .filter(|item| item.is_match(&query))
95                            .cloned()
96                            .collect();
97                        if group.items.is_empty() {
98                            None
99                        } else {
100                            Some(group)
101                        }
102                    })
103                    .collect();
104                let mut page = page.clone();
105                page.groups = filtered_groups;
106                if page.groups.is_empty() {
107                    None
108                } else {
109                    Some(page)
110                }
111            })
112            .collect()
113    }
114
115    fn render_active_page(
116        &self,
117        state: &Entity<SettingsState>,
118        pages: &Vec<SettingPage>,
119        options: &RenderOptions,
120        window: &mut Window,
121        cx: &mut App,
122    ) -> impl IntoElement {
123        let selected_index = state.read(cx).selected_index;
124
125        for (ix, page) in pages.into_iter().enumerate() {
126            if selected_index.page_ix == ix {
127                return page
128                    .render(ix, state, &options, window, cx)
129                    .into_any_element();
130            }
131        }
132
133        return div().into_any_element();
134    }
135
136    fn render_sidebar(
137        &self,
138        state: &Entity<SettingsState>,
139        pages: &Vec<SettingPage>,
140        _: &mut Window,
141        cx: &mut App,
142    ) -> impl IntoElement {
143        let selected_index = state.read(cx).selected_index;
144        let search_input = state.read(cx).search_input.clone();
145
146        Sidebar::left()
147            .w(relative(1.))
148            .border_0()
149            .refine_style(&self.sidebar_style)
150            .collapsed(false)
151            .header(
152                div()
153                    .w_full()
154                    .child(Input::new(&search_input).prefix(IconName::Search)),
155            )
156            .child(
157                SidebarMenu::new().children(pages.iter().enumerate().map(|(page_ix, page)| {
158                    let is_page_active =
159                        selected_index.page_ix == page_ix && selected_index.group_ix.is_none();
160                    SidebarMenuItem::new(page.title.clone())
161                        .default_open(page.default_open)
162                        .active(is_page_active)
163                        .on_click({
164                            let state = state.clone();
165                            move |_, _, cx| {
166                                state.update(cx, |state, cx| {
167                                    state.selected_index = SelectIndex {
168                                        page_ix,
169                                        ..Default::default()
170                                    };
171                                    cx.notify();
172                                })
173                            }
174                        })
175                        .when(page.groups.len() > 1, |this| {
176                            this.children(
177                                page.groups
178                                    .iter()
179                                    .filter(|g| g.title.is_some())
180                                    .enumerate()
181                                    .map(|(group_ix, group)| {
182                                        let is_active = selected_index.page_ix == page_ix
183                                            && selected_index.group_ix == Some(group_ix);
184                                        let title = group.title.clone().unwrap_or_default();
185
186                                        SidebarMenuItem::new(title).active(is_active).on_click({
187                                            let state = state.clone();
188                                            move |_, _, cx| {
189                                                state.update(cx, |state, cx| {
190                                                    state.selected_index = SelectIndex {
191                                                        page_ix,
192                                                        group_ix: Some(group_ix),
193                                                    };
194                                                    state.deferred_scroll_group_ix = Some(group_ix);
195                                                    cx.notify();
196                                                })
197                                            }
198                                        })
199                                    }),
200                            )
201                        })
202                })),
203            )
204    }
205}
206
207impl Sizable for Settings {
208    fn with_size(mut self, size: impl Into<Size>) -> Self {
209        self.size = size.into();
210        self
211    }
212}
213
214pub(super) struct SettingsState {
215    pub(super) selected_index: SelectIndex,
216    /// If set, defer scrolling to this group index after rendering.
217    pub(super) deferred_scroll_group_ix: Option<usize>,
218    pub(super) search_input: Entity<InputState>,
219}
220
221/// Options for rendering setting item.
222#[derive(Clone, Copy)]
223pub struct RenderOptions {
224    pub page_ix: usize,
225    pub group_ix: usize,
226    pub item_ix: usize,
227    pub size: Size,
228    pub group_variant: GroupBoxVariant,
229    pub layout: Axis,
230}
231
232#[derive(Clone, Copy, Default)]
233pub(super) struct SelectIndex {
234    page_ix: usize,
235    group_ix: Option<usize>,
236}
237
238impl RenderOnce for Settings {
239    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
240        let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
241            let search_input = cx.new(|cx| {
242                InputState::new(window, cx)
243                    .placeholder(t!("Settings.search_placeholder"))
244                    .default_value("")
245            });
246
247            SettingsState {
248                search_input,
249                selected_index: SelectIndex::default(),
250                deferred_scroll_group_ix: None,
251            }
252        });
253
254        let query = state.read(cx).search_input.read(cx).value();
255        let filtered_pages = self.filtered_pages(&query);
256        let options = RenderOptions {
257            page_ix: 0,
258            group_ix: 0,
259            item_ix: 0,
260            size: self.size,
261            group_variant: self.group_variant,
262            layout: Axis::Horizontal,
263        };
264
265        h_resizable(self.id.clone())
266            .child(
267                resizable_panel()
268                    .size(self.sidebar_width)
269                    .child(self.render_sidebar(&state, &filtered_pages, window, cx)),
270            )
271            .child(resizable_panel().child(self.render_active_page(
272                &state,
273                &filtered_pages,
274                &options,
275                window,
276                cx,
277            )))
278    }
279}