From fdcc560eb9844f5d010b304b7ba69e39c98fc232 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 18 Sep 2026 20:30:37 +0000 Subject: [PATCH 1/2] feat(setting): add footers outside group surfaces Co-authored-by: Xuan Zhang --- crates/component/src/group_box.rs | 14 +++++++++ crates/component/src/setting/group.rs | 24 ++++++++++++-- crates/component/src/setting/tests.rs | 35 ++++++++++++++++++++- crates/story/src/stories/group_box_story.rs | 6 ++++ crates/story/src/stories/settings_story.rs | 5 +++ website/component/group-box.md | 18 +++++++++++ website/component/settings.md | 20 ++++++++++++ website/zh-CN/component/group-box.md | 17 ++++++++++ website/zh-CN/component/settings.md | 19 +++++++++++ 9 files changed, 155 insertions(+), 3 deletions(-) diff --git a/crates/component/src/group_box.rs b/crates/component/src/group_box.rs index 5f044636a5..16c85e8e06 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 content below the group's filled or outlined surface. + /// + /// The footer participates in normal layout and uses the variant's default + /// horizontal content inset. Typography and colors belong to the caller. + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } } impl ParentElement for GroupBox { @@ -161,6 +172,9 @@ impl RenderOnce for GroupBox { .refine_style(&self.content_style) .children(self.children), ) + .when_some(self.footer, |this, footer| { + this.child(div().when(has_paddings, |this| this.px_4()).child(footer)) + }) } } diff --git a/crates/component/src/setting/group.rs b/crates/component/src/setting/group.rs index 5c0ca045b4..25b3122bc5 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,21 @@ impl SettingGroup { self } + /// Render supporting content below, and outside, the group's surface. + /// + /// The footer scrolls with the group and follows its search visibility; it + /// does not add an independently searchable item or a sidebar entry. + 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 +128,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..671aaf7d7b 100644 --- a/crates/component/src/setting/tests.rs +++ b/crates/component/src/setting/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::{ - Root, + ActiveTheme as _, Root, setting::{SettingGroup, SettingItem}, }; use gpui::{ @@ -179,6 +179,39 @@ 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(|_, cx| { + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .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()); + + search(&host, "colors", cx); + assert!(cx.debug_bounds("setting-1-1-0").is_some()); + assert!(cx.debug_bounds("font-footer").is_none()); + + // Footer copy does not independently make a group match the query. + search(&host, "this device", cx); + assert!(cx.debug_bounds("setting-1-2-1").is_none()); + 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..f1311ac678 100644 --- a/crates/story/src/stories/group_box_story.rs +++ b/crates/story/src/stories/group_box_story.rs @@ -9,6 +9,7 @@ use gpui_kit::component::{ checkbox::Checkbox, group_box::{GroupBox, GroupBoxVariants as _}, h_flex, + label::Label, radio::{Radio, RadioGroup}, switch::Switch, text::markdown, @@ -110,6 +111,11 @@ impl Render for GroupBoxStory { .id("activity") .fill() .title("Contributions & activity") + .footer( + Label::new("Private contributions never reveal repository names.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ) .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..a2b0fa1a6c 100644 --- a/crates/story/src/stories/settings_story.rs +++ b/crates/story/src/stories/settings_story.rs @@ -250,6 +250,11 @@ impl SettingsStory { ]), SettingGroup::new() .title("Font") + .footer(|_, cx| { + Label::new("Font preferences apply to this story only.") + .text_sm() + .text_color(cx.theme().muted_foreground) + }) .item( SettingItem::new( "Font Family", diff --git a/website/component/group-box.md b/website/component/group-box.md index 23c89db27e..926d29e20f 100644 --- a/website/component/group-box.md +++ b/website/component/group-box.md @@ -60,6 +60,24 @@ 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 uses the variant's default horizontal content inset +(`Fill` and `Outline` are inset; `Normal` is not). `content_style` only changes +the body. Style the footer element itself to choose its typography and color. + +```rust +GroupBox::new() + .fill() + .child("Update preferences") + .footer( + Label::new("Changes apply to this device only.") + .text_sm() + .text_color(cx.theme().muted_foreground) + ) +``` + ### Custom ID ```rust diff --git a/website/component/settings.md b/website/component/settings.md index 76189118b5..9a1b64429a 100644 --- a/website/component/settings.md +++ b/website/component/settings.md @@ -199,6 +199,26 @@ SettingGroup::new() .items(vec![...]) ``` +### Footer outside the group surface + +Use `footer` to render supporting content below the group's background or +border. The callback receives the current window and application context, so +the footer can use the active theme. It scrolls and is filtered with the group; +it is not an independently searchable setting or a sidebar entry. + +```rust +SettingGroup::new() + .item(SettingItem::new( + "Update source", + SettingField::render(|_, _, _| "GitHub Releases"), + )) + .footer(|_, cx| { + Label::new("Changes apply to this device only.") + .text_sm() + .text_color(cx.theme().muted_foreground) + }) +``` + ## Setting Item ### Basic Item diff --git a/website/zh-CN/component/group-box.md b/website/zh-CN/component/group-box.md index 7528177fef..9fc4a52752 100644 --- a/website/zh-CN/component/group-box.md +++ b/website/zh-CN/component/group-box.md @@ -57,6 +57,23 @@ GroupBox::new() .child(Button::new("save").primary().label("Save Changes")) ``` +### 卡片外的底部说明 + +用 `footer` 在填充背景或边框下方放置辅助内容,而不是将其放进内容区域。 +它沿用当前变体默认的水平内容缩进:`Fill` 和 `Outline` 有缩进,`Normal` 没有。 +`content_style` 只影响主体内容;底部说明的字体和颜色由传入的元素控制。 + +```rust +GroupBox::new() + .fill() + .child("Update preferences") + .footer( + Label::new("Changes apply to this device only.") + .text_sm() + .text_color(cx.theme().muted_foreground) + ) +``` + ### 自定义 ID ```rust diff --git a/website/zh-CN/component/settings.md b/website/zh-CN/component/settings.md index 97ac6f91f7..d9207eed80 100644 --- a/website/zh-CN/component/settings.md +++ b/website/zh-CN/component/settings.md @@ -193,6 +193,25 @@ SettingGroup::new() .items(vec![...]) ``` +### 分组卡片外的底部说明 + +用 `footer` 在分组的背景或边框下方渲染辅助内容。闭包接收当前窗口和应用上下文, +因此说明内容可以使用当前主题。它随分组一起滚动和过滤,不会成为独立的可搜索设置项, +也不会新增侧栏入口。 + +```rust +SettingGroup::new() + .item(SettingItem::new( + "Update source", + SettingField::render(|_, _, _| "GitHub Releases"), + )) + .footer(|_, cx| { + Label::new("Changes apply to this device only.") + .text_sm() + .text_color(cx.theme().muted_foreground) + }) +``` + ## Setting Item ### 基础设置项 From 7fdc726477dbc7cc4d09a805e087026484f092e9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sun, 20 Sep 2026 21:18:56 +0800 Subject: [PATCH 2/2] group_box: Align the footer with the title and own its muted style The footer shares the title's leading edge instead of the content inset, which also removes the one-pixel drift under the Outline border. It sits 8 px under the surface on its own gap, independent of the root gap a caller may override, and renders as small muted text like a description so callers pass plain content. Co-Authored-By: Claude Fable 5.1 --- crates/component/src/group_box.rs | 39 +++++++++++++-------- crates/component/src/setting/group.rs | 6 ++-- crates/component/src/setting/tests.rs | 12 ++----- crates/story/src/stories/group_box_story.rs | 7 +--- crates/story/src/stories/settings_story.rs | 6 +--- website/component/group-box.md | 12 +++---- website/component/settings.md | 14 ++++---- website/zh-CN/component/group-box.md | 12 +++---- website/zh-CN/component/settings.md | 12 ++----- 9 files changed, 51 insertions(+), 69 deletions(-) diff --git a/crates/component/src/group_box.rs b/crates/component/src/group_box.rs index 16c85e8e06..51140d83ac 100644 --- a/crates/component/src/group_box.rs +++ b/crates/component/src/group_box.rs @@ -109,10 +109,10 @@ impl GroupBox { self } - /// Set content below the group's filled or outlined surface. + /// Set supporting content below the group's filled or outlined surface. /// - /// The footer participates in normal layout and uses the variant's default - /// horizontal content inset. Typography and colors belong to the caller. + /// 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 @@ -162,19 +162,30 @@ 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), + ) + }), ) - .when_some(self.footer, |this, footer| { - this.child(div().when(has_paddings, |this| this.px_4()).child(footer)) - }) } } diff --git a/crates/component/src/setting/group.rs b/crates/component/src/setting/group.rs index 25b3122bc5..d87e5bef9a 100644 --- a/crates/component/src/setting/group.rs +++ b/crates/component/src/setting/group.rs @@ -56,8 +56,10 @@ impl SettingGroup { /// Render supporting content below, and outside, the group's surface. /// - /// The footer scrolls with the group and follows its search visibility; it - /// does not add an independently searchable item or a sidebar entry. + /// 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, diff --git a/crates/component/src/setting/tests.rs b/crates/component/src/setting/tests.rs index 671aaf7d7b..2b17c999c9 100644 --- a/crates/component/src/setting/tests.rs +++ b/crates/component/src/setting/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::{ - ActiveTheme as _, Root, + Root, setting::{SettingGroup, SettingItem}, }; use gpui::{ @@ -184,10 +184,8 @@ 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(|_, cx| { + host.pages[1].groups[2] = host.pages[1].groups[2].clone().footer(|_, _| { div() - .text_sm() - .text_color(cx.theme().muted_foreground) .child("Changes apply to this device only.") .debug_selector(|| "font-footer".into()) }); @@ -198,15 +196,11 @@ fn footer_follows_group_search_visibility(cx: &mut TestAppContext) { 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()); - // Footer copy does not independently make a group match the query. - search(&host, "this device", cx); - assert!(cx.debug_bounds("setting-1-2-1").is_none()); - 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)); diff --git a/crates/story/src/stories/group_box_story.rs b/crates/story/src/stories/group_box_story.rs index f1311ac678..01b283055b 100644 --- a/crates/story/src/stories/group_box_story.rs +++ b/crates/story/src/stories/group_box_story.rs @@ -9,7 +9,6 @@ use gpui_kit::component::{ checkbox::Checkbox, group_box::{GroupBox, GroupBoxVariants as _}, h_flex, - label::Label, radio::{Radio, RadioGroup}, switch::Switch, text::markdown, @@ -111,11 +110,7 @@ impl Render for GroupBoxStory { .id("activity") .fill() .title("Contributions & activity") - .footer( - Label::new("Private contributions never reveal repository names.") - .text_sm() - .text_color(cx.theme().muted_foreground), - ) + .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 a2b0fa1a6c..fbb607f6cb 100644 --- a/crates/story/src/stories/settings_story.rs +++ b/crates/story/src/stories/settings_story.rs @@ -250,11 +250,7 @@ impl SettingsStory { ]), SettingGroup::new() .title("Font") - .footer(|_, cx| { - Label::new("Font preferences apply to this story only.") - .text_sm() - .text_color(cx.theme().muted_foreground) - }) + .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 926d29e20f..85062e16c3 100644 --- a/website/component/group-box.md +++ b/website/component/group-box.md @@ -63,19 +63,15 @@ GroupBox::new() ### Footer outside the surface Use `footer` for supporting content below the filled background or outline, not -inside the content area. It uses the variant's default horizontal content inset -(`Fill` and `Outline` are inset; `Normal` is not). `content_style` only changes -the body. Style the footer element itself to choose its typography and color. +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( - Label::new("Changes apply to this device only.") - .text_sm() - .text_color(cx.theme().muted_foreground) - ) + .footer("Changes apply to this device only.") ``` ### Custom ID diff --git a/website/component/settings.md b/website/component/settings.md index 9a1b64429a..3685689d23 100644 --- a/website/component/settings.md +++ b/website/component/settings.md @@ -202,9 +202,11 @@ SettingGroup::new() ### Footer outside the group surface Use `footer` to render supporting content below the group's background or -border. The callback receives the current window and application context, so -the footer can use the active theme. It scrolls and is filtered with the group; -it is not an independently searchable setting or a sidebar entry. +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() @@ -212,11 +214,7 @@ SettingGroup::new() "Update source", SettingField::render(|_, _, _| "GitHub Releases"), )) - .footer(|_, cx| { - Label::new("Changes apply to this device only.") - .text_sm() - .text_color(cx.theme().muted_foreground) - }) + .footer(|_, _| "Changes apply to this device only.") ``` ## Setting Item diff --git a/website/zh-CN/component/group-box.md b/website/zh-CN/component/group-box.md index 9fc4a52752..73ede571ff 100644 --- a/website/zh-CN/component/group-box.md +++ b/website/zh-CN/component/group-box.md @@ -57,21 +57,17 @@ GroupBox::new() .child(Button::new("save").primary().label("Save Changes")) ``` -### 卡片外的底部说明 +### 表面外的底部说明 用 `footer` 在填充背景或边框下方放置辅助内容,而不是将其放进内容区域。 -它沿用当前变体默认的水平内容缩进:`Fill` 和 `Outline` 有缩进,`Normal` 没有。 -`content_style` 只影响主体内容;底部说明的字体和颜色由传入的元素控制。 +它与标题左对齐,位于表面下方 8 px 处,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可。 +`content_style` 只影响主体内容。 ```rust GroupBox::new() .fill() .child("Update preferences") - .footer( - Label::new("Changes apply to this device only.") - .text_sm() - .text_color(cx.theme().muted_foreground) - ) + .footer("Changes apply to this device only.") ``` ### 自定义 ID diff --git a/website/zh-CN/component/settings.md b/website/zh-CN/component/settings.md index d9207eed80..b6641ac1f9 100644 --- a/website/zh-CN/component/settings.md +++ b/website/zh-CN/component/settings.md @@ -193,11 +193,9 @@ SettingGroup::new() .items(vec![...]) ``` -### 分组卡片外的底部说明 +### 分组表面外的底部说明 -用 `footer` 在分组的背景或边框下方渲染辅助内容。闭包接收当前窗口和应用上下文, -因此说明内容可以使用当前主题。它随分组一起滚动和过滤,不会成为独立的可搜索设置项, -也不会新增侧栏入口。 +用 `footer` 在分组的背景或边框下方渲染辅助内容。它与分组标题左对齐,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可;闭包接收当前窗口和应用上下文,可用于更复杂的内容。它随分组一起滚动和过滤,不会成为独立的可搜索设置项,也不会新增侧栏入口;分组仍需至少一个设置项才会显示。 ```rust SettingGroup::new() @@ -205,11 +203,7 @@ SettingGroup::new() "Update source", SettingField::render(|_, _, _| "GitHub Releases"), )) - .footer(|_, cx| { - Label::new("Changes apply to this device only.") - .text_sm() - .text_color(cx.theme().muted_foreground) - }) + .footer(|_, _| "Changes apply to this device only.") ``` ## Setting Item