use gpui::{
div, prelude::*, AnyElement, App, ElementId, Entity, EntityId, FocusHandle, Focusable,
IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Svg, Window,
};
use crate::element_id::for_entity;
use crate::elements::input::{disabled_display, input};
use crate::input::InputState;
use crate::layout::h_stack;
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
const MIN_CONTENT_WIDTH: Rems = Rems(8.0);
fn text_field_element_id(state_id: EntityId) -> ElementId {
for_entity("text-field", state_id)
}
pub fn text_field(state: &Entity<InputState>, cx: &App) -> TextField {
TextField::new(state, cx)
}
pub struct Adornment(AdornmentKind);
enum AdornmentKind {
Icon(Box<Svg>),
Text(SharedString),
Element(AnyElement),
}
impl Adornment {
pub fn icon(icon: Svg) -> Self {
Adornment(AdornmentKind::Icon(Box::new(icon)))
}
pub fn text(text: impl Into<SharedString>) -> Self {
Adornment(AdornmentKind::Text(text.into()))
}
pub fn element(element: impl IntoElement) -> Self {
Adornment(AdornmentKind::Element(element.into_any_element()))
}
}
#[derive(IntoElement)]
pub struct TextField {
state: Entity<InputState>,
focus_handle: FocusHandle,
placeholder: Option<SharedString>,
prefix: Option<Adornment>,
suffix: Option<Adornment>,
full_width: bool,
disabled: bool,
read_only: Option<bool>,
size: ControlSize,
element_id: Option<ElementId>,
}
impl TextField {
pub fn new(state: &Entity<InputState>, cx: &App) -> Self {
Self {
state: state.clone(),
focus_handle: state.focus_handle(cx),
placeholder: None,
prefix: None,
suffix: None,
full_width: false,
disabled: false,
read_only: None,
size: ControlSize::default(),
element_id: None,
}
}
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.element_id = Some(id.into());
self
}
pub fn element_id(&self) -> ElementId {
self.element_id
.clone()
.unwrap_or_else(|| text_field_element_id(self.state.entity_id()))
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn prefix(mut self, adornment: Adornment) -> Self {
self.prefix = Some(adornment);
self
}
pub fn suffix(mut self, adornment: Adornment) -> Self {
self.suffix = Some(adornment);
self
}
pub fn full_width(mut self, full_width: bool) -> Self {
self.full_width = full_width;
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
self.read_only = Some(read_only);
self
}
}
impl Disableable for TextField {
fn is_disabled(&self) -> bool {
self.disabled
}
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl ControlSized for TextField {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl Focusable for TextField {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl RenderOnce for TextField {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let element_id = self.element_id();
if let Some(read_only) = self.read_only {
self.state
.update(cx, |state, cx| state.set_read_only(read_only, cx));
}
let read_only = self.state.read(cx).is_read_only();
let theme = cx.theme();
let metrics = theme.control(self.size);
let is_focused = self.focus_handle.is_focused(window);
let disabled = self.disabled;
let bg_color = if disabled {
theme.surface_tertiary()
} else if read_only {
theme.surface_secondary()
} else {
theme.input_bg()
};
let border_color = if disabled {
theme.border_subtle()
} else if is_focused {
theme.input_border_focused()
} else {
theme.input_border()
};
let content = if disabled {
let value = self.state.read(cx).content();
let (text, is_placeholder) = disabled_display(value, self.placeholder.as_ref());
let color = if is_placeholder {
theme.input_placeholder()
} else {
theme.fg_disabled()
};
div()
.flex_1()
.min_w(MIN_CONTENT_WIDTH)
.overflow_hidden()
.whitespace_nowrap()
.text_color(color)
.child(text)
.into_any_element()
} else {
let mut inner = input(&self.state, cx)
.control_size(self.size)
.flex_1()
.h_full()
.min_w(MIN_CONTENT_WIDTH)
.text_color(theme.input_text());
if let Some(placeholder) = self.placeholder {
inner = inner.placeholder(placeholder);
}
inner.into_any_element()
};
let focus_handle = self.focus_handle.clone();
h_stack()
.id(element_id)
.items_center()
.h(metrics.height)
.gap(metrics.gap)
.px(metrics.padding_x)
.when(!self.full_width, |this| this.flex_none())
.when(self.full_width, |this| this.w_full())
.bg(bg_color)
.border_1()
.border_color(border_color)
.rounded(metrics.radius)
.overflow_hidden()
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.when(disabled, |this| this.cursor_not_allowed().opacity(0.65))
.when(!disabled, |this| {
this.cursor_text()
.when(!is_focused && !read_only, |this| {
this.hover(|style| style.border_color(theme.input_border_hover()))
})
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
window.focus(&focus_handle, cx);
})
})
.when_some(self.prefix, |this, adornment| {
this.child(render_adornment(adornment, disabled, metrics.ink, cx))
})
.child(content)
.when_some(self.suffix, |this, adornment| {
this.child(render_adornment(adornment, disabled, metrics.ink, cx))
})
}
}
fn render_adornment(adornment: Adornment, disabled: bool, icon_size: Rems, cx: &App) -> AnyElement {
let theme = cx.theme();
let color = if disabled {
theme.fg_disabled()
} else {
theme.fg_muted()
};
match adornment.0 {
AdornmentKind::Icon(icon) => div()
.flex()
.flex_none()
.items_center()
.child(icon.size(icon_size).text_color(color))
.into_any_element(),
AdornmentKind::Text(text) => div()
.flex_none()
.whitespace_nowrap()
.text_color(color)
.child(text)
.into_any_element(),
AdornmentKind::Element(element) => div().flex_none().child(element).into_any_element(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elements::input::tests::focused_input_window;
use gpui::TestAppContext;
fn type_into(
cx: &mut TestAppContext,
build: impl Fn(&Entity<InputState>, &App) -> TextField + 'static,
content: &str,
keystrokes: &str,
) -> String {
let state = cx.update(|cx| cx.new(InputState::new_singleline));
let content = content.to_string();
state.update(cx, |state, cx| state.set_content(content, cx));
let for_render = state.clone();
let cx = focused_input_window(cx, &state, move |_window, cx| {
build(&for_render, cx).into_any_element()
});
cx.run_until_parked();
cx.simulate_keystrokes(keystrokes);
cx.run_until_parked();
state.read_with(cx, |state, _| state.content().to_string())
}
#[gpui::test]
fn a_disabled_field_does_not_take_a_keystroke(cx: &mut TestAppContext) {
let after = type_into(
cx,
|state, cx| text_field(state, cx).disabled(true),
"kept",
"x",
);
assert_eq!(after, "kept");
}
#[gpui::test]
fn a_read_only_field_does_not_take_a_keystroke(cx: &mut TestAppContext) {
let after = type_into(
cx,
|state, cx| text_field(state, cx).read_only(true),
"kept",
"x",
);
assert_eq!(after, "kept");
}
#[gpui::test]
fn an_editable_field_takes_a_keystroke(cx: &mut TestAppContext) {
let after = type_into(cx, text_field, "kept", "x");
assert_eq!(after, "xkept");
}
#[gpui::test]
fn each_field_renders_under_its_own_state(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let (one, two) = cx.update(|cx| {
(
cx.new(InputState::new_singleline),
cx.new(InputState::new_singleline),
)
});
let (first, second, overridden) = cx.update(|cx| {
(
text_field(&one, cx).element_id(),
text_field(&two, cx).element_id(),
text_field(&one, cx).id("shared-state-left").element_id(),
)
});
assert_eq!(first, text_field_element_id(one.entity_id()));
assert_ne!(first, second);
assert_eq!(overridden, ElementId::Name("shared-state-left".into()));
}
#[gpui::test]
fn the_fields_focus_handle_is_the_states(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let state = cx.update(|cx| cx.new(InputState::new_singleline));
cx.update(|cx| {
assert_eq!(
text_field(&state, cx).focus_handle(cx),
state.focus_handle(cx)
);
});
}
#[test]
fn a_disabled_field_shows_its_value_then_its_placeholder() {
let placeholder = SharedString::from("Search");
assert_eq!(
disabled_display("typed", Some(&placeholder)),
(SharedString::from("typed"), false)
);
assert_eq!(
disabled_display("", Some(&placeholder)),
(placeholder.clone(), true)
);
assert_eq!(disabled_display("", None), (SharedString::default(), false));
}
}