diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 7e2fea900878483a43f4a4d5c7d2f14c7c15d95a..aef906c0357a3f07216fc375818927dd21abe446 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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, + 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) -> Div { + fn render_page( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> 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(); diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index f276d483a6fb391ac0a56f6c4322fc7c8dad221f..f4a4b875cc887c226e5796cb2381d17ce4d6858f 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/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, full_width: bool, disabled: bool, handle: Option>, + attach: Option, + offset: Option>, } 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) -> 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>, -} - -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) - }) - }) - } -} diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index c57d071075ea0c0479f7700d9c52da2692a42d8d..af3cc34cc9092aef55aadea5d90a2abdb80b087c 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/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()