diff --git a/crates/story/src/dropdown_story.rs b/crates/story/src/dropdown_story.rs index 85453f4a..83ddddfc 100644 --- a/crates/story/src/dropdown_story.rs +++ b/crates/story/src/dropdown_story.rs @@ -206,7 +206,7 @@ impl DropdownStory { } impl FocusableCycle for DropdownStory { - fn cycle_focus_handles(&self, cx: &mut ViewContext) -> Vec + fn cycle_focus_handles(&self, cx: &mut WindowContext) -> Vec where Self: Sized, { diff --git a/crates/story/src/form_story.rs b/crates/story/src/form_story.rs new file mode 100644 index 00000000..0ba2d093 --- /dev/null +++ b/crates/story/src/form_story.rs @@ -0,0 +1,202 @@ +use gpui::{ + actions, div, Axis, InteractiveElement, IntoElement, ParentElement as _, Render, Styled, View, + ViewContext, VisualContext, WindowContext, +}; +use ui::{ + button::{Button, ButtonGroup}, + date_picker::DatePicker, + divider::Divider, + form::{form_field, v_form}, + h_flex, + input::TextInput, + prelude::FluentBuilder as _, + switch::Switch, + v_flex, AxisExt, FocusableCycle, Selectable, Sizable, Size, +}; + +actions!(input_story, [Tab, TabPrev]); + +pub struct FormStory { + name_input: View, + email_input: View, + bio_input: View, + subscribe_email: bool, + date_picker: View, + layout: Axis, + size: Size, +} + +impl super::Story for FormStory { + fn title() -> &'static str { + "FormStory" + } + + fn closable() -> bool { + false + } + + fn new_view(cx: &mut WindowContext) -> View { + Self::view(cx) + } +} + +impl FormStory { + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(Self::new) + } + + fn new(cx: &mut ViewContext) -> Self { + let name_input = cx.new_view(|cx| { + let mut input = TextInput::new(cx).cleanable(); + input.set_text("Jason Lee", cx); + input + }); + + let email_input = cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")); + let bio_input = cx.new_view(|cx| { + let mut input = TextInput::new(cx) + .multi_line() + .rows(10) + .placeholder("Enter text here..."); + input.set_text("Hello 世界,this is GPUI component.", cx); + input + }); + let date_picker = cx.new_view(|cx| DatePicker::new("birthday", cx)); + + Self { + name_input, + email_input, + bio_input, + date_picker, + subscribe_email: false, + layout: Axis::Vertical, + size: Size::default(), + } + } +} + +impl FocusableCycle for FormStory { + fn cycle_focus_handles(&self, cx: &mut WindowContext) -> Vec + where + Self: Sized, + { + vec![ + self.name_input.focus_handle(cx), + self.email_input.focus_handle(cx), + self.bio_input.focus_handle(cx), + ] + } +} + +impl gpui::FocusableView for FormStory { + fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle { + self.name_input.focus_handle(cx) + } +} + +impl Render for FormStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .id("form-story") + .size_full() + .p_4() + .justify_start() + .gap_3() + .child( + h_flex() + .gap_3() + .flex_wrap() + .justify_between() + .child( + Switch::new("layout") + .checked(self.layout.is_horizontal()) + .label("Horizontal") + .on_click(cx.listener(|this, checked: &bool, cx| { + if *checked { + this.layout = Axis::Horizontal; + } else { + this.layout = Axis::Vertical; + } + cx.notify(); + })), + ) + .child( + ButtonGroup::new("size") + .small() + .child( + Button::new("large") + .selected(self.size == Size::Large) + .child("Large"), + ) + .child( + Button::new("medium") + .child("Medium") + .selected(self.size == Size::Medium), + ) + .child( + Button::new("small") + .child("Small") + .selected(self.size == Size::Small), + ) + .on_click(cx.listener(|this, selecteds: &Vec, cx| { + if selecteds.contains(&0) { + this.size = Size::Large; + } else if selecteds.contains(&1) { + this.size = Size::Medium; + } else if selecteds.contains(&2) { + this.size = Size::Small; + } + cx.notify(); + })), + ), + ) + .child(Divider::horizontal()) + .child( + v_form() + .layout(self.layout) + .with_size(self.size) + .child( + form_field() + .label_fn(|_| "Name") + .child(self.name_input.clone()), + ) + .child( + form_field() + .label("Email") + .child(self.email_input.clone()) + .required(true), + ) + .child( + form_field() + .label("Bio") + .when(self.layout.is_vertical(), |this| this.items_start()) + .child(self.bio_input.clone()) + .description_fn(|_| { + div().child("Use at most 100 words to describe yourself.") + }), + ) + .child( + form_field() + .no_label_indent() + .child("This is a full width form field."), + ) + .child( + form_field() + .label("Birthday") + .child(self.date_picker.clone()) + .description("Select your birthday, we will send you a gift."), + ) + .child( + form_field().child( + Switch::new("subscribe-newsletter") + .label("Subscribe our newsletter") + .checked(self.subscribe_email) + .on_click(cx.listener(|this, checked: &bool, cx| { + this.subscribe_email = *checked; + cx.notify(); + })), + ), + ), + ) + } +} diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index 3b13f1ec..a2e1c4ff 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -323,7 +323,7 @@ impl InputStory { } impl FocusableCycle for InputStory { - fn cycle_focus_handles(&self, cx: &mut ViewContext) -> Vec { + fn cycle_focus_handles(&self, cx: &mut WindowContext) -> Vec { [ self.input1.focus_handle(cx), self.input2.focus_handle(cx), diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 6bfc0064..ca29b0aa 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -3,6 +3,7 @@ mod assets; mod button_story; mod calendar_story; mod dropdown_story; +mod form_story; mod icon_story; mod image_story; mod input_story; @@ -25,6 +26,14 @@ pub use accordion_story::AccordionStory; pub use button_story::ButtonStory; pub use calendar_story::CalendarStory; pub use dropdown_story::DropdownStory; +pub use form_story::FormStory; + +use gpui::{ + actions, div, prelude::FluentBuilder as _, px, AnyElement, AnyView, AppContext, Context as _, + Div, EventEmitter, FocusableView, Global, Hsla, InteractiveElement, IntoElement, Model, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled as _, View, + ViewContext, VisualContext, WindowContext, +}; pub use icon_story::IconStory; pub use image_story::ImageStory; pub use input_story::InputStory; @@ -42,13 +51,6 @@ pub use text_story::TextStory; pub use tooltip_story::TooltipStory; pub use webview_story::WebViewStory; -use gpui::{ - actions, div, prelude::FluentBuilder as _, px, AnyElement, AnyView, AppContext, Context as _, - Div, EventEmitter, FocusableView, Global, Hsla, InteractiveElement, IntoElement, Model, - ParentElement, Render, SharedString, StatefulInteractiveElement, Styled as _, View, - ViewContext, VisualContext, WindowContext, -}; - use ui::{ button::Button, divider::Divider, @@ -298,6 +300,7 @@ impl StoryState { "WebViewStory" => story!(WebViewStory), "AccordionStory" => story!(AccordionStory), "SidebarStory" => story!(SidebarStory), + "FormStory" => story!(FormStory), _ => { unreachable!("Invalid story klass: {}", self.story_klass) } diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index eeec4a40..785a92fd 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -4,10 +4,10 @@ use prelude::FluentBuilder as _; use serde::Deserialize; use std::{sync::Arc, time::Duration}; use story::{ - AccordionStory, AppState, Assets, ButtonStory, CalendarStory, DropdownStory, IconStory, - ImageStory, InputStory, ListStory, ModalStory, PopupStory, ProgressStory, ResizableStory, - ScrollableStory, SidebarStory, StoryContainer, SwitchStory, TableStory, TextStory, - TooltipStory, + AccordionStory, AppState, Assets, ButtonStory, CalendarStory, DropdownStory, FormStory, + IconStory, ImageStory, InputStory, ListStory, ModalStory, PopupStory, ProgressStory, + ResizableStory, ScrollableStory, SidebarStory, StoryContainer, SwitchStory, TableStory, + TextStory, TooltipStory, }; use ui::{ badge::Badge, @@ -318,6 +318,7 @@ impl StoryWorkspace { Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), + Arc::new(StoryContainer::panel::(cx)), // Arc::new(StoryContainer::panel::(cx)), ], None, diff --git a/crates/ui/src/focusable.rs b/crates/ui/src/focusable.rs index 70b0cd96..649e2ea0 100644 --- a/crates/ui/src/focusable.rs +++ b/crates/ui/src/focusable.rs @@ -1,4 +1,4 @@ -use gpui::{FocusHandle, ViewContext}; +use gpui::{FocusHandle, WindowContext}; /// A trait for views that can cycle focus between its children. /// @@ -8,13 +8,13 @@ use gpui::{FocusHandle, ViewContext}; /// should be cycled, and the cycle will follow the order of the list. pub trait FocusableCycle { /// Returns a list of focus handles that should be cycled. - fn cycle_focus_handles(&self, cx: &mut ViewContext) -> Vec + fn cycle_focus_handles(&self, cx: &mut WindowContext) -> Vec where Self: Sized; /// Cycles focus between the focus handles returned by `cycle_focus_handles`. /// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev. - fn cycle_focus(&self, is_next: bool, cx: &mut ViewContext) + fn cycle_focus(&self, is_next: bool, cx: &mut WindowContext) where Self: Sized, { diff --git a/crates/ui/src/form.rs b/crates/ui/src/form.rs new file mode 100644 index 00000000..ccfda7f1 --- /dev/null +++ b/crates/ui/src/form.rs @@ -0,0 +1,428 @@ +use std::rc::{Rc, Weak}; + +use gpui::{ + div, prelude::FluentBuilder as _, px, AlignItems, AnyElement, AnyView, Axis, Div, Element, + ElementId, FocusHandle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Rems, + RenderOnce, SharedString, Styled, WindowContext, +}; + +use crate::{h_flex, v_flex, ActiveTheme as _, AxisExt, FocusableCycle, Sizable, Size, StyledExt}; + +/// Create a new form with a vertical layout. +pub fn v_form() -> Form { + Form::vertical() +} + +/// Create a new form with a horizontal layout. +pub fn h_form() -> Form { + Form::horizontal() +} + +/// Create a new form field. +pub fn form_field() -> FormField { + FormField::new() +} + +#[derive(IntoElement)] +pub struct Form { + fields: Vec, + props: FieldProps, +} + +#[derive(Clone, Copy)] +struct FieldProps { + size: Size, + label_width: Option, + label_text_size: Option, + layout: Axis, + /// Field gap + gap: Option, +} + +impl Default for FieldProps { + fn default() -> Self { + Self { + label_width: Some(px(140.)), + label_text_size: None, + layout: Axis::Vertical, + size: Size::default(), + gap: None, + } + } +} + +impl Form { + fn new() -> Self { + Self { + props: FieldProps::default(), + fields: Vec::new(), + } + } + + /// Creates a new form with a horizontal layout. + pub fn horizontal() -> Self { + Self::new().layout(Axis::Horizontal) + } + + /// Creates a new form with a vertical layout. + pub fn vertical() -> Self { + Self::new().layout(Axis::Vertical) + } + + /// Set the layout for the form, default is `Axis::Vertical`. + pub fn layout(mut self, layout: Axis) -> Self { + self.props.layout = layout; + self + } + + /// Set the width of the labels in the form. Default is `px(100.)`. + pub fn label_width(mut self, width: Pixels) -> Self { + self.props.label_width = Some(width); + self + } + + /// Set the text size of the labels in the form. Default is `None`. + pub fn label_text_size(mut self, size: Rems) -> Self { + self.props.label_text_size = Some(size); + self + } + + /// Set the gap between the form fields. + pub fn gap(mut self, gap: Pixels) -> Self { + self.props.gap = Some(gap); + self + } + + /// Add a child to the form. + pub fn child(mut self, field: impl Into) -> Self { + self.fields.push(field.into()); + self + } + + /// Add multiple children to the form. + pub fn children(mut self, fields: impl IntoIterator) -> Self { + self.fields.extend(fields); + self + } +} + +impl Sizable for Form { + fn with_size(mut self, size: impl Into) -> Self { + self.props.size = size.into(); + self + } +} + +impl FocusableCycle for Form { + fn cycle_focus_handles(&self, _: &mut WindowContext) -> Vec + where + Self: Sized, + { + self.fields + .iter() + .filter_map(|item| item.focus_handle.clone()) + .collect() + } +} + +pub enum FieldBuilder { + String(SharedString), + Element(Rc AnyElement>), + View(AnyView), +} + +impl Default for FieldBuilder { + fn default() -> Self { + Self::String(SharedString::default()) + } +} + +impl From for FieldBuilder { + fn from(view: AnyView) -> Self { + Self::View(view) + } +} + +impl RenderOnce for FieldBuilder { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + match self { + FieldBuilder::String(value) => value.into_any_element(), + FieldBuilder::Element(builder) => builder(cx), + FieldBuilder::View(view) => view.into_any(), + } + } +} + +impl From<&'static str> for FieldBuilder { + fn from(value: &'static str) -> Self { + Self::String(value.into()) + } +} + +impl From for FieldBuilder { + fn from(value: String) -> Self { + Self::String(value.into()) + } +} + +impl From for FieldBuilder { + fn from(value: SharedString) -> Self { + Self::String(value) + } +} + +#[derive(IntoElement)] +pub struct FormField { + id: ElementId, + form: Weak
, + label: Option, + no_label_indent: bool, + focus_handle: Option, + description: Option, + /// Used to render the actual form field, e.g.: TextInput, Switch... + child: Div, + visible: bool, + required: bool, + /// Alignment of the form field. + align_items: Option, + props: FieldProps, +} + +impl FormField { + pub fn new() -> Self { + Self { + id: 0.into(), + form: Weak::new(), + label: None, + description: None, + child: div(), + visible: true, + required: false, + no_label_indent: false, + focus_handle: None, + align_items: None, + props: FieldProps::default(), + } + } + + /// Sets the label for the form field. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Sets not indent with the label width (in Horizontal layout). + /// + /// Sometimes you want to align the input form left (Default is align after the label width in Horizontal layout). + /// + /// This is only work when the `label` is not set. + pub fn no_label_indent(mut self) -> Self { + self.no_label_indent = true; + self + } + + /// Sets the label for the form field using a function. + pub fn label_fn(mut self, label: F) -> Self + where + E: IntoElement, + F: Fn(&mut WindowContext) -> E + 'static, + { + self.label = Some(FieldBuilder::Element(Rc::new(move |cx| { + label(cx).into_any_element() + }))); + self + } + + /// Sets the description for the form field. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets the description for the form field using a function. + pub fn description_fn(mut self, description: F) -> Self + where + E: IntoElement, + F: Fn(&mut WindowContext) -> E + 'static, + { + self.description = Some(FieldBuilder::Element(Rc::new(move |cx| { + description(cx).into_any_element() + }))); + self + } + + /// Set the visibility of the form field, default is `true`. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Set the required status of the form field, default is `false`. + pub fn required(mut self, required: bool) -> Self { + self.required = required; + self + } + + /// Set the focus handle for the form field. + /// + /// If not set, the form field will not be focusable. + pub fn track_focus(mut self, focus_handle: FocusHandle) -> Self { + self.focus_handle = Some(focus_handle); + self + } + + pub fn parent(mut self, form: &Rc) -> Self { + self.form = Rc::downgrade(form); + self + } + + /// Set the properties for the form field. + /// + /// This is internal API for sync props from From. + fn props(mut self, ix: usize, props: FieldProps) -> Self { + self.id = ix.into(); + self.props = props; + self + } + + /// Align the form field items to the start, this is the default. + pub fn items_start(mut self) -> Self { + self.align_items = Some(AlignItems::Start); + self + } + + /// Align the form field items to the end. + pub fn items_end(mut self) -> Self { + self.align_items = Some(AlignItems::End); + self + } + + /// Align the form field items to the center. + pub fn items_center(mut self) -> Self { + self.align_items = Some(AlignItems::Center); + self + } +} +impl ParentElement for FormField { + fn extend(&mut self, elements: impl IntoIterator) { + self.child.extend(elements); + } +} + +impl RenderOnce for FormField { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let layout = self.props.layout; + + let label_width = if layout.is_vertical() { + None + } else { + self.props.label_width + }; + let has_label = !self.no_label_indent; + + #[inline] + fn wrap_div(layout: Axis) -> Div { + if layout.is_vertical() { + v_flex() + } else { + h_flex() + } + } + + #[inline] + fn wrap_label(label_width: Option) -> Div { + h_flex() + .truncate() + .when_some(label_width, |this, width| this.w(width).flex_shrink_0()) + } + + let gap = match self.props.gap { + Some(v) => v, + None => match self.props.size { + Size::Large => px(8.), + Size::XSmall | Size::Small => px(4.), + _ => px(4.), + }, + }; + let inner_gap = if layout.is_horizontal() { + gap + } else { + gap / 2. + }; + + v_flex() + .flex_1() + .gap(gap / 2.) + .child( + // This warp for aligning the Label + Input + wrap_div(layout) + .id(self.id) + .gap(inner_gap) + .when_some(self.align_items, |this, align| { + this.map(|this| match align { + AlignItems::Start => this.items_start(), + AlignItems::End => this.items_end(), + AlignItems::Center => this.items_center(), + AlignItems::Baseline => this.items_baseline(), + _ => this, + }) + }) + .when(has_label, |this| { + // Label + this.child( + wrap_label(label_width) + .text_sm() + .when_some(self.props.label_text_size, |this, size| { + this.text_size(size) + }) + .font_medium() + .gap_1() + .items_center() + .when_some(self.label, |this, builder| { + this.child(builder.render(cx)).when(self.required, |this| { + this.child(div().text_color(cx.theme().danger).child("*")) + }) + }), + ) + }) + .child(div().w_full().child(self.child)), + ) + .child( + // Other + wrap_div(layout) + .gap(inner_gap) + .when(has_label && layout.is_horizontal(), |this| { + this.child( + // Empty for spacing to align with the input + wrap_label(label_width), + ) + }) + .when_some(self.description, |this, builder| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(builder.render(cx)), + ) + }), + ) + } +} +impl RenderOnce for Form { + fn render(self, _: &mut WindowContext) -> impl IntoElement { + let props = self.props; + + let gap = match props.size { + Size::XSmall | Size::Small => px(6.), + Size::Large => px(12.), + _ => px(8.), + }; + + v_flex().w_full().gap(gap).children( + self.fields + .into_iter() + .enumerate() + .map(|(ix, field)| field.props(ix, props)), + ) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index d297443a..f20cc5a0 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -23,6 +23,7 @@ pub mod divider; pub mod dock; pub mod drawer; pub mod dropdown; +pub mod form; pub mod history; pub mod indicator; pub mod input;