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    MouseButton, SharedString, Window,
23};
24
25use super::{apply_key, control_metrics, edit::TextEdit, Field, KeyOutcome};
26use crate::icon::{Icon, IconName};
27use crate::reactive::Signal;
28use crate::theme::{theme, ColorName, Size};
29
30/// Emitted as the user edits or submits the field.
31#[derive(Debug, Clone)]
32pub enum PasswordInputEvent {
33    /// The text changed. Carries the full new value.
34    Change(String),
35    /// The user pressed Enter. Carries the current value.
36    Submit(String),
37}
38
39/// A password field with an eye toggle. Create with
40/// `cx.new(|cx| PasswordInput::new(cx))`.
41pub struct PasswordInput {
42    edit: TextEdit,
43    focus: FocusHandle,
44    visible: bool,
45    placeholder: SharedString,
46    label: Option<SharedString>,
47    description: Option<SharedString>,
48    error: Option<SharedString>,
49    size: Size,
50    disabled: bool,
51}
52
53impl EventEmitter<PasswordInputEvent> for PasswordInput {}
54
55impl PasswordInput {
56    pub fn new(cx: &mut Context<Self>) -> Self {
57        PasswordInput {
58            edit: TextEdit::new(""),
59            focus: cx.focus_handle(),
60            visible: false,
61            placeholder: SharedString::default(),
62            label: None,
63            description: None,
64            error: None,
65            size: Size::Sm,
66            disabled: false,
67        }
68    }
69
70    pub fn value(mut self, value: &str) -> Self {
71        self.edit = TextEdit::new(value);
72        self
73    }
74
75    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
76        self.placeholder = placeholder.into();
77        self
78    }
79
80    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
81        self.label = Some(label.into());
82        self
83    }
84
85    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
86        self.description = Some(description.into());
87        self
88    }
89
90    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
91        self.error = Some(error.into());
92        self
93    }
94
95    pub fn size(mut self, size: Size) -> Self {
96        self.size = size;
97        self
98    }
99
100    pub fn disabled(mut self, disabled: bool) -> Self {
101        self.disabled = disabled;
102        self
103    }
104
105    /// Start with the text revealed (the eye still toggles it).
106    pub fn visible(mut self, visible: bool) -> Self {
107        self.visible = visible;
108        self
109    }
110
111    /// The field's focus handle, so a host can focus it on open.
112    pub fn focus_handle(&self) -> FocusHandle {
113        self.focus.clone()
114    }
115
116    /// The current text.
117    pub fn text(&self) -> String {
118        self.edit.text()
119    }
120
121    /// Replace the text programmatically.
122    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
123        self.edit = TextEdit::new(value);
124        cx.notify();
125    }
126
127    /// Two-way bind this input's text to a `Signal<String>`. The signal is
128    /// the source of truth: the field adopts its value now, edits write back
129    /// through [`Signal::set_if_changed`], and signal writes replace the text.
130    /// Equality guards on both directions prevent update loops.
131    pub fn bind(entity: &Entity<PasswordInput>, signal: &Signal<String>, cx: &mut App) {
132        let initial = signal.get(cx);
133        entity.update(cx, |this, cx| {
134            if this.text() != initial {
135                this.set_text(&initial, cx);
136            }
137        });
138        let sink = signal.clone();
139        cx.subscribe(entity, move |_input, event: &PasswordInputEvent, cx| {
140            if let PasswordInputEvent::Change(text) = event {
141                sink.set_if_changed(cx, text.clone());
142            }
143        })
144        .detach();
145        let input = entity.downgrade();
146        cx.observe(signal.entity(), move |observed, cx| {
147            let value = observed.read(cx).clone();
148            input
149                .update(cx, |this, cx| {
150                    if this.text() != value {
151                        this.set_text(&value, cx);
152                    }
153                })
154                .ok();
155        })
156        .detach();
157    }
158
159    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
160        if self.disabled {
161            return;
162        }
163        match apply_key(&mut self.edit, &event.keystroke) {
164            KeyOutcome::Submit => {
165                cx.emit(PasswordInputEvent::Submit(self.edit.text()));
166                cx.notify();
167                cx.stop_propagation();
168            }
169            KeyOutcome::Edited => {
170                cx.emit(PasswordInputEvent::Change(self.edit.text()));
171                cx.notify();
172                cx.stop_propagation();
173            }
174            // Escape and unhandled keys (Tab, Cmd+W, …) bubble to the host.
175            KeyOutcome::Cancel | KeyOutcome::Pass => {}
176        }
177    }
178
179    fn mask(&self, s: String) -> String {
180        if self.visible {
181            s
182        } else {
183            "\u{2022}".repeat(s.chars().count())
184        }
185    }
186}
187
188impl Render for PasswordInput {
189    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
190        let t = theme(cx);
191        let (height, pad_x, font) = control_metrics(self.size);
192        let radius = t.radius(t.default_radius);
193        let focused = self.focus.is_focused(window) && !self.disabled;
194
195        let border = if self.error.is_some() {
196            t.color(ColorName::Red, 6)
197        } else if focused {
198            t.primary()
199        } else {
200            t.border()
201        }
202        .hsla();
203        let text_color = t.text().hsla();
204        let dimmed = t.dimmed().hsla();
205        let surface = t.surface().hsla();
206        let caret = t.primary().hsla();
207
208        let interior = if focused {
209            let (before, after) = self.edit.split();
210            div()
211                .flex()
212                .items_center()
213                .text_color(text_color)
214                .child(SharedString::from(self.mask(before)))
215                .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
216                .child(SharedString::from(self.mask(after)))
217        } else if self.edit.is_empty() {
218            div().text_color(dimmed).child(self.placeholder.clone())
219        } else {
220            div()
221                .text_color(text_color)
222                .child(SharedString::from(self.mask(self.edit.text())))
223        };
224
225        // While hidden the eye offers "reveal"; while revealed it offers "hide".
226        let eye_icon = if self.visible {
227            IconName::EyeOff
228        } else {
229            IconName::Eye
230        };
231        let eye = div()
232            .id("guise-password-eye")
233            .flex()
234            .items_center()
235            .justify_center()
236            .w(px(height - 16.0))
237            .h(px(height - 16.0))
238            .rounded(px(4.0))
239            .text_color(dimmed)
240            .cursor_pointer()
241            .hover(move |s| s.text_color(text_color))
242            .child(Icon::new(eye_icon).size(Size::Xs))
243            .on_click(cx.listener(|this, _ev, _window, cx| {
244                if !this.disabled {
245                    this.visible = !this.visible;
246                    cx.notify();
247                }
248            }));
249
250        let field = div()
251            .id("guise-passwordinput")
252            .track_focus(&self.focus)
253            .on_key_down(cx.listener(Self::on_key))
254            .on_mouse_down(
255                MouseButton::Left,
256                cx.listener(|this, _ev, window, cx| {
257                    window.focus(&this.focus);
258                    cx.notify();
259                }),
260            )
261            .flex()
262            .items_center()
263            .justify_between()
264            .gap(px(8.0))
265            .h(px(height))
266            .px(px(pad_x))
267            .rounded(px(radius))
268            .border_1()
269            .border_color(border)
270            .bg(surface)
271            .text_size(px(font))
272            .child(interior)
273            .child(eye);
274
275        let mut chrome = Field::new().child(if self.disabled {
276            field.opacity(0.6)
277        } else {
278            field
279        });
280        if let Some(label) = self.label.clone() {
281            chrome = chrome.label(label);
282        }
283        if let Some(error) = self.error.clone() {
284            chrome = chrome.error(error);
285        } else if let Some(description) = self.description.clone() {
286            chrome = chrome.description(description);
287        }
288        chrome
289    }
290}