Skip to main content

gpui_component/setting/
settings.rs

1use std::ops::Range;
2
3use crate::{
4    Icon, IconName, Sizable, Size, StyledExt,
5    empty::{Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyMediaVariant, EmptyTitle},
6    group_box::GroupBoxVariant,
7    h_resizable,
8    input::{Input, InputState},
9    resizable_panel,
10    setting::SettingPage,
11    sidebar::{Sidebar, SidebarMenu, SidebarMenuItem},
12};
13use gpui::{
14    App, AppContext as _, Axis, ElementId, Entity, InteractiveElement as _, IntoElement,
15    ParentElement as _, Pixels, RenderOnce, StyleRefinement, Styled, Window, container_query, div,
16    prelude::FluentBuilder as _, px, relative,
17};
18use rust_i18n::t;
19
20const STACKED_LAYOUT_MAX_WIDTH: Pixels = px(480.);
21
22/// The settings structure containing multiple pages for app settings.
23///
24/// The hierarchy of settings is as follows:
25///
26/// ```ignore
27/// Settings
28///   SettingPage     <- The single active page displayed
29///     SettingGroup
30///       SettingItem
31///         Label
32///         SettingField (e.g., Switch, Dropdown, Input)
33/// ```
34#[derive(IntoElement)]
35pub struct Settings {
36    id: ElementId,
37    pages: Vec<SettingPage>,
38    group_variant: GroupBoxVariant,
39    size: Size,
40    sidebar_width: Pixels,
41    sidebar_size_range: Range<Pixels>,
42    sidebar_style: StyleRefinement,
43    default_selected_index: SelectIndex,
44    header_style: StyleRefinement,
45}
46
47impl Settings {
48    /// Create a new settings with the given ID.
49    pub fn new(id: impl Into<ElementId>) -> Self {
50        Self {
51            id: id.into(),
52            pages: vec![],
53            group_variant: GroupBoxVariant::default(),
54            size: Size::default(),
55            sidebar_width: px(250.0),
56            sidebar_size_range: px(160.0)..px(360.0),
57            sidebar_style: StyleRefinement::default(),
58            default_selected_index: SelectIndex::default(),
59            header_style: StyleRefinement::default(),
60        }
61    }
62
63    /// Set the width of the sidebar, default is `250px`.
64    pub fn sidebar_width(mut self, width: impl Into<Pixels>) -> Self {
65        self.sidebar_width = width.into();
66        self
67    }
68
69    /// Set the resize range of the sidebar, default is `160px..360px`.
70    pub fn sidebar_size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
71        self.sidebar_size_range = range.into();
72        self
73    }
74
75    /// Add a page to the settings.
76    pub fn page(mut self, page: SettingPage) -> Self {
77        self.pages.push(page);
78        self
79    }
80
81    /// Add pages to the settings.
82    pub fn pages(mut self, pages: impl IntoIterator<Item = SettingPage>) -> Self {
83        self.pages.extend(pages);
84        self
85    }
86
87    /// Set the default variant for all setting groups.
88    ///
89    /// All setting groups will use this variant unless overridden individually.
90    pub fn with_group_variant(mut self, variant: GroupBoxVariant) -> Self {
91        self.group_variant = variant;
92        self
93    }
94
95    /// Set the style refinement for the sidebar.
96    pub fn sidebar_style(mut self, style: &StyleRefinement) -> Self {
97        self.sidebar_style = style.clone();
98        self
99    }
100
101    /// Set the default index of the page to be selected.
102    pub fn default_selected_index(mut self, index: SelectIndex) -> Self {
103        self.default_selected_index = index;
104        self
105    }
106
107    /// Set the style refinement for the header.
108    pub fn header_style(mut self, style: &StyleRefinement) -> Self {
109        self.header_style = style.clone();
110        self
111    }
112
113    fn render_active_page(
114        &self,
115        state: &Entity<SettingsState>,
116        filter: &SettingsFilter,
117        options: &RenderOptions,
118        window: &mut Window,
119        cx: &mut App,
120    ) -> gpui::AnyElement {
121        let page_ix = state.read(cx).selected_index.page_ix;
122        if let Some(page) = self.pages.get(page_ix)
123            && !filter.groups[page_ix].is_empty()
124        {
125            return page
126                .render(page_ix, &filter.groups[page_ix], state, options, window, cx)
127                .into_any_element();
128        }
129
130        if filter.visible_pages().next().is_none() {
131            return div()
132                .size_full()
133                .debug_selector(|| "settings-no-results".into())
134                .child(Self::render_no_results())
135                .into_any_element();
136        }
137
138        div().into_any_element()
139    }
140
141    fn render_no_results() -> impl IntoElement {
142        Empty::new().size_full().header(
143            EmptyHeader::new()
144                .media(
145                    EmptyMedia::new()
146                        .with_variant(EmptyMediaVariant::Icon)
147                        .child(Icon::new(IconName::Search)),
148                )
149                .title(EmptyTitle::new().child(t!("Settings.no_results")))
150                .description(EmptyDescription::new().child(t!("Settings.no_results_description"))),
151        )
152    }
153
154    fn render_sidebar(
155        &self,
156        state: &Entity<SettingsState>,
157        filter: &SettingsFilter,
158        _: &mut Window,
159        cx: &mut App,
160    ) -> impl IntoElement {
161        let selected_index = state.read(cx).selected_index;
162        let search_input = state.read(cx).search_input.clone();
163
164        Sidebar::new("settings-sidebar")
165            .w(relative(1.))
166            .border_0()
167            .refine_style(&self.sidebar_style)
168            .collapsible(false)
169            .collapsed(false)
170            .header(
171                div()
172                    .w_full()
173                    .refine_style(&self.header_style)
174                    .child(Input::new(&search_input).prefix(IconName::Search)),
175            )
176            .child(
177                SidebarMenu::new().children(filter.visible_pages().map(|page_ix| {
178                    let page = &self.pages[page_ix];
179                    let groups = &filter.groups[page_ix];
180                    let is_page_active = selected_index.page_ix == page_ix
181                        && (selected_index.group_ix.is_none() || groups.len() == 1);
182                    SidebarMenuItem::new(page.title.clone())
183                        .click_to_open(true)
184                        .when_some(page.icon.clone(), |this, icon| this.icon(icon))
185                        .default_open(page.default_open)
186                        .active(is_page_active)
187                        .on_click({
188                            let state = state.clone();
189                            move |_, _, cx| {
190                                state.update(cx, |state, cx| {
191                                    state.selected_index = SelectIndex {
192                                        page_ix,
193                                        ..Default::default()
194                                    };
195                                    state.deferred_scroll_group_ix = None;
196                                    cx.notify();
197                                })
198                            }
199                        })
200                        .when(groups.len() > 1, |this| {
201                            this.children(
202                                groups
203                                    .iter()
204                                    .copied()
205                                    .filter(|&ix| page.groups[ix].title.is_some())
206                                    .map(|group_ix| {
207                                        let group = &page.groups[group_ix];
208                                        let is_active = selected_index.page_ix == page_ix
209                                            && selected_index.group_ix == Some(group_ix);
210                                        let title = group.title.clone().unwrap_or_default();
211
212                                        SidebarMenuItem::new(title).active(is_active).on_click({
213                                            let state = state.clone();
214                                            move |_, _, cx| {
215                                                state.update(cx, |state, cx| {
216                                                    state.selected_index = SelectIndex {
217                                                        page_ix,
218                                                        group_ix: Some(group_ix),
219                                                    };
220                                                    state.deferred_scroll_group_ix = Some(group_ix);
221                                                    cx.notify();
222                                                })
223                                            }
224                                        })
225                                    }),
226                            )
227                        })
228                })),
229            )
230    }
231}
232
233impl Sizable for Settings {
234    fn with_size(mut self, size: impl Into<Size>) -> Self {
235        self.size = size.into();
236        self
237    }
238}
239
240/// Visible groups in each original page. Filtering never renumbers source data.
241struct SettingsFilter {
242    groups: Vec<Vec<usize>>,
243}
244
245impl SettingsFilter {
246    fn new(pages: &[SettingPage], query: &str, cx: &App) -> Self {
247        Self {
248            groups: pages
249                .iter()
250                .map(|page| {
251                    page.groups
252                        .iter()
253                        .enumerate()
254                        .filter_map(|(ix, group)| group.is_match(query, cx).then_some(ix))
255                        .collect()
256                })
257                .collect(),
258        }
259    }
260
261    fn visible_pages(&self) -> impl Iterator<Item = usize> + '_ {
262        self.groups
263            .iter()
264            .enumerate()
265            .filter_map(|(ix, groups)| (!groups.is_empty()).then_some(ix))
266    }
267
268    fn selected_index(&self, selected: SelectIndex) -> SelectIndex {
269        let page_ix = self
270            .visible_pages()
271            .find(|&ix| ix == selected.page_ix)
272            .or_else(|| self.visible_pages().next());
273        let Some(page_ix) = page_ix else {
274            // Keep the selection while there are no results so clearing the query
275            // can restore it. The empty filter prevents rendering a stale page.
276            return selected;
277        };
278
279        SelectIndex {
280            page_ix,
281            group_ix: selected
282                .group_ix
283                .filter(|ix| page_ix == selected.page_ix && self.groups[page_ix].contains(ix)),
284        }
285    }
286}
287
288pub(super) struct SettingsState {
289    pub(super) selected_index: SelectIndex,
290    /// If set, defer scrolling to this group index after rendering.
291    pub(super) deferred_scroll_group_ix: Option<usize>,
292    pub(super) search_input: Entity<InputState>,
293}
294
295/// Options for rendering setting item.
296///
297/// The fields are private and reached through the methods below, so that a new
298/// one can be added without breaking the item renderers. The setters take
299/// `self` by value, so a nested renderer narrows a copy of its parent options:
300///
301/// ```ignore
302/// item.render_item(&options.with_item_ix(item_ix), window, cx)
303/// ```
304#[derive(Clone, Copy)]
305pub struct RenderOptions {
306    page_ix: usize,
307    group_ix: usize,
308    item_ix: usize,
309    size: Size,
310    group_variant: GroupBoxVariant,
311    layout: Axis,
312    disabled: bool,
313}
314
315impl RenderOptions {
316    pub fn new() -> Self {
317        Self {
318            page_ix: 0,
319            group_ix: 0,
320            item_ix: 0,
321            size: Size::default(),
322            group_variant: GroupBoxVariant::default(),
323            layout: Axis::Horizontal,
324            disabled: false,
325        }
326    }
327
328    pub fn with_page_ix(mut self, page_ix: usize) -> Self {
329        self.page_ix = page_ix;
330        self
331    }
332
333    pub fn with_group_ix(mut self, group_ix: usize) -> Self {
334        self.group_ix = group_ix;
335        self
336    }
337
338    pub fn with_item_ix(mut self, item_ix: usize) -> Self {
339        self.item_ix = item_ix;
340        self
341    }
342
343    pub fn with_size(mut self, size: Size) -> Self {
344        self.size = size;
345        self
346    }
347
348    pub fn with_group_variant(mut self, group_variant: GroupBoxVariant) -> Self {
349        self.group_variant = group_variant;
350        self
351    }
352
353    pub fn with_layout(mut self, layout: Axis) -> Self {
354        self.layout = layout;
355        self
356    }
357
358    pub fn with_disabled(mut self, disabled: bool) -> Self {
359        self.disabled = disabled;
360        self
361    }
362
363    pub fn page_ix(&self) -> usize {
364        self.page_ix
365    }
366
367    pub fn group_ix(&self) -> usize {
368        self.group_ix
369    }
370
371    pub fn item_ix(&self) -> usize {
372        self.item_ix
373    }
374
375    pub fn size(&self) -> Size {
376        self.size
377    }
378
379    pub fn group_variant(&self) -> GroupBoxVariant {
380        self.group_variant
381    }
382
383    pub fn layout(&self) -> Axis {
384        self.layout
385    }
386
387    pub fn is_disabled(&self) -> bool {
388        self.disabled
389    }
390}
391
392impl Default for RenderOptions {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398#[derive(Clone, Copy, Default)]
399pub struct SelectIndex {
400    pub page_ix: usize,
401    pub group_ix: Option<usize>,
402}
403
404impl RenderOnce for Settings {
405    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
406        let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
407            let search_input = cx.new(|cx| {
408                InputState::new(window, cx)
409                    .placeholder(t!("Settings.search_placeholder"))
410                    .default_value("")
411            });
412
413            SettingsState {
414                search_input,
415                selected_index: self.default_selected_index,
416                deferred_scroll_group_ix: None,
417            }
418        });
419
420        let query = state.read(cx).search_input.read(cx).value();
421        let filter = SettingsFilter::new(&self.pages, &query, cx);
422        let previous = state.read(cx).selected_index;
423        let selected = filter.selected_index(previous);
424        if selected.page_ix != previous.page_ix || selected.group_ix != previous.group_ix {
425            state.update(cx, |state, _| {
426                state.selected_index = selected;
427                state.deferred_scroll_group_ix = None;
428            });
429        }
430        let options = RenderOptions::new()
431            .with_size(self.size)
432            .with_group_variant(self.group_variant);
433        let sidebar_size_range = self.sidebar_size_range.clone();
434        let sidebar = self
435            .render_sidebar(&state, &filter, window, cx)
436            .into_any_element();
437
438        h_resizable(self.id.clone())
439            .child(
440                resizable_panel()
441                    .size(self.sidebar_width)
442                    .size_range(sidebar_size_range)
443                    .child(sidebar),
444            )
445            .child(
446                resizable_panel().child(container_query(move |size, window, cx| {
447                    let options = options.with_layout(if size.width <= STACKED_LAYOUT_MAX_WIDTH {
448                        Axis::Vertical
449                    } else {
450                        Axis::Horizontal
451                    });
452                    self.render_active_page(&state, &filter, &options, window, cx)
453                })),
454            )
455    }
456}
457
458#[cfg(test)]
459#[path = "tests.rs"]
460mod tests;