1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24enum Withheld {
25 Managed(SharedString),
27 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 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#[derive(IntoElement)]
63pub struct SettingsRow {
64 ident: Ident,
65 label: SharedString,
66 description: Option<SharedString>,
67 badge: Option<SharedString>,
68 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 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
107 self.badge = Some(badge.into());
108 self
109 }
110
111 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 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 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#[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 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 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}