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}