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 {
23 let focus_handle = cx.focus_handle();
24 let blink_cursor = cx.new(|_| BlinkCursor::new());
25
26 let _subscriptions = vec![
27 cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
29 cx.observe_window_activation(window, |this, window, cx| {
31 if window.is_window_active() {
32 let focus_handle = this.focus_handle.clone();
33 if focus_handle.is_focused(window) {
34 this.blink_cursor.update(cx, |blink_cursor, cx| {
35 blink_cursor.start(cx);
36 });
37 }
38 }
39 }),
40 cx.on_focus(&focus_handle, window, Self::on_focus),
41 cx.on_blur(&focus_handle, window, Self::on_blur),
42 ];
43
44 Self {
45 length,
46 focus_handle: focus_handle.clone(),
47 value: SharedString::default(),
48 blink_cursor: blink_cursor.clone(),
49 masked: false,
50 _subscriptions,
51 }
52 }
53
54 pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
56 self.value = value.into();
57 self
58 }
59
60 pub fn set_value(
62 &mut self,
63 value: impl Into<SharedString>,
64 _: &mut Window,
65 cx: &mut Context<Self>,
66 ) {
67 self.value = value.into();
68 cx.notify();
69 }
70
71 pub fn value(&self) -> &SharedString {
73 &self.value
74 }
75
76 pub fn masked(mut self, masked: bool) -> Self {
78 self.masked = masked;
79 self
80 }
81
82 pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
84 self.masked = masked;
85 cx.notify();
86 }
87
88 pub fn focus(&self, window: &mut Window, _: &mut Context<Self>) {
90 self.focus_handle.focus(window);
91 }
92
93 fn on_input_mouse_down(
94 &mut self,
95 _: &MouseDownEvent,
96 window: &mut Window,
97 _: &mut Context<Self>,
98 ) {
99 window.focus(&self.focus_handle);
100 }
101
102 fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
103 let mut chars: Vec<char> = self.value.chars().collect();
104 let ix = chars.len();
105
106 let key = event.keystroke.key.as_str();
107
108 match key {
109 "backspace" => {
110 if ix > 0 {
111 let ix = ix - 1;
112 chars.remove(ix);
113 }
114
115 window.prevent_default();
116 cx.stop_propagation();
117 }
118 _ => {
119 let c = key.chars().next().unwrap();
120 if !matches!(c, '0'..='9') {
121 return;
122 }
123 if ix >= self.length {
124 return;
125 }
126
127 chars.push(c);
128
129 window.prevent_default();
130 cx.stop_propagation();
131 }
132 }
133
134 self.pause_blink_cursor(cx);
135 self.value = SharedString::from(chars.iter().collect::<String>());
136
137 if self.value.chars().count() == self.length {
138 cx.emit(InputEvent::Change);
139 }
140 cx.notify()
141 }
142
143 fn on_focus(&mut self, _: &mut Window, cx: &mut Context<Self>) {
144 self.blink_cursor.update(cx, |cursor, cx| {
145 cursor.start(cx);
146 });
147 cx.emit(InputEvent::Focus);
148 }
149
150 fn on_blur(&mut self, _: &mut Window, cx: &mut Context<Self>) {
151 self.blink_cursor.update(cx, |cursor, cx| {
152 cursor.stop(cx);
153 });
154 cx.emit(InputEvent::Blur);
155 }
156
157 fn pause_blink_cursor(&mut self, cx: &mut Context<Self>) {
158 self.blink_cursor.update(cx, |cursor, cx| {
159 cursor.pause(cx);
160 });
161 }
162}
163impl Focusable for OtpState {
164 fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
165 self.focus_handle.clone()
166 }
167}
168impl EventEmitter<InputEvent> for OtpState {}
169impl Render for OtpState {
170 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
171 Empty
172 }
173}
174
175#[derive(IntoElement)]
184pub struct OtpInput {
185 state: Entity<OtpState>,
186 number_of_groups: usize,
187 size: Size,
188 disabled: bool,
189}
190
191impl OtpInput {
192 pub fn new(state: &Entity<OtpState>) -> Self {
194 Self {
195 state: state.clone(),
196 number_of_groups: 2,
197 size: Size::Medium,
198 disabled: false,
199 }
200 }
201
202 pub fn groups(mut self, n: usize) -> Self {
204 self.number_of_groups = n;
205 self
206 }
207}
208impl Disableable for OtpInput {
209 fn disabled(mut self, disabled: bool) -> Self {
210 self.disabled = disabled;
211 self
212 }
213}
214impl Sizable for OtpInput {
215 fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
216 self.size = size.into();
217 self
218 }
219}
220impl RenderOnce for OtpInput {
221 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
222 let state = self.state.read(cx);
223 let blink_show = state.blink_cursor.read(cx).visible();
224 let is_focused = state.focus_handle.is_focused(window);
225
226 let text_size = match self.size {
227 Size::XSmall => px(14.),
228 Size::Small => px(14.),
229 Size::Medium => px(16.),
230 Size::Large => px(18.),
231 Size::Size(v) => v * 0.5,
232 };
233
234 let cursor_ix = state
235 .value
236 .chars()
237 .count()
238 .min(state.length.saturating_sub(1));
239 let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(self.number_of_groups);
240 let mut group_ix = 0;
241 let group_items_count = state.length / self.number_of_groups;
242 for _ in 0..self.number_of_groups {
243 groups.push(vec![]);
244 }
245
246 for ix in 0..state.length {
247 let c = state.value.chars().nth(ix);
248 if ix % group_items_count == 0 && ix != 0 {
249 group_ix += 1;
250 }
251
252 let is_input_focused = ix == cursor_ix && is_focused;
253
254 groups[group_ix].push(
255 h_flex()
256 .id(ix)
257 .border_1()
258 .border_color(cx.theme().input)
259 .bg(cx.theme().background)
260 .when(self.disabled, |this| {
261 this.bg(cx.theme().muted)
262 .text_color(cx.theme().muted_foreground)
263 })
264 .when(is_input_focused, |this| this.border_color(cx.theme().ring))
265 .when(cx.theme().shadow, |this| this.shadow_xs())
266 .items_center()
267 .justify_center()
268 .rounded(cx.theme().radius)
269 .text_size(text_size)
270 .map(|this| match self.size {
271 Size::XSmall => this.w_6().h_6(),
272 Size::Small => this.w_6().h_6(),
273 Size::Medium => this.w_8().h_8(),
274 Size::Large => this.w_11().h_11(),
275 Size::Size(px) => this.w(px).h(px),
276 })
277 .on_mouse_down(
278 MouseButton::Left,
279 window.listener_for(&self.state, OtpState::on_input_mouse_down),
280 )
281 .map(|this| match c {
282 Some(c) => {
283 if state.masked {
284 this.child(
285 Icon::new(IconName::Asterisk)
286 .text_color(cx.theme().secondary_foreground)
287 .when(self.disabled, |this| {
288 this.text_color(cx.theme().muted_foreground)
289 })
290 .with_size(text_size),
291 )
292 } else {
293 this.child(c.to_string())
294 }
295 }
296 None => this.when(is_input_focused && blink_show, |this| {
297 this.child(
298 div()
299 .h_4()
300 .w_0()
301 .border_l_3()
302 .border_color(crate::blue_500()),
303 )
304 }),
305 })
306 .into_any_element(),
307 );
308 }
309
310 v_flex()
311 .id(("otp-input", self.state.entity_id()))
312 .track_focus(&self.state.read(cx).focus_handle)
313 .when(!self.disabled, |this| {
314 this.on_key_down(window.listener_for(&self.state, OtpState::on_key_down))
315 })
316 .items_center()
317 .child(
318 h_flex().items_center().gap_5().children(
319 groups
320 .into_iter()
321 .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
322 ),
323 )
324 }
325}