diff --git a/crates/assets/assets/icons/redo-2.svg b/crates/assets/assets/icons/redo-2.svg
new file mode 100644
index 00000000..7ea3df93
--- /dev/null
+++ b/crates/assets/assets/icons/redo-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/assets/assets/icons/redo.svg b/crates/assets/assets/icons/redo.svg
new file mode 100644
index 00000000..b90dfa5c
--- /dev/null
+++ b/crates/assets/assets/icons/redo.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/assets/assets/icons/undo-2.svg b/crates/assets/assets/icons/undo-2.svg
new file mode 100644
index 00000000..660356e6
--- /dev/null
+++ b/crates/assets/assets/icons/undo-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/assets/assets/icons/undo.svg b/crates/assets/assets/icons/undo.svg
new file mode 100644
index 00000000..e66126ab
--- /dev/null
+++ b/crates/assets/assets/icons/undo.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/story/examples/brush.rs b/crates/story/examples/brush.rs
index f01ac701..6702099b 100644
--- a/crates/story/examples/brush.rs
+++ b/crates/story/examples/brush.rs
@@ -5,7 +5,7 @@ use gpui_component::{
ActiveTheme, Colorize as _, IconName, Sizable,
button::Button,
checkbox::Checkbox,
- group_box::GroupBox,
+ group_box::{GroupBox, GroupBoxVariants as _},
h_flex,
slider::{Slider, SliderState},
v_flex,
diff --git a/crates/story/src/collapsible_story.rs b/crates/story/src/collapsible_story.rs
index 0df53a3a..5d78c64a 100644
--- a/crates/story/src/collapsible_story.rs
+++ b/crates/story/src/collapsible_story.rs
@@ -4,7 +4,7 @@ use gpui::{
Styled, Window, prelude::FluentBuilder as _,
};
-use gpui_component::group_box::GroupBox;
+use gpui_component::group_box::{GroupBox, GroupBoxVariants as _};
use gpui_component::label::Label;
use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex};
diff --git a/crates/story/src/group_box_story.rs b/crates/story/src/group_box_story.rs
index da942fbb..a062980f 100644
--- a/crates/story/src/group_box_story.rs
+++ b/crates/story/src/group_box_story.rs
@@ -1,17 +1,18 @@
use gpui::{
- relative, App, AppContext, Context, Entity, Focusable, IntoElement, ParentElement, Render,
- StyleRefinement, Styled, Window,
+ App, AppContext, Context, Entity, Focusable, IntoElement, ParentElement, Render,
+ StyleRefinement, Styled, Window, relative,
};
use gpui_component::{
+ ActiveTheme as _, StyledExt,
button::{Button, ButtonVariants},
checkbox::Checkbox,
- group_box::GroupBox,
+ group_box::{GroupBox, GroupBoxVariants as _},
h_flex,
radio::{Radio, RadioGroup},
switch::Switch,
text::TextView,
- v_flex, ActiveTheme as _, StyledExt,
+ v_flex,
};
use crate::section;
diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs
index 431c7276..fdeac8b8 100644
--- a/crates/story/src/lib.rs
+++ b/crates/story/src/lib.rs
@@ -32,6 +32,7 @@ mod radio_story;
mod resizable_story;
mod scrollable_story;
mod select_story;
+mod settings_story;
mod sheet_story;
mod sidebar_story;
mod skeleton_story;
@@ -93,6 +94,7 @@ pub use resizable_story::ResizableStory;
pub use scrollable_story::ScrollableStory;
pub use select_story::SelectStory;
use serde::{Deserialize, Serialize};
+pub use settings_story::SettingsStory;
pub use sheet_story::SheetStory;
pub use sidebar_story::SidebarStory;
pub use skeleton_story::SkeletonStory;
@@ -115,7 +117,7 @@ use gpui_component::{
ActiveTheme, IconName, Root, TitleBar, WindowExt,
button::Button,
dock::{Panel, PanelControl, PanelEvent, PanelInfo, PanelState, TitleStyle, register_panel},
- group_box::GroupBox,
+ group_box::{GroupBox, GroupBoxVariants as _},
h_flex,
menu::PopupMenu,
notification::Notification,
diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs
index ddaca766..f35b0481 100644
--- a/crates/story/src/main.rs
+++ b/crates/story/src/main.rs
@@ -70,6 +70,7 @@ impl Gallery {
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
+ StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
diff --git a/crates/story/src/settings_story.rs b/crates/story/src/settings_story.rs
new file mode 100644
index 00000000..a55257cb
--- /dev/null
+++ b/crates/story/src/settings_story.rs
@@ -0,0 +1,386 @@
+use gpui::{
+ App, AppContext, Axis, Context, Element, Entity, FocusHandle, Focusable, Global, IntoElement,
+ ParentElement as _, Render, SharedString, Styled, Window,
+};
+
+use gpui_component::{
+ ActiveTheme, Icon, IconName, Sizable, Size, Theme, ThemeMode,
+ button::Button,
+ group_box::GroupBoxVariant,
+ h_flex,
+ label::Label,
+ setting::{NumberFieldOptions, SettingField, SettingGroup, SettingItem, SettingPage, Settings},
+ text::TextView,
+ v_flex,
+};
+
+struct AppSettings {
+ auto_switch_theme: bool,
+ cli_path: SharedString,
+ font_family: SharedString,
+ font_size: f64,
+ notifications_enabled: bool,
+ auto_update: bool,
+ resettable: bool,
+}
+
+impl Default for AppSettings {
+ fn default() -> Self {
+ Self {
+ auto_switch_theme: false,
+ cli_path: "/usr/local/bin/bash".into(),
+ font_family: "Arial".into(),
+ font_size: 14.0,
+ notifications_enabled: true,
+ auto_update: true,
+ resettable: true,
+ }
+ }
+}
+
+impl Global for AppSettings {}
+
+impl AppSettings {
+ fn global(cx: &App) -> &AppSettings {
+ cx.global::()
+ }
+
+ pub fn global_mut(cx: &mut App) -> &mut AppSettings {
+ cx.global_mut::()
+ }
+}
+
+pub struct SettingsStory {
+ focus_handle: FocusHandle,
+ group_variant: GroupBoxVariant,
+ size: Size,
+}
+
+impl super::Story for SettingsStory {
+ fn title() -> &'static str {
+ "Settings"
+ }
+
+ fn description() -> &'static str {
+ "A collection of settings groups and items for the application."
+ }
+
+ fn new_view(window: &mut Window, cx: &mut App) -> Entity {
+ Self::view(window, cx)
+ }
+}
+
+impl SettingsStory {
+ pub fn view(window: &mut Window, cx: &mut App) -> Entity {
+ cx.new(|cx| Self::new(window, cx))
+ }
+
+ fn new(_: &mut Window, cx: &mut Context) -> Self {
+ cx.set_global::(AppSettings::default());
+
+ Self {
+ focus_handle: cx.focus_handle(),
+ group_variant: GroupBoxVariant::Outline,
+ size: Size::default(),
+ }
+ }
+
+ fn setting_pages(&self, window: &mut Window, cx: &mut Context) -> Vec {
+ let view = cx.entity();
+ let default_settings = AppSettings::default();
+ let resettable = AppSettings::global(cx).resettable;
+
+ vec![
+ SettingPage::new("General")
+ .resettable(resettable)
+ .default_open(true)
+ .groups(vec![
+ SettingGroup::new().title("Appearance").items(vec![
+ SettingItem::new(
+ "Dark Mode",
+ SettingField::switch(
+ |cx: &App| cx.theme().mode.is_dark(),
+ |val: bool, cx: &mut App| {
+ let mode = if val {
+ ThemeMode::Dark
+ } else {
+ ThemeMode::Light
+ };
+ Theme::global_mut(cx).mode = mode;
+ Theme::change(mode, None, cx);
+ },
+ )
+ .default_value(false),
+ )
+ .description("Switch between light and dark themes."),
+ SettingItem::new(
+ "Auto Switch Theme",
+ SettingField::checkbox(
+ |cx: &App| AppSettings::global(cx).auto_switch_theme,
+ |val: bool, cx: &mut App| {
+ AppSettings::global_mut(cx).auto_switch_theme = val;
+ },
+ )
+ .default_value(default_settings.auto_switch_theme),
+ )
+ .description("Automatically switch theme based on system settings."),
+ SettingItem::new(
+ "resettable",
+ SettingField::switch(
+ |cx: &App| AppSettings::global(cx).resettable,
+ |checked: bool, cx: &mut App| {
+ AppSettings::global_mut(cx).resettable = checked
+ },
+ ),
+ )
+ .description("Enable/Disable reset button for settings."),
+ SettingItem::new(
+ "Group Variant",
+ SettingField::dropdown(
+ vec![
+ (GroupBoxVariant::Normal.as_str().into(), "Normal".into()),
+ (GroupBoxVariant::Outline.as_str().into(), "Outline".into()),
+ (GroupBoxVariant::Fill.as_str().into(), "Fill".into()),
+ ],
+ {
+ let view = view.clone();
+ move |cx: &App| {
+ SharedString::from(
+ view.read(cx).group_variant.as_str().to_string(),
+ )
+ }
+ },
+ {
+ let view = view.clone();
+ move |val: SharedString, cx: &mut App| {
+ view.update(cx, |view, cx| {
+ view.group_variant =
+ GroupBoxVariant::from_str(val.as_str());
+ cx.notify();
+ });
+ }
+ },
+ )
+ .default_value(GroupBoxVariant::Outline.as_str().to_string()),
+ )
+ .description("Select the variant for setting groups."),
+ SettingItem::new(
+ "Group Size",
+ SettingField::dropdown(
+ vec![
+ (Size::Medium.as_str().into(), "Medium".into()),
+ (Size::Small.as_str().into(), "Small".into()),
+ (Size::XSmall.as_str().into(), "XSmall".into()),
+ ],
+ {
+ let view = view.clone();
+ move |cx: &App| {
+ SharedString::from(view.read(cx).size.as_str().to_string())
+ }
+ },
+ {
+ let view = view.clone();
+ move |val: SharedString, cx: &mut App| {
+ view.update(cx, |view, cx| {
+ view.size = Size::from_str(val.as_str());
+ cx.notify();
+ });
+ }
+ },
+ )
+ .default_value(Size::default().as_str().to_string()),
+ )
+ .description("Select the size for the setting group."),
+ ]),
+ SettingGroup::new()
+ .title("Font")
+ .item(
+ SettingItem::new(
+ "Font Family",
+ SettingField::dropdown(
+ vec![
+ ("Arial".into(), "Arial".into()),
+ ("Helvetica".into(), "Helvetica".into()),
+ ("Times New Roman".into(), "Times New Roman".into()),
+ ("Courier New".into(), "Courier New".into()),
+ ],
+ |cx: &App| AppSettings::global(cx).font_family.clone(),
+ |val: SharedString, cx: &mut App| {
+ AppSettings::global_mut(cx).font_family = val;
+ },
+ )
+ .default_value(default_settings.font_family),
+ )
+ .description("Select the font family for the story."),
+ )
+ .item(
+ SettingItem::new(
+ "Font Size",
+ SettingField::number_input(
+ NumberFieldOptions {
+ min: 8.0,
+ max: 72.0,
+ ..Default::default()
+ },
+ |cx: &App| AppSettings::global(cx).font_size,
+ |val: f64, cx: &mut App| {
+ AppSettings::global_mut(cx).font_size = val;
+ },
+ )
+ .default_value(default_settings.font_size),
+ )
+ .description("Adjust the font size for better readability."),
+ ),
+ SettingGroup::new().title("Other").items(vec![
+ SettingItem::element(|options, _, _| {
+ h_flex()
+ .w_full()
+ .justify_between()
+ .flex_wrap()
+ .gap_3()
+ .child("This is a custom element item by use SettingItem::element.")
+ .child(
+ Button::new("action")
+ .icon(IconName::Globe)
+ .label("Repository...")
+ .outline()
+ .with_size(options.size)
+ .on_click(|_, _, cx| {
+ cx.open_url(
+ "https://github.com/longbridge/gpui-component",
+ );
+ }),
+ )
+ .into_any_element()
+ }),
+ SettingItem::new(
+ "CLI Path",
+ SettingField::input(
+ |cx: &App| AppSettings::global(cx).cli_path.clone(),
+ |val: SharedString, cx: &mut App| {
+ println!("cli-path set value: {}", val);
+ AppSettings::global_mut(cx).cli_path = val;
+ },
+ )
+ .default_value(default_settings.cli_path),
+ )
+ .layout(Axis::Vertical)
+ .description(
+ "Path to the CLI executable. \n\
+ This item uses Vertical layout. The title,\
+ description, and field are all aligned vertically with width 100%.",
+ ),
+ ]),
+ ]),
+ SettingPage::new("Software Update")
+ .resettable(resettable)
+ .groups(vec![SettingGroup::new().title("Updates").items(vec![
+ SettingItem::new(
+ "Enable Notifications",
+ SettingField::switch(
+ |cx: &App| AppSettings::global(cx).notifications_enabled,
+ |val: bool, cx: &mut App| {
+ AppSettings::global_mut(cx).notifications_enabled = val;
+ },
+ )
+ .default_value(default_settings.notifications_enabled),
+ )
+ .description("Receive notifications about updates and news."),
+ SettingItem::new(
+ "Auto Update",
+ SettingField::switch(
+ |cx: &App| AppSettings::global(cx).auto_update,
+ |val: bool, cx: &mut App| {
+ AppSettings::global_mut(cx).auto_update = val;
+ },
+ )
+ .default_value(default_settings.auto_update),
+ )
+ .description("Automatically download and install updates."),
+ ])]),
+ SettingPage::new("About")
+ .resettable(resettable)
+ .group(
+ SettingGroup::new().item(SettingItem::element(|_options, _, cx| {
+ v_flex()
+ .gap_3()
+ .w_full()
+ .items_center()
+ .justify_center()
+ .child(Icon::new(IconName::GalleryVerticalEnd).size_16())
+ .child("GPUI Component")
+ .child(
+ Label::new(
+ "Rust GUI components for building fantastic cross-platform \
+ desktop application by using GPUI.",
+ )
+ .text_sm()
+ .text_color(cx.theme().muted_foreground),
+ )
+ .into_any()
+ })),
+ )
+ .group(SettingGroup::new().title("Links").items(vec![
+ SettingItem::new(
+ "GitHub Repository",
+ SettingField::element(|options, _window, _cx| {
+ Button::new("open-url")
+ .outline()
+ .label("Repository...")
+ .with_size(options.size)
+ .on_click(|_, _window, cx| {
+ cx.open_url("https://github.com/longbridge/gpui-component");
+ })
+ }),
+ )
+ .description("Open the GitHub repository in your default browser."),
+ SettingItem::new(
+ "Documentation",
+ SettingField::element(|options, _window, _cx| {
+ Button::new("open-url")
+ .outline()
+ .label("Rust Docs...")
+ .with_size(options.size)
+ .on_click(|_, _window, cx| {
+ cx.open_url("https://docs.rs/gpui-component");
+ })
+ }),
+ )
+ .description(TextView::markdown(
+ "desc",
+ "Rust doc for the `gpui-component` crate.",
+ window,
+ cx,
+ )),
+ SettingItem::new(
+ "Website",
+ SettingField::element(|options, _window, _cx| {
+ Button::new("open-url")
+ .outline()
+ .label("Website...")
+ .with_size(options.size)
+ .on_click(|_, _window, cx| {
+ cx.open_url("https://longbridge.github.io/gpui-component/");
+ })
+ }),
+ )
+ .description("Official website and documentation for the GPUI Component."),
+ ])),
+ ]
+ }
+}
+
+impl Focusable for SettingsStory {
+ fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for SettingsStory {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ Settings::new("app-settings")
+ .with_size(self.size)
+ .with_group_variant(self.group_variant)
+ .pages(self.setting_pages(window, cx))
+ }
+}
diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml
index 1267ef45..3ede8421 100644
--- a/crates/ui/locales/ui.yml
+++ b/crates/ui/locales/ui.yml
@@ -188,3 +188,14 @@ Input:
en: Show Code Actions
zh-CN: 显示代码操作
zh-HK: 顯示代碼操作
+Settings:
+ search_placeholder:
+ en: Search...
+ zh-CN: 搜索...
+ zh-HK: 搜索...
+ it: Ricerca...
+ Reset All:
+ en: Reset All
+ zh-CN: 重置全部
+ zh-HK: 重置全部
+ it: Resetta Tutto
diff --git a/crates/ui/src/group_box.rs b/crates/ui/src/group_box.rs
index 88941fe1..070f41bb 100644
--- a/crates/ui/src/group_box.rs
+++ b/crates/ui/src/group_box.rs
@@ -15,6 +15,47 @@ pub enum GroupBoxVariant {
Outline,
}
+/// Trait to add GroupBox variant methods to elements.
+pub trait GroupBoxVariants: Sized {
+ /// Set the variant of the [`GroupBox`].
+ fn with_variant(self, variant: GroupBoxVariant) -> Self;
+ /// Set to use [`GroupBoxVariant::Normal`] to GroupBox.
+ fn normal(mut self) -> Self {
+ self = self.with_variant(GroupBoxVariant::Normal);
+ self
+ }
+ /// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
+ fn fill(mut self) -> Self {
+ self = self.with_variant(GroupBoxVariant::Fill);
+ self
+ }
+ /// Set to use [`GroupBoxVariant::Outline`] to GroupBox.
+ fn outline(mut self) -> Self {
+ self = self.with_variant(GroupBoxVariant::Outline);
+ self
+ }
+}
+
+impl GroupBoxVariant {
+ /// Create a GroupBoxVariant from a string.
+ pub fn from_str(s: &str) -> Self {
+ match s.to_lowercase().as_str() {
+ "fill" => GroupBoxVariant::Fill,
+ "outline" => GroupBoxVariant::Outline,
+ _ => GroupBoxVariant::Normal,
+ }
+ }
+
+ /// Convert the GroupBoxVariant to a string.
+ pub fn as_str(&self) -> &str {
+ match self {
+ GroupBoxVariant::Normal => "normal",
+ GroupBoxVariant::Fill => "fill",
+ GroupBoxVariant::Outline => "outline",
+ }
+ }
+}
+
/// GroupBox is a styled container element that with
/// an optional title to groups related content together.
#[derive(IntoElement)]
@@ -42,26 +83,6 @@ impl GroupBox {
}
}
- /// Set the variant of the group box.
- pub fn with_variant(mut self, variant: GroupBoxVariant) -> Self {
- self.variant = variant;
- self
- }
-
- /// Set to use Fill variant.
- pub fn fill(mut self) -> Self {
- self.variant = GroupBoxVariant::Fill;
- self
- }
-
- /// Set use outline style of the group box.
- ///
- /// If true, the group box will have a border around it, and no background color.
- pub fn outline(mut self) -> Self {
- self.variant = GroupBoxVariant::Outline;
- self
- }
-
/// Set the id of the group box, default is None.
pub fn id(mut self, id: impl Into) -> Self {
self.id = Some(id.into());
@@ -99,6 +120,13 @@ impl Styled for GroupBox {
}
}
+impl GroupBoxVariants for GroupBox {
+ fn with_variant(mut self, variant: GroupBoxVariant) -> Self {
+ self.variant = variant;
+ self
+ }
+}
+
impl RenderOnce for GroupBox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (bg, border, has_paddings) = match self.variant {
@@ -135,3 +163,29 @@ impl RenderOnce for GroupBox {
)
}
}
+
+#[cfg(test)]
+mod test {
+ #[test]
+ fn test_group_variant_from_str() {
+ use super::GroupBoxVariant;
+
+ assert_eq!(GroupBoxVariant::from_str("normal"), GroupBoxVariant::Normal);
+ assert_eq!(GroupBoxVariant::from_str("fill"), GroupBoxVariant::Fill);
+ assert_eq!(
+ GroupBoxVariant::from_str("outline"),
+ GroupBoxVariant::Outline
+ );
+ assert_eq!(GroupBoxVariant::from_str("other"), GroupBoxVariant::Normal);
+
+ assert_eq!(GroupBoxVariant::from_str("FILL"), GroupBoxVariant::Fill);
+ assert_eq!(
+ GroupBoxVariant::from_str("OutLine"),
+ GroupBoxVariant::Outline
+ );
+
+ assert_eq!(GroupBoxVariant::Normal.as_str(), "normal");
+ assert_eq!(GroupBoxVariant::Fill.as_str(), "fill");
+ assert_eq!(GroupBoxVariant::Outline.as_str(), "outline");
+ }
+}
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index e83bb4d3..b6867a43 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -86,6 +86,8 @@ pub enum IconName {
PanelRightClose,
PanelRightOpen,
Plus,
+ Redo,
+ Redo2,
Replace,
ResizeCorner,
Search,
@@ -100,6 +102,8 @@ pub enum IconName {
ThumbsDown,
ThumbsUp,
TriangleAlert,
+ Undo,
+ Undo2,
User,
WindowClose,
WindowMaximize,
@@ -180,6 +184,8 @@ impl IconNamed for IconName {
Self::PanelRightClose => "icons/panel-right-close.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::Plus => "icons/plus.svg",
+ Self::Redo => "icons/redo.svg",
+ Self::Redo2 => "icons/redo-2.svg",
Self::Replace => "icons/replace.svg",
Self::ResizeCorner => "icons/resize-corner.svg",
Self::Search => "icons/search.svg",
@@ -194,6 +200,8 @@ impl IconNamed for IconName {
Self::ThumbsDown => "icons/thumbs-down.svg",
Self::ThumbsUp => "icons/thumbs-up.svg",
Self::TriangleAlert => "icons/triangle-alert.svg",
+ Self::Undo => "icons/undo.svg",
+ Self::Undo2 => "icons/undo-2.svg",
Self::User => "icons/user.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index 05a65fde..cc47ece9 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -50,6 +50,7 @@ pub mod radio;
pub mod resizable;
pub mod scroll;
pub mod select;
+pub mod setting;
pub mod sheet;
pub mod sidebar;
pub mod skeleton;
diff --git a/crates/ui/src/setting/fields/bool.rs b/crates/ui/src/setting/fields/bool.rs
new file mode 100644
index 00000000..83920c1b
--- /dev/null
+++ b/crates/ui/src/setting/fields/bool.rs
@@ -0,0 +1,57 @@
+use std::rc::Rc;
+
+use crate::{
+ checkbox::Checkbox,
+ setting::{
+ fields::{get_value, set_value, SettingFieldRender},
+ AnySettingField, RenderOptions,
+ },
+ switch::Switch,
+ Sizable, StyledExt,
+};
+use gpui::{div, AnyElement, App, IntoElement, ParentElement as _, StyleRefinement, Window};
+
+pub(crate) struct BoolField {
+ use_switch: bool,
+}
+
+impl BoolField {
+ pub(crate) fn new(use_switch: bool) -> Self {
+ Self { use_switch }
+ }
+}
+
+impl SettingFieldRender for BoolField {
+ fn render(
+ &self,
+ field: Rc,
+ options: &RenderOptions,
+ style: &StyleRefinement,
+ _: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let checked = get_value::(&field, cx);
+ let set_value = set_value::(&field, cx);
+
+ div()
+ .refine_style(style)
+ .child(if self.use_switch {
+ Switch::new("check")
+ .checked(checked)
+ .with_size(options.size)
+ .on_click(move |checked: &bool, _, cx: &mut App| {
+ set_value(*checked, cx);
+ })
+ .into_any_element()
+ } else {
+ Checkbox::new("check")
+ .checked(checked)
+ .with_size(options.size)
+ .on_click(move |checked: &bool, _, cx: &mut App| {
+ set_value(*checked, cx);
+ })
+ .into_any_element()
+ })
+ .into_any_element()
+ }
+}
diff --git a/crates/ui/src/setting/fields/dropdown.rs b/crates/ui/src/setting/fields/dropdown.rs
new file mode 100644
index 00000000..c15986fc
--- /dev/null
+++ b/crates/ui/src/setting/fields/dropdown.rs
@@ -0,0 +1,83 @@
+use std::rc::Rc;
+
+use gpui::{
+ prelude::FluentBuilder as _, AnyElement, App, Corner, IntoElement, SharedString,
+ StyleRefinement, Styled, Window,
+};
+
+use crate::{
+ button::Button,
+ menu::{DropdownMenu, PopupMenuItem},
+ setting::{
+ fields::{get_value, set_value, SettingFieldRender},
+ AnySettingField, RenderOptions,
+ },
+ AxisExt, Sizable, StyledExt,
+};
+
+pub(crate) struct DropdownField {
+ options: Vec<(SharedString, SharedString)>,
+ _marker: std::marker::PhantomData,
+}
+
+impl DropdownField {
+ pub(crate) fn new(options: Option<&Vec<(SharedString, SharedString)>>) -> Self {
+ Self {
+ options: options.cloned().unwrap_or(vec![]),
+ _marker: std::marker::PhantomData,
+ }
+ }
+}
+
+impl SettingFieldRender for DropdownField
+where
+ T: Into + From + Clone + 'static,
+{
+ fn render(
+ &self,
+ field: Rc,
+ options: &RenderOptions,
+ style: &StyleRefinement,
+ _: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let old_value = get_value::(&field, cx);
+ let set_value = set_value::(&field, cx);
+ let dropdown_options = self.options.clone();
+
+ let old_label = dropdown_options
+ .iter()
+ .find(|(value, _)| *value == old_value.clone().into())
+ .map(|(_, label)| label.clone())
+ .unwrap_or_else(|| old_value.clone().into());
+
+ Button::new("btn")
+ .when(options.layout.is_vertical(), |this| this.w_full())
+ .label(old_label)
+ .dropdown_caret(true)
+ .outline()
+ .with_size(options.size)
+ .refine_style(style)
+ .dropdown_menu_with_anchor(Corner::TopRight, move |menu, _, _| {
+ let set_value = set_value.clone();
+ let menu = dropdown_options.iter().fold(menu, |menu, (value, label)| {
+ let old_value: SharedString = old_value.clone().into();
+ let checked = &old_value == value;
+ menu.item(
+ PopupMenuItem::new(label.clone())
+ .checked(checked)
+ .on_click({
+ let value = value.clone();
+ let set_value = set_value.clone();
+ move |_, _, cx| {
+ set_value(T::from(value.clone()), cx);
+ }
+ }),
+ )
+ });
+
+ menu
+ })
+ .into_any_element()
+ }
+}
diff --git a/crates/ui/src/setting/fields/element.rs b/crates/ui/src/setting/fields/element.rs
new file mode 100644
index 00000000..5df18e1d
--- /dev/null
+++ b/crates/ui/src/setting/fields/element.rs
@@ -0,0 +1,29 @@
+use gpui::{AnyElement, App, StyleRefinement, Window};
+use std::rc::Rc;
+
+use crate::setting::{fields::SettingFieldRender, AnySettingField, RenderOptions};
+
+pub(crate) struct ElementField {
+ element_render: Rc AnyElement>,
+}
+
+impl ElementField {
+ pub(crate) fn new(
+ element_render: Rc AnyElement + 'static>,
+ ) -> Self {
+ Self { element_render }
+ }
+}
+
+impl SettingFieldRender for ElementField {
+ fn render(
+ &self,
+ _: Rc,
+ options: &RenderOptions,
+ _style: &StyleRefinement,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ (self.element_render)(options, window, cx)
+ }
+}
diff --git a/crates/ui/src/setting/fields/mod.rs b/crates/ui/src/setting/fields/mod.rs
new file mode 100644
index 00000000..4852a371
--- /dev/null
+++ b/crates/ui/src/setting/fields/mod.rs
@@ -0,0 +1,283 @@
+mod bool;
+mod dropdown;
+mod element;
+mod number;
+mod string;
+
+pub(crate) use bool::*;
+pub(crate) use dropdown::*;
+pub(crate) use element::*;
+pub(crate) use number::*;
+pub(crate) use string::*;
+
+pub use number::NumberFieldOptions;
+
+use gpui::{AnyElement, App, IntoElement, SharedString, StyleRefinement, Styled, Window};
+use std::{any::Any, rc::Rc};
+
+use crate::setting::RenderOptions;
+
+pub(crate) trait SettingFieldRender {
+ #[allow(clippy::too_many_arguments)]
+ fn render(
+ &self,
+ field: Rc,
+ options: &RenderOptions,
+ style: &StyleRefinement,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement;
+}
+
+pub(crate) fn get_value(field: &Rc, cx: &mut App) -> T {
+ let setting_field = field
+ .as_any()
+ .downcast_ref::>()
+ .expect("Failed to downcast setting field");
+ (setting_field.value)(cx)
+}
+
+pub(crate) fn set_value(
+ field: &Rc,
+ _cx: &mut App,
+) -> Rc {
+ let setting_field = field
+ .as_any()
+ .downcast_ref::>()
+ .expect("Failed to downcast setting field");
+ setting_field.set_value.clone()
+}
+
+/// The type of setting field to render.
+#[derive(Clone)]
+pub enum SettingFieldType {
+ Switch,
+ Checkbox,
+ NumberInput {
+ options: NumberFieldOptions,
+ },
+ Input,
+ Dropdown {
+ options: Vec<(SharedString, SharedString)>,
+ },
+ Element {
+ element_render: Rc AnyElement>,
+ },
+}
+
+impl SettingFieldType {
+ #[inline]
+ pub(crate) fn is_switch(&self) -> bool {
+ matches!(self, SettingFieldType::Switch)
+ }
+
+ #[inline]
+ pub(crate) fn is_number_input(&self) -> bool {
+ matches!(self, SettingFieldType::NumberInput { .. })
+ }
+
+ #[inline]
+ pub(crate) fn is_input(&self) -> bool {
+ matches!(self, SettingFieldType::Input)
+ }
+
+ #[inline]
+ pub(crate) fn is_dropdown(&self) -> bool {
+ matches!(self, SettingFieldType::Dropdown { .. })
+ }
+
+ #[inline]
+ pub(crate) fn is_element(&self) -> bool {
+ matches!(self, SettingFieldType::Element { .. })
+ }
+
+ #[inline]
+ pub(super) fn dropdown_options(&self) -> Option<&Vec<(SharedString, SharedString)>> {
+ match self {
+ SettingFieldType::Dropdown { options } => Some(options),
+ _ => None,
+ }
+ }
+
+ #[inline]
+ pub(super) fn number_input_options(&self) -> Option<&NumberFieldOptions> {
+ match self {
+ SettingFieldType::NumberInput { options } => Some(options),
+ _ => None,
+ }
+ }
+
+ #[inline]
+ pub(super) fn element_render(
+ &self,
+ ) -> Rc AnyElement + 'static> {
+ match self {
+ SettingFieldType::Element { element_render } => element_render.clone(),
+ _ => unreachable!("element_render called on non-element field"),
+ }
+ }
+}
+
+/// A setting field that can get and set a value of type T in the App.
+pub struct SettingField {
+ pub(crate) field_type: SettingFieldType,
+ pub(crate) style: StyleRefinement,
+ /// Function to get the value for this field.
+ pub(crate) value: Rc T>,
+ /// Function to set the value for this field.
+ pub(crate) set_value: Rc,
+ pub(crate) default_value: Option,
+}
+
+impl SettingField {
+ /// Create a new Switch field.
+ pub fn switch(value: V, set_value: S) -> Self
+ where
+ V: Fn(&App) -> bool + 'static,
+ S: Fn(bool, &mut App) + 'static,
+ {
+ Self::new(SettingFieldType::Switch, value, set_value)
+ }
+
+ /// Create a new Checkbox field.
+ pub fn checkbox(value: V, set_value: S) -> Self
+ where
+ V: Fn(&App) -> bool + 'static,
+ S: Fn(bool, &mut App) + 'static,
+ {
+ Self::new(SettingFieldType::Checkbox, value, set_value)
+ }
+}
+
+impl SettingField {
+ /// Create a new Input field.
+ pub fn input(value: V, set_value: S) -> Self
+ where
+ V: Fn(&App) -> SharedString + 'static,
+ S: Fn(SharedString, &mut App) + 'static,
+ {
+ Self::new(SettingFieldType::Input, value, set_value)
+ }
+
+ /// Create a new Dropdown field with the given options.
+ pub fn dropdown(
+ options: Vec<(SharedString, SharedString)>,
+ value: V,
+ set_value: S,
+ ) -> Self
+ where
+ V: Fn(&App) -> SharedString + 'static,
+ S: Fn(SharedString, &mut App) + 'static,
+ {
+ Self::new(SettingFieldType::Dropdown { options }, value, set_value)
+ }
+
+ /// Create a new setting field with the given element render function.
+ pub fn element(element_render: R) -> Self
+ where
+ E: IntoElement,
+ R: Fn(&RenderOptions, &mut Window, &mut App) -> E + 'static,
+ {
+ Self::new(
+ SettingFieldType::Element {
+ element_render: Rc::new(move |options, window, cx| {
+ element_render(options, window, cx).into_any_element()
+ }),
+ },
+ |_| SharedString::default(),
+ |_, _| {},
+ )
+ }
+}
+
+impl SettingField {
+ /// Create a new Number Input field with the given options.
+ pub fn number_input(options: NumberFieldOptions, value: V, set_value: S) -> Self
+ where
+ V: Fn(&App) -> f64 + 'static,
+ S: Fn(f64, &mut App) + 'static,
+ {
+ Self::new(SettingFieldType::NumberInput { options }, value, set_value)
+ }
+}
+
+impl SettingField {
+ /// Create a new setting field with the given get and set functions.
+ fn new(field_type: SettingFieldType, value: V, set_value: S) -> Self
+ where
+ V: Fn(&App) -> T + 'static,
+ S: Fn(T, &mut App) + 'static,
+ {
+ Self {
+ field_type,
+ style: StyleRefinement::default(),
+ value: Rc::new(value),
+ set_value: Rc::new(set_value),
+ default_value: None,
+ }
+ }
+
+ /// Set the default value for this setting field, default is None.
+ ///
+ /// If set, this value can be used to reset the setting to its default state.
+ /// If not set, the setting cannot be reset.
+ pub fn default_value(mut self, default_value: impl Into) -> Self {
+ self.default_value = Some(default_value.into());
+ self
+ }
+}
+
+impl Styled for SettingField {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
+/// A trait for setting fields that allows for dynamic typing.
+pub trait AnySettingField {
+ fn as_any(&self) -> &dyn std::any::Any;
+ fn type_name(&self) -> &'static str;
+ fn type_id(&self) -> std::any::TypeId;
+ fn field_type(&self) -> &SettingFieldType;
+ fn style(&self) -> &StyleRefinement;
+ fn is_resettable(&self, cx: &App) -> bool;
+ fn reset(&self, window: &mut Window, cx: &mut App);
+}
+
+impl AnySettingField for SettingField {
+ fn as_any(&self) -> &dyn Any {
+ self
+ }
+
+ fn type_name(&self) -> &'static str {
+ std::any::type_name::()
+ }
+
+ fn type_id(&self) -> std::any::TypeId {
+ std::any::TypeId::of::()
+ }
+
+ fn field_type(&self) -> &SettingFieldType {
+ &self.field_type
+ }
+
+ fn style(&self) -> &StyleRefinement {
+ &self.style
+ }
+
+ fn is_resettable(&self, cx: &App) -> bool {
+ let Some(default_value) = self.default_value.as_ref() else {
+ return false;
+ };
+
+ &(self.value)(cx) != default_value
+ }
+
+ fn reset(&self, _: &mut Window, cx: &mut App) {
+ let Some(default_value) = self.default_value.as_ref() else {
+ return;
+ };
+
+ (self.set_value)(default_value.clone(), cx)
+ }
+}
diff --git a/crates/ui/src/setting/fields/number.rs b/crates/ui/src/setting/fields/number.rs
new file mode 100644
index 00000000..1e419b27
--- /dev/null
+++ b/crates/ui/src/setting/fields/number.rs
@@ -0,0 +1,111 @@
+use std::rc::Rc;
+
+use gpui::{
+ prelude::FluentBuilder as _, AnyElement, App, AppContext as _, Entity, IntoElement,
+ SharedString, StyleRefinement, Styled, Window,
+};
+
+use crate::{
+ input::{InputState, NumberInput, NumberInputEvent},
+ setting::{
+ fields::{get_value, set_value, SettingFieldRender},
+ AnySettingField, RenderOptions,
+ },
+ AxisExt, Sizable, StyledExt,
+};
+
+#[derive(Clone, Debug)]
+pub struct NumberFieldOptions {
+ /// The minimum value for the number input, default is `f64::MIN`.
+ pub min: f64,
+ /// The maximum value for the number input, default is `f64::MAX`.
+ pub max: f64,
+ /// The step value for the number input, default is `1.0`.
+ pub step: f64,
+}
+
+impl Default for NumberFieldOptions {
+ fn default() -> Self {
+ Self {
+ min: f64::MIN,
+ max: f64::MAX,
+ step: 1.0,
+ }
+ }
+}
+
+pub(crate) struct NumberField {
+ options: NumberFieldOptions,
+}
+
+impl NumberField {
+ pub(crate) fn new(options: Option<&NumberFieldOptions>) -> Self {
+ Self {
+ options: options.cloned().unwrap_or_default(),
+ }
+ }
+}
+
+struct State {
+ input: Entity,
+ _subscription: gpui::Subscription,
+}
+
+impl SettingFieldRender for NumberField {
+ fn render(
+ &self,
+ field: Rc,
+ options: &RenderOptions,
+ style: &StyleRefinement,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let value = get_value::(&field, cx);
+ let set_value = set_value::(&field, cx);
+ let num_options = self.options.clone();
+
+ let state = window
+ .use_keyed_state("number-state", cx, |window, cx| {
+ let input =
+ cx.new(|cx| InputState::new(window, cx).default_value(value.to_string()));
+ let _subscription = cx.subscribe_in(&input, window, {
+ move |_, input, event: &NumberInputEvent, window, cx| match event {
+ NumberInputEvent::Step(action) => input.update(cx, |input, cx| {
+ let value = input.value();
+ if let Ok(value) = value.parse::() {
+ let new_value = if *action == crate::input::StepAction::Increment {
+ (value + num_options.step).min(num_options.max)
+ } else {
+ (value - num_options.step).max(num_options.min)
+ };
+ set_value(new_value, cx);
+ input.set_value(
+ SharedString::from(new_value.to_string()),
+ window,
+ cx,
+ );
+ }
+ }),
+ }
+ });
+
+ State {
+ input,
+ _subscription,
+ }
+ })
+ .read(cx);
+
+ NumberInput::new(&state.input)
+ .with_size(options.size)
+ .map(|this| {
+ if options.layout.is_horizontal() {
+ this.w_32()
+ } else {
+ this.w_full()
+ }
+ })
+ .refine_style(style)
+ .into_any_element()
+ }
+}
diff --git a/crates/ui/src/setting/fields/string.rs b/crates/ui/src/setting/fields/string.rs
new file mode 100644
index 00000000..d7a222b5
--- /dev/null
+++ b/crates/ui/src/setting/fields/string.rs
@@ -0,0 +1,81 @@
+use std::rc::Rc;
+
+use gpui::{
+ prelude::FluentBuilder as _, AnyElement, App, AppContext as _, Entity, IntoElement,
+ SharedString, StyleRefinement, Styled, Window,
+};
+
+use crate::{
+ input::{Input, InputEvent, InputState},
+ setting::{
+ fields::{get_value, set_value, SettingFieldRender},
+ AnySettingField, RenderOptions,
+ },
+ AxisExt as _, Sizable, StyledExt,
+};
+
+pub(crate) struct StringField {
+ _marker: std::marker::PhantomData,
+}
+
+impl StringField {
+ pub(crate) fn new() -> Self {
+ Self {
+ _marker: std::marker::PhantomData,
+ }
+ }
+}
+
+struct State {
+ input: Entity,
+ _subscription: gpui::Subscription,
+}
+
+impl SettingFieldRender for StringField
+where
+ T: Into + From + Clone + 'static,
+{
+ fn render(
+ &self,
+ field: Rc,
+ options: &RenderOptions,
+ style: &StyleRefinement,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let value = get_value::(&field, cx);
+ let set_value = set_value::(&field, cx);
+
+ let state = window
+ .use_keyed_state("string-state", cx, |window, cx| {
+ let input = cx.new(|cx| InputState::new(window, cx).default_value(value));
+ let _subscription = cx.subscribe(&input, {
+ move |_, input, event: &InputEvent, cx| match event {
+ InputEvent::Change => {
+ let value = input.read(cx).value();
+ set_value(value.into(), cx);
+ }
+ _ => {}
+ }
+ });
+
+ State {
+ input,
+ _subscription,
+ }
+ })
+ .read(cx);
+
+ Input::new(&state.input)
+ .with_size(options.size)
+ .map(|this| {
+ if options.layout.is_horizontal() {
+ this.w_64()
+ } else {
+ this.w_full()
+ }
+ })
+ .refine_style(style)
+ .into_any_element()
+ }
+}
diff --git a/crates/ui/src/setting/group.rs b/crates/ui/src/setting/group.rs
new file mode 100644
index 00000000..33fdc634
--- /dev/null
+++ b/crates/ui/src/setting/group.rs
@@ -0,0 +1,115 @@
+use gpui::{
+ prelude::FluentBuilder as _, App, IntoElement, ParentElement as _, SharedString,
+ StyleRefinement, Styled, Window,
+};
+
+use crate::{
+ group_box::{GroupBox, GroupBoxVariants},
+ label::Label,
+ setting::{RenderOptions, SettingItem},
+ v_flex, ActiveTheme, StyledExt,
+};
+
+/// A setting group that can contain multiple setting items.
+#[derive(Clone)]
+pub struct SettingGroup {
+ style: StyleRefinement,
+
+ pub(super) title: Option,
+ pub(super) description: Option,
+ pub(super) items: Vec,
+}
+
+impl Styled for SettingGroup {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
+impl SettingGroup {
+ /// Create a new setting group.
+ pub fn new() -> Self {
+ Self {
+ style: StyleRefinement::default(),
+ title: None,
+ description: None,
+ items: Vec::new(),
+ }
+ }
+
+ /// Set the label of the setting group, default is None.
+ pub fn title(mut self, title: impl Into) -> Self {
+ self.title = Some(title.into());
+ self
+ }
+
+ /// Set the description of the setting group, default is None.
+ pub fn description(mut self, description: impl Into) -> Self {
+ self.description = Some(description.into());
+ self
+ }
+
+ /// Add a setting item to the group.
+ pub fn item(mut self, item: SettingItem) -> Self {
+ self.items.push(item);
+ self
+ }
+
+ /// Add multiple setting items to the group.
+ pub fn items(mut self, items: I) -> Self
+ where
+ I: IntoIterator- ,
+ {
+ self.items.extend(items);
+ self
+ }
+
+ /// Return true if any of the setting items in the group match the given query.
+ pub(super) fn is_match(&self, query: &str) -> bool {
+ self.items.iter().any(|item| item.is_match(query))
+ }
+
+ pub(super) fn is_resettable(&self, cx: &App) -> bool {
+ self.items.iter().any(|item| item.is_resettable(cx))
+ }
+
+ pub(crate) fn render(
+ self,
+ group_ix: usize,
+ query: &str,
+ options: &RenderOptions,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ GroupBox::new()
+ .id(SharedString::from(format!("group-{}", group_ix)))
+ .with_variant(options.group_variant)
+ .when_some(self.title.clone(), |this, title| {
+ this.title(v_flex().gap_1().child(title).when_some(
+ self.description.clone(),
+ |this, description| {
+ this.child(
+ Label::new(description)
+ .text_sm()
+ .text_color(cx.theme().muted_foreground),
+ )
+ },
+ ))
+ })
+ .gap_4()
+ .children(self.items.iter().enumerate().filter_map(|(item_ix, item)| {
+ if item.is_match(&query) {
+ Some(item.clone().render(item_ix, options, window, cx))
+ } else {
+ None
+ }
+ }))
+ .refine_style(&self.style)
+ }
+
+ pub(crate) fn reset(&self, window: &mut Window, cx: &mut App) {
+ for item in &self.items {
+ item.reset(window, cx);
+ }
+ }
+}
diff --git a/crates/ui/src/setting/item.rs b/crates/ui/src/setting/item.rs
new file mode 100644
index 00000000..a977f569
--- /dev/null
+++ b/crates/ui/src/setting/item.rs
@@ -0,0 +1,212 @@
+use gpui::{
+ div, prelude::FluentBuilder as _, AnyElement, App, Axis, InteractiveElement as _, IntoElement,
+ ParentElement, SharedString, Styled, Window,
+};
+use std::{any::TypeId, ops::Deref, rc::Rc};
+
+use crate::{
+ label::Label,
+ setting::{
+ fields::{BoolField, DropdownField, NumberField, SettingFieldRender, StringField},
+ AnySettingField, ElementField, RenderOptions,
+ },
+ text::Text,
+ v_flex, ActiveTheme as _, AxisExt, StyledExt as _,
+};
+
+/// Setting item.
+#[derive(Clone)]
+pub enum SettingItem {
+ /// A normal setting item with a title, description, and field.
+ Item {
+ title: SharedString,
+ description: Option,
+ layout: Axis,
+ field: Rc,
+ },
+ /// A full custom element to render.
+ Element {
+ render: Rc AnyElement + 'static>,
+ },
+}
+
+impl SettingItem {
+ /// Create a new setting item.
+ pub fn new(title: impl Into, field: F) -> Self
+ where
+ F: AnySettingField + 'static,
+ {
+ SettingItem::Item {
+ title: title.into(),
+ description: None,
+ layout: Axis::Horizontal,
+ field: Rc::new(field),
+ }
+ }
+
+ /// Create a new custom element setting item.
+ pub fn element(render: R) -> Self
+ where
+ E: IntoElement,
+ R: Fn(&RenderOptions, &mut Window, &mut App) -> E + 'static,
+ {
+ SettingItem::Element {
+ render: Rc::new(move |options, window, cx| {
+ render(options, window, cx).into_any_element()
+ }),
+ }
+ }
+
+ /// Set the description of the setting item.
+ ///
+ /// Only applies to [`SettingItem::Item`].
+ pub fn description(mut self, description: impl Into) -> Self {
+ match &mut self {
+ SettingItem::Item { description: d, .. } => {
+ *d = Some(description.into());
+ }
+ SettingItem::Element { .. } => {}
+ }
+ self
+ }
+
+ /// Set the layout of the setting item.
+ ///
+ /// Only applies to [`SettingItem::Item`].
+ pub fn layout(mut self, layout: Axis) -> Self {
+ match &mut self {
+ SettingItem::Item { layout: l, .. } => {
+ *l = layout;
+ }
+ SettingItem::Element { .. } => {}
+ }
+ self
+ }
+
+ pub(crate) fn is_match(&self, query: &str) -> bool {
+ match self {
+ SettingItem::Item {
+ title, description, ..
+ } => {
+ title.to_lowercase().contains(&query.to_lowercase())
+ || description.as_ref().map_or(false, |d| {
+ d.as_str().to_lowercase().contains(&query.to_lowercase())
+ })
+ }
+ // We need to show all custom elements when not searching.
+ SettingItem::Element { .. } => query.is_empty(),
+ }
+ }
+
+ pub(crate) fn is_resettable(&self, cx: &App) -> bool {
+ match self {
+ SettingItem::Item { field, .. } => field.is_resettable(cx),
+ SettingItem::Element { .. } => false,
+ }
+ }
+
+ pub(crate) fn reset(&self, window: &mut Window, cx: &mut App) {
+ match self {
+ SettingItem::Item { field, .. } => field.reset(window, cx),
+ SettingItem::Element { .. } => {}
+ }
+ }
+
+ fn render_field(
+ field: Rc,
+ options: RenderOptions,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let field_type = field.field_type();
+ let style = field.style().clone();
+ let type_id = field.deref().type_id();
+ let renderer: Box = match type_id {
+ t if t == std::any::TypeId::of::() => {
+ Box::new(BoolField::new(field_type.is_switch()))
+ }
+ t if t == TypeId::of::() && field_type.is_number_input() => {
+ Box::new(NumberField::new(field_type.number_input_options()))
+ }
+ t if t == TypeId::of::() && field_type.is_input() => {
+ Box::new(StringField::::new())
+ }
+ t if t == TypeId::of::() && field_type.is_input() => {
+ Box::new(StringField::::new())
+ }
+ t if t == TypeId::of::() && field_type.is_dropdown() => Box::new(
+ DropdownField::::new(field_type.dropdown_options()),
+ ),
+ t if t == TypeId::of::() && field_type.is_dropdown() => {
+ Box::new(DropdownField::::new(field_type.dropdown_options()))
+ }
+ _ if field_type.is_element() => {
+ Box::new(ElementField::new(field_type.element_render()))
+ }
+ _ => unimplemented!("Unsupported setting type: {}", field.deref().type_name()),
+ };
+
+ renderer.render(field, &options, &style, window, cx)
+ }
+
+ pub(super) fn render(
+ self,
+ ix: usize,
+ options: &RenderOptions,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ div()
+ .id(SharedString::from(format!("item-{}", ix)))
+ .w_full()
+ .child(match self {
+ SettingItem::Item {
+ title,
+ description,
+ layout,
+ field,
+ } => div()
+ .w_full()
+ .overflow_hidden()
+ .map(|this| {
+ if layout.is_horizontal() {
+ this.h_flex().justify_between().items_start()
+ } else {
+ this.v_flex()
+ }
+ })
+ .gap_3()
+ .child(
+ v_flex()
+ .map(|this| {
+ if layout.is_horizontal() {
+ this.flex_1().max_w_3_5()
+ } else {
+ this.w_full()
+ }
+ })
+ .gap_1()
+ .child(Label::new(title.clone()).text_sm())
+ .when_some(description.clone(), |this, description| {
+ this.child(
+ div()
+ .size_full()
+ .text_sm()
+ .text_color(cx.theme().muted_foreground)
+ .child(description),
+ )
+ }),
+ )
+ .child(div().id("field").child(Self::render_field(
+ field,
+ RenderOptions { layout, ..*options },
+ window,
+ cx,
+ )))
+ .into_any_element(),
+ SettingItem::Element { render } => {
+ (render)(&options, window, cx).into_any_element()
+ }
+ })
+ }
+}
diff --git a/crates/ui/src/setting/mod.rs b/crates/ui/src/setting/mod.rs
new file mode 100644
index 00000000..3310d2b4
--- /dev/null
+++ b/crates/ui/src/setting/mod.rs
@@ -0,0 +1,11 @@
+mod fields;
+mod group;
+mod item;
+mod page;
+mod settings;
+
+pub use fields::*;
+pub use group::*;
+pub use item::*;
+pub use page::*;
+pub use settings::*;
diff --git a/crates/ui/src/setting/page.rs b/crates/ui/src/setting/page.rs
new file mode 100644
index 00000000..71b12716
--- /dev/null
+++ b/crates/ui/src/setting/page.rs
@@ -0,0 +1,174 @@
+use gpui::{
+ list, prelude::FluentBuilder as _, px, App, Entity, InteractiveElement as _, IntoElement,
+ ParentElement as _, SharedString, StatefulInteractiveElement, Styled, Window,
+};
+use rust_i18n::t;
+
+use crate::{
+ button::{Button, ButtonVariants},
+ divider::Divider,
+ h_flex,
+ label::Label,
+ setting::{settings::SettingsState, RenderOptions, SettingGroup},
+ v_flex, ActiveTheme, IconName, Sizable,
+};
+
+/// A setting page that can contain multiple setting groups.
+#[derive(Clone)]
+pub struct SettingPage {
+ resettable: bool,
+ pub(super) default_open: bool,
+ pub(super) title: SharedString,
+ pub(super) description: Option,
+ pub(super) groups: Vec,
+}
+
+impl SettingPage {
+ pub fn new(title: impl Into) -> Self {
+ Self {
+ resettable: true,
+ default_open: false,
+ title: title.into(),
+ description: None,
+ groups: Vec::new(),
+ }
+ }
+
+ /// Set the title of the setting page.
+ pub fn title(mut self, title: impl Into) -> Self {
+ self.title = title.into();
+ self
+ }
+
+ /// Set the description of the setting page, default is None.
+ pub fn description(mut self, description: impl Into) -> Self {
+ self.description = Some(description.into());
+ self
+ }
+
+ /// Set the default open state of the setting page, default is false.
+ pub fn default_open(mut self, default_open: bool) -> Self {
+ self.default_open = default_open;
+ self
+ }
+
+ /// Set whether the setting page is resettable, default is true.
+ ///
+ /// If true and the items in this page has changed, the reset button will appear.
+ pub fn resettable(mut self, resettable: bool) -> Self {
+ self.resettable = resettable;
+ self
+ }
+
+ /// Add a setting group to the page.
+ pub fn group(mut self, group: SettingGroup) -> Self {
+ self.groups.push(group);
+ self
+ }
+
+ /// Add multiple setting groups to the page.
+ pub fn groups(mut self, groups: impl IntoIterator
- ) -> Self {
+ self.groups.extend(groups);
+ self
+ }
+
+ fn is_resettable(&self, cx: &App) -> bool {
+ self.resettable && self.groups.iter().any(|group| group.is_resettable(cx))
+ }
+
+ fn reset_all(&self, window: &mut Window, cx: &mut App) {
+ for group in &self.groups {
+ group.reset(window, cx);
+ }
+ }
+
+ pub(super) fn render(
+ &self,
+ ix: usize,
+ state: &Entity,
+ options: &RenderOptions,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let search_input = state.read(cx).search_input.clone();
+ let query = search_input.read(cx).value();
+ let groups = self
+ .groups
+ .iter()
+ .filter(|group| group.is_match(&query))
+ .cloned()
+ .collect::>();
+ let groups_count = groups.len();
+
+ let list_state = window
+ .use_keyed_state(
+ SharedString::from(format!("list-state:{}", ix)),
+ cx,
+ |_, _| gpui::ListState::new(groups_count, gpui::ListAlignment::Top, px(0.)),
+ )
+ .read(cx)
+ .clone();
+
+ if list_state.item_count() != groups_count {
+ list_state.reset(groups_count);
+ }
+
+ let deferred_scroll_group_ix = state.read(cx).deferred_scroll_group_ix;
+ if let Some(ix) = deferred_scroll_group_ix {
+ state.update(cx, |state, _| {
+ state.deferred_scroll_group_ix = None;
+ });
+ list_state.scroll_to_reveal_item(ix);
+ }
+
+ v_flex()
+ .id(ix)
+ .p_4()
+ .size_full()
+ .overflow_scroll()
+ .child(
+ v_flex()
+ .gap_3()
+ .child(h_flex().justify_between().child(self.title.clone()).when(
+ self.is_resettable(cx),
+ |this| {
+ this.child(
+ Button::new("reset")
+ .icon(IconName::Undo2)
+ .ghost()
+ .small()
+ .tooltip(t!("Settings.Reset All"))
+ .on_click({
+ let page = self.clone();
+ move |_, window, cx| {
+ page.reset_all(window, cx);
+ }
+ }),
+ )
+ },
+ ))
+ .when_some(self.description.clone(), |this, description| {
+ this.child(
+ Label::new(description)
+ .text_sm()
+ .text_color(cx.theme().muted_foreground),
+ )
+ })
+ .child(Divider::horizontal()),
+ )
+ .child(
+ list(list_state.clone(), {
+ let query = query.clone();
+ let options = *options;
+ move |ix, window, cx| {
+ let group = groups[ix].clone();
+ group
+ .pt_6()
+ .render(ix, &query, &options, window, cx)
+ .into_any_element()
+ }
+ })
+ .size_full(),
+ )
+ }
+}
diff --git a/crates/ui/src/setting/settings.rs b/crates/ui/src/setting/settings.rs
new file mode 100644
index 00000000..823b938e
--- /dev/null
+++ b/crates/ui/src/setting/settings.rs
@@ -0,0 +1,269 @@
+use crate::{
+ group_box::GroupBoxVariant,
+ input::{Input, InputState},
+ resizable::{h_resizable, resizable_panel},
+ setting::{SettingGroup, SettingPage},
+ sidebar::{Sidebar, SidebarMenu, SidebarMenuItem},
+ IconName, Sizable, Size,
+};
+use gpui::{
+ div, prelude::FluentBuilder as _, px, relative, App, AppContext as _, Axis, ElementId, Entity,
+ IntoElement, ParentElement as _, Pixels, RenderOnce, Styled, Window,
+};
+use rust_i18n::t;
+
+/// The settings structure containing multiple pages for app settings.
+///
+/// The hierarchy of settings is as follows:
+///
+/// ```ignore
+/// Settings
+/// SettingPage <- The single active page displayed
+/// SettingGroup
+/// SettingItem
+/// Label
+/// SettingField (e.g., Switch, Dropdown, Input)
+/// ```
+#[derive(IntoElement)]
+pub struct Settings {
+ id: ElementId,
+ pages: Vec,
+ group_variant: GroupBoxVariant,
+ size: Size,
+ sidebar_width: Pixels,
+}
+
+impl Settings {
+ /// Create a new settings with the given ID.
+ pub fn new(id: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ pages: vec![],
+ group_variant: GroupBoxVariant::default(),
+ size: Size::default(),
+ sidebar_width: px(250.0),
+ }
+ }
+
+ /// Set the width of the sidebar, default is `250px`.
+ pub fn sidebar_width(mut self, width: impl Into) -> Self {
+ self.sidebar_width = width.into();
+ self
+ }
+
+ /// Add a page to the settings.
+ pub fn page(mut self, page: SettingPage) -> Self {
+ self.pages.push(page);
+ self
+ }
+
+ /// Add pages to the settings.
+ pub fn pages(mut self, pages: impl IntoIterator
- ) -> Self {
+ self.pages.extend(pages);
+ self
+ }
+
+ /// Set the default variant for all setting groups.
+ ///
+ /// All setting groups will use this variant unless overridden individually.
+ pub fn with_group_variant(mut self, variant: GroupBoxVariant) -> Self {
+ self.group_variant = variant;
+ self
+ }
+
+ fn filtered_pages(&self, query: &str) -> Vec {
+ self.pages
+ .iter()
+ .filter_map(|page| {
+ let filtered_groups: Vec = page
+ .groups
+ .iter()
+ .filter_map(|group| {
+ let mut group = group.clone();
+ group.items = group
+ .items
+ .iter()
+ .filter(|item| item.is_match(&query))
+ .cloned()
+ .collect();
+ if group.items.is_empty() {
+ None
+ } else {
+ Some(group)
+ }
+ })
+ .collect();
+ let mut page = page.clone();
+ page.groups = filtered_groups;
+ if page.groups.is_empty() {
+ None
+ } else {
+ Some(page)
+ }
+ })
+ .collect()
+ }
+
+ fn render_active_page(
+ &self,
+ state: &Entity,
+ pages: &Vec,
+ options: &RenderOptions,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let selected_index = state.read(cx).selected_index;
+
+ for (ix, page) in pages.into_iter().enumerate() {
+ if selected_index.page_ix == ix {
+ return page
+ .render(ix, state, &options, window, cx)
+ .into_any_element();
+ }
+ }
+
+ return div().into_any_element();
+ }
+
+ fn render_sidebar(
+ &self,
+ state: &Entity,
+ pages: &Vec,
+ _: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let selected_index = state.read(cx).selected_index;
+ let search_input = state.read(cx).search_input.clone();
+
+ Sidebar::left()
+ .width(relative(1.))
+ .border_width(px(0.))
+ .collapsed(false)
+ .header(
+ div()
+ .w_full()
+ .child(Input::new(&search_input).prefix(IconName::Search)),
+ )
+ .child(
+ SidebarMenu::new()
+ .p_2()
+ .children(pages.iter().enumerate().map(|(page_ix, page)| {
+ let is_page_active =
+ selected_index.page_ix == page_ix && selected_index.group_ix.is_none();
+ SidebarMenuItem::new(page.title.clone())
+ .default_open(page.default_open)
+ .active(is_page_active)
+ .on_click({
+ let state = state.clone();
+ move |_, _, cx| {
+ state.update(cx, |state, cx| {
+ state.selected_index = SelectIndex {
+ page_ix,
+ ..Default::default()
+ };
+ cx.notify();
+ })
+ }
+ })
+ .when(page.groups.len() > 1, |this| {
+ this.children(
+ page.groups
+ .iter()
+ .filter(|g| g.title.is_some())
+ .enumerate()
+ .map(|(group_ix, group)| {
+ let is_active = selected_index.page_ix == page_ix
+ && selected_index.group_ix == Some(group_ix);
+ let title = group.title.clone().unwrap_or_default();
+
+ SidebarMenuItem::new(title).active(is_active).on_click(
+ {
+ let state = state.clone();
+ move |_, _, cx| {
+ state.update(cx, |state, cx| {
+ state.selected_index = SelectIndex {
+ page_ix,
+ group_ix: Some(group_ix),
+ };
+ state.deferred_scroll_group_ix =
+ Some(group_ix);
+ cx.notify();
+ })
+ }
+ },
+ )
+ }),
+ )
+ })
+ })),
+ )
+ }
+}
+
+impl Sizable for Settings {
+ fn with_size(mut self, size: impl Into) -> Self {
+ self.size = size.into();
+ self
+ }
+}
+
+pub(super) struct SettingsState {
+ pub(super) selected_index: SelectIndex,
+ /// If set, defer scrolling to this group index after rendering.
+ pub(super) deferred_scroll_group_ix: Option,
+ pub(super) search_input: Entity,
+}
+
+/// Options for rendering setting item.
+#[derive(Clone, Copy)]
+pub struct RenderOptions {
+ pub size: Size,
+ pub group_variant: GroupBoxVariant,
+ pub layout: Axis,
+}
+
+#[derive(Clone, Copy, Default)]
+pub(super) struct SelectIndex {
+ page_ix: usize,
+ group_ix: Option,
+}
+
+impl RenderOnce for Settings {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let state = window.use_keyed_state(self.id.clone(), cx, |window, cx| {
+ let search_input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .placeholder(t!("Settings.search_placeholder"))
+ .default_value("")
+ });
+
+ SettingsState {
+ search_input,
+ selected_index: SelectIndex::default(),
+ deferred_scroll_group_ix: None,
+ }
+ });
+
+ let query = state.read(cx).search_input.read(cx).value();
+ let filtered_pages = self.filtered_pages(&query);
+ let options = RenderOptions {
+ size: self.size,
+ group_variant: self.group_variant,
+ layout: Axis::Horizontal,
+ };
+
+ h_resizable(self.id.clone())
+ .child(
+ resizable_panel()
+ .size(self.sidebar_width)
+ .child(self.render_sidebar(&state, &filtered_pages, window, cx)),
+ )
+ .child(resizable_panel().child(self.render_active_page(
+ &state,
+ &filtered_pages,
+ &options,
+ window,
+ cx,
+ )))
+ }
+}
diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs
index ff3c83f7..e43ad903 100644
--- a/crates/ui/src/sidebar/menu.rs
+++ b/crates/ui/src/sidebar/menu.rs
@@ -5,13 +5,14 @@ use crate::{
use gpui::{
div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId,
InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
- StatefulInteractiveElement as _, Styled as _, Window,
+ StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
};
use std::rc::Rc;
/// Menu for the [`super::Sidebar`]
#[derive(IntoElement)]
pub struct SidebarMenu {
+ style: StyleRefinement,
collapsed: bool,
items: Vec,
}
@@ -20,6 +21,7 @@ impl SidebarMenu {
/// Create a new SidebarMenu
pub fn new() -> Self {
Self {
+ style: StyleRefinement::default(),
items: Vec::new(),
collapsed: false,
}
@@ -54,9 +56,15 @@ impl Collapsible for SidebarMenu {
}
}
+impl Styled for SidebarMenu {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
impl RenderOnce for SidebarMenu {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
- v_flex().gap_2().children(
+ v_flex().gap_2().refine_style(&self.style).children(
self.items
.into_iter()
.enumerate()
diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs
index c2117daa..bcd480dd 100644
--- a/crates/ui/src/styled.rs
+++ b/crates/ui/src/styled.rs
@@ -233,6 +233,35 @@ impl Size {
}
}
+ /// Returns the size as a static string.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Size::XSmall => "xs",
+ Size::Small => "sm",
+ Size::Medium => "md",
+ Size::Large => "lg",
+ Size::Size(_) => "custom",
+ }
+ }
+
+ /// Create a Size from a static string.
+ ///
+ /// - "xs" or "xsmall"
+ /// - "sm" or "small"
+ /// - "md" or "medium"
+ /// - "lg" or "large"
+ ///
+ /// Any other value will return Size::Medium.
+ pub fn from_str(size: &str) -> Self {
+ match size.to_lowercase().as_str() {
+ "xs" | "xsmall" => Size::XSmall,
+ "sm" | "small" => Size::Small,
+ "md" | "medium" => Size::Medium,
+ "lg" | "large" => Size::Large,
+ _ => Size::Medium,
+ }
+ }
+
/// Returns the height for table row.
#[inline]
pub fn table_row_height(&self) -> Pixels {
@@ -762,4 +791,31 @@ mod tests {
Size::Size(px(10.))
);
}
+
+ #[test]
+ fn test_size_as_str() {
+ assert_eq!(Size::XSmall.as_str(), "xs");
+ assert_eq!(Size::Small.as_str(), "sm");
+ assert_eq!(Size::Medium.as_str(), "md");
+ assert_eq!(Size::Large.as_str(), "lg");
+ assert_eq!(Size::Size(px(15.)).as_str(), "custom");
+ }
+
+ #[test]
+ fn test_size_from_str() {
+ assert_eq!(Size::from_str("xs"), Size::XSmall);
+ assert_eq!(Size::from_str("xsmall"), Size::XSmall);
+ assert_eq!(Size::from_str("sm"), Size::Small);
+ assert_eq!(Size::from_str("small"), Size::Small);
+ assert_eq!(Size::from_str("md"), Size::Medium);
+ assert_eq!(Size::from_str("medium"), Size::Medium);
+ assert_eq!(Size::from_str("lg"), Size::Large);
+ assert_eq!(Size::from_str("large"), Size::Large);
+ assert_eq!(Size::from_str("unknown"), Size::Medium);
+
+ // Case insensitive
+ assert_eq!(Size::from_str("XS"), Size::XSmall);
+ assert_eq!(Size::from_str("SMALL"), Size::Small);
+ assert_eq!(Size::from_str("Md"), Size::Medium);
+ }
}
diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs
index 58124a94..d0e4ad17 100644
--- a/crates/ui/src/text/text_view.rs
+++ b/crates/ui/src/text/text_view.rs
@@ -87,6 +87,7 @@ impl RenderOnce for TextViewElement {
pub struct TextView {
id: ElementId,
init_state: Option,
+ raw: SharedString,
state: Entity,
style: StyleRefinement,
selectable: bool,
@@ -343,6 +344,14 @@ impl Text {
Self::TextView(e) => Self::TextView(Box::new(e.style(style))),
}
}
+
+ /// Get the str
+ pub fn as_str(&self) -> &str {
+ match self {
+ Self::String(s) => s.as_str(),
+ Self::TextView(view) => view.raw.as_str(),
+ }
+ }
}
impl RenderOnce for Text {
@@ -403,11 +412,12 @@ impl TextView {
cx,
);
if let Some(tx) = &state.read(cx).tx {
- let _ = tx.try_send(Update::Text(markdown));
+ let _ = tx.try_send(Update::Text(markdown.clone()));
}
Self {
id,
init_state: Some(init_state),
+ raw: markdown.clone(),
style: StyleRefinement::default(),
state,
selectable: false,
@@ -432,13 +442,14 @@ impl TextView {
let init_state =
Self::create_init_state(TextViewType::Html, &html, &highlight_theme, &state, cx);
if let Some(tx) = &state.read(cx).tx {
- let _ = tx.try_send(Update::Text(html));
+ let _ = tx.try_send(Update::Text(html.clone()));
}
Self {
id,
init_state: Some(init_state),
style: StyleRefinement::default(),
state,
+ raw: html,
selectable: false,
scrollable: false,
}
@@ -446,15 +457,16 @@ impl TextView {
/// Set the source text of the text view.
pub fn text(mut self, raw: impl Into) -> Self {
+ let raw: SharedString = raw.into();
if let Some(init_state) = &mut self.init_state {
match init_state {
- InitState::Initializing { text, .. } => *text = raw.into(),
+ InitState::Initializing { text, .. } => *text = raw.clone(),
InitState::Initialized { tx } => {
- let _ = tx.try_send(Update::Text(raw.into()));
+ let _ = tx.try_send(Update::Text(raw.clone()));
}
}
}
-
+ self.raw = raw;
self
}
diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs
index a12ea9d8..0156f2fb 100644
--- a/crates/ui/src/tree.rs
+++ b/crates/ui/src/tree.rs
@@ -139,8 +139,8 @@ impl TreeItem {
}
/// Add multiple child items to this tree item.
- pub fn children(mut self, children: impl Into>) -> Self {
- self.children.extend(children.into());
+ pub fn children(mut self, children: impl IntoIterator
- ) -> Self {
+ self.children.extend(children);
self
}
diff --git a/docs/docs/components/group-box.md b/docs/docs/components/group-box.md
index 50cbede3..09a2aa86 100644
--- a/docs/docs/components/group-box.md
+++ b/docs/docs/components/group-box.md
@@ -10,7 +10,7 @@ The GroupBox component is a versatile container that groups related content toge
## Import
```rust
-use gpui_component::group_box::{GroupBox, GroupBoxVariant};
+use gpui_component::group_box::{GroupBox, GroupBoxVariant, GroupBoxVariants as _};
```
## Usage
@@ -159,29 +159,6 @@ GroupBox::new()
)
```
-## API Reference
-
-### GroupBox
-
-| Method | Description |
-| ---------------------- | ------------------------------------------- |
-| `new()` | Create a new GroupBox with default settings |
-| `variant(variant)` | Set the variant of the group box |
-| `fill()` | Set to use Fill variant (with background) |
-| `outline()` | Set to use Outline variant (with border) |
-| `id(id)` | Set the element ID for the group box |
-| `title(title)` | Set the title/header for the group box |
-| `title_style(style)` | Customize the styling of the title |
-| `content_style(style)` | Customize the styling of the content area |
-
-### GroupBoxVariant
-
-| Variant | Description |
-| --------- | ---------------------------------------------- |
-| `Normal` | Default variant with no background or border |
-| `Fill` | Variant with background color and padding |
-| `Outline` | Variant with border and padding, no background |
-
## Examples
### Form Section
diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md
index b1bcdd3b..f4dbedc7 100644
--- a/docs/docs/components/index.md
+++ b/docs/docs/components/index.md
@@ -59,6 +59,7 @@ collapsed: false
- [Chart](chart) - Data visualization charts (Line, Bar, Area, Pie)
- [List](list) - List display with items
- [Menu](menu) - Menu and context menu and dropdown menu.
+- [Settings](settings) - Settings UI
- [Table](table) - High-performance data tables
- [Tabs](tabs) - Tabbed interface
- [Tree](tree) - Hierarchical tree data display
diff --git a/docs/docs/components/settings.md b/docs/docs/components/settings.md
new file mode 100644
index 00000000..10c465f9
--- /dev/null
+++ b/docs/docs/components/settings.md
@@ -0,0 +1,458 @@
+---
+title: Settings
+description: A settings UI with grouped setting items and pages.
+---
+
+# Settings
+
+> Since: v0.5.0
+
+The Settings component provides a UI for managing application settings. It includes grouped setting items and pages.
+We can search by title and description to filter the settings to display only relevant settings (Like this macOS, iOS Settings).
+
+## Import
+
+```rust
+use gpui_component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField};
+```
+
+## Usage
+
+### Build a settings
+
+Here we have components that can be used to build a settings page.
+
+- [Settings] - The main settings component that holds multiple setting pages.
+- [SettingPage] - A page of related setting groups.
+- [SettingGroup] - A group of related setting items based on [GroupBox] style.
+- [SettingItem] - A single setting item with title, description, and field.
+- [SettingField] - Provide different field types like Input, Dropdown, Switch, etc.
+
+The layout of the settings is like this:
+
+```
+Settings
+ SettingPage
+ SettingGroup
+ SettingItem
+ Title
+ Description (optional)
+ SettingField
+```
+
+### Basic Settings
+
+```rust
+use gpui_component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField};
+
+Settings::new("my-settings")
+ .pages(vec![
+ SettingPage::new("General")
+ .group(
+ SettingGroup::new()
+ .title("Basic Options")
+ .item(
+ SettingItem::new(
+ "Enable Feature",
+ SettingField::switch(
+ |cx: &App| true,
+ |val: bool, cx: &mut App| {
+ println!("Feature enabled: {}", val);
+ },
+ )
+ )
+ )
+ )
+ ])
+```
+
+### With Multiple Pages
+
+:::info
+When you want default expland a page, you can use `default_open(true)` on the [SettingPage].
+:::
+
+```rust
+Settings::new("app-settings")
+ .pages(vec![
+ SettingPage::new("General")
+ .default_open(true)
+ .group(SettingGroup::new().title("Appearance").items(vec![...])),
+ SettingPage::new("Software Update")
+ .group(SettingGroup::new().title("Updates").items(vec![...])),
+ SettingPage::new("About")
+ .group(SettingGroup::new().items(vec![...])),
+ ])
+```
+
+### Group Variants
+
+```rust
+use gpui_component::group_box::GroupBoxVariant;
+
+Settings::new("my-settings")
+ .with_group_variant(GroupBoxVariant::Outline)
+ .pages(vec![...])
+
+Settings::new("my-settings")
+ .with_group_variant(GroupBoxVariant::Fill)
+ .pages(vec![...])
+```
+
+## Setting Page
+
+### Basic Page
+
+```rust
+SettingPage::new("General")
+ .group(SettingGroup::new().title("Options").items(vec![...]))
+```
+
+### Multiple Groups
+
+```rust
+SettingPage::new("General")
+ .groups(vec![
+ SettingGroup::new().title("Appearance").items(vec![...]),
+ SettingGroup::new().title("Font").items(vec![...]),
+ SettingGroup::new().title("Other").items(vec![...]),
+ ])
+```
+
+### Default Open
+
+```rust
+SettingPage::new("General")
+ .default_open(true)
+ .groups(vec![...])
+```
+
+### resettable
+
+Enable reset functionality for a page:
+
+```rust
+SettingPage::new("General")
+ .resettable(true)
+ .groups(vec![...])
+```
+
+## Setting Group
+
+### Basic Group
+
+```rust
+SettingGroup::new()
+ .title("Appearance")
+ .items(vec![
+ SettingItem::new(...),
+ SettingItem::new(...),
+ ])
+```
+
+### Single Item
+
+```rust
+SettingGroup::new()
+ .title("Font")
+ .item(SettingItem::new(...))
+```
+
+### Without Title
+
+```rust
+SettingGroup::new()
+ .items(vec![...])
+```
+
+## Setting Item
+
+### Basic Item
+
+```rust
+SettingItem::new("Title", SettingField::switch(...))
+ .description("Description text")
+```
+
+### Custom Element Item
+
+You can create a fully custom setting item using `SettingItem::element`:
+
+```rust
+SettingItem::element(|options, _, _| {
+ h_flex()
+ .w_full()
+ .justify_between()
+ .child("Custom content")
+ .child(
+ Button::new("action")
+ .label("Action")
+ .with_size(options.size)
+ )
+ .into_any_element()
+})
+```
+
+### Vertical Layout
+
+By default, setting items use horizontal layout. Use `layout(Axis::Vertical)` for vertical layout:
+
+```rust
+SettingItem::new(
+ "CLI Path",
+ SettingField::input(...)
+)
+.layout(Axis::Vertical)
+.description("This item uses vertical layout.")
+```
+
+### With Markdown Description
+
+```rust
+use gpui_component::text::TextView;
+
+SettingItem::new(
+ "Documentation",
+ SettingField::element(...)
+)
+.description(TextView::markdown(
+ "desc",
+ "Rust doc for the `gpui-component` crate.",
+ window,
+ cx,
+))
+```
+
+## Setting Fields
+
+The [SettingField] enum provides different field types for various input needs.
+
+### Switch
+
+The switch field represents a `boolean` on/off state.
+
+```rust
+SettingItem::new(
+ "Dark Mode",
+ SettingField::switch(
+ |cx: &App| cx.theme().mode.is_dark(),
+ |val: bool, cx: &mut App| {
+ // Handle value change
+ },
+ )
+ .default_value(false)
+)
+```
+
+### Checkbox
+
+Like the switch, but uses a checkbox UI.
+
+```rust
+SettingItem::new(
+ "Auto Switch Theme",
+ SettingField::checkbox(
+ |cx: &App| AppSettings::global(cx).auto_switch_theme,
+ |val: bool, cx: &mut App| {
+ AppSettings::global_mut(cx).auto_switch_theme = val;
+ },
+ )
+ .default_value(false)
+)
+```
+
+### Input
+
+Display a single line text input.
+
+```rust
+SettingItem::new(
+ "CLI Path",
+ SettingField::input(
+ |cx: &App| AppSettings::global(cx).cli_path.clone(),
+ |val: SharedString, cx: &mut App| {
+ AppSettings::global_mut(cx).cli_path = val;
+ },
+ )
+ .default_value("/usr/local/bin/bash".into())
+)
+.layout(Axis::Vertical)
+.description("Path to the CLI executable.")
+```
+
+### Dropdown
+
+A dropdown with a list of options.
+
+```rust
+SettingItem::new(
+ "Font Family",
+ SettingField::dropdown(
+ vec![
+ ("Arial".into(), "Arial".into()),
+ ("Helvetica".into(), "Helvetica".into()),
+ ("Times New Roman".into(), "Times New Roman".into()),
+ ],
+ |cx: &App| AppSettings::global(cx).font_family.clone(),
+ |val: SharedString, cx: &mut App| {
+ AppSettings::global_mut(cx).font_family = val;
+ },
+ )
+ .default_value("Arial".into())
+)
+```
+
+### NumberInput
+
+```rust
+use gpui_component::setting::NumberFieldOptions;
+
+SettingItem::new(
+ "Font Size",
+ SettingField::number_input(
+ NumberFieldOptions {
+ min: 8.0,
+ max: 72.0,
+ ..Default::default()
+ },
+ |cx: &App| AppSettings::global(cx).font_size,
+ |val: f64, cx: &mut App| {
+ AppSettings::global_mut(cx).font_size = val;
+ },
+ )
+ .default_value(14.0)
+)
+```
+
+### Custom Element Field
+
+```rust
+SettingItem::new(
+ "GitHub Repository",
+ SettingField::element(|options, _window, _cx| {
+ Button::new("open-url")
+ .outline()
+ .label("Repository...")
+ .with_size(options.size)
+ .on_click(|_, _window, cx| {
+ cx.open_url("https://github.com/example/repo");
+ })
+ })
+)
+```
+
+## API Reference
+
+- [Settings]
+- [SettingPage]
+- [SettingGroup]
+- [SettingItem]
+- [SettingField]
+- [NumberFieldOptions]
+
+### Sizing
+
+Implements [Sizable] trait:
+
+- `xsmall()` - Extra small size
+- `small()` - Small size
+- `medium()` - Medium size (default)
+- `large()` - Large size
+- `with_size(Size)` - Set specific size
+
+## Examples
+
+### Complete Settings Example
+
+```rust
+use gpui::{App, SharedString};
+use gpui_component::{
+ Settings, SettingPage, SettingGroup, SettingItem, SettingField,
+ setting::NumberFieldOptions,
+ group_box::GroupBoxVariant,
+ Size,
+};
+
+Settings::new("app-settings")
+ .with_size(Size::Medium)
+ .with_group_variant(GroupBoxVariant::Outline)
+ .pages(vec![
+ SettingPage::new("General")
+ .resettable(true)
+ .default_open(true)
+ .groups(vec![
+ SettingGroup::new()
+ .title("Appearance")
+ .items(vec![
+ SettingItem::new(
+ "Dark Mode",
+ SettingField::switch(
+ |cx: &App| cx.theme().mode.is_dark(),
+ |val: bool, cx: &mut App| {
+ // Handle theme change
+ },
+ )
+ )
+ .description("Switch between light and dark themes."),
+ ]),
+ SettingGroup::new()
+ .title("Font")
+ .items(vec![
+ SettingItem::new(
+ "Font Family",
+ SettingField::dropdown(
+ vec![
+ ("Arial".into(), "Arial".into()),
+ ("Helvetica".into(), "Helvetica".into()),
+ ],
+ |cx: &App| "Arial".into(),
+ |val: SharedString, cx: &mut App| {
+ // Handle font change
+ },
+ )
+ ),
+ SettingItem::new(
+ "Font Size",
+ SettingField::number_input(
+ NumberFieldOptions {
+ min: 8.0,
+ max: 72.0,
+ ..Default::default()
+ },
+ |cx: &App| 14.0,
+ |val: f64, cx: &mut App| {
+ // Handle size change
+ },
+ )
+ ),
+ ]),
+ ]),
+ SettingPage::new("Software Update")
+ .resettable(true)
+ .group(
+ SettingGroup::new()
+ .title("Updates")
+ .items(vec![
+ SettingItem::new(
+ "Auto Update",
+ SettingField::switch(
+ |cx: &App| true,
+ |val: bool, cx: &mut App| {
+ // Handle auto update
+ },
+ )
+ )
+ .description("Automatically download and install updates."),
+ ])
+ ),
+ ])
+```
+
+[Settings]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.Settings.html
+[SettingPage]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingPage.html
+[SettingGroup]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingGroup.html
+[SettingItem]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingItem.html
+[SettingField]: https://docs.rs/gpui-component/latest/gpui_component/setting/enum.SettingField.html
+[NumberFieldOptions]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.NumberFieldOptions.html
+[GroupBox]: ./group_box.md
+[Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html