gpui_component/input/
otp_input.rs

1use gpui::{
2    div, prelude::FluentBuilder, px, AnyElement, App, AppContext as _, Context, Empty, Entity,
3    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent,
4    MouseButton, MouseDownEvent, ParentElement as _, Render, RenderOnce, SharedString, Styled as _,
5    Subscription, Window,
6};
7
8use super::{blink_cursor::BlinkCursor, InputEvent};
9use crate::{h_flex, v_flex, ActiveTheme, Disableable, Icon, IconName, Sizable, Size};
10
11pub struct OtpState {
12    focus_handle: FocusHandle,
13    value: SharedString,
14    blink_cursor: Entity<BlinkCursor>,
15    masked: bool,
16    length: usize,
17    _subscriptions: Vec<Subscription>,
18}
19
20impl OtpState {
21    /// Create a new [`OtpState`] with the specified length.
22    pub fn new(length: usize, window: &mut Window, cx: &mut Context<Self>) -> Self {
23        let focus_handle = cx.focus_handle();
24        let blink_cursor = cx.new(|_| BlinkCursor::new());
25
26        let _subscriptions = vec![
27            // Observe the blink cursor to repaint the view when it changes.
28            cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
29            // Blink the cursor when the window is active, pause when it's not.
30            cx.observe_window_activation(window, |this, window, cx| {
31                if window.is_window_active() {
32                    let focus_handle = this.focus_handle.clone();
33                    if focus_handle.is_focused(window) {
34                        this.blink_cursor.update(cx, |blink_cursor, cx| {
35                            blink_cursor.start(cx);
36                        });
37                    }
38                }
39            }),
40            cx.on_focus(&focus_handle, window, Self::on_focus),
41            cx.on_blur(&focus_handle, window, Self::on_blur),
42        ];
43
44        Self {
45            length,
46            focus_handle: focus_handle.clone(),
47            value: SharedString::default(),
48            blink_cursor: blink_cursor.clone(),
49            masked: false,
50            _subscriptions,
51        }
52    }
53
54    /// Set default value of the OTP Input.
55    pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
56        self.value = value.into();
57        self
58    }
59
60    /// Set value of the OTP Input.
61    pub fn set_value(
62        &mut self,
63        value: impl Into<SharedString>,
64        _: &mut Window,
65        cx: &mut Context<Self>,
66    ) {
67        self.value = value.into();
68        cx.notify();
69    }
70
71    /// Return the value of the OTP Input.
72    pub fn value(&self) -> &SharedString {
73        &self.value
74    }
75
76    /// Set masked to true use masked input.
77    pub fn masked(mut self, masked: bool) -> Self {
78        self.masked = masked;
79        self
80    }
81
82    /// Set masked to true use masked input.
83    pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
84        self.masked = masked;
85        cx.notify();
86    }
87
88    /// Focus the OTP Input.
89    pub fn focus(&self, window: &mut Window, _: &mut Context<Self>) {
90        self.focus_handle.focus(window);
91    }
92
93    fn on_input_mouse_down(
94        &mut self,
95        _: &MouseDownEvent,
96        window: &mut Window,
97        _: &mut Context<Self>,
98    ) {
99        window.focus(&self.focus_handle);
100    }
101
102    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
103        let mut chars: Vec<char> = self.value.chars().collect();
104        let ix = chars.len();
105
106        let key = event.keystroke.key.as_str();
107
108        match key {
109            "backspace" => {
110                if ix > 0 {
111                    let ix = ix - 1;
112                    chars.remove(ix);
113                }
114
115                window.prevent_default();
116                cx.stop_propagation();
117            }
118            _ => {
119                let c = key.chars().next().unwrap();
120                if !matches!(c, '0'..='9') {
121                    return;
122                }
123                if ix >= self.length {
124                    return;
125                }
126
127                chars.push(c);
128
129                window.prevent_default();
130                cx.stop_propagation();
131            }
132        }
133
134        self.pause_blink_cursor(cx);
135        self.value = SharedString::from(chars.iter().collect::<String>());
136
137        if self.value.chars().count() == self.length {
138            cx.emit(InputEvent::Change);
139        }
140        cx.notify()
141    }
142
143    fn on_focus(&mut self, _: &mut Window, cx: &mut Context<Self>) {
144        self.blink_cursor.update(cx, |cursor, cx| {
145            cursor.start(cx);
146        });
147        cx.emit(InputEvent::Focus);
148    }
149
150    fn on_blur(&mut self, _: &mut Window, cx: &mut Context<Self>) {
151        self.blink_cursor.update(cx, |cursor, cx| {
152            cursor.stop(cx);
153        });
154        cx.emit(InputEvent::Blur);
155    }
156
157    fn pause_blink_cursor(&mut self, cx: &mut Context<Self>) {
158        self.blink_cursor.update(cx, |cursor, cx| {
159            cursor.pause(cx);
160        });
161    }
162}
163impl Focusable for OtpState {
164    fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
165        self.focus_handle.clone()
166    }
167}
168impl EventEmitter<InputEvent> for OtpState {}
169impl Render for OtpState {
170    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
171        Empty
172    }
173}
174
175/// A One Time Password (OTP) input element.
176///
177/// This can accept a fixed length number and can be masked.
178///
179/// Use case example:
180///
181/// - SMS OTP
182/// - Authenticator OTP
183#[derive(IntoElement)]
184pub struct OtpInput {
185    state: Entity<OtpState>,
186    number_of_groups: usize,
187    size: Size,
188    disabled: bool,
189}
190
191impl OtpInput {
192    /// Create a new [`OtpInput`] element bind to the [`OtpState`].
193    pub fn new(state: &Entity<OtpState>) -> Self {
194        Self {
195            state: state.clone(),
196            number_of_groups: 2,
197            size: Size::Medium,
198            disabled: false,
199        }
200    }
201
202    /// Set number of groups in the OTP Input.
203    pub fn groups(mut self, n: usize) -> Self {
204        self.number_of_groups = n;
205        self
206    }
207}
208impl Disableable for OtpInput {
209    fn disabled(mut self, disabled: bool) -> Self {
210        self.disabled = disabled;
211        self
212    }
213}
214impl Sizable for OtpInput {
215    fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
216        self.size = size.into();
217        self
218    }
219}
220impl RenderOnce for OtpInput {
221    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
222        let state = self.state.read(cx);
223        let blink_show = state.blink_cursor.read(cx).visible();
224        let is_focused = state.focus_handle.is_focused(window);
225
226        let text_size = match self.size {
227            Size::XSmall => px(14.),
228            Size::Small => px(14.),
229            Size::Medium => px(16.),
230            Size::Large => px(18.),
231            Size::Size(v) => v * 0.5,
232        };
233
234        let cursor_ix = state
235            .value
236            .chars()
237            .count()
238            .min(state.length.saturating_sub(1));
239        let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(self.number_of_groups);
240        let mut group_ix = 0;
241        let group_items_count = state.length / self.number_of_groups;
242        for _ in 0..self.number_of_groups {
243            groups.push(vec![]);
244        }
245
246        for ix in 0..state.length {
247            let c = state.value.chars().nth(ix);
248            if ix % group_items_count == 0 && ix != 0 {
249                group_ix += 1;
250            }
251
252            let is_input_focused = ix == cursor_ix && is_focused;
253
254            groups[group_ix].push(
255                h_flex()
256                    .id(ix)
257                    .border_1()
258                    .border_color(cx.theme().input)
259                    .bg(cx.theme().background)
260                    .when(self.disabled, |this| {
261                        this.bg(cx.theme().muted)
262                            .text_color(cx.theme().muted_foreground)
263                    })
264                    .when(is_input_focused, |this| this.border_color(cx.theme().ring))
265                    .when(cx.theme().shadow, |this| this.shadow_xs())
266                    .items_center()
267                    .justify_center()
268                    .rounded(cx.theme().radius)
269                    .text_size(text_size)
270                    .map(|this| match self.size {
271                        Size::XSmall => this.w_6().h_6(),
272                        Size::Small => this.w_6().h_6(),
273                        Size::Medium => this.w_8().h_8(),
274                        Size::Large => this.w_11().h_11(),
275                        Size::Size(px) => this.w(px).h(px),
276                    })
277                    .on_mouse_down(
278                        MouseButton::Left,
279                        window.listener_for(&self.state, OtpState::on_input_mouse_down),
280                    )
281                    .map(|this| match c {
282                        Some(c) => {
283                            if state.masked {
284                                this.child(
285                                    Icon::new(IconName::Asterisk)
286                                        .text_color(cx.theme().secondary_foreground)
287                                        .when(self.disabled, |this| {
288                                            this.text_color(cx.theme().muted_foreground)
289                                        })
290                                        .with_size(text_size),
291                                )
292                            } else {
293                                this.child(c.to_string())
294                            }
295                        }
296                        None => this.when(is_input_focused && blink_show, |this| {
297                            this.child(
298                                div()
299                                    .h_4()
300                                    .w_0()
301                                    .border_l_3()
302                                    .border_color(crate::blue_500()),
303                            )
304                        }),
305                    })
306                    .into_any_element(),
307            );
308        }
309
310        v_flex()
311            .id(("otp-input", self.state.entity_id()))
312            .track_focus(&self.state.read(cx).focus_handle)
313            .when(!self.disabled, |this| {
314                this.on_key_down(window.listener_for(&self.state, OtpState::on_key_down))
315            })
316            .items_center()
317            .child(
318                h_flex().items_center().gap_5().children(
319                    groups
320                        .into_iter()
321                        .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
322                ),
323            )
324    }
325}