Skip to main content

guise/input/
text.rs

1//! `TextInput` — a stateful single-line text field (gpui entity).
2//!
3//! Owns its buffer and focus; renders Mantine chrome (label, field,
4//! description/error) and emits [`TextInputEvent`] on edit and submit.
5
6use gpui::prelude::*;
7use gpui::{
8    div, px, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
9    SharedString, Window,
10};
11
12use super::{apply_key, control_metrics, edit::TextEdit, KeyOutcome};
13use crate::theme::{theme, ColorName, Size};
14
15/// Emitted as the user edits or submits the field.
16#[derive(Debug, Clone)]
17pub enum TextInputEvent {
18    /// The text changed. Carries the full new value.
19    Change(String),
20    /// The user pressed Enter. Carries the current value.
21    Submit(String),
22}
23
24/// A single-line text field. Create with `cx.new(|cx| TextInput::new(cx))`.
25pub struct TextInput {
26    edit: TextEdit,
27    focus: FocusHandle,
28    placeholder: SharedString,
29    label: Option<SharedString>,
30    description: Option<SharedString>,
31    error: Option<SharedString>,
32    size: Size,
33    radius: Option<Size>,
34    disabled: bool,
35    password: bool,
36}
37
38impl EventEmitter<TextInputEvent> for TextInput {}
39
40impl TextInput {
41    pub fn new(cx: &mut Context<Self>) -> Self {
42        TextInput {
43            edit: TextEdit::new(""),
44            focus: cx.focus_handle(),
45            placeholder: SharedString::default(),
46            label: None,
47            description: None,
48            error: None,
49            size: Size::Sm,
50            radius: None,
51            disabled: false,
52            password: false,
53        }
54    }
55
56    pub fn value(mut self, value: &str) -> Self {
57        self.edit = TextEdit::new(value);
58        self
59    }
60
61    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
62        self.placeholder = placeholder.into();
63        self
64    }
65
66    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
67        self.label = Some(label.into());
68        self
69    }
70
71    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
72        self.description = Some(description.into());
73        self
74    }
75
76    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
77        self.error = Some(error.into());
78        self
79    }
80
81    pub fn size(mut self, size: Size) -> Self {
82        self.size = size;
83        self
84    }
85
86    pub fn radius(mut self, radius: Size) -> Self {
87        self.radius = Some(radius);
88        self
89    }
90
91    pub fn disabled(mut self, disabled: bool) -> Self {
92        self.disabled = disabled;
93        self
94    }
95
96    pub fn password(mut self, password: bool) -> Self {
97        self.password = password;
98        self
99    }
100
101    /// The field's focus handle, so a host can focus it on open.
102    pub fn focus_handle(&self) -> FocusHandle {
103        self.focus.clone()
104    }
105
106    /// The current text.
107    pub fn text(&self) -> String {
108        self.edit.text()
109    }
110
111    /// Replace the text programmatically.
112    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
113        self.edit = TextEdit::new(value);
114        cx.notify();
115    }
116
117    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
118        if self.disabled {
119            return;
120        }
121        match apply_key(&mut self.edit, &event.keystroke) {
122            KeyOutcome::Submit => {
123                cx.emit(TextInputEvent::Submit(self.edit.text()));
124                cx.notify();
125                cx.stop_propagation();
126            }
127            KeyOutcome::Edited => {
128                cx.emit(TextInputEvent::Change(self.edit.text()));
129                cx.notify();
130                cx.stop_propagation();
131            }
132            // Escape (Cancel) and unhandled keys (Tab, Cmd+W, …) bubble to the
133            // host: dialogs cancel on Escape, forms move focus on Tab.
134            KeyOutcome::Cancel | KeyOutcome::Pass => {}
135        }
136    }
137}
138
139impl Render for TextInput {
140    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
141        let t = theme(cx);
142        let (height, pad_x, font) = control_metrics(self.size);
143        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
144        let focused = self.focus.is_focused(window) && !self.disabled;
145        let has_error = self.error.is_some();
146
147        let border = if has_error {
148            t.color(ColorName::Red, 6)
149        } else if focused {
150            t.primary()
151        } else {
152            t.border()
153        };
154        let text_color = t.text().hsla();
155        let dimmed = t.dimmed().hsla();
156        let surface = t.surface().hsla();
157        let caret_color = t.primary().hsla();
158        let error_color = t.color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 7 }).hsla();
159        let border = border.hsla();
160        let font_sm = t.font_size(Size::Sm);
161        let font_xs = t.font_size(Size::Xs);
162
163        let mask = |s: String| {
164            if self.password {
165                "\u{2022}".repeat(s.chars().count())
166            } else {
167                s
168            }
169        };
170
171        // The interior: caret split when focused, else value or placeholder.
172        let interior = if focused {
173            let (before, after) = self.edit.split();
174            div()
175                .flex()
176                .items_center()
177                .text_color(text_color)
178                .child(SharedString::from(mask(before)))
179                .child(
180                    div()
181                        .w(px(1.0))
182                        .h(px(font * 1.15))
183                        .bg(caret_color),
184                )
185                .child(SharedString::from(mask(after)))
186        } else if self.edit.is_empty() {
187            div()
188                .text_color(dimmed)
189                .child(self.placeholder.clone())
190        } else {
191            div()
192                .text_color(text_color)
193                .child(SharedString::from(mask(self.edit.text())))
194        };
195
196        let field = div()
197            .id("guise-textinput")
198            .track_focus(&self.focus)
199            .on_key_down(cx.listener(Self::on_key))
200            .on_mouse_down(
201                MouseButton::Left,
202                cx.listener(|this, _ev, window, cx| {
203                    window.focus(&this.focus);
204                    cx.notify();
205                }),
206            )
207            .flex()
208            .items_center()
209            .h(px(height))
210            .px(px(pad_x))
211            .rounded(px(radius))
212            .border_1()
213            .border_color(border)
214            .bg(surface)
215            .text_size(px(font))
216            .child(interior);
217
218        let mut column = div().flex().flex_col().gap(px(4.0));
219        if let Some(label) = self.label.clone() {
220            column = column.child(
221                div()
222                    .text_size(px(font_sm))
223                    .text_color(text_color)
224                    .child(label),
225            );
226        }
227        column = column.child(field);
228        if let Some(error) = self.error.clone() {
229            column = column.child(
230                div()
231                    .text_size(px(font_xs))
232                    .text_color(error_color)
233                    .child(error),
234            );
235        } else if let Some(description) = self.description.clone() {
236            column = column.child(
237                div()
238                    .text_size(px(font_xs))
239                    .text_color(dimmed)
240                    .child(description),
241            );
242        }
243
244        if self.disabled {
245            column.opacity(0.6)
246        } else {
247            column
248        }
249    }
250}