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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum OtpEvent {
11 Change,
13 Complete,
15 Focus,
16 Blur,
17}
18
19pub 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#[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 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}