1use std::ops::Range;
2
3use crate::{
4 Icon, IconName, Sizable, Size, StyledExt,
5 empty::{Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyMediaVariant, EmptyTitle},
6 group_box::GroupBoxVariant,
7 h_resizable,
8 input::{Escape, Input, InputState},
9 resizable_panel,
10 setting::SettingPage,
11 sidebar::{Sidebar, SidebarMenu, SidebarMenuItem},
12};
13use gpui::{
14 App, AppContext as _, Axis, ElementId, Entity, InteractiveElement as _, IntoElement,
15 ParentElement as _, Pixels, RenderOnce, StyleRefinement, Styled, Window, container_query, div,
16 prelude::FluentBuilder as _, px, relative,
17};
18use rust_i18n::t;
19
20const STACKED_LAYOUT_MAX_WIDTH: Pixels = px(480.);
21
22#[derive(IntoElement)]
35pub struct Settings {
36 id: ElementId,
37 pages: Vec<SettingPage>,
38 group_variant: GroupBoxVariant,
39 size: Size,
40 sidebar_width: Pixels,
41 sidebar_size_range: Range<Pixels>,
42 sidebar_style: StyleRefinement,
43 default_selected_index: SelectIndex,
44 header_style: StyleRefinement,
45}
46
47impl Settings {
48 pub fn new(id: impl Into<ElementId>) -> Self {
50 Self {
51 id: id.into(),
52 pages: vec![],
53 group_variant: GroupBoxVariant::default(),
54 size: Size::default(),
55 sidebar_width: px(250.0),
56 sidebar_size_range: px(160.0)..px(360.0),
57 sidebar_style: StyleRefinement::default(),
58 default_selected_index: SelectIndex::default(),
59 header_style: StyleRefinement::default(),
60 }
61 }
62
63 pub fn sidebar_width(mut self, width: impl Into<Pixels>) -> Self {
65 self.sidebar_width = width.into();
66 self
67 }
68
69 pub fn sidebar_size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
71 self.sidebar_size_range = range.into();
72 self
73 }
74
75 pub fn page(mut self, page: SettingPage) -> Self {
77 self.pages.push(page);
78 self
79 }
80
81 pub fn pages(mut self, pages: impl IntoIterator<Item = SettingPage>) -> Self {
83 self.pages.extend(pages);
84 self
85 }
86
87 pub fn with_group_variant(mut self, variant: GroupBoxVariant) -> Self {
91 self.group_variant = variant;
92 self
93 }
94
95 pub fn sidebar_style(mut self, style: &StyleRefinement) -> Self {
97 self.sidebar_style = style.clone();
98 self
99 }
100
101 pub fn default_selected_index(mut self, index: SelectIndex) -> Self {
103 self.default_selected_index = index;
104 self
105 }
106
107 pub fn header_style(mut self, style: &StyleRefinement) -> Self {
109 self.header_style = style.clone();
110 self
111 }
112
113 fn render_active_page(
114 &self,
115 state: &Entity<SettingsState>,
116 filter: &SettingsFilter,
117 options: &RenderOptions,
118 window: &mut Window,
119 cx: &mut App,
120 ) -> gpui::AnyElement {
121 let page_ix = state.read(cx).selected_index.page_ix;
122 if let Some(page) = self.pages.get(page_ix)
123 && !filter.groups[page_ix].is_empty()
124 {
125 return page
126 .render(page_ix, &filter.groups[page_ix], state, options, window, cx)
127 .into_any_element();
128 }
129
130 if filter.visible_pages().next().is_none() {
131 return div()
132 .size_full()
133 .debug_selector(|| "settings-no-results".into())
134 .child(Self::render_no_results())
135 .into_any_element();
136 }
137
138 div().into_any_element()
139 }
140
141 fn render_no_results() -> impl IntoElement {
142 Empty::new().size_full().header(
143 EmptyHeader::new()
144 .media(
145 EmptyMedia::new()
146 .with_variant(EmptyMediaVariant::Icon)
147 .child(Icon::new(IconName::Search)),
148 )
149 .title(EmptyTitle::new().child(t!("Settings.no_results")))
150 .description(EmptyDescription::new().child(t!("Settings.no_results_description"))),
151 )
152 }
153
154 fn render_sidebar(
155 &self,
156 state: &Entity<SettingsState>,
157 filter: &SettingsFilter,
158 _: &mut Window,
159 cx: &mut App,
160 ) -> impl IntoElement {
161 let selected_index = state.read(cx).selected_index;
162 let search_input = state.read(cx).search_input.clone();
163
164 Sidebar::new("settings-sidebar")
165 .w(relative(1.))
166 .border_0()
167 .refine_style(&self.sidebar_style)
168 .collapsible(false)
169 .collapsed(false)
170 .header(
171 div()
172 .w_full()
173 .refine_style(&self.header_style)
174 .on_action({
179 let search_input = search_input.clone();
180 move |_: &Escape, window, cx| {
181 if search_input.read(cx).value().is_empty()
182 || cfg!(target_os = "android")
183 {
184 cx.propagate();
185 return;
186 }
187 search_input.update(cx, |input, cx| input.clean(window, cx));
188 }
189 })
190 .child(Input::new(&search_input).prefix(IconName::Search)),
191 )
192 .child(
193 SidebarMenu::new().children(filter.visible_pages().map(|page_ix| {
194 let page = &self.pages[page_ix];
195 let groups = &filter.groups[page_ix];
196 let is_page_active = selected_index.page_ix == page_ix
197 && (selected_index.group_ix.is_none() || groups.len() == 1);
198 SidebarMenuItem::new(page.title.clone())
199 .click_to_open(true)
200 .when_some(page.icon.clone(), |this, icon| this.icon(icon))
201 .default_open(page.default_open)
202 .active(is_page_active)
203 .on_click({
204 let state = state.clone();
205 move |_, _, cx| {
206 state.update(cx, |state, cx| {
207 state.selected_index = SelectIndex {
208 page_ix,
209 ..Default::default()
210 };
211 state.deferred_scroll_group_ix = None;
212 cx.notify();
213 })
214 }
215 })
216 .when(groups.len() > 1, |this| {
217 this.children(
218 groups
219 .iter()
220 .copied()
221 .filter(|&ix| page.groups[ix].title.is_some())
222 .map(|group_ix| {
223 let group = &page.groups[group_ix];
224 let is_active = selected_index.page_ix == page_ix
225 && selected_index.group_ix == Some(group_ix);
226 let title = group.title.clone().unwrap_or_default();
227
228 SidebarMenuItem::new(title).active(is_active).on_click({
229 let state = state.clone();
230 move |_, _, cx| {
231 state.update(cx, |state, cx| {
232 state.selected_index = SelectIndex {
233 page_ix,
234 group_ix: Some(group_ix),
235 };
236 state.deferred_scroll_group_ix = Some(group_ix);
237 cx.notify();
238 })
239 }
240 })
241 }),
242 )
243 })
244 })),
245 )
246 }
247}
248
249impl Sizable for Settings {
250 fn with_size(mut self, size: impl Into<Size>) -> Self {
251 self.size = size.into();
252 self
253 }
254}
255
256struct SettingsFilter {
258 groups: Vec<Vec<usize>>,
259}
260
261impl SettingsFilter {
262 fn new(pages: &[SettingPage], query: &str, cx: &App) -> Self {
263 Self {
264 groups: pages
265 .iter()
266 .map(|page| {
267 page.groups
268 .iter()
269 .enumerate()
270 .filter_map(|(ix, group)| group.is_match(query, cx).then_some(ix))
271 .collect()
272 })
273 .collect(),
274 }
275 }
276
277 fn visible_pages(&self) -> impl Iterator<Item = usize> + '_ {
278 self.groups
279 .iter()
280 .enumerate()
281 .filter_map(|(ix, groups)| (!groups.is_empty()).then_some(ix))
282 }
283
284 fn selected_index(&self, selected: SelectIndex) -> SelectIndex {
285 let page_ix = self
286 .visible_pages()
287 .find(|&ix| ix == selected.page_ix)
288 .or_else(|| self.visible_pages().next());
289 let Some(page_ix) = page_ix else {
290 return selected;
293 };
294
295 SelectIndex {
296 page_ix,
297 group_ix: selected
298 .group_ix
299 .filter(|ix| page_ix == selected.page_ix && self.groups[page_ix].contains(ix)),
300 }
301 }
302}
303
304pub(super) struct SettingsState {
305 pub(super) selected_index: SelectIndex,
306 pub(super) deferred_scroll_group_ix: Option<usize>,
308 pub(super) search_input: Entity<InputState>,
309}
310
311#[derive(Clone, Copy)]
321pub struct RenderOptions {
322 page_ix: usize,
323 group_ix: usize,
324 item_ix: usize,
325 size: Size,
326 group_variant: GroupBoxVariant,
327 layout: Axis,
328 disabled: bool,
329}
330
331impl RenderOptions {
332 pub fn new() -> Self {
333 Self {
334 page_ix: 0,
335 group_ix: 0,
336 item_ix: 0,
337 size: Size::default(),
338 group_variant: GroupBoxVariant::default(),
339 layout: Axis::Horizontal,
340 disabled: false,
341 }
342 }
343
344 pub fn with_page_ix(mut self, page_ix: usize) -> Self {
345 self.page_ix = page_ix;
346 self
347 }
348
349 pub fn with_group_ix(mut self, group_ix: usize) -> Self {
350 self.group_ix = group_ix;
351 self
352 }
353
354 pub fn with_item_ix(mut self, item_ix: usize) -> Self {
355 self.item_ix = item_ix;
356 self
357 }
358
359 pub fn with_size(mut self, size: Size) -> Self {
360 self.size = size;
361 self
362 }
363
364 pub fn with_group_variant(mut self, group_variant: GroupBoxVariant) -> Self {
365 self.group_variant = group_variant;
366 self
367 }
368
369 pub fn with_layout(mut self, layout: Axis) -> Self {
370 self.layout = layout;
371 self
372 }
373
374 pub fn with_disabled(mut self, disabled: bool) -> Self {
375 self.disabled = disabled;
376 self
377 }
378
379 pub fn page_ix(&self) -> usize {
380 self.page_ix
381 }
382
383 pub fn group_ix(&self) -> usize {
384 self.group_ix
385 }
386
387 pub fn item_ix(&self) -> usize {
388 self.item_ix
389 }
390
391 pub fn size(&self) -> Size {
392 self.size
393 }
394
395 pub fn group_variant(&self) -> GroupBoxVariant {
396 self.group_variant
397 }
398
399 pub fn layout(&self) -> Axis {
400 self.layout
401 }
402
403 pub fn is_disabled(&self) -> bool {
404 self.disabled
405 }
406}
407
408impl Default for RenderOptions {
409 fn default() -> Self {
410 Self::new()
411 }
412}
413
414#[derive(Clone, Copy, Default)]
415pub struct SelectIndex {
416 pub page_ix: usize,
417 pub group_ix: Option<usize>,
418}
419
420impl RenderOnce for Settings {
421 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
422 let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
423 let search_input = cx.new(|cx| {
424 InputState::new(window, cx)
425 .placeholder(t!("Settings.search_placeholder"))
426 .default_value("")
427 });
428
429 SettingsState {
430 search_input,
431 selected_index: self.default_selected_index,
432 deferred_scroll_group_ix: None,
433 }
434 });
435
436 let placeholder = t!("Settings.search_placeholder");
440 let search_input = state.read(cx).search_input.clone();
441 if search_input.read(cx).presentation().placeholder() != &*placeholder {
442 search_input.update(cx, |input, cx| {
443 input.set_placeholder(placeholder, window, cx);
444 });
445 }
446
447 let query = search_input.read(cx).value();
448 let filter = SettingsFilter::new(&self.pages, &query, cx);
449 let previous = state.read(cx).selected_index;
450 let selected = filter.selected_index(previous);
451 if selected.page_ix != previous.page_ix || selected.group_ix != previous.group_ix {
452 state.update(cx, |state, _| {
453 state.selected_index = selected;
454 state.deferred_scroll_group_ix = None;
455 });
456 }
457 let options = RenderOptions::new()
458 .with_size(self.size)
459 .with_group_variant(self.group_variant);
460 let sidebar_size_range = self.sidebar_size_range.clone();
461 let sidebar = self
462 .render_sidebar(&state, &filter, window, cx)
463 .into_any_element();
464
465 h_resizable(self.id.clone())
466 .child(
467 resizable_panel()
468 .size(self.sidebar_width)
469 .size_range(sidebar_size_range)
470 .child(sidebar),
471 )
472 .child(
473 resizable_panel().child(container_query(move |size, window, cx| {
474 let options = options.with_layout(if size.width <= STACKED_LAYOUT_MAX_WIDTH {
475 Axis::Vertical
476 } else {
477 Axis::Horizontal
478 });
479 self.render_active_page(&state, &filter, &options, window, cx)
480 })),
481 )
482 }
483}
484
485#[cfg(test)]
486#[path = "tests.rs"]
487mod tests;