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

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-10-06 08:00:47 -03:00 committed by GitHub
parent d2b91eb2bc
commit 79a8986cb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 169 additions and 201 deletions

View file

@ -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();

View file

@ -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)
})
})
}
}

View file

@ -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()