Skip to main content

gpui_component/setting/
page.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Entity, InteractiveElement as _, IntoElement, ListAlignment, ListOffset,
5    ListState, ParentElement as _, SharedString, StyleRefinement, Styled, Window, div, list,
6    prelude::FluentBuilder as _, px,
7};
8use rust_i18n::t;
9
10use crate::{
11    ActiveTheme, Icon, IconName, Sizable, StyledExt,
12    button::{Button, ButtonVariants},
13    h_flex,
14    label::Label,
15    scroll::ScrollableElement,
16    setting::{RenderOptions, SettingGroup, settings::SettingsState},
17    v_flex,
18};
19
20/// A setting page that can contain multiple setting groups.
21#[derive(Clone)]
22pub struct SettingPage {
23    pub(super) icon: Option<Icon>,
24    resettable: bool,
25    pub(super) default_open: bool,
26    pub(super) title: SharedString,
27    pub(super) title_suffix: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
28    pub(super) description: Option<SharedString>,
29    pub(super) groups: Vec<SettingGroup>,
30    pub(super) header_style: StyleRefinement,
31}
32
33impl SettingPage {
34    pub fn new(title: impl Into<SharedString>) -> Self {
35        Self {
36            icon: None,
37            resettable: true,
38            default_open: false,
39            title: title.into(),
40            title_suffix: None,
41            description: None,
42            groups: Vec::new(),
43            header_style: StyleRefinement::default(),
44        }
45    }
46
47    /// Set the title of the setting page.
48    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
49        self.title = title.into();
50        self
51    }
52
53    /// Set a custom element to render after the title in the page header.
54    ///
55    /// For example, an info icon button that opens the help documentation.
56    pub fn title_suffix<F, E>(mut self, suffix: F) -> Self
57    where
58        E: IntoElement,
59        F: Fn(&mut Window, &mut App) -> E + 'static,
60    {
61        self.title_suffix = Some(Rc::new(move |window, cx| {
62            suffix(window, cx).into_any_element()
63        }));
64        self
65    }
66
67    /// Set the icon of the setting page.
68    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
69        self.icon = Some(icon.into());
70        self
71    }
72
73    /// Set the description of the setting page, default is None.
74    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
75        self.description = Some(description.into());
76        self
77    }
78
79    /// Set the default open state of the setting page, default is false.
80    pub fn default_open(mut self, default_open: bool) -> Self {
81        self.default_open = default_open;
82        self
83    }
84
85    /// Set whether the setting page is resettable, default is true.
86    ///
87    /// If true and the items in this page has changed, the reset button will appear.
88    pub fn resettable(mut self, resettable: bool) -> Self {
89        self.resettable = resettable;
90        self
91    }
92
93    /// Add a setting group to the page.
94    pub fn group(mut self, group: SettingGroup) -> Self {
95        self.groups.push(group);
96        self
97    }
98
99    /// Add multiple setting groups to the page.
100    pub fn groups(mut self, groups: impl IntoIterator<Item = SettingGroup>) -> Self {
101        self.groups.extend(groups);
102        self
103    }
104
105    /// Set the style refinement for the header of the setting page.
106    pub fn header_style(mut self, style: &StyleRefinement) -> Self {
107        self.header_style = style.clone();
108        self
109    }
110
111    fn is_resettable(&self, query: &str, cx: &App) -> bool {
112        self.resettable
113            && self
114                .groups
115                .iter()
116                .any(|group| group.is_resettable(query, cx))
117    }
118
119    fn reset_all(&self, query: &str, window: &mut Window, cx: &mut App) {
120        for group in &self.groups {
121            group.reset(query, window, cx);
122        }
123    }
124
125    /// The group the sidebar marks for the given scroll position: the titled
126    /// group at or above the top of the viewport. At the end of the list the
127    /// last groups cannot reach the top, so a group selected below the top
128    /// stays marked, and the last group is marked otherwise. `None` marks the
129    /// page itself.
130    fn group_at_scroll_top(
131        &self,
132        group_indices: &[usize],
133        selected_group_ix: Option<usize>,
134        scroll_top: ListOffset,
135        is_scrolled_to_end: bool,
136    ) -> Option<usize> {
137        let selected_pos =
138            selected_group_ix.and_then(|ix| group_indices.iter().position(|&g| g == ix));
139        if is_scrolled_to_end && selected_pos.is_some_and(|pos| pos > scroll_top.item_ix) {
140            return selected_group_ix;
141        }
142
143        let top_pos = if is_scrolled_to_end {
144            group_indices.len().saturating_sub(1)
145        } else {
146            scroll_top.item_ix
147        };
148        group_indices
149            .iter()
150            .take(top_pos + 1)
151            .rev()
152            .copied()
153            .find(|&ix| self.groups[ix].title.is_some())
154    }
155
156    pub(super) fn render(
157        &self,
158        ix: usize,
159        group_indices: &[usize],
160        state: &Entity<SettingsState>,
161        options: &RenderOptions,
162        window: &mut Window,
163        cx: &mut App,
164    ) -> impl IntoElement {
165        let search_input = state.read(cx).search_input.clone();
166        let query = search_input.read(cx).value();
167        let groups = group_indices
168            .iter()
169            .map(|&ix| (ix, self.groups[ix].clone()))
170            .collect::<Vec<_>>();
171        let groups_count = groups.len();
172
173        let page_state = window.use_keyed_state(
174            SharedString::from(format!("list-state:{}", ix)),
175            cx,
176            |_, _| PageState {
177                list: ListState::new(groups_count, ListAlignment::Top, px(100.)),
178                query: query.clone(),
179                groups: group_indices.to_vec(),
180                scroll_top: ListOffset::default(),
181                scrolled_programmatically: false,
182            },
183        );
184        let changed =
185            page_state.read(cx).query != query || page_state.read(cx).groups != group_indices;
186        let list_state = page_state.read(cx).list.clone();
187        if changed {
188            page_state.update(cx, |state, _| {
189                state.list.reset(groups_count);
190                state.query = query.clone();
191                state.groups = group_indices.to_vec();
192            });
193        }
194
195        let deferred_scroll_group_ix = state.read(cx).deferred_scroll_group_ix;
196        let scroll_group_ix = deferred_scroll_group_ix.or_else(|| {
197            changed
198                .then_some(state.read(cx).selected_index.group_ix)
199                .flatten()
200        });
201        if deferred_scroll_group_ix.is_some() {
202            state.update(cx, |state, _| state.deferred_scroll_group_ix = None);
203        }
204        if let Some(group_ix) = scroll_group_ix
205            && let Some(visible_ix) = group_indices.iter().position(|&ix| ix == group_ix)
206        {
207            // Scroll by index: after a page switch the list is rebuilt unmeasured,
208            // and `scroll_to_reveal_item` would resolve to the top.
209            list_state.scroll_to(ListOffset {
210                item_ix: visible_ix,
211                offset_in_item: px(0.),
212            });
213        }
214
215        // Follow the scroll position in the sidebar, like a table of contents.
216        let scroll_top = list_state.logical_scroll_top();
217        let programmatic = changed || scroll_group_ix.is_some();
218        let scrolled = !programmatic
219            && !page_state.read(cx).scrolled_programmatically
220            && page_state.read(cx).scroll_top != scroll_top;
221        page_state.update(cx, |state, _| {
222            state.scroll_top = scroll_top;
223            state.scrolled_programmatically = programmatic;
224        });
225        if scrolled {
226            let selected_group_ix = state.read(cx).selected_index.group_ix;
227            let group_ix = self.group_at_scroll_top(
228                group_indices,
229                selected_group_ix,
230                scroll_top,
231                list_state.is_scrolled_to_end() == Some(true),
232            );
233            if group_ix != selected_group_ix {
234                state.update(cx, |state, _| state.selected_index.group_ix = group_ix);
235                // The sidebar has already been rendered in this frame.
236                window.refresh();
237            }
238        }
239
240        v_flex()
241            .id(ix)
242            .size_full()
243            .child(
244                v_flex()
245                    .p_4()
246                    .gap_3()
247                    .border_b_1()
248                    .border_color(cx.theme().border)
249                    .refine_style(&self.header_style)
250                    .child(
251                        h_flex()
252                            .justify_between()
253                            .child(
254                                h_flex()
255                                    .gap_1()
256                                    .child(self.title.clone())
257                                    .when_some(self.title_suffix.clone(), |this, suffix| {
258                                        this.child(suffix(window, cx))
259                                    }),
260                            )
261                            .when(self.is_resettable(&query, cx), |this| {
262                                this.child(
263                                    Button::new("reset")
264                                        .icon(IconName::Undo2)
265                                        .ghost()
266                                        .small()
267                                        .tooltip(t!("Settings.Reset All"))
268                                        .on_click({
269                                            let page = self.clone();
270                                            let query = query.clone();
271                                            move |_, window, cx| {
272                                                page.reset_all(&query, window, cx);
273                                            }
274                                        }),
275                                )
276                            }),
277                    )
278                    .when_some(self.description.clone(), |this, description| {
279                        this.child(
280                            Label::new(description)
281                                .text_sm()
282                                .text_color(cx.theme().muted_foreground),
283                        )
284                    }),
285            )
286            .child(
287                div()
288                    .relative()
289                    .flex_1()
290                    .w_full()
291                    .child(gpui_base::ScrollBounce::new(
292                        "bounce",
293                        &list_state,
294                        list(list_state.clone(), {
295                            let query = query.clone();
296                            let options = *options;
297                            move |visible_ix, window, cx| {
298                                let (group_ix, group) = groups[visible_ix].clone();
299                                // Keep horizontal padding inside the list's clip bounds
300                                // so focus rings can extend beyond the group's edges.
301                                div()
302                                    .w_full()
303                                    .px_4()
304                                    .child(group.py_4().render(
305                                        &query,
306                                        &options.with_page_ix(ix).with_group_ix(group_ix),
307                                        window,
308                                        cx,
309                                    ))
310                                    .into_any_element()
311                            }
312                        })
313                        .size_full(),
314                    ))
315                    .vertical_scrollbar(&list_state),
316            )
317    }
318}
319
320struct PageState {
321    list: ListState,
322    query: SharedString,
323    groups: Vec<usize>,
324    /// The scroll position seen by the last render.
325    scroll_top: ListOffset,
326    /// Set when the page scrolls itself, so the clamping applied by the next
327    /// layout is not mistaken for the user moving through the page.
328    scrolled_programmatically: bool,
329}