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