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::{Escape, 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                    // The input passes on an Escape it has no use for. Clear a
175                    // non-empty query with it; otherwise let it reach an enclosing
176                    // dialog or window. On Android, Escape is the back button,
177                    // which never edits a field.
178                    .on_action({
179                        let search_input = search_input.clone();
180                        move |_: &Escape, window, cx| {
181                            if search_input.read(cx).value().is_empty()
182                                || cfg!(target_os = "android")
183                            {
184                                cx.propagate();
185                                return;
186                            }
187                            search_input.update(cx, |input, cx| input.clean(window, cx));
188                        }
189                    })
190                    .child(Input::new(&search_input).prefix(IconName::Search)),
191            )
192            .child(
193                SidebarMenu::new().children(filter.visible_pages().map(|page_ix| {
194                    let page = &self.pages[page_ix];
195                    let groups = &filter.groups[page_ix];
196                    let is_page_active = selected_index.page_ix == page_ix
197                        && (selected_index.group_ix.is_none() || groups.len() == 1);
198                    SidebarMenuItem::new(page.title.clone())
199                        .click_to_open(true)
200                        .when_some(page.icon.clone(), |this, icon| this.icon(icon))
201                        .default_open(page.default_open)
202                        .active(is_page_active)
203                        .on_click({
204                            let state = state.clone();
205                            move |_, _, cx| {
206                                state.update(cx, |state, cx| {
207                                    state.selected_index = SelectIndex {
208                                        page_ix,
209                                        ..Default::default()
210                                    };
211                                    state.deferred_scroll_group_ix = None;
212                                    cx.notify();
213                                })
214                            }
215                        })
216                        .when(groups.len() > 1, |this| {
217                            this.children(
218                                groups
219                                    .iter()
220                                    .copied()
221                                    .filter(|&ix| page.groups[ix].title.is_some())
222                                    .map(|group_ix| {
223                                        let group = &page.groups[group_ix];
224                                        let is_active = selected_index.page_ix == page_ix
225                                            && selected_index.group_ix == Some(group_ix);
226                                        let title = group.title.clone().unwrap_or_default();
227
228                                        SidebarMenuItem::new(title).active(is_active).on_click({
229                                            let state = state.clone();
230                                            move |_, _, cx| {
231                                                state.update(cx, |state, cx| {
232                                                    state.selected_index = SelectIndex {
233                                                        page_ix,
234                                                        group_ix: Some(group_ix),
235                                                    };
236                                                    state.deferred_scroll_group_ix = Some(group_ix);
237                                                    cx.notify();
238                                                })
239                                            }
240                                        })
241                                    }),
242                            )
243                        })
244                })),
245            )
246    }
247}
248
249impl Sizable for Settings {
250    fn with_size(mut self, size: impl Into<Size>) -> Self {
251        self.size = size.into();
252        self
253    }
254}
255
256/// Visible groups in each original page. Filtering never renumbers source data.
257struct SettingsFilter {
258    groups: Vec<Vec<usize>>,
259}
260
261impl SettingsFilter {
262    fn new(pages: &[SettingPage], query: &str, cx: &App) -> Self {
263        Self {
264            groups: pages
265                .iter()
266                .map(|page| {
267                    page.groups
268                        .iter()
269                        .enumerate()
270                        .filter_map(|(ix, group)| group.is_match(query, cx).then_some(ix))
271                        .collect()
272                })
273                .collect(),
274        }
275    }
276
277    fn visible_pages(&self) -> impl Iterator<Item = usize> + '_ {
278        self.groups
279            .iter()
280            .enumerate()
281            .filter_map(|(ix, groups)| (!groups.is_empty()).then_some(ix))
282    }
283
284    fn selected_index(&self, selected: SelectIndex) -> SelectIndex {
285        let page_ix = self
286            .visible_pages()
287            .find(|&ix| ix == selected.page_ix)
288            .or_else(|| self.visible_pages().next());
289        let Some(page_ix) = page_ix else {
290            // Keep the selection while there are no results so clearing the query
291            // can restore it. The empty filter prevents rendering a stale page.
292            return selected;
293        };
294
295        SelectIndex {
296            page_ix,
297            group_ix: selected
298                .group_ix
299                .filter(|ix| page_ix == selected.page_ix && self.groups[page_ix].contains(ix)),
300        }
301    }
302}
303
304pub(super) struct SettingsState {
305    pub(super) selected_index: SelectIndex,
306    /// If set, defer scrolling to this group index after rendering.
307    pub(super) deferred_scroll_group_ix: Option<usize>,
308    pub(super) search_input: Entity<InputState>,
309}
310
311/// Options for rendering setting item.
312///
313/// The fields are private and reached through the methods below, so that a new
314/// one can be added without breaking the item renderers. The setters take
315/// `self` by value, so a nested renderer narrows a copy of its parent options:
316///
317/// ```ignore
318/// item.render_item(&options.with_item_ix(item_ix), window, cx)
319/// ```
320#[derive(Clone, Copy)]
321pub struct RenderOptions {
322    page_ix: usize,
323    group_ix: usize,
324    item_ix: usize,
325    size: Size,
326    group_variant: GroupBoxVariant,
327    layout: Axis,
328    disabled: bool,
329}
330
331impl RenderOptions {
332    pub fn new() -> Self {
333        Self {
334            page_ix: 0,
335            group_ix: 0,
336            item_ix: 0,
337            size: Size::default(),
338            group_variant: GroupBoxVariant::default(),
339            layout: Axis::Horizontal,
340            disabled: false,
341        }
342    }
343
344    pub fn with_page_ix(mut self, page_ix: usize) -> Self {
345        self.page_ix = page_ix;
346        self
347    }
348
349    pub fn with_group_ix(mut self, group_ix: usize) -> Self {
350        self.group_ix = group_ix;
351        self
352    }
353
354    pub fn with_item_ix(mut self, item_ix: usize) -> Self {
355        self.item_ix = item_ix;
356        self
357    }
358
359    pub fn with_size(mut self, size: Size) -> Self {
360        self.size = size;
361        self
362    }
363
364    pub fn with_group_variant(mut self, group_variant: GroupBoxVariant) -> Self {
365        self.group_variant = group_variant;
366        self
367    }
368
369    pub fn with_layout(mut self, layout: Axis) -> Self {
370        self.layout = layout;
371        self
372    }
373
374    pub fn with_disabled(mut self, disabled: bool) -> Self {
375        self.disabled = disabled;
376        self
377    }
378
379    pub fn page_ix(&self) -> usize {
380        self.page_ix
381    }
382
383    pub fn group_ix(&self) -> usize {
384        self.group_ix
385    }
386
387    pub fn item_ix(&self) -> usize {
388        self.item_ix
389    }
390
391    pub fn size(&self) -> Size {
392        self.size
393    }
394
395    pub fn group_variant(&self) -> GroupBoxVariant {
396        self.group_variant
397    }
398
399    pub fn layout(&self) -> Axis {
400        self.layout
401    }
402
403    pub fn is_disabled(&self) -> bool {
404        self.disabled
405    }
406}
407
408impl Default for RenderOptions {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414#[derive(Clone, Copy, Default)]
415pub struct SelectIndex {
416    pub page_ix: usize,
417    pub group_ix: Option<usize>,
418}
419
420impl RenderOnce for Settings {
421    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
422        let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
423            let search_input = cx.new(|cx| {
424                InputState::new(window, cx)
425                    .placeholder(t!("Settings.search_placeholder"))
426                    .default_value("")
427            });
428
429            SettingsState {
430                search_input,
431                selected_index: self.default_selected_index,
432                deferred_scroll_group_ix: None,
433            }
434        });
435
436        // The placeholder is localized when the state is created, and rebuilding
437        // the state would lose the selected page, so follow a runtime locale
438        // change here. `set_placeholder` notifies, so only write on a change.
439        let placeholder = t!("Settings.search_placeholder");
440        let search_input = state.read(cx).search_input.clone();
441        if search_input.read(cx).presentation().placeholder() != &*placeholder {
442            search_input.update(cx, |input, cx| {
443                input.set_placeholder(placeholder, window, cx);
444            });
445        }
446
447        let query = search_input.read(cx).value();
448        let filter = SettingsFilter::new(&self.pages, &query, cx);
449        let previous = state.read(cx).selected_index;
450        let selected = filter.selected_index(previous);
451        if selected.page_ix != previous.page_ix || selected.group_ix != previous.group_ix {
452            state.update(cx, |state, _| {
453                state.selected_index = selected;
454                state.deferred_scroll_group_ix = None;
455            });
456        }
457        let options = RenderOptions::new()
458            .with_size(self.size)
459            .with_group_variant(self.group_variant);
460        let sidebar_size_range = self.sidebar_size_range.clone();
461        let sidebar = self
462            .render_sidebar(&state, &filter, window, cx)
463            .into_any_element();
464
465        h_resizable(self.id.clone())
466            .child(
467                resizable_panel()
468                    .size(self.sidebar_width)
469                    .size_range(sidebar_size_range)
470                    .child(sidebar),
471            )
472            .child(
473                resizable_panel().child(container_query(move |size, window, cx| {
474                    let options = options.with_layout(if size.width <= STACKED_LAYOUT_MAX_WIDTH {
475                        Axis::Vertical
476                    } else {
477                        Axis::Horizontal
478                    });
479                    self.render_active_page(&state, &filter, &options, window, cx)
480                })),
481            )
482    }
483}
484
485#[cfg(test)]
486#[path = "tests.rs"]
487mod tests;