From c8652f901096155f9675e947e618e1b1b1e3fa8a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 20 Nov 2025 21:51:44 +0800 Subject: [PATCH] setting: Add Settings component. (#1632) https://github.com/user-attachments/assets/3c6315d9-1183-419c-bb17-a8f7ac0cf25c --- crates/assets/assets/icons/redo-2.svg | 14 + crates/assets/assets/icons/redo.svg | 14 + crates/assets/assets/icons/undo-2.svg | 14 + crates/assets/assets/icons/undo.svg | 14 + crates/story/examples/brush.rs | 2 +- crates/story/src/collapsible_story.rs | 2 +- crates/story/src/group_box_story.rs | 9 +- crates/story/src/lib.rs | 4 +- crates/story/src/main.rs | 1 + crates/story/src/settings_story.rs | 386 +++++++++++++++++++ crates/ui/locales/ui.yml | 11 + crates/ui/src/group_box.rs | 94 ++++- crates/ui/src/icon.rs | 8 + crates/ui/src/lib.rs | 1 + crates/ui/src/setting/fields/bool.rs | 57 +++ crates/ui/src/setting/fields/dropdown.rs | 83 ++++ crates/ui/src/setting/fields/element.rs | 29 ++ crates/ui/src/setting/fields/mod.rs | 283 ++++++++++++++ crates/ui/src/setting/fields/number.rs | 111 ++++++ crates/ui/src/setting/fields/string.rs | 81 ++++ crates/ui/src/setting/group.rs | 115 ++++++ crates/ui/src/setting/item.rs | 212 +++++++++++ crates/ui/src/setting/mod.rs | 11 + crates/ui/src/setting/page.rs | 174 +++++++++ crates/ui/src/setting/settings.rs | 269 +++++++++++++ crates/ui/src/sidebar/menu.rs | 12 +- crates/ui/src/styled.rs | 56 +++ crates/ui/src/text/text_view.rs | 22 +- crates/ui/src/tree.rs | 4 +- docs/docs/components/group-box.md | 25 +- docs/docs/components/index.md | 1 + docs/docs/components/settings.md | 458 +++++++++++++++++++++++ 32 files changed, 2517 insertions(+), 60 deletions(-) create mode 100644 crates/assets/assets/icons/redo-2.svg create mode 100644 crates/assets/assets/icons/redo.svg create mode 100644 crates/assets/assets/icons/undo-2.svg create mode 100644 crates/assets/assets/icons/undo.svg create mode 100644 crates/story/src/settings_story.rs create mode 100644 crates/ui/src/setting/fields/bool.rs create mode 100644 crates/ui/src/setting/fields/dropdown.rs create mode 100644 crates/ui/src/setting/fields/element.rs create mode 100644 crates/ui/src/setting/fields/mod.rs create mode 100644 crates/ui/src/setting/fields/number.rs create mode 100644 crates/ui/src/setting/fields/string.rs create mode 100644 crates/ui/src/setting/group.rs create mode 100644 crates/ui/src/setting/item.rs create mode 100644 crates/ui/src/setting/mod.rs create mode 100644 crates/ui/src/setting/page.rs create mode 100644 crates/ui/src/setting/settings.rs create mode 100644 docs/docs/components/settings.md 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