1use gpui::prelude::*;
7use gpui::{
8 div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
9 MouseButton, SharedString, Window,
10};
11
12use super::{apply_key, control_metrics, edit::TextEdit, KeyOutcome};
13use crate::reactive::Signal;
14use crate::theme::{theme, ColorName, Size};
15
16#[derive(Debug, Clone)]
18pub enum TextInputEvent {
19 Change(String),
21 Submit(String),
23}
24
25pub struct TextInput {
27 edit: TextEdit,
28 focus: FocusHandle,
29 placeholder: SharedString,
30 label: Option<SharedString>,
31 description: Option<SharedString>,
32 error: Option<SharedString>,
33 size: Size,
34 radius: Option<Size>,
35 disabled: bool,
36 password: bool,
37}
38
39impl EventEmitter<TextInputEvent> for TextInput {}
40
41impl TextInput {
42 pub fn new(cx: &mut Context<Self>) -> Self {
43 TextInput {
44 edit: TextEdit::new(""),
45 focus: cx.focus_handle(),
46 placeholder: SharedString::default(),
47 label: None,
48 description: None,
49 error: None,
50 size: Size::Sm,
51 radius: None,
52 disabled: false,
53 password: false,
54 }
55 }
56
57 pub fn value(mut self, value: &str) -> Self {
58 self.edit = TextEdit::new(value);
59 self
60 }
61
62 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
63 self.placeholder = placeholder.into();
64 self
65 }
66
67 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
68 self.label = Some(label.into());
69 self
70 }
71
72 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
73 self.description = Some(description.into());
74 self
75 }
76
77 pub fn error(mut self, error: impl Into<SharedString>) -> Self {
78 self.error = Some(error.into());
79 self
80 }
81
82 pub fn size(mut self, size: Size) -> Self {
83 self.size = size;
84 self
85 }
86
87 pub fn radius(mut self, radius: Size) -> Self {
88 self.radius = Some(radius);
89 self
90 }
91
92 pub fn disabled(mut self, disabled: bool) -> Self {
93 self.disabled = disabled;
94 self
95 }
96
97 pub fn password(mut self, password: bool) -> Self {
98 self.password = password;
99 self
100 }
101
102 pub fn focus_handle(&self) -> FocusHandle {
104 self.focus.clone()
105 }
106
107 pub fn text(&self) -> String {
109 self.edit.text()
110 }
111
112 pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
114 self.edit = TextEdit::new(value);
115 cx.notify();
116 }
117
118 pub fn bind(entity: &Entity<TextInput>, signal: &Signal<String>, cx: &mut App) {
123 let initial = signal.get(cx);
124 entity.update(cx, |this, cx| {
125 if this.text() != initial {
126 this.set_text(&initial, cx);
127 }
128 });
129 let sink = signal.clone();
130 cx.subscribe(entity, move |_input, event: &TextInputEvent, cx| {
131 if let TextInputEvent::Change(text) = event {
132 sink.set_if_changed(cx, text.clone());
133 }
134 })
135 .detach();
136 let input = entity.downgrade();
139 cx.observe(signal.entity(), move |observed, cx| {
140 let value = observed.read(cx).clone();
141 input
142 .update(cx, |this, cx| {
143 if this.text() != value {
144 this.set_text(&value, cx);
145 }
146 })
147 .ok();
148 })
149 .detach();
150 }
151
152 fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
153 if self.disabled {
154 return;
155 }
156 match apply_key(&mut self.edit, &event.keystroke) {
157 KeyOutcome::Submit => {
158 cx.emit(TextInputEvent::Submit(self.edit.text()));
159 cx.notify();
160 cx.stop_propagation();
161 }
162 KeyOutcome::Edited => {
163 cx.emit(TextInputEvent::Change(self.edit.text()));
164 cx.notify();
165 cx.stop_propagation();
166 }
167 KeyOutcome::Cancel | KeyOutcome::Pass => {}
170 }
171 }
172}
173
174impl Render for TextInput {
175 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
176 let t = theme(cx);
177 let (height, pad_x, font) = control_metrics(self.size);
178 let radius = t.radius(self.radius.unwrap_or(t.default_radius));
179 let focused = self.focus.is_focused(window) && !self.disabled;
180 let has_error = self.error.is_some();
181
182 let border = if has_error {
183 t.color(ColorName::Red, 6)
184 } else if focused {
185 t.primary()
186 } else {
187 t.border()
188 };
189 let text_color = t.text().hsla();
190 let dimmed = t.dimmed().hsla();
191 let surface = t.surface().hsla();
192 let caret_color = t.primary().hsla();
193 let error_color = t
194 .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 7 })
195 .hsla();
196 let border = border.hsla();
197 let font_sm = t.font_size(Size::Sm);
198 let font_xs = t.font_size(Size::Xs);
199
200 let mask = |s: String| {
201 if self.password {
202 "\u{2022}".repeat(s.chars().count())
203 } else {
204 s
205 }
206 };
207
208 let interior = if focused {
210 let (before, after) = self.edit.split();
211 div()
212 .flex()
213 .items_center()
214 .text_color(text_color)
215 .child(SharedString::from(mask(before)))
216 .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret_color))
217 .child(SharedString::from(mask(after)))
218 } else if self.edit.is_empty() {
219 div().text_color(dimmed).child(self.placeholder.clone())
220 } else {
221 div()
222 .text_color(text_color)
223 .child(SharedString::from(mask(self.edit.text())))
224 };
225
226 let field = div()
227 .id("guise-textinput")
228 .track_focus(&self.focus)
229 .on_key_down(cx.listener(Self::on_key))
230 .on_mouse_down(
231 MouseButton::Left,
232 cx.listener(|this, _ev, window, cx| {
233 window.focus(&this.focus);
234 cx.notify();
235 }),
236 )
237 .flex()
238 .items_center()
239 .h(px(height))
240 .px(px(pad_x))
241 .rounded(px(radius))
242 .border_1()
243 .border_color(border)
244 .bg(surface)
245 .text_size(px(font))
246 .child(interior);
247
248 let mut column = div().flex().flex_col().gap(px(4.0));
249 if let Some(label) = self.label.clone() {
250 column = column.child(
251 div()
252 .text_size(px(font_sm))
253 .text_color(text_color)
254 .child(label),
255 );
256 }
257 column = column.child(field);
258 if let Some(error) = self.error.clone() {
259 column = column.child(
260 div()
261 .text_size(px(font_xs))
262 .text_color(error_color)
263 .child(error),
264 );
265 } else if let Some(description) = self.description.clone() {
266 column = column.child(
267 div()
268 .text_size(px(font_xs))
269 .text_color(dimmed)
270 .child(description),
271 );
272 }
273
274 if self.disabled {
275 column.opacity(0.6)
276 } else {
277 column
278 }
279 }
280}