Introduce TextField by adding the `ui_text_field` crate (#10361)

Nate Butler and Marshall Bowers created

There hasn't been a componentized way to create inputs or text fields
thus far due to the innate circular dependency between the `ui` and
`editor` crates. To bypass this issue we are introducing a new
`ui_text_field` crate to specifically handle this component.

`TextField` provides the ability to add stacked or inline labels, as
well as applies a standard visual style to inputs.

Example:

![CleanShot - 2024-04-10 at 11 22
13@2x](https://github.com/zed-industries/zed/assets/1714999/9bf5fc40-5024-4d01-9a8b-fb76f67d7e6e)

We'll continue to evolve this component in the near future and start
using it in the app once we've built out the needed functionality.

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

Cargo.lock                                |  11 +
Cargo.toml                                |   2 
crates/ui_text_field/Cargo.toml           |  22 ++
crates/ui_text_field/LICENSE-GPL          |   1 
crates/ui_text_field/src/ui_text_field.rs | 184 +++++++++++++++++++++++++
5 files changed, 220 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -10718,6 +10718,17 @@ dependencies = [
  "windows 0.53.0",
 ]
 
+[[package]]
+name = "ui_text_field"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "gpui",
+ "settings",
+ "theme",
+ "ui",
+]
+
 [[package]]
 name = "unicase"
 version = "2.7.0"

Cargo.toml 🔗

@@ -90,6 +90,7 @@ members = [
     "crates/telemetry_events",
     "crates/time_format",
     "crates/ui",
+    "crates/ui_text_field",
     "crates/util",
     "crates/vcs_menu",
     "crates/vim",
@@ -214,6 +215,7 @@ theme_selector = { path = "crates/theme_selector" }
 telemetry_events = { path = "crates/telemetry_events" }
 time_format = { path = "crates/time_format" }
 ui = { path = "crates/ui" }
+ui_text_field = { path = "crates/ui_text_field" }
 util = { path = "crates/util" }
 vcs_menu = { path = "crates/vcs_menu" }
 vim = { path = "crates/vim" }

crates/ui_text_field/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "ui_text_field"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/ui_text_field.rs"
+
+[dependencies]
+editor.workspace = true
+gpui.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+
+[features]
+default = []

crates/ui_text_field/src/ui_text_field.rs 🔗

@@ -0,0 +1,184 @@
+//! # UI – Text Field
+//!
+//! This crate provides a text field component that can be used to create text fields like search inputs, form fields, etc.
+//!
+//! It can't be located in the `ui` crate because it depends on `editor`.
+//!
+
+use editor::*;
+use gpui::*;
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::*;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum FieldLabelLayout {
+    Inline,
+    Stacked,
+}
+
+pub struct TextFieldStyle {
+    text_color: Hsla,
+    background_color: Hsla,
+    border_color: Hsla,
+}
+
+/// A Text Field view that can be used to create text fields like search inputs, form fields, etc.
+///
+/// It wraps a single line [`Editor`] view and allows for common field properties like labels, placeholders, icons, etc.
+pub struct TextField {
+    /// An optional label for the text field.
+    ///
+    /// Its position is determined by the [`FieldLabelLayout`].
+    label: Option<SharedString>,
+    /// The placeholder text for the text field.
+    ///
+    /// All text fields must have placeholder text that is displayed when the field is empty.
+    placeholder: SharedString,
+    /// Exposes the underlying [`View<Editor>`] to allow for customizing the editor beyond the provided API.
+    ///
+    /// This likely will only be public in the short term, ideally the API will be expanded to cover necessary use cases.
+    pub editor: View<Editor>,
+    /// An optional icon that is displayed at the start of the text field.
+    ///
+    /// For example, a magnifying glass icon in a search field.
+    start_icon: Option<IconName>,
+    /// The layout of the label relative to the text field.
+    label_layout: FieldLabelLayout,
+}
+
+impl FocusableView for TextField {
+    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl TextField {
+    pub fn new(placeholder: impl Into<SharedString>, cx: &mut WindowContext) -> Self {
+        let placeholder_text = placeholder.into();
+
+        let editor = cx.new_view(|cx| {
+            let mut input = Editor::single_line(cx);
+            input.set_placeholder_text(placeholder_text.clone(), cx);
+            input
+        });
+
+        Self {
+            label: None,
+            placeholder: placeholder_text,
+            editor,
+            start_icon: None,
+            label_layout: FieldLabelLayout::Stacked,
+        }
+    }
+
+    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
+        self.label = Some(label.into());
+        self
+    }
+
+    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
+        self.placeholder = placeholder.into();
+        self
+    }
+
+    pub fn start_icon(mut self, icon: IconName) -> Self {
+        self.start_icon = Some(icon);
+        self
+    }
+
+    pub fn label_layout(mut self, layout: FieldLabelLayout) -> Self {
+        self.label_layout = layout;
+        self
+    }
+}
+
+impl Render for TextField {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let settings = ThemeSettings::get_global(cx);
+        let theme_color = cx.theme().colors();
+
+        let style = TextFieldStyle {
+            text_color: theme_color.text,
+            background_color: theme_color.ghost_element_background,
+            border_color: theme_color.border,
+        };
+
+        // if self.disabled {
+        //     style.text_color = theme_color.text_disabled;
+        //     style.background_color = theme_color.ghost_element_disabled;
+        //     style.border_color = theme_color.border_disabled;
+        // }
+
+        // if self.error_message.is_some() {
+        //     style.text_color = cx.theme().status().error;
+        //     style.border_color = cx.theme().status().error_border
+        // }
+
+        let text_style = TextStyle {
+            font_family: settings.buffer_font.family.clone(),
+            font_features: settings.buffer_font.features,
+            font_size: rems(0.875).into(),
+            font_weight: FontWeight::NORMAL,
+            font_style: FontStyle::Normal,
+            line_height: relative(1.2),
+            color: style.text_color,
+            ..Default::default()
+        };
+
+        let editor_style = EditorStyle {
+            background: theme_color.ghost_element_background,
+            local_player: cx.theme().players().local(),
+            text: text_style,
+            ..Default::default()
+        };
+
+        let stacked_label: Option<Label> = if self.label_layout == FieldLabelLayout::Stacked {
+            self.label
+                .clone()
+                .map(|label| Label::new(label).size(LabelSize::Small))
+        } else {
+            None
+        };
+
+        let inline_label: Option<Label> = if self.label_layout == FieldLabelLayout::Inline {
+            self.label
+                .clone()
+                .map(|label| Label::new(label).size(LabelSize::Small))
+        } else {
+            None
+        };
+
+        div()
+            .when_some(stacked_label, |this, label| this.child(label))
+            .child(
+                v_flex()
+                    .w_full()
+                    .px_2()
+                    .py_1()
+                    .bg(style.background_color)
+                    .text_color(style.text_color)
+                    .rounded_lg()
+                    .border()
+                    .border_color(style.border_color)
+                    .w_48()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .when_some(inline_label, |this, label| this.child(label))
+                            .child(
+                                h_flex()
+                                    .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)),
+                            ),
+                    ),
+            )
+    }
+}