Skip to main content

gpui_component/setting/
settings.rs

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