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 the shared field chrome (label, box,
4//! description/error) and emits [`TextInputEvent`] on edit and submit.
5//!
6//! The editing surface itself lives in [`line`](super::line), which is what
7//! gives the field the behaviour an `<input>` has: click and drag to select,
8//! double-click a word, Tab to the next field, the clipboard, undo, IME, and
9//! horizontal scrolling for values wider than the box.
10
11use gpui::prelude::*;
12use gpui::{
13    div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
14    SharedString, Window,
15};
16
17use super::line::{self, Line, LineEditor, LineState};
18use super::{control_metrics, edit::TextEdit, Field, KeyOutcome};
19use crate::devtools::ProbedAny;
20use crate::reactive::Signal;
21use crate::theme::{theme, ColorName, Size};
22
23/// Emitted as the user edits or submits the field.
24#[derive(Debug, Clone)]
25pub enum TextInputEvent {
26    /// The text changed. Carries the full new value.
27    Change(String),
28    /// The user pressed Enter. Carries the current value.
29    Submit(String),
30}
31
32/// A single-line text field. Create with `cx.new(|cx| TextInput::new(cx))`.
33pub struct TextInput {
34    edit: TextEdit,
35    state: LineState,
36    focus: FocusHandle,
37    placeholder: SharedString,
38    label: Option<SharedString>,
39    description: Option<SharedString>,
40    error: Option<SharedString>,
41    size: Size,
42    radius: Option<Size>,
43    disabled: bool,
44    read_only: bool,
45    password: bool,
46    max_length: Option<usize>,
47}
48
49impl EventEmitter<TextInputEvent> for TextInput {}
50
51impl TextInput {
52    pub fn new(cx: &mut Context<Self>) -> Self {
53        TextInput {
54            edit: TextEdit::new(""),
55            state: LineState::new(),
56            // Every field is a tab stop by default, the way a form control is
57            // in a browser. Ordering falls out of render order unless a host
58            // sets `tab_index`.
59            focus: cx.focus_handle().tab_stop(true),
60            placeholder: SharedString::default(),
61            label: None,
62            description: None,
63            error: None,
64            size: Size::Sm,
65            radius: None,
66            disabled: false,
67            read_only: false,
68            password: false,
69            max_length: None,
70        }
71    }
72
73    pub fn value(mut self, value: &str) -> Self {
74        self.edit = TextEdit::new(value);
75        self
76    }
77
78    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
79        self.placeholder = placeholder.into();
80        self
81    }
82
83    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
84        self.label = Some(label.into());
85        self
86    }
87
88    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
89        self.description = Some(description.into());
90        self
91    }
92
93    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
94        self.error = Some(error.into());
95        self
96    }
97
98    pub fn size(mut self, size: Size) -> Self {
99        self.size = size;
100        self
101    }
102
103    pub fn radius(mut self, radius: Size) -> Self {
104        self.radius = Some(radius);
105        self
106    }
107
108    pub fn disabled(mut self, disabled: bool) -> Self {
109        self.disabled = disabled;
110        self
111    }
112
113    /// Selectable and copyable, but not editable — an `<input readonly>`.
114    /// Unlike [`disabled`](Self::disabled) the field still takes focus.
115    pub fn read_only(mut self, read_only: bool) -> Self {
116        self.read_only = read_only;
117        self
118    }
119
120    pub fn password(mut self, password: bool) -> Self {
121        self.password = password;
122        self
123    }
124
125    /// Cap the value's length in characters, like `<input maxlength>`.
126    pub fn max_length(mut self, max: usize) -> Self {
127        self.max_length = Some(max);
128        self
129    }
130
131    /// The current text.
132    pub fn text(&self) -> String {
133        self.edit.text()
134    }
135
136    /// Replace the text programmatically.
137    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
138        self.edit.set_text(value);
139        cx.notify();
140    }
141
142    /// Select the whole value, as focusing a field with `<input autofocus>`
143    /// and a preset value tends to.
144    pub fn select_all(&mut self, cx: &mut Context<Self>) {
145        self.edit.select_all();
146        cx.notify();
147    }
148
149    /// Two-way bind the text to a `Signal<String>`. The signal is the source
150    /// of truth: the field adopts its value now, edits write back through
151    /// [`Signal::set_if_changed`], and signal writes replace the text without
152    /// emitting. Equality guards on both directions prevent update loops.
153    pub fn bind(entity: &Entity<TextInput>, signal: &Signal<String>, cx: &mut App) {
154        let initial = signal.get(cx);
155        entity.update(cx, |this, cx| this.sync_text(initial, cx));
156        let sink = signal.clone();
157        cx.subscribe(entity, move |_input, event: &TextInputEvent, cx| {
158            if let TextInputEvent::Change(text) = event {
159                sink.set_if_changed(cx, text.clone());
160            }
161        })
162        .detach();
163        let field = entity.downgrade();
164        cx.observe(signal.entity(), move |observed, cx| {
165            let text = observed.read(cx).clone();
166            field.update(cx, |this, cx| this.sync_text(text, cx)).ok();
167        })
168        .detach();
169    }
170
171    /// Programmatic set: repaint without emitting an event.
172    fn sync_text(&mut self, text: String, cx: &mut Context<Self>) {
173        if self.edit.text() != text {
174            self.edit.set_text(&text);
175            cx.notify();
176        }
177    }
178
179    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
180        if self.disabled {
181            return;
182        }
183        match line::keys(self, event, window, cx) {
184            KeyOutcome::Submit => {
185                cx.emit(TextInputEvent::Submit(self.edit.text()));
186                cx.notify();
187                cx.stop_propagation();
188            }
189            KeyOutcome::Edited => {
190                self.line_changed(cx);
191                cx.stop_propagation();
192            }
193            // Escape (Cancel) bubbles so dialogs can close on it. Printable
194            // keys pass too: the platform hands them to the input handler,
195            // which is what makes IME and dead keys work.
196            KeyOutcome::Cancel | KeyOutcome::Pass => {}
197        }
198    }
199}
200
201impl LineEditor for TextInput {
202    fn edit(&self) -> &TextEdit {
203        &self.edit
204    }
205
206    fn edit_mut(&mut self) -> &mut TextEdit {
207        &mut self.edit
208    }
209
210    fn line(&self) -> &LineState {
211        &self.state
212    }
213
214    fn line_mut(&mut self) -> &mut LineState {
215        &mut self.state
216    }
217
218    fn line_focus(&self) -> &FocusHandle {
219        &self.focus
220    }
221
222    fn line_masked(&self) -> bool {
223        self.password
224    }
225
226    fn line_read_only(&self) -> bool {
227        self.read_only || self.disabled
228    }
229
230    fn line_max_length(&self) -> Option<usize> {
231        self.max_length
232    }
233
234    fn line_changed(&mut self, cx: &mut Context<Self>) {
235        cx.emit(TextInputEvent::Change(self.edit.text()));
236        cx.notify();
237    }
238}
239
240line::line_input_handler!(TextInput);
241line::line_focus_builders!(TextInput);
242
243impl Render for TextInput {
244    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
245        let t = theme(cx);
246        let (height, pad_x, font) = control_metrics(self.size);
247        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
248        let focused = self.focus.is_focused(window) && !self.disabled;
249        let has_error = self.error.is_some();
250
251        let border = if has_error {
252            t.color(ColorName::Red, 6)
253        } else if focused {
254            t.primary()
255        } else {
256            t.border()
257        }
258        .hsla();
259        let dimmed = t.dimmed().hsla();
260        let surface = t.surface().hsla();
261
262        let line = Line::new(cx.entity()).placeholder(self.placeholder.clone(), dimmed);
263
264        let field = line::wire(div().id("guise-textinput"), &self.focus, cx)
265            .on_key_down(cx.listener(Self::on_key))
266            .flex()
267            .items_center()
268            .w_full()
269            .overflow_hidden()
270            .h(px(height))
271            .px(px(pad_x))
272            .rounded(px(radius))
273            .border_1()
274            .border_color(border)
275            .bg(surface)
276            .text_size(px(font))
277            .line_height(px(font * 1.3))
278            .child(div().flex_1().min_w(px(0.0)).child(line));
279
280        let mut chrome = Field::new().child(if self.disabled {
281            field.opacity(0.6)
282        } else {
283            field
284        });
285        if let Some(label) = self.label.clone() {
286            chrome = chrome.label(label);
287        }
288        if let Some(error) = self.error.clone() {
289            chrome = chrome.error(error);
290        } else if let Some(description) = self.description.clone() {
291            chrome = chrome.description(description);
292        }
293        chrome.probe_any("TextInput")
294    }
295}