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, px,
4};
5
6use super::input::input_style;
7use super::state::sync_focused_input_registry;
8use crate::ThemeStyled as _;
9use crate::{ActiveTheme, Disableable, Icon, IconName, Sizable, Size, h_flex, v_flex};
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 text_size = match self.size {
82            Size::XSmall => px(14.),
83            Size::Small => px(14.),
84            Size::Medium => px(16.),
85            Size::Large => px(18.),
86            Size::Size(v) => v * 0.5,
87        };
88
89        let cursor_ix = state
90            .value()
91            .chars()
92            .count()
93            .min(state.len().saturating_sub(1));
94        let number_of_groups = Self::resolved_groups(state.len(), self.number_of_groups);
95        let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(number_of_groups);
96        let mut group_ix = 0;
97        let group_items_count = state.len().div_ceil(number_of_groups).max(1);
98        for _ in 0..number_of_groups {
99            groups.push(vec![]);
100        }
101
102        let (bg, fg) = input_style(self.disabled, cx);
103
104        for ix in 0..state.len() {
105            let c = state.value().chars().nth(ix);
106            if ix % group_items_count == 0 && ix != 0 {
107                group_ix += 1;
108            }
109
110            let is_input_focused = ix == cursor_ix && is_focused;
111            let focus_visible = is_input_focused && !self.disabled && self.focus_ring_enabled;
112
113            groups[group_ix].push(
114                h_flex()
115                    .id(ix)
116                    .border_1()
117                    .border_color(cx.theme().input)
118                    .bg(bg)
119                    .text_color(fg)
120                    .when(self.disabled, |this| this.opacity(0.5))
121                    .when(focus_visible, |this| this.border_color(cx.theme().ring))
122                    .items_center()
123                    .justify_center()
124                    .rounded(cx.theme().radius)
125                    .text_size(text_size)
126                    .map(|this| match self.size {
127                        Size::XSmall => this.w_6().h_6(),
128                        Size::Small => this.w_6().h_6(),
129                        Size::Medium => this.w_8().h_8(),
130                        Size::Large => this.w_11().h_11(),
131                        Size::Size(px) => this.w(px).h(px),
132                    })
133                    .when(focus_visible, |this| this.focus_ring_style(window, cx))
134                    .on_mouse_down(MouseButton::Left, {
135                        let state = self.state.clone();
136                        move |_, window, cx| state.read(cx).focus_handle(cx).focus(window, cx)
137                    })
138                    .map(|this| match c {
139                        Some(c) => {
140                            if state.is_masked() {
141                                this.child(
142                                    Icon::new(IconName::Asterisk)
143                                        .text_color(cx.theme().secondary_foreground)
144                                        .when(self.disabled, |this| {
145                                            this.text_color(cx.theme().muted_foreground)
146                                        })
147                                        .with_size(text_size),
148                                )
149                            } else {
150                                this.child(c.to_string())
151                            }
152                        }
153                        None => this.when(is_input_focused && blink_show, |this| {
154                            this.child(
155                                div()
156                                    .h_4()
157                                    .w_0()
158                                    .border_l_3()
159                                    .border_color(cx.theme().caret),
160                            )
161                        }),
162                    })
163                    .into_any_element(),
164            );
165        }
166
167        BaseOtpInput::new(&self.state)
168            .disabled(self.disabled)
169            .child(
170                v_flex()
171                    .id(("otp-input", self.state.entity_id()))
172                    .items_center()
173                    .child(
174                        h_flex().items_center().gap_5().children(
175                            groups
176                                .into_iter()
177                                .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
178                        ),
179                    ),
180            )
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::OtpInput;
187
188    #[test]
189    fn invalid_group_counts_are_safely_clamped() {
190        assert_eq!(OtpInput::resolved_groups(6, 0), 1);
191        assert_eq!(OtpInput::resolved_groups(6, 20), 6);
192        assert_eq!(OtpInput::resolved_groups(0, 0), 1);
193        assert_eq!(OtpInput::resolved_groups(5, 2), 2);
194    }
195}