Skip to main content

gpui_kit/controls/
settings_row.rs

1//! The row every preferences page is made of, and the section it sits in.
2//!
3//! A setting the typist cannot change is the thing settings pages get wrong.
4//! `SettingsRow::managed` renders the value the policy holds, names who holds
5//! it, and never renders the caller's control — so there is no handler to
6//! install and nothing on screen that looks operable and is not.
7
8use std::rc::Rc;
9
10use gpui::{
11    AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
12    prelude::FluentBuilder, px,
13};
14use gpui_kit_assets::{Icon, icon};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, Theme, TypeScale};
17
18use crate::display::badge::Badge;
19use crate::foundation::{Ident, StyledExt, text as foundation_text};
20use crate::strings::{ActiveStrings, StringKey};
21
22/// Why a row is showing its value instead of its control.
23#[derive(Debug, Clone, PartialEq, Eq)]
24enum Withheld {
25    /// Held by policy: someone else decides this, and this is who.
26    Managed(SharedString),
27    /// The whole group it belongs to does not apply here.
28    Inapplicable(SharedString),
29}
30
31impl Withheld {
32    fn as_str(&self) -> &'static str {
33        match self {
34            Self::Managed(_) => "managed",
35            Self::Inapplicable(_) => "inapplicable",
36        }
37    }
38
39    /// What the row says where its control would have been.
40    ///
41    /// An inapplicable row says only that it does not apply: the reason
42    /// belongs to the whole section, which states it once above the rows
43    /// rather than once per row.
44    fn sentence(&self, cx: &App) -> SharedString {
45        match self {
46            Self::Managed(controller) => cx
47                .strings()
48                .format(StringKey::SettingsManagedBy, &[controller.as_ref()]),
49            Self::Inapplicable(_) => cx.strings().text(StringKey::SettingsInapplicable),
50        }
51    }
52
53    fn glyph(&self) -> Icon {
54        match self {
55            Self::Managed(_) => Icon::Key,
56            Self::Inapplicable(_) => Icon::Info,
57        }
58    }
59}
60
61/// One setting: what it is called on the left, what it is set to on the right.
62#[derive(IntoElement)]
63pub struct SettingsRow {
64    ident: Ident,
65    label: SharedString,
66    description: Option<SharedString>,
67    badge: Option<SharedString>,
68    /// What the setting currently holds, in the caller's words.
69    value: Option<SharedString>,
70    control: Option<AnyElement>,
71    withheld: Option<Withheld>,
72}
73
74impl std::fmt::Debug for SettingsRow {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter
77            .debug_struct("SettingsRow")
78            .field("ident", &self.ident)
79            .field("label", &self.label)
80            .field("badge", &self.badge)
81            .field("withheld", &self.withheld)
82            .field("has_control", &self.control.is_some())
83            .finish()
84    }
85}
86
87impl SettingsRow {
88    pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
89        Self {
90            ident: ident.into(),
91            label: label.into(),
92            description: None,
93            badge: None,
94            value: None,
95            control: None,
96            withheld: None,
97        }
98    }
99
100    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
101        self.description = Some(description.into());
102        self
103    }
104
105    /// A short note beside the label, such as "Requires restart".
106    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
107        self.badge = Some(badge.into());
108        self
109    }
110
111    /// What the setting holds, published whether or not the control is drawn.
112    /// A managed row has nothing else to show, so it is the only reading the
113    /// typist gets.
114    pub fn value(mut self, value: impl Into<SharedString>) -> Self {
115        self.value = Some(value.into());
116        self
117    }
118
119    pub fn control(mut self, control: impl IntoElement) -> Self {
120        self.control = Some(control.into_any_element());
121        self
122    }
123
124    /// Marks the setting as decided elsewhere, and by whom.
125    ///
126    /// The control is not rendered at all — a managed setting installs no
127    /// handler because there is nothing there to install one on — and the row
128    /// states the value and its controller instead.
129    pub fn managed(mut self, controller: impl Into<SharedString>) -> Self {
130        self.withheld = Some(Withheld::Managed(controller.into()));
131        self
132    }
133
134    fn inapplicable(mut self, reason: SharedString) -> Self {
135        if self.withheld.is_none() {
136            self.withheld = Some(Withheld::Inapplicable(reason));
137        }
138        self
139    }
140
141    fn render_in(self, theme: &Theme, cx: &mut App) -> AnyElement {
142        let withheld = self.withheld.clone();
143        let ident = self.ident.clone();
144
145        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row).text(self.label.clone());
146        if let Some(value) = self.value.clone() {
147            spec = spec.value(value);
148        }
149        if withheld.is_some() {
150            spec = spec.disabled(true);
151        }
152
153        let names = div()
154            .column()
155            .flex_1()
156            .min_w_0()
157            .gap(px(2.0))
158            .child(
159                foundation_text(theme, TypeScale::Label, self.label.clone())
160                    .row()
161                    .gap_token(theme, Space::Sm)
162                    .children(
163                        self.badge
164                            .clone()
165                            .map(|badge| Badge::new(badge).id(ident.child("badge")).warning()),
166                    ),
167            )
168            .children(self.description.clone().map(|description| {
169                foundation_text(theme, TypeScale::Caption, description)
170                    .text_tone(theme, gpui_kit_theme::TextTone::Muted)
171            }));
172
173        // A withheld row shows what is set and who set it. The control never
174        // reaches the tree, so nothing can be operated by mistake.
175        let right = match (&withheld, self.control) {
176            (Some(withheld), _) => div()
177                .column()
178                .items_end()
179                .flex_none()
180                .gap(px(2.0))
181                .children(self.value.clone().map(|value| {
182                    foundation_text(theme, TypeScale::Label, value)
183                        .text_tone(theme, gpui_kit_theme::TextTone::Muted)
184                }))
185                .child(
186                    div()
187                        .row()
188                        .gap(px(theme.space(Space::Xs)))
189                        .child(
190                            icon(withheld.glyph())
191                                .size(px(theme.control.xs.icon_size))
192                                .text_color(theme.colors.text_faint),
193                        )
194                        .child(
195                            foundation_text(theme, TypeScale::Caption, withheld.sentence(cx))
196                                .text_tone(theme, gpui_kit_theme::TextTone::Faint),
197                        )
198                        .semantic_in(
199                            cx,
200                            NodeSpec::new(ident.child("managed").semantic_id(), Role::Status)
201                                .parent(ident.semantic_id())
202                                .text(withheld.sentence(cx))
203                                .value(withheld.as_str()),
204                        ),
205                )
206                .into_any_element(),
207            (None, Some(control)) => div().flex_none().child(control).into_any_element(),
208            (None, None) => div()
209                .flex_none()
210                .children(self.value.clone().map(|value| {
211                    foundation_text(theme, TypeScale::Label, value)
212                        .text_tone(theme, gpui_kit_theme::TextTone::Muted)
213                }))
214                .into_any_element(),
215        };
216
217        div()
218            .row()
219            .w_full()
220            .items_center()
221            .gap_token(theme, Space::Md)
222            .px_token(theme, Space::Lg)
223            .py_token(theme, Space::Md)
224            .child(names)
225            .child(right)
226            .semantic_in(cx, spec)
227            .into_any_element()
228    }
229}
230
231impl RenderOnce for SettingsRow {
232    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
233        let theme = cx.theme().clone();
234        self.render_in(&theme, cx)
235    }
236}
237
238type ActionSlot = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
239
240/// A headed group of settings rows.
241#[derive(IntoElement)]
242pub struct SettingsSection {
243    ident: Ident,
244    title: SharedString,
245    description: Option<SharedString>,
246    dimmed: Option<SharedString>,
247    rows: Vec<SettingsRow>,
248    action: Option<ActionSlot>,
249}
250
251impl std::fmt::Debug for SettingsSection {
252    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        formatter
254            .debug_struct("SettingsSection")
255            .field("ident", &self.ident)
256            .field("title", &self.title)
257            .field("dimmed", &self.dimmed)
258            .field("rows", &self.rows.len())
259            .finish()
260    }
261}
262
263impl SettingsSection {
264    pub fn new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self {
265        Self {
266            ident: ident.into(),
267            title: title.into(),
268            description: None,
269            dimmed: None,
270            rows: Vec::new(),
271            action: None,
272        }
273    }
274
275    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
276        self.description = Some(description.into());
277        self
278    }
279
280    /// States that nothing in this group applies here, and why.
281    ///
282    /// A dimmed group renders none of its controls, for the same reason a
283    /// managed row renders none of its own: a setting that cannot take effect
284    /// must not look as though it can.
285    pub fn dimmed_by(mut self, reason: impl Into<SharedString>) -> Self {
286        self.dimmed = Some(reason.into());
287        self
288    }
289
290    pub fn row(mut self, row: SettingsRow) -> Self {
291        self.rows.push(row);
292        self
293    }
294
295    pub fn rows(mut self, rows: impl IntoIterator<Item = SettingsRow>) -> Self {
296        self.rows.extend(rows);
297        self
298    }
299
300    /// A control in the section heading, such as "Reset to defaults".
301    pub fn action(
302        mut self,
303        action: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
304    ) -> Self {
305        self.action = Some(Rc::new(action));
306        self
307    }
308}
309
310impl RenderOnce for SettingsSection {
311    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
312        let theme = cx.theme().clone();
313        let dimmed = self.dimmed.clone();
314        let ident = self.ident.clone();
315
316        let heading = div()
317            .row()
318            .w_full()
319            .gap_token(&theme, Space::Sm)
320            .child(
321                div()
322                    .column()
323                    .flex_1()
324                    .min_w_0()
325                    .gap(px(2.0))
326                    .child(foundation_text(
327                        &theme,
328                        TypeScale::Label,
329                        self.title.clone(),
330                    ))
331                    .children(self.description.clone().map(|description| {
332                        foundation_text(&theme, TypeScale::Caption, description)
333                            .text_tone(&theme, gpui_kit_theme::TextTone::Muted)
334                    })),
335            )
336            .children(
337                self.action
338                    .as_ref()
339                    .filter(|_| dimmed.is_none())
340                    .map(|action| action(window, cx)),
341            );
342
343        let reason = dimmed.clone().map(|reason| {
344            div()
345                .row()
346                .w_full()
347                .gap_token(&theme, Space::Xs)
348                .child(
349                    icon(Icon::Info)
350                        .size(px(theme.control.xs.icon_size))
351                        .text_color(theme.colors.text_faint),
352                )
353                .child(
354                    foundation_text(&theme, TypeScale::Caption, reason.clone())
355                        .text_tone(&theme, gpui_kit_theme::TextTone::Faint),
356                )
357                .semantic_in(
358                    cx,
359                    NodeSpec::new(ident.child("dimmed").semantic_id(), Role::Status)
360                        .parent(ident.semantic_id())
361                        .text(reason)
362                        .value("inapplicable"),
363                )
364        });
365
366        let rows = self.rows.into_iter().map(|row| {
367            let row = match dimmed.clone() {
368                Some(reason) => row.inapplicable(reason),
369                None => row,
370            };
371            row.render_in(&theme, cx)
372        });
373
374        div()
375            .column()
376            .w_full()
377            .gap_token(&theme, Space::Sm)
378            .child(heading)
379            .children(reason)
380            .child(
381                div()
382                    .column()
383                    .w_full()
384                    .radius(&theme, Radius::Card)
385                    .frame(&theme, Surface::Panel, Elevation::Raised)
386                    .overflow_hidden()
387                    .when(dimmed.is_some(), |element| {
388                        element.opacity(theme.opacity.disabled)
389                    })
390                    .children(rows),
391            )
392            .semantic_in(
393                cx,
394                NodeSpec::new(ident.semantic_id(), Role::Group)
395                    .text(self.title.clone())
396                    .disabled(dimmed.is_some()),
397            )
398    }
399}