use gpui::{
AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _,
Render, RenderOnce, SharedString, Styled as _, Subscription, Window, div,
prelude::FluentBuilder, px,
};
use super::{InputEvent, blink_cursor::BlinkCursor};
use crate::{ActiveTheme, Disableable, Icon, IconName, Sizable, Size, h_flex, v_flex};
pub struct OtpState {
focus_handle: FocusHandle,
value: SharedString,
blink_cursor: Entity<BlinkCursor>,
masked: bool,
length: usize,
_subscriptions: Vec<Subscription>,
}
impl OtpState {
pub fn new(length: usize, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let blink_cursor = cx.new(|_| BlinkCursor::new());
let _subscriptions = vec![
cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
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,
}
}
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub fn set_value(
&mut self,
value: impl Into<SharedString>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.value = value.into();
cx.notify();
}
pub fn value(&self) -> &SharedString {
&self.value
}
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
self.masked = masked;
cx.notify();
}
pub fn focus(&self, window: &mut Window, _: &mut Context<Self>) {
self.focus_handle.focus(window);
}
fn on_input_mouse_down(
&mut self,
_: &MouseDownEvent,
window: &mut Window,
_: &mut Context<Self>,
) {
window.focus(&self.focus_handle);
}
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let mut chars: Vec<char> = 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::<String>());
if self.value.chars().count() == self.length {
cx.emit(InputEvent::Change);
}
cx.notify()
}
fn on_focus(&mut self, _: &mut Window, cx: &mut Context<Self>) {
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>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.stop(cx);
});
cx.emit(InputEvent::Blur);
}
fn pause_blink_cursor(&mut self, cx: &mut Context<Self>) {
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<InputEvent> for OtpState {}
impl Render for OtpState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(IntoElement)]
pub struct OtpInput {
state: Entity<OtpState>,
number_of_groups: usize,
size: Size,
disabled: bool,
}
impl OtpInput {
pub fn new(state: &Entity<OtpState>) -> Self {
Self {
state: state.clone(),
number_of_groups: 2,
size: Size::Medium,
disabled: false,
}
}
pub fn groups(mut self, n: usize) -> Self {
self.number_of_groups = n;
self
}
}
impl Disableable for OtpInput {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for OtpInput {
fn with_size(mut self, size: impl Into<crate::Size>) -> 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 cursor_ix = state
.value
.chars()
.count()
.min(state.length.saturating_sub(1));
let mut groups: Vec<Vec<AnyElement>> = 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 ix in 0..state.length {
let c = state.value.chars().nth(ix);
if ix % group_items_count == 0 && ix != 0 {
group_ix += 1;
}
let is_input_focused = ix == cursor_ix && is_focused;
groups[group_ix].push(
h_flex()
.id(ix)
.border_1()
.border_color(cx.theme().input)
.bg(cx.theme().background)
.when(self.disabled, |this| {
this.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
})
.when(is_input_focused, |this| this.border_color(cx.theme().ring))
.when(cx.theme().shadow, |this| this.shadow_xs())
.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)
.when(self.disabled, |this| {
this.text_color(cx.theme().muted_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(cx.theme().caret),
)
}),
})
.into_any_element(),
);
}
v_flex()
.id(("otp-input", self.state.entity_id()))
.track_focus(&self.state.read(cx).focus_handle)
.when(!self.disabled, |this| {
this.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)),
),
)
}
}