Skip to main content

gpui_base/
otp_input.rs

1use crate::{StyledExt as _, input::blink_cursor::BlinkCursor};
2use gpui::{
3    AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable,
4    InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement, Render, RenderOnce,
5    SharedString, StyleRefinement, Styled, Subscription, Window, div, prelude::FluentBuilder as _,
6};
7
8/// A semantic notification from a one-time-code state.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum OtpEvent {
11    /// The value changed through keyboard editing.
12    Change,
13    /// Keyboard editing filled the final cell.
14    Complete,
15    Focus,
16    Blur,
17}
18
19/// Stateful input and focus behavior for a fixed-length numeric one-time code.
20pub struct OtpState {
21    focus_handle: FocusHandle,
22    value: SharedString,
23    blink_cursor: Entity<BlinkCursor>,
24    masked: bool,
25    length: usize,
26    _subscriptions: Vec<Subscription>,
27}
28
29impl OtpState {
30    pub fn new(length: usize, window: &mut Window, cx: &mut Context<Self>) -> Self {
31        let focus_handle = cx.focus_handle();
32        let blink_cursor = cx.new(|_| BlinkCursor::new());
33        let subscriptions = vec![
34            cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
35            cx.observe_window_activation(window, |this, window, cx| {
36                if window.is_window_active() && this.focus_handle.is_focused(window) {
37                    this.blink_cursor.update(cx, |cursor, cx| cursor.start(cx));
38                }
39            }),
40            cx.on_focus(&focus_handle, window, Self::on_focus),
41            cx.on_blur(&focus_handle, window, Self::on_blur),
42        ];
43        Self {
44            focus_handle,
45            value: SharedString::default(),
46            blink_cursor,
47            masked: false,
48            length,
49            _subscriptions: subscriptions,
50        }
51    }
52
53    pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
54        self.value = value.into();
55        self
56    }
57
58    pub fn set_value(
59        &mut self,
60        value: impl Into<SharedString>,
61        _: &mut Window,
62        cx: &mut Context<Self>,
63    ) {
64        self.value = value.into();
65        cx.notify();
66    }
67
68    pub fn value(&self) -> &SharedString {
69        &self.value
70    }
71    pub fn len(&self) -> usize {
72        self.length
73    }
74    pub fn is_empty(&self) -> bool {
75        self.value.is_empty()
76    }
77    pub fn is_masked(&self) -> bool {
78        self.masked
79    }
80    pub fn cursor_visible(&self, cx: &App) -> bool {
81        self.blink_cursor.read(cx).visible()
82    }
83    pub fn masked(mut self, masked: bool) -> Self {
84        self.masked = masked;
85        self
86    }
87    pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
88        self.masked = masked;
89        cx.notify();
90    }
91    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
92        self.focus_handle.focus(window, cx);
93    }
94
95    fn to_digit_char(value: char) -> Option<char> {
96        value.to_digit(10).map(|_| value).or_else(|| {
97            let digit = (value as u32).checked_sub('0' as u32)?;
98            char::from_digit(digit, 10)
99        })
100    }
101
102    fn edit_value(value: &str, key: &str, key_char: Option<&str>, length: usize) -> Option<String> {
103        let mut chars: Vec<char> = value.chars().collect();
104        if key == "backspace" {
105            chars.pop();
106        } else {
107            let digit = key
108                .chars()
109                .next()
110                .and_then(Self::to_digit_char)
111                .or_else(|| key_char?.chars().next().and_then(Self::to_digit_char));
112            let digit = digit?;
113            if chars.len() >= length {
114                return None;
115            }
116            chars.push(digit);
117        }
118        Some(chars.iter().collect())
119    }
120
121    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
122        let Some(value) = Self::edit_value(
123            &self.value,
124            &event.keystroke.key,
125            event.keystroke.key_char.as_deref(),
126            self.length,
127        ) else {
128            return;
129        };
130        window.prevent_default();
131        cx.stop_propagation();
132        self.blink_cursor.update(cx, |cursor, cx| cursor.pause(cx));
133        self.value = value.into();
134        cx.emit(OtpEvent::Change);
135        if self.value.chars().count() == self.length {
136            cx.emit(OtpEvent::Complete);
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| cursor.start(cx));
143        cx.emit(OtpEvent::Focus);
144    }
145    fn on_blur(&mut self, _: &mut Window, cx: &mut Context<Self>) {
146        self.blink_cursor.update(cx, |cursor, cx| cursor.stop(cx));
147        cx.emit(OtpEvent::Blur);
148    }
149}
150
151impl Focusable for OtpState {
152    fn focus_handle(&self, _: &App) -> FocusHandle {
153        self.focus_handle.clone()
154    }
155}
156impl EventEmitter<OtpEvent> for OtpState {}
157impl Render for OtpState {
158    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
159        Empty
160    }
161}
162
163/// Unstyled OTP interaction root. Applications provide the visual cells as children.
164#[derive(IntoElement)]
165pub struct OtpInput {
166    state: Entity<OtpState>,
167    disabled: bool,
168    style: StyleRefinement,
169    children: Vec<AnyElement>,
170}
171
172impl OtpInput {
173    pub fn new(state: &Entity<OtpState>) -> Self {
174        Self {
175            state: state.clone(),
176            disabled: false,
177            style: StyleRefinement::default(),
178            children: vec![],
179        }
180    }
181    pub fn disabled(mut self, disabled: bool) -> Self {
182        self.disabled = disabled;
183        self
184    }
185}
186impl Styled for OtpInput {
187    fn style(&mut self) -> &mut StyleRefinement {
188        &mut self.style
189    }
190}
191impl ParentElement for OtpInput {
192    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
193        self.children.extend(elements);
194    }
195}
196impl RenderOnce for OtpInput {
197    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
198        let state = self.state;
199        div()
200            .id(("base-otp-input", state.entity_id()))
201            .track_focus(&state.read(cx).focus_handle)
202            .when(!self.disabled, |this| {
203                this.on_key_down(window.listener_for(&state, OtpState::on_key_down))
204            })
205            .children(self.children)
206            .refine_style(&self.style)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::OtpState;
213
214    #[test]
215    fn keyboard_editing_backspaces_and_stops_at_length() {
216        assert_eq!(
217            OtpState::edit_value("12", "backspace", None, 4).as_deref(),
218            Some("1")
219        );
220        assert_eq!(
221            OtpState::edit_value("12", "3", None, 4).as_deref(),
222            Some("123")
223        );
224        assert_eq!(OtpState::edit_value("1234", "5", None, 4), None);
225        assert_eq!(OtpState::edit_value("12", "left", None, 4), None);
226    }
227
228    #[test]
229    fn programmatic_values_remain_unfiltered_for_compatibility() {
230        // Programmatic values intentionally do not use the keyboard editor path.
231        // This captures the legacy contract: callers may display arbitrary or
232        // over-length values, while actual key entry remains digit-only.
233        let value: gpui::SharedString = "token-over-length".into();
234        assert_eq!(value.as_ref(), "token-over-length");
235        assert_eq!(OtpState::edit_value("token", "x", None, 2), None);
236    }
237}