Skip to main content

gpui_kit/controls/
auth.rs

1//! Sensitive text controls for product-neutral authentication composition.
2//!
3//! Both controls reuse [`TextInput`] as their only editor. They add visual
4//! transient state and presentation, never account models, credential policy,
5//! provider policy, or transport.
6
7use std::ops::Range;
8
9use gpui::{
10    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
11    ParentElement, Render, SharedString, Styled, Subscription, Window, div,
12    prelude::FluentBuilder as _, px,
13};
14use gpui_kit_assets::Icon;
15use gpui_kit_theme::{ActiveTheme, ControlSize, TypeScale};
16use unicode_segmentation::UnicodeSegmentation;
17
18use crate::controls::button::Button;
19use crate::controls::field::{FieldState, field_shell};
20use crate::controls::input::{TextInput, TextInputEvent};
21use crate::foundation::direction::{ActiveDirection, DirectionalExt};
22use crate::foundation::{Disableable, Ident, Sizable, text as foundation_text};
23use crate::strings::{ActiveStrings, StringKey};
24
25const DEFAULT_CODE_SLOTS: usize = 6;
26const MIN_CODE_SLOTS: usize = 1;
27const MAX_CODE_SLOTS: usize = 12;
28
29/// What a password field reports to its owner.
30#[derive(Clone, PartialEq, Eq)]
31pub enum PasswordInputEvent {
32    Change(SharedString),
33    Submit,
34    Cancel,
35    BackspaceAtStart,
36    Focus,
37    Blur,
38}
39
40impl std::fmt::Debug for PasswordInputEvent {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Change(_) => formatter
44                .debug_tuple("Change")
45                .field(&"[REDACTED]")
46                .finish(),
47            Self::Submit => formatter.write_str("Submit"),
48            Self::Cancel => formatter.write_str("Cancel"),
49            Self::BackspaceAtStart => formatter.write_str("BackspaceAtStart"),
50            Self::Focus => formatter.write_str("Focus"),
51            Self::Blur => formatter.write_str("Blur"),
52        }
53    }
54}
55
56impl EventEmitter<PasswordInputEvent> for PasswordInput {}
57
58/// One sensitive password editor with a visual reveal action.
59///
60/// Revealing changes only the pixels. The value remains a secret for
61/// deterministic semantics, AccessKit text runs and values, Debug, and
62/// clipboard copy/cut.
63pub struct PasswordInput {
64    ident: Ident,
65    field: Entity<TextInput>,
66    reveal_focus: FocusHandle,
67    placeholder: Option<SharedString>,
68    name: Option<SharedString>,
69    initial: Option<SharedString>,
70    size: ControlSize,
71    disabled: bool,
72    invalid: bool,
73    required: bool,
74    read_only: bool,
75    revealed: bool,
76    seeded: bool,
77    configured: bool,
78    _subscriptions: Vec<Subscription>,
79}
80
81impl std::fmt::Debug for PasswordInput {
82    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        formatter
84            .debug_struct("PasswordInput")
85            .field("ident", &self.ident)
86            .field("size", &self.size)
87            .field("disabled", &self.disabled)
88            .field("invalid", &self.invalid)
89            .field("required", &self.required)
90            .field("read_only", &self.read_only)
91            .field("revealed", &self.revealed)
92            .finish()
93    }
94}
95
96impl PasswordInput {
97    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
98        let ident = ident.into();
99        let field = cx.new(|cx| {
100            TextInput::new(ident.clone(), window, cx)
101                .secret(true)
102                .bare(true)
103        });
104        let subscription = cx.subscribe(&field, |_password, _field, event, cx| {
105            let event = match event {
106                TextInputEvent::Change(value) => PasswordInputEvent::Change(value.clone()),
107                TextInputEvent::Submit => PasswordInputEvent::Submit,
108                TextInputEvent::Cancel => PasswordInputEvent::Cancel,
109                TextInputEvent::BackspaceAtStart => PasswordInputEvent::BackspaceAtStart,
110                TextInputEvent::Focus => PasswordInputEvent::Focus,
111                TextInputEvent::Blur => PasswordInputEvent::Blur,
112            };
113            cx.emit(event);
114        });
115        Self {
116            ident,
117            field,
118            reveal_focus: cx.focus_handle(),
119            placeholder: None,
120            name: None,
121            initial: None,
122            size: ControlSize::Md,
123            disabled: false,
124            invalid: false,
125            required: false,
126            read_only: false,
127            revealed: false,
128            seeded: false,
129            configured: false,
130            _subscriptions: vec![subscription],
131        }
132    }
133
134    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
135        self.placeholder = Some(placeholder.into());
136        self
137    }
138
139    /// Names the one native password input when its visible label is outside
140    /// this view.
141    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
142        self.name = Some(name.into());
143        self
144    }
145
146    /// Seeds the sensitive text without reporting a caller edit.
147    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
148        self.initial = Some(text.into());
149        self
150    }
151
152    pub fn invalid(mut self, invalid: bool) -> Self {
153        self.invalid = invalid;
154        self
155    }
156
157    pub fn required(mut self, required: bool) -> Self {
158        self.required = required;
159        self
160    }
161
162    pub fn read_only(mut self, read_only: bool) -> Self {
163        self.read_only = read_only;
164        self
165    }
166
167    pub fn value(&self, cx: &App) -> SharedString {
168        self.field.read(cx).value().clone()
169    }
170
171    pub fn is_revealed(&self) -> bool {
172        self.revealed
173    }
174
175    pub fn selected_range(&self, cx: &App) -> Range<usize> {
176        self.field.read(cx).selected_range()
177    }
178
179    pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut Context<Self>) {
180        self.seeded = true;
181        self.field
182            .update(cx, |field, cx| field.set_value(value, cx));
183    }
184
185    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
186        self.disabled = disabled;
187        self.field
188            .update(cx, |field, cx| field.set_disabled(disabled, cx));
189        cx.notify();
190    }
191
192    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
193        self.read_only = read_only;
194        self.field
195            .update(cx, |field, cx| field.set_read_only(read_only, cx));
196        cx.notify();
197    }
198
199    pub fn set_invalid(&mut self, invalid: bool, cx: &mut Context<Self>) {
200        self.invalid = invalid;
201        self.field
202            .update(cx, |field, cx| field.set_invalid(invalid, cx));
203        cx.notify();
204    }
205
206    fn configure(&mut self, cx: &mut Context<Self>) {
207        if self.configured {
208            return;
209        }
210        self.configured = true;
211        let placeholder = self.placeholder.take();
212        let name = self.name.take();
213        let initial = self.initial.take().filter(|_| !self.seeded);
214        self.seeded = true;
215        let (disabled, invalid, required, read_only, size) = (
216            self.disabled,
217            self.invalid,
218            self.required,
219            self.read_only,
220            self.size,
221        );
222        self.field.update(cx, move |field, cx| {
223            if let Some(placeholder) = placeholder {
224                field.set_placeholder(placeholder, cx);
225            }
226            if let Some(name) = name {
227                field.set_name(name, cx);
228            }
229            if let Some(initial) = initial {
230                field.set_text_quietly(initial, cx);
231            }
232            field.set_disabled(disabled, cx);
233            field.set_invalid(invalid, cx);
234            field.set_required(required, cx);
235            field.set_read_only(read_only, cx);
236            field.set_control_size(size, cx);
237        });
238    }
239
240    fn toggle_reveal(&mut self, cx: &mut Context<Self>) {
241        if self.disabled {
242            return;
243        }
244        self.revealed = !self.revealed;
245        let masked = !self.revealed;
246        self.field
247            .update(cx, |field, cx| field.set_visually_masked(masked, cx));
248        cx.notify();
249    }
250}
251
252impl Disableable for PasswordInput {
253    fn disabled(mut self, disabled: bool) -> Self {
254        self.disabled = disabled;
255        self
256    }
257}
258
259impl Sizable for PasswordInput {
260    fn control_size(mut self, size: ControlSize) -> Self {
261        self.size = size;
262        self
263    }
264}
265
266impl Focusable for PasswordInput {
267    fn focus_handle(&self, cx: &App) -> FocusHandle {
268        self.field.read(cx).focus_handle(cx)
269    }
270}
271
272impl Render for PasswordInput {
273    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
274        self.configure(cx);
275        let theme = cx.theme().clone();
276        let focused = self.field.read(cx).focus_handle(cx).is_focused(window);
277        let name = cx.strings().text(if self.revealed {
278            StringKey::PasswordConceal
279        } else {
280            StringKey::PasswordReveal
281        });
282        let control = cx.entity().downgrade();
283        let mut reveal = Button::new(self.ident.child("reveal"))
284            .ghost()
285            .icon_only(Icon::Key, name)
286            .checked_state(self.revealed)
287            .control_size(self.size)
288            .semantic_parent(self.ident.semantic_id())
289            .disabled(self.disabled);
290        if !self.disabled {
291            reveal = reveal
292                .track_focus(&self.reveal_focus)
293                .on_click(move |_, cx| {
294                    control
295                        .update(cx, |password, cx| password.toggle_reveal(cx))
296                        .ok();
297                });
298        }
299
300        field_shell(
301            &theme,
302            self.size,
303            FieldState::default()
304                .focused(focused)
305                .invalid(self.invalid)
306                .disabled(self.disabled),
307        )
308        .child(div().flex_1().min_w_0().child(self.field.clone()))
309        .child(reveal)
310    }
311}
312
313/// What a one-time code field reports to its owner.
314#[derive(Clone, PartialEq, Eq)]
315pub enum OneTimeCodeInputEvent {
316    Change(SharedString),
317    Submit,
318}
319
320impl std::fmt::Debug for OneTimeCodeInputEvent {
321    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        match self {
323            Self::Change(_) => formatter
324                .debug_tuple("Change")
325                .field(&"[REDACTED]")
326                .finish(),
327            Self::Submit => formatter.write_str("Submit"),
328        }
329    }
330}
331
332impl EventEmitter<OneTimeCodeInputEvent> for OneTimeCodeInput {}
333
334/// One sensitive editor presented as a bounded run of visual slots.
335///
336/// A slot accepts one Unicode grapheme. The slots are not fields: one
337/// `TextInput` owns the focus, selection, composition, paste, and native text
338/// actions for the entire control.
339pub struct OneTimeCodeInput {
340    ident: Ident,
341    field: Entity<TextInput>,
342    name: Option<SharedString>,
343    initial: Option<SharedString>,
344    slots: usize,
345    size: ControlSize,
346    disabled: bool,
347    invalid: bool,
348    required: bool,
349    read_only: bool,
350    seeded: bool,
351    configured: bool,
352    _subscriptions: Vec<Subscription>,
353}
354
355impl std::fmt::Debug for OneTimeCodeInput {
356    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        formatter
358            .debug_struct("OneTimeCodeInput")
359            .field("ident", &self.ident)
360            .field("slots", &self.slots)
361            .field("disabled", &self.disabled)
362            .field("invalid", &self.invalid)
363            .field("required", &self.required)
364            .field("read_only", &self.read_only)
365            .finish()
366    }
367}
368
369impl OneTimeCodeInput {
370    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
371        let ident = ident.into();
372        let field = cx.new(|cx| {
373            TextInput::new(ident.clone(), window, cx)
374                .secret(true)
375                .bare(true)
376        });
377        let subscription = cx.subscribe(&field, |_code, _field, event, cx| match event {
378            TextInputEvent::Change(value) => {
379                cx.emit(OneTimeCodeInputEvent::Change(value.clone()));
380            }
381            TextInputEvent::Submit => cx.emit(OneTimeCodeInputEvent::Submit),
382            _ => {}
383        });
384        Self {
385            ident,
386            field,
387            name: None,
388            initial: None,
389            slots: DEFAULT_CODE_SLOTS,
390            size: ControlSize::Md,
391            disabled: false,
392            invalid: false,
393            required: false,
394            read_only: false,
395            seeded: false,
396            configured: false,
397            _subscriptions: vec![subscription],
398        }
399    }
400
401    /// Names the one native sensitive input when its visible label is outside
402    /// this view.
403    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
404        self.name = Some(name.into());
405        self
406    }
407
408    /// Seeds the sensitive text without reporting a caller edit.
409    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
410        self.initial = Some(text.into());
411        self
412    }
413
414    /// Chooses the visual/code length. Values outside 1 through 12 use the
415    /// nearest bound so the control remains legible and finite.
416    pub fn slots(mut self, slots: usize) -> Self {
417        self.slots = slots.clamp(MIN_CODE_SLOTS, MAX_CODE_SLOTS);
418        self
419    }
420
421    pub fn invalid(mut self, invalid: bool) -> Self {
422        self.invalid = invalid;
423        self
424    }
425
426    pub fn required(mut self, required: bool) -> Self {
427        self.required = required;
428        self
429    }
430
431    pub fn read_only(mut self, read_only: bool) -> Self {
432        self.read_only = read_only;
433        self
434    }
435
436    pub fn value(&self, cx: &App) -> SharedString {
437        self.field.read(cx).value().clone()
438    }
439
440    pub fn len(&self, cx: &App) -> usize {
441        self.field.read(cx).value().graphemes(true).count()
442    }
443
444    pub fn is_empty(&self, cx: &App) -> bool {
445        self.field.read(cx).is_empty()
446    }
447
448    pub fn is_complete(&self, cx: &App) -> bool {
449        self.len(cx) == self.slots
450    }
451
452    pub fn slot_count(&self) -> usize {
453        self.slots
454    }
455
456    pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut Context<Self>) {
457        self.seeded = true;
458        let value = value.into();
459        let value = value.graphemes(true).take(self.slots).collect::<String>();
460        self.field
461            .update(cx, |field, cx| field.set_value(value, cx));
462    }
463
464    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
465        self.disabled = disabled;
466        self.field
467            .update(cx, |field, cx| field.set_disabled(disabled, cx));
468        cx.notify();
469    }
470
471    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
472        self.read_only = read_only;
473        self.field
474            .update(cx, |field, cx| field.set_read_only(read_only, cx));
475        cx.notify();
476    }
477
478    pub fn set_invalid(&mut self, invalid: bool, cx: &mut Context<Self>) {
479        self.invalid = invalid;
480        self.field
481            .update(cx, |field, cx| field.set_invalid(invalid, cx));
482        cx.notify();
483    }
484
485    fn configure(&mut self, cx: &mut Context<Self>) {
486        if self.configured {
487            return;
488        }
489        self.configured = true;
490        let name = self.name.take();
491        let initial = self
492            .initial
493            .take()
494            .filter(|_| !self.seeded)
495            .map(|value| value.graphemes(true).take(self.slots).collect::<String>());
496        self.seeded = true;
497        let (slots, disabled, invalid, required, read_only, size) = (
498            self.slots,
499            self.disabled,
500            self.invalid,
501            self.required,
502            self.read_only,
503            self.size,
504        );
505        self.field.update(cx, move |field, cx| {
506            field.set_sensitive_slots(slots, cx);
507            if let Some(name) = name {
508                field.set_name(name, cx);
509            }
510            if let Some(initial) = initial {
511                field.set_text_quietly(initial, cx);
512            }
513            field.set_disabled(disabled, cx);
514            field.set_invalid(invalid, cx);
515            field.set_required(required, cx);
516            field.set_read_only(read_only, cx);
517            field.set_control_size(size, cx);
518        });
519    }
520}
521
522impl Disableable for OneTimeCodeInput {
523    fn disabled(mut self, disabled: bool) -> Self {
524        self.disabled = disabled;
525        self
526    }
527}
528
529impl Sizable for OneTimeCodeInput {
530    fn control_size(mut self, size: ControlSize) -> Self {
531        self.size = size;
532        self
533    }
534}
535
536impl Focusable for OneTimeCodeInput {
537    fn focus_handle(&self, cx: &App) -> FocusHandle {
538        self.field.read(cx).focus_handle(cx)
539    }
540}
541
542impl Render for OneTimeCodeInput {
543    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
544        self.configure(cx);
545        let theme = cx.theme().clone();
546        let metrics = theme.control.get(self.size);
547        let (focused, value, selection, cursor) = {
548            let field = self.field.read(cx);
549            (
550                field.focus_handle(cx).is_focused(window),
551                field.value().clone(),
552                field.selected_range(),
553                field.cursor_offset(),
554            )
555        };
556
557        let length = value.graphemes(true).count();
558        let selected_start = value[..selection.start].graphemes(true).count();
559        let selected_end = value[..selection.end].graphemes(true).count();
560        let cursor = value[..cursor].graphemes(true).count();
561        let direction = cx.layout_direction();
562        let slots = (0..self.slots).map(|index| {
563            let selected = selected_start <= index && index < selected_end;
564            let active = focused && selection.is_empty() && cursor == index;
565            div()
566                .flex_1()
567                .h(px(metrics.height))
568                .flex()
569                .items_center()
570                .justify_center()
571                .when(index > 0, |slot| {
572                    slot.border_s(direction, px(theme.borders.hairline))
573                        .border_color(theme.colors.hairline)
574                })
575                .when(selected, |slot| slot.bg(theme.colors.selected))
576                .when(active, |slot| slot.bg(theme.colors.hover))
577                .child(foundation_text(
578                    &theme,
579                    TypeScale::Label,
580                    if index < length { "•" } else { "" },
581                ))
582        });
583
584        field_shell(
585            &theme,
586            self.size,
587            FieldState::default()
588                .focused(focused)
589                .invalid(self.invalid)
590                .disabled(self.disabled),
591        )
592        .relative()
593        .px(px(0.0))
594        .child(
595            div()
596                .row_reading(direction)
597                .w_full()
598                .overflow_hidden()
599                .children(slots),
600        )
601        // The one editor occupies exactly the segmented surface. It paints
602        // nothing in slot mode, but owns input, hit testing, IME bounds, and
603        // the one semantic/native node for the control.
604        .child(div().absolute().inset_0().child(self.field.clone()))
605    }
606}