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 cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
28 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 pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
55 self.value = value.into();
56 self
57 }
58
59 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 pub fn value(&self) -> &SharedString {
72 &self.value
73 }
74
75 pub fn masked(mut self, masked: bool) -> Self {
77 self.masked = masked;
78 self
79 }
80
81 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#[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 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 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}