Skip to main content

gpui_component/input/
otp_input.rs

1use gpui::{
2    AnyElement, App, Entity, Focusable, InteractiveElement as _, IntoElement, MouseButton,
3    ParentElement as _, RenderOnce, Styled as _, Window, div, prelude::FluentBuilder,
4};
5
6use super::input::input_style;
7use super::state::sync_focused_input_registry;
8use crate::{ActiveTheme, Disableable, Icon, IconName, Sizable, Size, h_flex, v_flex};
9use crate::{StyleSized as _, ThemeStyled as _};
10use gpui_base::OtpInput as BaseOtpInput;
11pub use gpui_base::{OtpEvent, OtpState};
12
13/// A One Time Password (OTP) input element.
14///
15/// This can accept a fixed length number and can be masked.
16///
17/// Use case example:
18///
19/// - SMS OTP
20/// - Authenticator OTP
21#[derive(IntoElement)]
22pub struct OtpInput {
23    state: Entity<OtpState>,
24    number_of_groups: usize,
25    size: Size,
26    focus_ring_enabled: bool,
27    disabled: bool,
28}
29
30impl OtpInput {
31    /// Create a new [`OtpInput`] element bind to the [`OtpState`].
32    pub fn new(state: &Entity<OtpState>) -> Self {
33        Self {
34            state: state.clone(),
35            number_of_groups: 2,
36            size: Size::Medium,
37            focus_ring_enabled: true,
38            disabled: false,
39        }
40    }
41
42    /// Set number of groups in the OTP Input.
43    pub fn groups(mut self, n: usize) -> Self {
44        self.number_of_groups = n;
45        self
46    }
47
48    fn resolved_groups(length: usize, requested: usize) -> usize {
49        requested.max(1).min(length.max(1))
50    }
51}
52impl Disableable for OtpInput {
53    fn disabled(mut self, disabled: bool) -> Self {
54        self.disabled = disabled;
55        self
56    }
57}
58impl crate::FocusableExt for OtpInput {
59    fn focus_ring(mut self, enabled: bool) -> Self {
60        self.focus_ring_enabled = enabled;
61        self
62    }
63
64    fn is_focus_ring_enabled(&self) -> bool {
65        self.focus_ring_enabled
66    }
67}
68impl Sizable for OtpInput {
69    fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
70        self.size = size.into();
71        self
72    }
73}
74impl RenderOnce for OtpInput {
75    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
76        sync_focused_input_registry(self.state.clone(), window, cx);
77        let state = self.state.read(cx);
78        let blink_show = state.cursor_visible(cx);
79        let is_focused = state.focus_handle(cx).is_focused(window);
80
81        let cursor_ix = state
82            .value()
83            .chars()
84            .count()
85            .min(state.len().saturating_sub(1));
86        let number_of_groups = Self::resolved_groups(state.len(), self.number_of_groups);
87        let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(number_of_groups);
88        let mut group_ix = 0;
89        let group_items_count = state.len().div_ceil(number_of_groups).max(1);
90        for _ in 0..number_of_groups {
91            groups.push(vec![]);
92        }
93
94        let (bg, fg) = input_style(self.disabled, cx);
95
96        for ix in 0..state.len() {
97            let c = state.value().chars().nth(ix);
98            if ix % group_items_count == 0 && ix != 0 {
99                group_ix += 1;
100            }
101
102            let is_input_focused = ix == cursor_ix && is_focused;
103            let focus_visible = is_input_focused && !self.disabled && self.focus_ring_enabled;
104
105            groups[group_ix].push(
106                h_flex()
107                    .id(ix)
108                    .border_1()
109                    .border_color(cx.theme().input)
110                    .bg(bg)
111                    .text_color(fg)
112                    .when(self.disabled, |this| this.opacity(0.5))
113                    .when(focus_visible, |this| this.border_color(cx.theme().ring))
114                    .items_center()
115                    .justify_center()
116                    .rounded(cx.theme().radius)
117                    .map(|this| match self.size {
118                        Size::Size(v) => this.text_size(v * 0.5),
119                        size => this.input_text_size(size),
120                    })
121                    .map(|this| match self.size {
122                        Size::XSmall => this.w_6().h_6(),
123                        Size::Small => this.w_6().h_6(),
124                        Size::Medium => this.w_8().h_8(),
125                        Size::Large => this.w_11().h_11(),
126                        Size::Size(px) => this.w(px).h(px),
127                    })
128                    .when(focus_visible, |this| this.focus_ring_style(window, cx))
129                    .on_mouse_down(MouseButton::Left, {
130                        let state = self.state.clone();
131                        move |_, window, cx| state.read(cx).focus_handle(cx).focus(window, cx)
132                    })
133                    .map(|this| match c {
134                        Some(c) => {
135                            if state.is_masked() {
136                                this.child(
137                                    Icon::new(IconName::Asterisk)
138                                        .text_color(cx.theme().secondary_foreground)
139                                        .when(self.disabled, |this| {
140                                            this.text_color(cx.theme().muted_foreground)
141                                        }),
142                                )
143                            } else {
144                                this.child(c.to_string())
145                            }
146                        }
147                        None => this.when(is_input_focused && blink_show, |this| {
148                            this.child(
149                                div()
150                                    .h_4()
151                                    .w_0()
152                                    .border_l_3()
153                                    .border_color(cx.theme().caret),
154                            )
155                        }),
156                    })
157                    .into_any_element(),
158            );
159        }
160
161        BaseOtpInput::new(&self.state)
162            .disabled(self.disabled)
163            .child(
164                v_flex()
165                    .id(("otp-input", self.state.entity_id()))
166                    .items_center()
167                    .child(
168                        h_flex().items_center().gap_5().children(
169                            groups
170                                .into_iter()
171                                .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
172                        ),
173                    ),
174            )
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::OtpInput;
181
182    #[test]
183    fn invalid_group_counts_are_safely_clamped() {
184        assert_eq!(OtpInput::resolved_groups(6, 0), 1);
185        assert_eq!(OtpInput::resolved_groups(6, 20), 6);
186        assert_eq!(OtpInput::resolved_groups(0, 0), 1);
187        assert_eq!(OtpInput::resolved_groups(5, 2), 2);
188    }
189}