use gpui::{ div, prelude::FluentBuilder, px, AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, Render, RenderOnce, SharedString, Styled as _, Subscription, Window, }; use super::{blink_cursor::BlinkCursor, InputEvent}; use crate::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, Size}; pub struct OtpState { focus_handle: FocusHandle, value: SharedString, blink_cursor: Entity, masked: bool, length: usize, _subscriptions: Vec, } impl OtpState { pub fn new(length: usize, window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new(|_| BlinkCursor::new()); let _subscriptions = vec![ // Observe the blink cursor to repaint the view when it changes. cx.observe(&blink_cursor, |_, _, cx| cx.notify()), // Blink the cursor when the window is active, pause when it's not. cx.observe_window_activation(window, |this, window, cx| { if window.is_window_active() { let focus_handle = this.focus_handle.clone(); if focus_handle.is_focused(window) { this.blink_cursor.update(cx, |blink_cursor, cx| { blink_cursor.start(cx); }); } } }), cx.on_focus(&focus_handle, window, Self::on_focus), cx.on_blur(&focus_handle, window, Self::on_blur), ]; Self { length, focus_handle: focus_handle.clone(), value: SharedString::default(), blink_cursor: blink_cursor.clone(), masked: false, _subscriptions, } } /// Set default value of the OTP Input. pub fn default_value(mut self, value: impl Into) -> Self { self.value = value.into(); self } /// Set value of the OTP Input. pub fn set_value( &mut self, value: impl Into, _: &mut Window, cx: &mut Context, ) { self.value = value.into(); cx.notify(); } /// Return the value of the OTP Input. pub fn value(&self) -> &SharedString { &self.value } /// Set masked to true use masked input. pub fn masked(mut self, masked: bool) -> Self { self.masked = masked; self } /// Set masked to true use masked input. pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { self.masked = masked; cx.notify(); } pub fn focus(&self, window: &mut Window, _: &mut Context) { self.focus_handle.focus(window); } fn on_input_mouse_down( &mut self, _: &MouseDownEvent, window: &mut Window, _: &mut Context, ) { window.focus(&self.focus_handle); } fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let mut chars: Vec = self.value.chars().collect(); let ix = chars.len(); let key = event.keystroke.key.as_str(); match key { "backspace" => { if ix > 0 { let ix = ix - 1; chars.remove(ix); } window.prevent_default(); cx.stop_propagation(); } _ => { let c = key.chars().next().unwrap(); if !matches!(c, '0'..='9') { return; } if ix >= self.length { return; } chars.push(c); window.prevent_default(); cx.stop_propagation(); } } self.pause_blink_cursor(cx); self.value = SharedString::from(chars.iter().collect::()); if self.value.chars().count() == self.length { cx.emit(InputEvent::Change(self.value.clone())); } cx.notify() } fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { self.blink_cursor.update(cx, |cursor, cx| { cursor.start(cx); }); cx.emit(InputEvent::Focus); } fn on_blur(&mut self, _: &mut Window, cx: &mut Context) { self.blink_cursor.update(cx, |cursor, cx| { cursor.stop(cx); }); cx.emit(InputEvent::Blur); } fn pause_blink_cursor(&mut self, cx: &mut Context) { self.blink_cursor.update(cx, |cursor, cx| { cursor.pause(cx); }); } } impl Focusable for OtpState { fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for OtpState {} impl Render for OtpState { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } /// A One Time Password (OTP) input element. /// /// This can accept a fixed length number and can be masked. /// /// Use case example: /// /// - SMS OTP /// - Authenticator OTP #[derive(IntoElement)] pub struct OtpInput { state: Entity, number_of_groups: usize, size: Size, } impl OtpInput { /// Create a new [`OtpInput`] element bind to the [`OtpState`]. pub fn new(state: &Entity) -> Self { Self { state: state.clone(), number_of_groups: 2, size: Size::Medium, } } /// Set number of groups in the OTP Input. pub fn groups(mut self, n: usize) -> Self { self.number_of_groups = n; self } } impl Sizable for OtpInput { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } impl RenderOnce for OtpInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); let blink_show = state.blink_cursor.read(cx).visible(); let is_focused = state.focus_handle.is_focused(window); let text_size = match self.size { Size::XSmall => px(14.), Size::Small => px(14.), Size::Medium => px(16.), Size::Large => px(18.), Size::Size(v) => v * 0.5, }; let mut groups: Vec> = Vec::with_capacity(self.number_of_groups); let mut group_ix = 0; let group_items_count = state.length / self.number_of_groups; for _ in 0..self.number_of_groups { groups.push(vec![]); } for i in 0..state.length { let c = state.value.chars().nth(i); if i % group_items_count == 0 && i != 0 { group_ix += 1; } let is_input_focused = i == state.value.chars().count() && is_focused; groups[group_ix].push( h_flex() .id(("input-otp", i)) .border_1() .border_color(cx.theme().input) .bg(cx.theme().background) .when(is_input_focused, |this| this.border_color(cx.theme().ring)) .when(cx.theme().shadow, |this| this.shadow_sm()) .items_center() .justify_center() .rounded(cx.theme().radius) .text_size(text_size) .map(|this| match self.size { Size::XSmall => this.w_6().h_6(), Size::Small => this.w_6().h_6(), Size::Medium => this.w_8().h_8(), Size::Large => this.w_11().h_11(), Size::Size(px) => this.w(px).h(px), }) .on_mouse_down( MouseButton::Left, window.listener_for(&self.state, OtpState::on_input_mouse_down), ) .map(|this| match c { Some(c) => { if state.masked { this.child( Icon::new(IconName::Asterisk) .text_color(cx.theme().secondary_foreground) .with_size(text_size), ) } else { this.child(c.to_string()) } } None => this.when(is_input_focused && blink_show, |this| { this.child( div() .h_4() .w_0() .border_l_3() .border_color(crate::blue_500()), ) }), }) .into_any_element(), ); } v_flex() .track_focus(&self.state.read(cx).focus_handle) .on_key_down(window.listener_for(&self.state, OtpState::on_key_down)) .items_center() .child( h_flex().items_center().gap_5().children( groups .into_iter() .map(|inputs| h_flex().items_center().gap_1().children(inputs)), ), ) } }