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::{SettingGroup, 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 filtered_pages(&self, query: &str, cx: &App) -> Vec<SettingPage> {
113        self.pages
114            .iter()
115            .filter_map(|page| {
116                let filtered_groups: Vec<SettingGroup> = page
117                    .groups
118                    .iter()
119                    .filter_map(|group| {
120                        let mut group = group.clone();
121                        group.items = group
122                            .items
123                            .iter()
124                            .filter(|item| item.is_match(&query, cx))
125                            .cloned()
126                            .collect();
127                        if group.items.is_empty() {
128                            None
129                        } else {
130                            Some(group)
131                        }
132                    })
133                    .collect();
134                let mut page = page.clone();
135                page.groups = filtered_groups;
136                if page.groups.is_empty() {
137                    None
138                } else {
139                    Some(page)
140                }
141            })
142            .collect()
143    }
144
145    fn render_active_page(
146        &self,
147        state: &Entity<SettingsState>,
148        pages: &Vec<SettingPage>,
149        options: &RenderOptions,
150        window: &mut Window,
151        cx: &mut App,
152    ) -> gpui::AnyElement {
153        let selected_index = state.read(cx).selected_index;
154
155        for (ix, page) in pages.into_iter().enumerate() {
156            if selected_index.page_ix == ix {
157                return page
158                    .render(ix, state, &options, window, cx)
159                    .into_any_element();
160            }
161        }
162
163        return div().into_any_element();
164    }
165
166    fn render_sidebar(
167        &self,
168        state: &Entity<SettingsState>,
169        pages: &Vec<SettingPage>,
170        _: &mut Window,
171        cx: &mut App,
172    ) -> impl IntoElement {
173        let selected_index = state.read(cx).selected_index;
174        let search_input = state.read(cx).search_input.clone();
175
176        Sidebar::new("settings-sidebar")
177            .w(relative(1.))
178            .border_0()
179            .refine_style(&self.sidebar_style)
180            .collapsible(false)
181            .collapsed(false)
182            .header(
183                div()
184                    .w_full()
185                    .refine_style(&self.header_style)
186                    .child(Input::new(&search_input).prefix(IconName::Search)),
187            )
188            .child(
189                SidebarMenu::new().children(pages.iter().enumerate().map(|(page_ix, page)| {
190                    let is_page_active =
191                        selected_index.page_ix == page_ix && selected_index.group_ix.is_none();
192                    SidebarMenuItem::new(page.title.clone())
193                        .click_to_open(true)
194                        .when_some(page.icon.clone(), |this, icon| this.icon(icon))
195                        .default_open(page.default_open)
196                        .active(is_page_active)
197                        .on_click({
198                            let state = state.clone();
199                            move |_, _, cx| {
200                                state.update(cx, |state, cx| {
201                                    state.selected_index = SelectIndex {
202                                        page_ix,
203                                        ..Default::default()
204                                    };
205                                    cx.notify();
206                                })
207                            }
208                        })
209                        .when(page.groups.len() > 1, |this| {
210                            this.children(
211                                page.groups
212                                    .iter()
213                                    .filter(|g| g.title.is_some())
214                                    .enumerate()
215                                    .map(|(group_ix, group)| {
216                                        let is_active = selected_index.page_ix == page_ix
217                                            && selected_index.group_ix == Some(group_ix);
218                                        let title = group.title.clone().unwrap_or_default();
219
220                                        SidebarMenuItem::new(title).active(is_active).on_click({
221                                            let state = state.clone();
222                                            move |_, _, cx| {
223                                                state.update(cx, |state, cx| {
224                                                    state.selected_index = SelectIndex {
225                                                        page_ix,
226                                                        group_ix: Some(group_ix),
227                                                    };
228                                                    state.deferred_scroll_group_ix = Some(group_ix);
229                                                    cx.notify();
230                                                })
231                                            }
232                                        })
233                                    }),
234                            )
235                        })
236                })),
237            )
238    }
239}
240
241impl Sizable for Settings {
242    fn with_size(mut self, size: impl Into<Size>) -> Self {
243        self.size = size.into();
244        self
245    }
246}
247
248pub(super) struct SettingsState {
249    pub(super) selected_index: SelectIndex,
250    /// If set, defer scrolling to this group index after rendering.
251    pub(super) deferred_scroll_group_ix: Option<usize>,
252    pub(super) search_input: Entity<InputState>,
253}
254
255/// Options for rendering setting item.
256///
257/// The fields are private and reached through the methods below, so that a new
258/// one can be added without breaking the item renderers. The setters take
259/// `self` by value, so a nested renderer narrows a copy of its parent options:
260///
261/// ```ignore
262/// item.render_item(&options.with_item_ix(item_ix), window, cx)
263/// ```
264#[derive(Clone, Copy)]
265pub struct RenderOptions {
266    page_ix: usize,
267    group_ix: usize,
268    item_ix: usize,
269    size: Size,
270    group_variant: GroupBoxVariant,
271    layout: Axis,
272    disabled: bool,
273}
274
275impl RenderOptions {
276    pub fn new() -> Self {
277        Self {
278            page_ix: 0,
279            group_ix: 0,
280            item_ix: 0,
281            size: Size::default(),
282            group_variant: GroupBoxVariant::default(),
283            layout: Axis::Horizontal,
284            disabled: false,
285        }
286    }
287
288    pub fn with_page_ix(mut self, page_ix: usize) -> Self {
289        self.page_ix = page_ix;
290        self
291    }
292
293    pub fn with_group_ix(mut self, group_ix: usize) -> Self {
294        self.group_ix = group_ix;
295        self
296    }
297
298    pub fn with_item_ix(mut self, item_ix: usize) -> Self {
299        self.item_ix = item_ix;
300        self
301    }
302
303    pub fn with_size(mut self, size: Size) -> Self {
304        self.size = size;
305        self
306    }
307
308    pub fn with_group_variant(mut self, group_variant: GroupBoxVariant) -> Self {
309        self.group_variant = group_variant;
310        self
311    }
312
313    pub fn with_layout(mut self, layout: Axis) -> Self {
314        self.layout = layout;
315        self
316    }
317
318    pub fn with_disabled(mut self, disabled: bool) -> Self {
319        self.disabled = disabled;
320        self
321    }
322
323    pub fn page_ix(&self) -> usize {
324        self.page_ix
325    }
326
327    pub fn group_ix(&self) -> usize {
328        self.group_ix
329    }
330
331    pub fn item_ix(&self) -> usize {
332        self.item_ix
333    }
334
335    pub fn size(&self) -> Size {
336        self.size
337    }
338
339    pub fn group_variant(&self) -> GroupBoxVariant {
340        self.group_variant
341    }
342
343    pub fn layout(&self) -> Axis {
344        self.layout
345    }
346
347    pub fn is_disabled(&self) -> bool {
348        self.disabled
349    }
350}
351
352impl Default for RenderOptions {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358#[derive(Clone, Copy, Default)]
359pub struct SelectIndex {
360    pub page_ix: usize,
361    pub group_ix: Option<usize>,
362}
363
364impl RenderOnce for Settings {
365    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
366        let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
367            let search_input = cx.new(|cx| {
368                InputState::new(window, cx)
369                    .placeholder(t!("Settings.search_placeholder"))
370                    .default_value("")
371            });
372
373            SettingsState {
374                search_input,
375                selected_index: self.default_selected_index,
376                deferred_scroll_group_ix: None,
377            }
378        });
379
380        let query = state.read(cx).search_input.read(cx).value();
381        let filtered_pages = self.filtered_pages(&query, cx);
382        let options = RenderOptions::new()
383            .with_size(self.size)
384            .with_group_variant(self.group_variant);
385        let sidebar_size_range = self.sidebar_size_range.clone();
386        let sidebar = self
387            .render_sidebar(&state, &filtered_pages, window, cx)
388            .into_any_element();
389
390        h_resizable(self.id.clone())
391            .child(
392                resizable_panel()
393                    .size(self.sidebar_width)
394                    .size_range(sidebar_size_range)
395                    .child(sidebar),
396            )
397            .child(
398                resizable_panel().child(container_query(move |size, window, cx| {
399                    let options = options.with_layout(if size.width <= STACKED_LAYOUT_MAX_WIDTH {
400                        Axis::Vertical
401                    } else {
402                        Axis::Horizontal
403                    });
404                    self.render_active_page(&state, &filtered_pages, &options, window, cx)
405                })),
406            )
407    }
408}