settings ui: Add scrollbar and other design details (#39504)

Danilo Leal created

Release Notes:

- N/A

Change summary

crates/settings_ui/src/settings_ui.rs      | 143 ++++++++++----
crates/ui/src/components/dropdown_menu.rs  | 221 +++++++----------------
crates/ui/src/components/tree_view_item.rs |   6 
3 files changed, 169 insertions(+), 201 deletions(-)

Detailed changes

crates/settings_ui/src/settings_ui.rs 🔗

@@ -24,8 +24,8 @@ use std::{
     sync::{Arc, atomic::AtomicBool},
 };
 use ui::{
-    ContextMenu, Divider, DropdownMenu, DropdownStyle, Switch, SwitchColor, TreeViewItem,
-    prelude::*,
+    ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, Switch, SwitchColor,
+    TreeViewItem, WithScrollbar, prelude::*,
 };
 use ui_input::{NumericStepper, NumericStepperType};
 use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
@@ -2896,6 +2896,7 @@ pub struct SettingsWindow {
     /// If this is empty the selected page is rendered,
     /// otherwise the last sub page gets rendered.
     sub_page_stack: Vec<SubPage>,
+    scroll_handle: ScrollHandle,
 }
 
 struct SubPage {
@@ -2970,10 +2971,14 @@ impl SettingsPageItem {
                     .gap_2()
                     .flex_wrap()
                     .justify_between()
-                    .when(!is_last, |this| {
-                        this.pb_4()
-                            .border_b_1()
-                            .border_color(cx.theme().colors().border_variant)
+                    .map(|this| {
+                        if is_last {
+                            this.pb_6()
+                        } else {
+                            this.pb_4()
+                                .border_b_1()
+                                .border_color(cx.theme().colors().border_variant)
+                        }
                     })
                     .child(
                         v_flex()
@@ -2983,10 +2988,7 @@ impl SettingsPageItem {
                                 h_flex()
                                     .w_full()
                                     .gap_4()
-                                    .child(
-                                        Label::new(SharedString::new_static(setting_item.title))
-                                            .size(LabelSize::Default),
-                                    )
+                                    .child(Label::new(SharedString::new_static(setting_item.title)))
                                     .when_some(
                                         file_set_in.filter(|file_set_in| file_set_in != &file),
                                         |elem, file_set_in| {
@@ -3027,15 +3029,18 @@ impl SettingsPageItem {
                         .border_color(cx.theme().colors().border_variant)
                 })
                 .child(
-                    v_flex().max_w_1_2().flex_shrink().child(
-                        Label::new(SharedString::new_static(sub_page_link.title))
-                            .size(LabelSize::Default),
-                    ),
+                    v_flex()
+                        .max_w_1_2()
+                        .flex_shrink()
+                        .child(Label::new(SharedString::new_static(sub_page_link.title))),
                 )
                 .child(
                     Button::new(("sub-page".into(), sub_page_link.title), "Configure")
-                        .icon(Some(IconName::ChevronRight))
-                        .icon_position(Some(IconPosition::End))
+                        .size(ButtonSize::Medium)
+                        .icon(IconName::ChevronRight)
+                        .icon_position(IconPosition::End)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::Small)
                         .style(ButtonStyle::Outlined),
                 )
                 .on_click({
@@ -3161,6 +3166,7 @@ impl SettingsWindow {
             search_task: None,
             search_matches: vec![],
             sub_page_stack: vec![],
+            scroll_handle: ScrollHandle::new(),
         };
 
         this.fetch_files(cx);
@@ -3487,22 +3493,27 @@ impl SettingsWindow {
             .child(Label::new(last))
     }
 
-    fn render_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
+    fn render_page(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<SettingsWindow>,
+    ) -> impl IntoElement {
         let mut page = v_flex()
             .w_full()
             .pt_4()
+            .pb_6()
             .px_6()
             .gap_4()
-            .bg(cx.theme().colors().editor_background);
+            .bg(cx.theme().colors().editor_background)
+            .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx);
+
         let mut page_content = v_flex()
             .id("settings-ui-page")
+            .size_full()
             .gap_4()
             .overflow_y_scroll()
-            .track_scroll(
-                window
-                    .use_state(cx, |_, _| ScrollHandle::default())
-                    .read(cx),
-            );
+            .track_scroll(&self.scroll_handle);
+
         if self.sub_page_stack.len() == 0 {
             page = page.child(self.render_files(window, cx));
 
@@ -3510,29 +3521,69 @@ impl SettingsWindow {
             let items_len = items.len();
             let mut section_header = None;
 
-            page_content =
-                page_content.children(items.into_iter().enumerate().map(|(index, item)| {
-                    let is_last = index == items_len - 1;
-                    if let SettingsPageItem::SectionHeader(header) = item {
-                        section_header = Some(*header);
-                    }
-                    item.render(
-                        self.current_file.clone(),
-                        section_header.expect("All items rendered after a section header"),
-                        is_last,
-                        window,
-                        cx,
-                    )
-                }))
+            let search_query = self.search_bar.read(cx).text(cx);
+            let has_active_search = !search_query.is_empty();
+            let has_no_results = items_len == 0 && has_active_search;
+
+            if has_no_results {
+                page_content = page_content.child(
+                    v_flex()
+                        .size_full()
+                        .items_center()
+                        .justify_center()
+                        .gap_1()
+                        .child(div().child("No Results"))
+                        .child(
+                            div()
+                                .text_sm()
+                                .text_color(cx.theme().colors().text_muted)
+                                .child(format!("No settings match \"{}\"", search_query)),
+                        ),
+                )
+            } else {
+                let last_non_header_index = items
+                    .iter()
+                    .enumerate()
+                    .rev()
+                    .find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
+                    .map(|(index, _)| index);
+
+                page_content = page_content.children(items.clone().into_iter().enumerate().map(
+                    |(index, item)| {
+                        let no_bottom_border = items
+                            .get(index + 1)
+                            .map(|next_item| {
+                                matches!(next_item, SettingsPageItem::SectionHeader(_))
+                            })
+                            .unwrap_or(false);
+                        let is_last = Some(index) == last_non_header_index;
+
+                        if let SettingsPageItem::SectionHeader(header) = item {
+                            section_header = Some(*header);
+                        }
+                        item.render(
+                            self.current_file.clone(),
+                            section_header.expect("All items rendered after a section header"),
+                            no_bottom_border || is_last,
+                            window,
+                            cx,
+                        )
+                    },
+                ))
+            }
         } else {
             page = page.child(
                 h_flex()
-                    .gap_2()
-                    .child(IconButton::new("back-btn", IconName::ChevronLeft).on_click(
-                        cx.listener(|this, _, _, cx| {
-                            this.pop_sub_page(cx);
-                        }),
-                    ))
+                    .ml_neg_1p5()
+                    .gap_1()
+                    .child(
+                        IconButton::new("back-btn", IconName::ArrowLeft)
+                            .icon_size(IconSize::Small)
+                            .shape(IconButtonShape::Square)
+                            .on_click(cx.listener(|this, _, _, cx| {
+                                this.pop_sub_page(cx);
+                            })),
+                    )
                     .child(self.render_sub_page_breadcrumbs()),
             );
 
@@ -3768,7 +3819,12 @@ where
             menu
         }),
     )
+    .trigger_size(ButtonSize::Medium)
     .style(DropdownStyle::Outlined)
+    .offset(gpui::Point {
+        x: px(0.0),
+        y: px(2.0),
+    })
     .into_any_element()
 }
 
@@ -3942,6 +3998,7 @@ mod test {
             search_matches: vec![],
             search_task: None,
             sub_page_stack: vec![],
+            scroll_handle: ScrollHandle::new(),
         };
 
         settings_window.build_search_matches();

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{ClickEvent, Corner, CursorStyle, Entity, Hsla, MouseButton};
+use gpui::{Corner, Entity, Pixels, Point};
 
 use crate::{ContextMenu, PopoverMenu, prelude::*};
 
@@ -21,11 +21,14 @@ enum LabelKind {
 pub struct DropdownMenu {
     id: ElementId,
     label: LabelKind,
+    trigger_size: ButtonSize,
     style: DropdownStyle,
     menu: Entity<ContextMenu>,
     full_width: bool,
     disabled: bool,
     handle: Option<PopoverMenuHandle<ContextMenu>>,
+    attach: Option<Corner>,
+    offset: Option<Point<Pixels>>,
 }
 
 impl DropdownMenu {
@@ -37,11 +40,14 @@ impl DropdownMenu {
         Self {
             id: id.into(),
             label: LabelKind::Text(label.into()),
+            trigger_size: ButtonSize::Default,
             style: DropdownStyle::default(),
             menu,
             full_width: false,
             disabled: false,
             handle: None,
+            attach: None,
+            offset: None,
         }
     }
 
@@ -53,14 +59,22 @@ impl DropdownMenu {
         Self {
             id: id.into(),
             label: LabelKind::Element(label),
+            trigger_size: ButtonSize::Default,
             style: DropdownStyle::default(),
             menu,
             full_width: false,
             disabled: false,
             handle: None,
+            attach: None,
+            offset: None,
         }
     }
 
+    pub fn trigger_size(mut self, size: ButtonSize) -> Self {
+        self.trigger_size = size;
+        self
+    }
+
     pub fn style(mut self, style: DropdownStyle) -> Self {
         self.style = style;
         self
@@ -75,6 +89,18 @@ impl DropdownMenu {
         self.handle = Some(handle);
         self
     }
+
+    /// Defines which corner of the handle to attach the menu's anchor to.
+    pub fn attach(mut self, attach: Corner) -> Self {
+        self.attach = Some(attach);
+        self
+    }
+
+    /// Offsets the position of the menu by that many pixels.
+    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
+        self.offset = Some(offset);
+        self
+    }
 }
 
 impl Disableable for DropdownMenu {
@@ -86,17 +112,46 @@ impl Disableable for DropdownMenu {
 
 impl RenderOnce for DropdownMenu {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        PopoverMenu::new(self.id)
+        let button_style = match self.style {
+            DropdownStyle::Solid => ButtonStyle::Filled,
+            DropdownStyle::Outlined => ButtonStyle::Outlined,
+            DropdownStyle::Ghost => ButtonStyle::Transparent,
+        };
+
+        let full_width = self.full_width;
+        let trigger_size = self.trigger_size;
+
+        let button = match self.label {
+            LabelKind::Text(text) => Button::new(self.id.clone(), text)
+                .style(button_style)
+                .icon(IconName::ChevronUpDown)
+                .icon_position(IconPosition::End)
+                .icon_size(IconSize::XSmall)
+                .icon_color(Color::Muted)
+                .when(full_width, |this| this.full_width())
+                .size(trigger_size)
+                .disabled(self.disabled),
+            LabelKind::Element(_element) => Button::new(self.id.clone(), "")
+                .style(button_style)
+                .icon(IconName::ChevronUpDown)
+                .icon_position(IconPosition::End)
+                .icon_size(IconSize::XSmall)
+                .icon_color(Color::Muted)
+                .when(full_width, |this| this.full_width())
+                .size(trigger_size)
+                .disabled(self.disabled),
+        };
+
+        PopoverMenu::new((self.id.clone(), "popover"))
             .full_width(self.full_width)
             .menu(move |_window, _cx| Some(self.menu.clone()))
-            .trigger(
-                DropdownMenuTrigger::new(self.label)
-                    .full_width(self.full_width)
-                    .disabled(self.disabled)
-                    .style(self.style),
-            )
-            .attach(Corner::BottomLeft)
-            .when_some(self.handle, |el, handle| el.with_handle(handle))
+            .trigger(button)
+            .attach(match self.attach {
+                Some(attach) => attach,
+                None => Corner::BottomRight,
+            })
+            .when_some(self.offset, |this, offset| this.offset(offset))
+            .when_some(self.handle, |this, handle| this.with_handle(handle))
     }
 }
 
@@ -179,149 +234,3 @@ impl Component for DropdownMenu {
         )
     }
 }
-
-#[derive(Debug, Clone, Copy)]
-pub struct DropdownTriggerStyle {
-    pub bg: Hsla,
-}
-
-impl DropdownTriggerStyle {
-    pub fn for_style(style: DropdownStyle, cx: &App) -> Self {
-        let colors = cx.theme().colors();
-
-        let bg = match style {
-            DropdownStyle::Solid => colors.editor_background,
-            DropdownStyle::Outlined => colors.surface_background,
-            DropdownStyle::Ghost => colors.ghost_element_background,
-        };
-
-        Self { bg }
-    }
-}
-
-#[derive(IntoElement)]
-struct DropdownMenuTrigger {
-    label: LabelKind,
-    full_width: bool,
-    selected: bool,
-    disabled: bool,
-    style: DropdownStyle,
-    cursor_style: CursorStyle,
-    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
-}
-
-impl DropdownMenuTrigger {
-    pub fn new(label: LabelKind) -> Self {
-        Self {
-            label,
-            full_width: false,
-            selected: false,
-            disabled: false,
-            style: DropdownStyle::default(),
-            cursor_style: CursorStyle::default(),
-            on_click: None,
-        }
-    }
-
-    pub fn full_width(mut self, full_width: bool) -> Self {
-        self.full_width = full_width;
-        self
-    }
-
-    pub fn style(mut self, style: DropdownStyle) -> Self {
-        self.style = style;
-        self
-    }
-}
-
-impl Disableable for DropdownMenuTrigger {
-    fn disabled(mut self, disabled: bool) -> Self {
-        self.disabled = disabled;
-        self
-    }
-}
-
-impl Toggleable for DropdownMenuTrigger {
-    fn toggle_state(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-}
-
-impl Clickable for DropdownMenuTrigger {
-    fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
-        self.on_click = Some(Box::new(handler));
-        self
-    }
-
-    fn cursor_style(mut self, cursor_style: CursorStyle) -> Self {
-        self.cursor_style = cursor_style;
-        self
-    }
-}
-
-impl RenderOnce for DropdownMenuTrigger {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let disabled = self.disabled;
-
-        let style = DropdownTriggerStyle::for_style(self.style, cx);
-        let is_outlined = matches!(self.style, DropdownStyle::Outlined);
-
-        h_flex()
-            .id("dropdown-menu-trigger")
-            .min_w_20()
-            .pl_2()
-            .pr_1p5()
-            .py_0p5()
-            .gap_2()
-            .justify_between()
-            .rounded_sm()
-            .map(|this| {
-                if self.full_width {
-                    this.w_full()
-                } else {
-                    this.flex_none().w_auto()
-                }
-            })
-            .when(is_outlined, |this| {
-                this.border_1()
-                    .border_color(cx.theme().colors().border)
-                    .overflow_hidden()
-            })
-            .map(|this| {
-                if disabled {
-                    this.cursor_not_allowed()
-                        .bg(cx.theme().colors().element_disabled)
-                } else {
-                    this.bg(style.bg)
-                        .hover(|s| s.bg(cx.theme().colors().element_hover))
-                }
-            })
-            .child(match self.label {
-                LabelKind::Text(text) => Label::new(text)
-                    .color(if disabled {
-                        Color::Disabled
-                    } else {
-                        Color::Default
-                    })
-                    .into_any_element(),
-                LabelKind::Element(element) => element,
-            })
-            .child(
-                Icon::new(IconName::ChevronUpDown)
-                    .size(IconSize::XSmall)
-                    .color(if disabled {
-                        Color::Disabled
-                    } else {
-                        Color::Muted
-                    }),
-            )
-            .when_some(self.on_click.filter(|_| !disabled), |el, on_click| {
-                el.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
-                    .on_click(move |event, window, cx| {
-                        cx.stop_propagation();
-                        (on_click)(event, window, cx)
-                    })
-            })
-    }
-}

crates/ui/src/components/tree_view_item.rs 🔗

@@ -122,8 +122,9 @@ impl RenderOnce for TreeViewItem {
         let selected_border = cx.theme().colors().border.opacity(0.6);
         let focused_border = cx.theme().colors().border_focused;
         let transparent_border = cx.theme().colors().border_transparent;
+        let item_size = rems_from_px(28.);
 
-        let indentation_line = h_flex().size_7().flex_none().justify_center().child(
+        let indentation_line = h_flex().size(item_size).flex_none().justify_center().child(
             div()
                 .w_px()
                 .h_full()
@@ -143,7 +144,8 @@ impl RenderOnce for TreeViewItem {
                     .map(|this| {
                         let label = self.label;
                         if self.root_item {
-                            this.px_1()
+                            this.h(item_size)
+                                .px_1()
                                 .mb_1()
                                 .gap_2p5()
                                 .rounded_sm()