Skip to main content

guise/input/
password.rs

1//! `PasswordInput` — a masked text field with a visibility toggle (gpui entity).
2//!
3//! Owns its buffer and focus like [`TextInput`](super::TextInput) in password
4//! mode, plus an eye button that reveals the plain text while toggled. Emits
5//! [`PasswordInputEvent`] on edit and submit.
6//!
7//! ```ignore
8//! let secret = cx.new(|cx| {
9//!     PasswordInput::new(cx)
10//!         .label("Password")
11//!         .placeholder("At least 8 characters")
12//! });
13//! cx.subscribe(&secret, |_this, _input, event: &PasswordInputEvent, _cx| {
14//!     if let PasswordInputEvent::Submit(value) = event { /* log in */ }
15//! })
16//! .detach();
17//! ```
18
19use gpui::prelude::*;
20use gpui::{
21  div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
22  SharedString, Window,
23};
24
25use super::line::{self, Line, LineEditor, LineState};
26use super::{control_metrics, edit::TextEdit, Field, KeyOutcome};
27use crate::devtools::ProbedAny;
28use crate::icon::{Icon, IconName};
29use crate::reactive::Signal;
30use crate::theme::{theme, ColorName, Size};
31
32/// Emitted as the user edits or submits the field.
33#[derive(Debug, Clone)]
34pub enum PasswordInputEvent {
35  /// The text changed. Carries the full new value.
36  Change(String),
37  /// The user pressed Enter. Carries the current value.
38  Submit(String),
39}
40
41/// A password field with an eye toggle. Create with
42/// `cx.new(|cx| PasswordInput::new(cx))`.
43pub struct PasswordInput {
44  edit: TextEdit,
45  state: LineState,
46  focus: FocusHandle,
47  visible: bool,
48  placeholder: SharedString,
49  label: Option<SharedString>,
50  description: Option<SharedString>,
51  error: Option<SharedString>,
52  size: Size,
53  disabled: bool,
54  read_only: bool,
55  max_length: Option<usize>,
56}
57
58impl EventEmitter<PasswordInputEvent> for PasswordInput {}
59
60impl PasswordInput {
61  pub fn new(cx: &mut Context<Self>) -> Self {
62    PasswordInput {
63      edit: TextEdit::new(""),
64      state: LineState::new(),
65      focus: cx.focus_handle().tab_stop(true),
66      visible: false,
67      placeholder: SharedString::default(),
68      label: None,
69      description: None,
70      error: None,
71      size: Size::Sm,
72      disabled: false,
73      read_only: false,
74      max_length: None,
75    }
76  }
77
78  pub fn value(mut self, value: &str) -> Self {
79    self.edit = TextEdit::new(value);
80    self
81  }
82
83  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
84    self.placeholder = placeholder.into();
85    self
86  }
87
88  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
89    self.label = Some(label.into());
90    self
91  }
92
93  pub fn description(mut self, description: impl Into<SharedString>) -> Self {
94    self.description = Some(description.into());
95    self
96  }
97
98  pub fn error(mut self, error: impl Into<SharedString>) -> Self {
99    self.error = Some(error.into());
100    self
101  }
102
103  pub fn size(mut self, size: Size) -> Self {
104    self.size = size;
105    self
106  }
107
108  pub fn disabled(mut self, disabled: bool) -> Self {
109    self.disabled = disabled;
110    self
111  }
112
113  /// Start with the text revealed (the eye still toggles it).
114  pub fn visible(mut self, visible: bool) -> Self {
115    self.visible = visible;
116    self
117  }
118
119  /// Selectable but not editable, like an `<input readonly>`.
120  pub fn read_only(mut self, read_only: bool) -> Self {
121    self.read_only = read_only;
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  /// Two-way bind this input's text to a `Signal<String>`. The signal is
143  /// the source of truth: the field adopts its value now, edits write back
144  /// through [`Signal::set_if_changed`], and signal writes replace the text.
145  /// Equality guards on both directions prevent update loops.
146  pub fn bind(entity: &Entity<PasswordInput>, signal: &Signal<String>, cx: &mut App) {
147    let initial = signal.get(cx);
148    entity.update(cx, |this, cx| {
149      if this.text() != initial {
150        this.set_text(&initial, cx);
151      }
152    });
153    let sink = signal.clone();
154    cx.subscribe(entity, move |_input, event: &PasswordInputEvent, cx| {
155      if let PasswordInputEvent::Change(text) = event {
156        sink.set_if_changed(cx, text.clone());
157      }
158    })
159    .detach();
160    let input = entity.downgrade();
161    cx.observe(signal.entity(), move |observed, cx| {
162      let value = observed.read(cx).clone();
163      input
164        .update(cx, |this, cx| {
165          if this.text() != value {
166            this.set_text(&value, cx);
167          }
168        })
169        .ok();
170    })
171    .detach();
172  }
173
174  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
175    if self.disabled {
176      return;
177    }
178    match line::keys(self, event, window, cx) {
179      KeyOutcome::Submit => {
180        cx.emit(PasswordInputEvent::Submit(self.edit.text()));
181        cx.notify();
182        cx.stop_propagation();
183      }
184      KeyOutcome::Edited => {
185        self.line_changed(cx);
186        cx.stop_propagation();
187      }
188      // Escape and unhandled keys bubble to the host.
189      KeyOutcome::Cancel | KeyOutcome::Pass => {}
190    }
191  }
192}
193
194impl LineEditor for PasswordInput {
195  fn edit(&self) -> &TextEdit {
196    &self.edit
197  }
198
199  fn edit_mut(&mut self) -> &mut TextEdit {
200    &mut self.edit
201  }
202
203  fn line(&self) -> &LineState {
204    &self.state
205  }
206
207  fn line_mut(&mut self) -> &mut LineState {
208    &mut self.state
209  }
210
211  fn line_focus(&self) -> &FocusHandle {
212    &self.focus
213  }
214
215  /// Revealing the field with the eye is a deliberate act, so it also lifts
216  /// the copy block — otherwise the toggle would be for looking only.
217  fn line_masked(&self) -> bool {
218    !self.visible
219  }
220
221  fn line_read_only(&self) -> bool {
222    self.read_only || self.disabled
223  }
224
225  fn line_max_length(&self) -> Option<usize> {
226    self.max_length
227  }
228
229  fn line_changed(&mut self, cx: &mut Context<Self>) {
230    cx.emit(PasswordInputEvent::Change(self.edit.text()));
231    cx.notify();
232  }
233}
234
235line::line_input_handler!(PasswordInput);
236line::line_focus_builders!(PasswordInput);
237
238impl Render for PasswordInput {
239  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
240    let t = theme(cx);
241    let (height, pad_x, font) = control_metrics(self.size);
242    let radius = t.radius(t.default_radius);
243    let focused = self.focus.is_focused(window) && !self.disabled;
244
245    let border = if self.error.is_some() {
246      t.color(ColorName::Red, 6)
247    } else if focused {
248      t.primary()
249    } else {
250      t.border()
251    }
252    .hsla();
253    let text_color = t.text().hsla();
254    let dimmed = t.dimmed().hsla();
255    let surface = t.surface().hsla();
256    let interior = Line::new(cx.entity()).placeholder(self.placeholder.clone(), dimmed);
257
258    // While hidden the eye offers "reveal"; while revealed it offers "hide".
259    let eye_icon = if self.visible {
260      IconName::EyeOff
261    } else {
262      IconName::Eye
263    };
264    let eye = div()
265      .id("guise-password-eye")
266      .flex()
267      .items_center()
268      .justify_center()
269      .w(px(height - 16.0))
270      .h(px(height - 16.0))
271      .rounded(px(4.0))
272      .text_color(dimmed)
273      .cursor_pointer()
274      .hover(move |s| s.text_color(text_color))
275      .child(Icon::new(eye_icon).size(Size::Xs))
276      .on_click(cx.listener(|this, _ev, _window, cx| {
277        if !this.disabled {
278          this.visible = !this.visible;
279          cx.notify();
280        }
281      }));
282
283    let field = line::wire(div().id("guise-passwordinput"), &self.focus, cx)
284      .on_key_down(cx.listener(Self::on_key))
285      .flex()
286      .items_center()
287      .justify_between()
288      .gap(px(8.0))
289      .w_full()
290      .overflow_hidden()
291      .h(px(height))
292      .px(px(pad_x))
293      .rounded(px(radius))
294      .border_1()
295      .border_color(border)
296      .bg(surface)
297      .text_size(px(font))
298      .line_height(px(font * 1.3))
299      .child(div().flex_1().min_w(px(0.0)).child(interior))
300      .child(eye);
301
302    let mut chrome = Field::new().child(if self.disabled {
303      field.opacity(0.6)
304    } else {
305      field
306    });
307    if let Some(label) = self.label.clone() {
308      chrome = chrome.label(label);
309    }
310    if let Some(error) = self.error.clone() {
311      chrome = chrome.error(error);
312    } else if let Some(description) = self.description.clone() {
313      chrome = chrome.description(description);
314    }
315    chrome.probe_any("PasswordInput")
316  }
317}