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}