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, 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/// Emitted as the user edits or submits the field.
17#[derive(Debug, Clone)]
18pub enum TextInputEvent {
19    /// The text changed. Carries the full new value.
20    Change(String),
21    /// The user pressed Enter. Carries the current value.
22    Submit(String),
23}
24
25/// A single-line text field. Create with `cx.new(|cx| TextInput::new(cx))`.
26pub 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    /// The field's focus handle, so a host can focus it on open.
103    pub fn focus_handle(&self) -> FocusHandle {
104        self.focus.clone()
105    }
106
107    /// The current text.
108    pub fn text(&self) -> String {
109        self.edit.text()
110    }
111
112    /// Replace the text programmatically.
113    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
114        self.edit = TextEdit::new(value);
115        cx.notify();
116    }
117
118    /// Two-way bind this input's text to a `Signal<String>`. The signal is
119    /// the source of truth: the field adopts its value now, edits write back
120    /// through [`Signal::set_if_changed`], and signal writes replace the text.
121    /// Equality guards on both directions prevent update loops.
122    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        // Weak handle: a strong clone would keep the input alive (and updated)
137        // for the signal's whole lifetime after the owning view is gone.
138        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            // Escape (Cancel) and unhandled keys (Tab, Cmd+W, …) bubble to the
168            // host: dialogs cancel on Escape, forms move focus on Tab.
169            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        // The interior: caret split when focused, else value or placeholder.
209        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}