diff --git a/crates/component/src/group_box.rs b/crates/component/src/group_box.rs index 5f044636a5..51140d83ac 100644 --- a/crates/component/src/group_box.rs +++ b/crates/component/src/group_box.rs @@ -67,6 +67,7 @@ pub struct GroupBox { title: Option, content_style: StyleRefinement, children: SmallVec<[AnyElement; 1]>, + footer: Option, } impl GroupBox { @@ -80,6 +81,7 @@ impl GroupBox { content_style: StyleRefinement::default(), title: None, children: SmallVec::new(), + footer: None, } } @@ -106,6 +108,15 @@ impl GroupBox { self.content_style = style; self } + + /// Set supporting content below the group's filled or outlined surface. + /// + /// The footer shares the title's leading edge, sits 8 px under the + /// surface, and renders as small muted text like a description. + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } } impl ParentElement for GroupBox { @@ -151,15 +162,29 @@ impl RenderOnce for GroupBox { ) }) .child( + // The footer sits inside the surface's slot so its 8 px gap is + // independent of the root gap between the title and surface. v_flex() - .when_some(bg, |this, bg| this.bg(bg)) - .when_some(border, |this, border| this.border_color(border).border_1()) - .text_color(cx.theme().group_box_foreground) - .when(has_paddings, |this| this.p_4()) - .gap_4() - .rounded(cx.theme().radius) - .refine_style(&self.content_style) - .children(self.children), + .gap_2() + .child( + v_flex() + .when_some(bg, |this, bg| this.bg(bg)) + .when_some(border, |this, border| this.border_color(border).border_1()) + .text_color(cx.theme().group_box_foreground) + .when(has_paddings, |this| this.p_4()) + .gap_4() + .rounded(cx.theme().radius) + .refine_style(&self.content_style) + .children(self.children), + ) + .when_some(self.footer, |this, footer| { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(footer), + ) + }), ) } } diff --git a/crates/component/src/setting/group.rs b/crates/component/src/setting/group.rs index 5c0ca045b4..d87e5bef9a 100644 --- a/crates/component/src/setting/group.rs +++ b/crates/component/src/setting/group.rs @@ -1,6 +1,8 @@ +use std::rc::Rc; + use gpui::{ - App, IntoElement, ParentElement as _, SharedString, StyleRefinement, Styled, Window, - prelude::FluentBuilder as _, + AnyElement, App, IntoElement, ParentElement as _, SharedString, StyleRefinement, Styled, + Window, prelude::FluentBuilder as _, }; use crate::{ @@ -15,6 +17,7 @@ use crate::{ #[derive(Clone)] pub struct SettingGroup { style: StyleRefinement, + footer: Option AnyElement>>, pub(super) title: Option, pub(super) description: Option, @@ -32,6 +35,7 @@ impl SettingGroup { pub fn new() -> Self { Self { style: StyleRefinement::default(), + footer: None, title: None, description: None, items: Vec::new(), @@ -50,6 +54,23 @@ impl SettingGroup { self } + /// Render supporting content below, and outside, the group's surface. + /// + /// The footer aligns with the group title and renders as small muted text, + /// like a description. It scrolls with the group and follows its search + /// visibility; it does not add an independently searchable item or a + /// sidebar entry, and a group needs at least one item to be shown. + pub fn footer(mut self, footer: F) -> Self + where + E: IntoElement, + F: Fn(&mut Window, &mut App) -> E + 'static, + { + self.footer = Some(Rc::new(move |window, cx| { + footer(window, cx).into_any_element() + })); + self + } + /// Add a setting item to the group. pub fn item(mut self, item: SettingItem) -> Self { self.items.push(item); @@ -109,6 +130,7 @@ impl SettingGroup { None } })) + .when_some(self.footer, |this, footer| this.footer(footer(window, cx))) .refine_style(&self.style) } diff --git a/crates/component/src/setting/tests.rs b/crates/component/src/setting/tests.rs index ac61538e39..2b17c999c9 100644 --- a/crates/component/src/setting/tests.rs +++ b/crates/component/src/setting/tests.rs @@ -179,6 +179,33 @@ fn search_preserves_group_and_item_identity(cx: &mut TestAppContext) { assert!(cx.debug_bounds("setting-1-1-0").is_some()); } +#[gpui::test] +fn footer_follows_group_search_visibility(cx: &mut TestAppContext) { + let (host, cx) = setup(cx); + cx.update(|_, cx| { + host.update(cx, |host, cx| { + host.pages[1].groups[2] = host.pages[1].groups[2].clone().footer(|_, _| { + div() + .child("Changes apply to this device only.") + .debug_selector(|| "font-footer".into()) + }); + cx.notify(); + }); + }); + search(&host, "font", cx); + assert!(cx.debug_bounds("setting-1-2-1").is_some()); + assert!(cx.debug_bounds("font-footer").is_some()); + + // Footer copy does not independently make a group match the query. + search(&host, "colors", cx); + assert!(cx.debug_bounds("setting-1-1-0").is_some()); + assert!(cx.debug_bounds("font-footer").is_none()); + + search(&host, "font", cx); + assert!(cx.debug_bounds("font-footer").is_some()); + assert_eq!(selection(&host, cx), (1, None)); +} + #[gpui::test] fn resetting_search_results_leaves_hidden_settings_unchanged(cx: &mut TestAppContext) { use std::{cell::Cell, rc::Rc}; diff --git a/crates/story/src/stories/group_box_story.rs b/crates/story/src/stories/group_box_story.rs index fed57258d0..01b283055b 100644 --- a/crates/story/src/stories/group_box_story.rs +++ b/crates/story/src/stories/group_box_story.rs @@ -110,6 +110,7 @@ impl Render for GroupBoxStory { .id("activity") .fill() .title("Contributions & activity") + .footer("Private contributions never reveal repository names.") .child( h_flex() .justify_between() diff --git a/crates/story/src/stories/settings_story.rs b/crates/story/src/stories/settings_story.rs index bc21c483ed..fbb607f6cb 100644 --- a/crates/story/src/stories/settings_story.rs +++ b/crates/story/src/stories/settings_story.rs @@ -250,6 +250,7 @@ impl SettingsStory { ]), SettingGroup::new() .title("Font") + .footer(|_, _| "Font preferences apply to this story only.") .item( SettingItem::new( "Font Family", diff --git a/website/component/group-box.md b/website/component/group-box.md index 23c89db27e..85062e16c3 100644 --- a/website/component/group-box.md +++ b/website/component/group-box.md @@ -60,6 +60,20 @@ GroupBox::new() .child(Button::new("save").primary().label("Save Changes")) ``` +### Footer outside the surface + +Use `footer` for supporting content below the filled background or outline, not +inside the content area. It shares the title's leading edge, sits 8 px under +the surface, and renders as small muted text like a description, so plain text +is enough. `content_style` only changes the body. + +```rust +GroupBox::new() + .fill() + .child("Update preferences") + .footer("Changes apply to this device only.") +``` + ### Custom ID ```rust diff --git a/website/component/settings.md b/website/component/settings.md index 76189118b5..3685689d23 100644 --- a/website/component/settings.md +++ b/website/component/settings.md @@ -199,6 +199,24 @@ SettingGroup::new() .items(vec![...]) ``` +### Footer outside the group surface + +Use `footer` to render supporting content below the group's background or +border. It aligns with the group title and renders as small muted text like a +description, so plain text is enough; the callback receives the current window +and application context for richer content. It scrolls and is filtered with +the group; it is not an independently searchable setting or a sidebar entry, +and a group still needs at least one item to be shown. + +```rust +SettingGroup::new() + .item(SettingItem::new( + "Update source", + SettingField::render(|_, _, _| "GitHub Releases"), + )) + .footer(|_, _| "Changes apply to this device only.") +``` + ## Setting Item ### Basic Item diff --git a/website/zh-CN/component/group-box.md b/website/zh-CN/component/group-box.md index 7528177fef..73ede571ff 100644 --- a/website/zh-CN/component/group-box.md +++ b/website/zh-CN/component/group-box.md @@ -57,6 +57,19 @@ GroupBox::new() .child(Button::new("save").primary().label("Save Changes")) ``` +### 表面外的底部说明 + +用 `footer` 在填充背景或边框下方放置辅助内容,而不是将其放进内容区域。 +它与标题左对齐,位于表面下方 8 px 处,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可。 +`content_style` 只影响主体内容。 + +```rust +GroupBox::new() + .fill() + .child("Update preferences") + .footer("Changes apply to this device only.") +``` + ### 自定义 ID ```rust diff --git a/website/zh-CN/component/settings.md b/website/zh-CN/component/settings.md index 97ac6f91f7..b6641ac1f9 100644 --- a/website/zh-CN/component/settings.md +++ b/website/zh-CN/component/settings.md @@ -193,6 +193,19 @@ SettingGroup::new() .items(vec![...]) ``` +### 分组表面外的底部说明 + +用 `footer` 在分组的背景或边框下方渲染辅助内容。它与分组标题左对齐,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可;闭包接收当前窗口和应用上下文,可用于更复杂的内容。它随分组一起滚动和过滤,不会成为独立的可搜索设置项,也不会新增侧栏入口;分组仍需至少一个设置项才会显示。 + +```rust +SettingGroup::new() + .item(SettingItem::new( + "Update source", + SettingField::render(|_, _, _| "GitHub Releases"), + )) + .footer(|_, _| "Changes apply to this device only.") +``` + ## Setting Item ### 基础设置项