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