ui_input: `TextField` -> `SingleLineInput` (#28031)

Nate Butler , Agus Zubiaga , Danilo Leal , and Danilo Leal created

- Rename `TextField` -> `SingleLineInput`
- Add a component preview for `SingleLineInput`
- Apply `SingleLineInput` to the AddContextServerModal

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>

Change summary

Cargo.lock                                                           |   3 
crates/agent/Cargo.toml                                              |   1 
crates/agent/src/assistant_configuration/add_context_server_modal.rs |  55 
crates/ui_input/Cargo.toml                                           |   2 
crates/ui_input/src/ui_input.rs                                      | 122 
5 files changed, 91 insertions(+), 92 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -116,6 +116,7 @@ dependencies = [
  "time",
  "time_format",
  "ui",
+ "ui_input",
  "util",
  "uuid",
  "vim_mode_setting",
@@ -15310,8 +15311,10 @@ dependencies = [
 name = "ui_input"
 version = "0.1.0"
 dependencies = [
+ "component",
  "editor",
  "gpui",
+ "linkme",
  "settings",
  "theme",
  "ui",

crates/agent/Cargo.toml 🔗

@@ -81,6 +81,7 @@ theme.workspace = true
 time.workspace = true
 time_format.workspace = true
 ui.workspace = true
+ui_input.workspace = true
 util.workspace = true
 uuid.workspace = true
 vim_mode_setting.workspace = true

crates/agent/src/assistant_configuration/add_context_server_modal.rs 🔗

@@ -1,17 +1,17 @@
 use context_server::{ContextServerSettings, ServerCommand, ServerConfig};
-use editor::Editor;
 use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*};
 use serde_json::json;
 use settings::update_settings_file;
 use ui::{Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
+use ui_input::SingleLineInput;
 use workspace::{ModalView, Workspace};
 
 use crate::AddContextServer;
 
 pub struct AddContextServerModal {
     workspace: WeakEntity<Workspace>,
-    name_editor: Entity<Editor>,
-    command_editor: Entity<Editor>,
+    name_editor: Entity<SingleLineInput>,
+    command_editor: Entity<SingleLineInput>,
 }
 
 impl AddContextServerModal {
@@ -33,15 +33,10 @@ impl AddContextServerModal {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let name_editor = cx.new(|cx| Editor::single_line(window, cx));
-        let command_editor = cx.new(|cx| Editor::single_line(window, cx));
-
-        name_editor.update(cx, |editor, cx| {
-            editor.set_placeholder_text("Context server name", cx);
-        });
-
-        command_editor.update(cx, |editor, cx| {
-            editor.set_placeholder_text("Command to run the context server", cx);
+        let name_editor =
+            cx.new(|cx| SingleLineInput::new(window, cx, "Your server name").label("Name"));
+        let command_editor = cx.new(|cx| {
+            SingleLineInput::new(window, cx, "Command").label("Command to run the context server")
         });
 
         Self {
@@ -52,8 +47,22 @@ impl AddContextServerModal {
     }
 
     fn confirm(&mut self, cx: &mut Context<Self>) {
-        let name = self.name_editor.read(cx).text(cx).trim().to_string();
-        let command = self.command_editor.read(cx).text(cx).trim().to_string();
+        let name = self
+            .name_editor
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+        let command = self
+            .command_editor
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
 
         if name.is_empty() || command.is_empty() {
             return;
@@ -104,8 +113,8 @@ impl EventEmitter<DismissEvent> for AddContextServerModal {}
 
 impl Render for AddContextServerModal {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let is_name_empty = self.name_editor.read(cx).text(cx).trim().is_empty();
-        let is_command_empty = self.command_editor.read(cx).text(cx).trim().is_empty();
+        let is_name_empty = self.name_editor.read(cx).is_empty(cx);
+        let is_command_empty = self.command_editor.read(cx).is_empty(cx);
 
         div()
             .elevation_3(cx)
@@ -122,18 +131,8 @@ impl Render for AddContextServerModal {
                     .header(ModalHeader::new().headline("Add Context Server"))
                     .section(
                         Section::new()
-                            .child(
-                                v_flex()
-                                    .gap_1()
-                                    .child(Label::new("Name"))
-                                    .child(self.name_editor.clone()),
-                            )
-                            .child(
-                                v_flex()
-                                    .gap_1()
-                                    .child(Label::new("Command"))
-                                    .child(self.command_editor.clone()),
-                            ),
+                            .child(self.name_editor.clone())
+                            .child(self.command_editor.clone()),
                     )
                     .footer(
                         ModalFooter::new()

crates/ui_input/Cargo.toml 🔗

@@ -12,8 +12,10 @@ workspace = true
 path = "src/ui_input.rs"
 
 [dependencies]
+component.workspace = true
 editor.workspace = true
 gpui.workspace = true
+linkme.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/ui_input/src/ui_input.rs 🔗

@@ -5,20 +5,14 @@
 //! It can't be located in the `ui` crate because it depends on `editor`.
 //!
 
+use component::{ComponentPreview, example_group, single_example};
 use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, TextStyle};
 use settings::Settings;
 use theme::ThemeSettings;
 use ui::prelude::*;
 
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub enum FieldLabelLayout {
-    Hidden,
-    Inline,
-    Stacked,
-}
-
-pub struct TextFieldStyle {
+pub struct SingleLineInputStyle {
     text_color: Hsla,
     background_color: Hsla,
     border_color: Hsla,
@@ -27,11 +21,13 @@ pub struct TextFieldStyle {
 /// A Text Field that can be used to create text fields like search inputs, form fields, etc.
 ///
 /// It wraps a single line [`Editor`] and allows for common field properties like labels, placeholders, icons, etc.
-pub struct TextField {
+#[derive(IntoComponent)]
+#[component(scope = "Input")]
+pub struct SingleLineInput {
     /// An optional label for the text field.
     ///
     /// Its position is determined by the [`FieldLabelLayout`].
-    label: SharedString,
+    label: Option<SharedString>,
     /// The placeholder text for the text field.
     placeholder: SharedString,
     /// Exposes the underlying [`Model<Editor>`] to allow for customizing the editor beyond the provided API.
@@ -42,25 +38,18 @@ pub struct TextField {
     ///
     /// For example, a magnifying glass icon in a search field.
     start_icon: Option<IconName>,
-    /// The layout of the label relative to the text field.
-    with_label: FieldLabelLayout,
     /// Whether the text field is disabled.
     disabled: bool,
 }
 
-impl Focusable for TextField {
+impl Focusable for SingleLineInput {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         self.editor.focus_handle(cx)
     }
 }
 
-impl TextField {
-    pub fn new(
-        window: &mut Window,
-        cx: &mut App,
-        label: impl Into<SharedString>,
-        placeholder: impl Into<SharedString>,
-    ) -> Self {
+impl SingleLineInput {
+    pub fn new(window: &mut Window, cx: &mut App, placeholder: impl Into<SharedString>) -> Self {
         let placeholder_text = placeholder.into();
 
         let editor = cx.new(|cx| {
@@ -70,11 +59,10 @@ impl TextField {
         });
 
         Self {
-            label: label.into(),
+            label: None,
             placeholder: placeholder_text,
             editor,
             start_icon: None,
-            with_label: FieldLabelLayout::Hidden,
             disabled: false,
         }
     }
@@ -84,8 +72,8 @@ impl TextField {
         self
     }
 
-    pub fn with_label(mut self, layout: FieldLabelLayout) -> Self {
-        self.with_label = layout;
+    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
+        self.label = Some(label.into());
         self
     }
 
@@ -95,25 +83,29 @@ impl TextField {
             .update(cx, |editor, _| editor.set_read_only(disabled))
     }
 
+    pub fn is_empty(&self, cx: &App) -> bool {
+        self.editor().read(cx).text(cx).trim().is_empty()
+    }
+
     pub fn editor(&self) -> &Entity<Editor> {
         &self.editor
     }
 }
 
-impl Render for TextField {
+impl Render for SingleLineInput {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let theme_color = cx.theme().colors();
 
-        let mut style = TextFieldStyle {
+        let mut style = SingleLineInputStyle {
             text_color: theme_color.text,
-            background_color: theme_color.ghost_element_background,
-            border_color: theme_color.border,
+            background_color: theme_color.editor_background,
+            border_color: theme_color.border_variant,
         };
 
         if self.disabled {
             style.text_color = theme_color.text_disabled;
-            style.background_color = theme_color.ghost_element_disabled;
+            style.background_color = theme_color.editor_background;
             style.border_color = theme_color.border_disabled;
         }
 
@@ -123,8 +115,8 @@ impl Render for TextField {
         // }
 
         let text_style = TextStyle {
-            font_family: settings.buffer_font.family.clone(),
-            font_features: settings.buffer_font.features.clone(),
+            font_family: settings.ui_font.family.clone(),
+            font_features: settings.ui_font.features.clone(),
             font_size: rems(0.875).into(),
             font_weight: settings.buffer_font.weight,
             font_style: FontStyle::Normal,
@@ -140,13 +132,13 @@ impl Render for TextField {
             ..Default::default()
         };
 
-        div()
+        v_flex()
             .id(self.placeholder.clone())
-            .group("text-field")
             .w_full()
-            .when(self.with_label == FieldLabelLayout::Stacked, |this| {
+            .gap_1()
+            .when_some(self.label.clone(), |this, label| {
                 this.child(
-                    Label::new(self.label.clone())
+                    Label::new(label)
                         .size(LabelSize::Default)
                         .color(if self.disabled {
                             Color::Disabled
@@ -156,35 +148,37 @@ impl Render for TextField {
                 )
             })
             .child(
-                v_flex().w_full().child(
-                    h_flex()
-                        .w_full()
-                        .flex_grow()
-                        .gap_2()
-                        .when(self.with_label == FieldLabelLayout::Inline, |this| {
-                            this.child(Label::new(self.label.clone()).size(LabelSize::Default))
-                        })
-                        .child(
-                            h_flex()
-                                .px_2()
-                                .py_1()
-                                .bg(style.background_color)
-                                .text_color(style.text_color)
-                                .rounded_lg()
-                                .border_1()
-                                .border_color(style.border_color)
-                                .min_w_48()
-                                .w_full()
-                                .flex_grow()
-                                .gap_1()
-                                .when_some(self.start_icon, |this, icon| {
-                                    this.child(
-                                        Icon::new(icon).size(IconSize::Small).color(Color::Muted),
-                                    )
-                                })
-                                .child(EditorElement::new(&self.editor, editor_style)),
-                        ),
-                ),
+                h_flex()
+                    .px_2()
+                    .py_1()
+                    .bg(style.background_color)
+                    .text_color(style.text_color)
+                    .rounded_md()
+                    .border_1()
+                    .border_color(style.border_color)
+                    .min_w_48()
+                    .w_full()
+                    .flex_grow()
+                    .when_some(self.start_icon, |this, icon| {
+                        this.gap_1()
+                            .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
+                    })
+                    .child(EditorElement::new(&self.editor, editor_style)),
             )
     }
 }
+
+impl ComponentPreview for SingleLineInput {
+    fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
+        let input_1 =
+            cx.new(|cx| SingleLineInput::new(window, cx, "placeholder").label("Some Label"));
+
+        v_flex()
+            .gap_6()
+            .children(vec![example_group(vec![single_example(
+                "Default",
+                div().child(input_1.clone()).into_any_element(),
+            )])])
+            .into_any_element()
+    }
+}