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::{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 .child(Input::new(&search_input).prefix(IconName::Search)),
175 )
176 .child(
177 SidebarMenu::new().children(filter.visible_pages().map(|page_ix| {
178 let page = &self.pages[page_ix];
179 let groups = &filter.groups[page_ix];
180 let is_page_active = selected_index.page_ix == page_ix
181 && (selected_index.group_ix.is_none() || groups.len() == 1);
182 SidebarMenuItem::new(page.title.clone())
183 .click_to_open(true)
184 .when_some(page.icon.clone(), |this, icon| this.icon(icon))
185 .default_open(page.default_open)
186 .active(is_page_active)
187 .on_click({
188 let state = state.clone();
189 move |_, _, cx| {
190 state.update(cx, |state, cx| {
191 state.selected_index = SelectIndex {
192 page_ix,
193 ..Default::default()
194 };
195 state.deferred_scroll_group_ix = None;
196 cx.notify();
197 })
198 }
199 })
200 .when(groups.len() > 1, |this| {
201 this.children(
202 groups
203 .iter()
204 .copied()
205 .filter(|&ix| page.groups[ix].title.is_some())
206 .map(|group_ix| {
207 let group = &page.groups[group_ix];
208 let is_active = selected_index.page_ix == page_ix
209 && selected_index.group_ix == Some(group_ix);
210 let title = group.title.clone().unwrap_or_default();
211
212 SidebarMenuItem::new(title).active(is_active).on_click({
213 let state = state.clone();
214 move |_, _, cx| {
215 state.update(cx, |state, cx| {
216 state.selected_index = SelectIndex {
217 page_ix,
218 group_ix: Some(group_ix),
219 };
220 state.deferred_scroll_group_ix = Some(group_ix);
221 cx.notify();
222 })
223 }
224 })
225 }),
226 )
227 })
228 })),
229 )
230 }
231}
232
233impl Sizable for Settings {
234 fn with_size(mut self, size: impl Into<Size>) -> Self {
235 self.size = size.into();
236 self
237 }
238}
239
240struct SettingsFilter {
242 groups: Vec<Vec<usize>>,
243}
244
245impl SettingsFilter {
246 fn new(pages: &[SettingPage], query: &str, cx: &App) -> Self {
247 Self {
248 groups: pages
249 .iter()
250 .map(|page| {
251 page.groups
252 .iter()
253 .enumerate()
254 .filter_map(|(ix, group)| group.is_match(query, cx).then_some(ix))
255 .collect()
256 })
257 .collect(),
258 }
259 }
260
261 fn visible_pages(&self) -> impl Iterator<Item = usize> + '_ {
262 self.groups
263 .iter()
264 .enumerate()
265 .filter_map(|(ix, groups)| (!groups.is_empty()).then_some(ix))
266 }
267
268 fn selected_index(&self, selected: SelectIndex) -> SelectIndex {
269 let page_ix = self
270 .visible_pages()
271 .find(|&ix| ix == selected.page_ix)
272 .or_else(|| self.visible_pages().next());
273 let Some(page_ix) = page_ix else {
274 return selected;
277 };
278
279 SelectIndex {
280 page_ix,
281 group_ix: selected
282 .group_ix
283 .filter(|ix| page_ix == selected.page_ix && self.groups[page_ix].contains(ix)),
284 }
285 }
286}
287
288pub(super) struct SettingsState {
289 pub(super) selected_index: SelectIndex,
290 pub(super) deferred_scroll_group_ix: Option<usize>,
292 pub(super) search_input: Entity<InputState>,
293}
294
295#[derive(Clone, Copy)]
305pub struct RenderOptions {
306 page_ix: usize,
307 group_ix: usize,
308 item_ix: usize,
309 size: Size,
310 group_variant: GroupBoxVariant,
311 layout: Axis,
312 disabled: bool,
313}
314
315impl RenderOptions {
316 pub fn new() -> Self {
317 Self {
318 page_ix: 0,
319 group_ix: 0,
320 item_ix: 0,
321 size: Size::default(),
322 group_variant: GroupBoxVariant::default(),
323 layout: Axis::Horizontal,
324 disabled: false,
325 }
326 }
327
328 pub fn with_page_ix(mut self, page_ix: usize) -> Self {
329 self.page_ix = page_ix;
330 self
331 }
332
333 pub fn with_group_ix(mut self, group_ix: usize) -> Self {
334 self.group_ix = group_ix;
335 self
336 }
337
338 pub fn with_item_ix(mut self, item_ix: usize) -> Self {
339 self.item_ix = item_ix;
340 self
341 }
342
343 pub fn with_size(mut self, size: Size) -> Self {
344 self.size = size;
345 self
346 }
347
348 pub fn with_group_variant(mut self, group_variant: GroupBoxVariant) -> Self {
349 self.group_variant = group_variant;
350 self
351 }
352
353 pub fn with_layout(mut self, layout: Axis) -> Self {
354 self.layout = layout;
355 self
356 }
357
358 pub fn with_disabled(mut self, disabled: bool) -> Self {
359 self.disabled = disabled;
360 self
361 }
362
363 pub fn page_ix(&self) -> usize {
364 self.page_ix
365 }
366
367 pub fn group_ix(&self) -> usize {
368 self.group_ix
369 }
370
371 pub fn item_ix(&self) -> usize {
372 self.item_ix
373 }
374
375 pub fn size(&self) -> Size {
376 self.size
377 }
378
379 pub fn group_variant(&self) -> GroupBoxVariant {
380 self.group_variant
381 }
382
383 pub fn layout(&self) -> Axis {
384 self.layout
385 }
386
387 pub fn is_disabled(&self) -> bool {
388 self.disabled
389 }
390}
391
392impl Default for RenderOptions {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398#[derive(Clone, Copy, Default)]
399pub struct SelectIndex {
400 pub page_ix: usize,
401 pub group_ix: Option<usize>,
402}
403
404impl RenderOnce for Settings {
405 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
406 let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
407 let search_input = cx.new(|cx| {
408 InputState::new(window, cx)
409 .placeholder(t!("Settings.search_placeholder"))
410 .default_value("")
411 });
412
413 SettingsState {
414 search_input,
415 selected_index: self.default_selected_index,
416 deferred_scroll_group_ix: None,
417 }
418 });
419
420 let query = state.read(cx).search_input.read(cx).value();
421 let filter = SettingsFilter::new(&self.pages, &query, cx);
422 let previous = state.read(cx).selected_index;
423 let selected = filter.selected_index(previous);
424 if selected.page_ix != previous.page_ix || selected.group_ix != previous.group_ix {
425 state.update(cx, |state, _| {
426 state.selected_index = selected;
427 state.deferred_scroll_group_ix = None;
428 });
429 }
430 let options = RenderOptions::new()
431 .with_size(self.size)
432 .with_group_variant(self.group_variant);
433 let sidebar_size_range = self.sidebar_size_range.clone();
434 let sidebar = self
435 .render_sidebar(&state, &filter, window, cx)
436 .into_any_element();
437
438 h_resizable(self.id.clone())
439 .child(
440 resizable_panel()
441 .size(self.sidebar_width)
442 .size_range(sidebar_size_range)
443 .child(sidebar),
444 )
445 .child(
446 resizable_panel().child(container_query(move |size, window, cx| {
447 let options = options.with_layout(if size.width <= STACKED_LAYOUT_MAX_WIDTH {
448 Axis::Vertical
449 } else {
450 Axis::Horizontal
451 });
452 self.render_active_page(&state, &filter, &options, window, cx)
453 })),
454 )
455 }
456}
457
458#[cfg(test)]
459#[path = "tests.rs"]
460mod tests;