1use crate::ActiveTheme;
2use gpui_kit::base::{OtpState, StyledExt as _};
3use gpui_kit::rems;
4use gpui_kit::{App, Entity, Focusable, MouseButton, Window, div, prelude::*};
5
6#[derive(IntoElement)]
8pub struct OtpInput {
9 state: Entity<OtpState>,
10 disabled: bool,
11 style: gpui_kit::StyleRefinement,
12 children: Vec<gpui_kit::AnyElement>,
13}
14impl OtpInput {
15 pub fn disabled(mut self, disabled: bool) -> Self {
16 self.disabled = disabled;
17 self
18 }
19}
20impl Styled for OtpInput {
21 fn style(&mut self) -> &mut gpui_kit::StyleRefinement {
22 &mut self.style
23 }
24}
25impl ParentElement for OtpInput {
26 fn extend(&mut self, elements: impl IntoIterator<Item = gpui_kit::AnyElement>) {
27 self.children.extend(elements);
28 }
29}
30
31pub fn otp_input(state: &Entity<OtpState>, _: &Window, _: &App) -> OtpInput {
32 OtpInput {
33 state: state.clone(),
34 disabled: false,
35 style: Default::default(),
36 children: vec![],
37 }
38}
39
40fn code_digits(text: &str, length: usize) -> String {
41 text.chars()
42 .filter_map(|ch| {
43 if ch.is_ascii_digit() {
44 Some(ch)
45 } else {
46 (ch as u32)
47 .checked_sub('0' as u32)
48 .and_then(|n| char::from_digit(n, 10))
49 }
50 })
51 .take(length)
52 .collect()
53}
54
55impl RenderOnce for OtpInput {
56 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
57 let state = self.state;
58 let t = cx.macos();
59 let value = state.read(cx);
60 let digits: Vec<char> = value.value().chars().collect();
61 let focused = !self.disabled && value.focus_handle(cx).is_focused(window);
62 let mut cells = Vec::new();
63 for index in 0..value.len() {
64 let target = state.clone();
65 let current = focused && index == digits.len().min(value.len().saturating_sub(1));
66 let character = digits
67 .get(index)
68 .map(|ch| if value.is_masked() { '•' } else { *ch });
69 cells.push(
70 div()
71 .w(rems(1.5))
72 .h(rems(1.5))
73 .flex()
74 .items_center()
75 .justify_center()
76 .border_1()
77 .rounded(rems(0.375))
78 .border_color(if current {
79 t.accent
80 } else {
81 t.control_border()
82 })
83 .bg(t.surface)
84 .shadow(if current {
85 t.focus_shadow()
86 } else {
87 Vec::new()
88 })
89 .font_family(t.font.clone())
90 .text_size(rems(0.8125))
91 .line_height(gpui_kit::relative(1.2))
92 .text_color(t.foreground)
93 .when(!self.disabled, |cell| {
94 cell.on_mouse_down(MouseButton::Left, move |_, window, cx| {
95 target.update(cx, |state, cx| state.focus(window, cx))
96 })
97 })
98 .child(character.map(|ch| ch.to_string()).unwrap_or_default())
99 .into_any_element(),
100 );
101 }
102 cells.extend(self.children);
103 if self.disabled {
104 return div()
105 .flex()
106 .gap(rems(0.375))
107 .children(cells)
108 .refine_style(&self.style)
109 .opacity(0.45)
110 .into_any_element();
111 }
112 let paste = state.clone();
113 div()
114 .id(("macos-otp", state.entity_id()))
115 .capture_key_down(move |event, window, cx| {
116 let mods = event.keystroke.modifiers;
117 let paste_key = event.keystroke.key == "v"
118 && !mods.alt
119 && !mods.shift
120 && if cfg!(target_os = "macos") {
121 mods.platform && !mods.control
122 } else {
123 mods.control && !mods.platform
124 };
125 if paste_key {
126 if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
127 paste.update(cx, |state, cx| {
128 let next = code_digits(&text, state.len());
129 if !next.is_empty() && next != state.value().as_ref() {
130 let complete = next.len() == state.len();
131 state.set_value(next, window, cx);
132 cx.emit(gpui_kit::base::OtpEvent::Change);
133 if complete {
134 cx.emit(gpui_kit::base::OtpEvent::Complete);
135 }
136 }
137 });
138 }
139 window.prevent_default();
140 cx.stop_propagation();
141 } else if (mods.control || mods.platform || mods.alt)
142 && (event.keystroke.key == "backspace"
143 || !code_digits(&event.keystroke.key, 1).is_empty()
144 || event
145 .keystroke
146 .key_char
147 .as_deref()
148 .is_some_and(|text| !code_digits(text, 1).is_empty()))
149 {
150 cx.stop_propagation();
152 } else if !mods.control
153 && !mods.platform
154 && !mods.alt
155 && event.keystroke.key != "backspace"
156 && let Some(text) = event.keystroke.key_char.as_deref()
157 {
158 let digit = code_digits(text, 1);
161 if !digit.is_empty() {
162 paste.update(cx, |state, cx| {
163 let mut next = state.value().to_string();
164 if next.chars().count() < state.len() {
165 next.push_str(&digit);
166 let complete = next.chars().count() == state.len();
167 state.set_value(next, window, cx);
168 cx.emit(gpui_kit::base::OtpEvent::Change);
169 if complete {
170 cx.emit(gpui_kit::base::OtpEvent::Complete);
171 }
172 }
173 });
174 window.prevent_default();
175 }
176 cx.stop_propagation();
177 }
178 })
179 .child(
180 gpui_kit::base::OtpInput::new(&state)
181 .flex()
182 .gap(rems(0.375))
183 .children(cells)
184 .refine_style(&self.style),
185 )
186 .into_any_element()
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use gpui_kit::px;
194 use gpui_kit::{Context, Render, TestAppContext};
195 struct Harness {
196 state: Entity<OtpState>,
197 disabled: bool,
198 }
199 impl Render for Harness {
200 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
201 crate::focus_scope("otp-test")
202 .size_full()
203 .child(otp_input(&self.state, window, cx).disabled(self.disabled))
204 }
205 }
206 #[gpui_kit::test]
207 fn otp_filters_digits_limits_length_and_backspaces(cx: &mut TestAppContext) {
208 cx.update(crate::init);
209 let (view, cx) = cx.add_window_view(|window, cx| Harness {
210 state: cx.new(|cx| OtpState::new(6, window, cx)),
211 disabled: false,
212 });
213 cx.update(|window, cx| {
214 let state = view.read(cx).state.clone();
215 state.update(cx, |state, cx| state.focus(window, cx));
216 window.draw(cx).clear(cx);
217 });
218 cx.simulate_keystrokes("1 a 2 3 4 5 6 7");
219 cx.update(|_, cx| assert_eq!(view.read(cx).state.read(cx).value().as_ref(), "123456"));
220 cx.simulate_keystrokes("backspace 9");
221 cx.update(|_, cx| assert_eq!(view.read(cx).state.read(cx).value().as_ref(), "123459"));
222 }
223 #[gpui_kit::test]
224 fn typed_characters_take_precedence_over_physical_digit_keys(cx: &mut TestAppContext) {
225 cx.update(crate::init);
226 let (view, cx) = cx.add_window_view(|window, cx| Harness {
227 state: cx.new(|cx| OtpState::new(6, window, cx)),
228 disabled: false,
229 });
230 cx.update(|window, cx| {
231 view.read(cx)
232 .state
233 .read(cx)
234 .focus_handle(cx)
235 .focus(window, cx);
236 window.draw(cx).clear(cx);
237 });
238 for (key, character, expected) in [
239 ("shift-1", "!", ""),
240 ("shift-&", "1", "1"),
241 ("2", "3", "13"),
242 ] {
243 cx.update(|window, cx| {
244 let mut stroke = gpui_kit::Keystroke::parse(key).unwrap();
245 stroke.key_char = Some(character.into());
246 window.dispatch_keystroke(stroke, cx);
247 assert_eq!(view.read(cx).state.read(cx).value().as_ref(), expected);
248 window.draw(cx).clear(cx);
249 });
250 }
251 }
252
253 #[gpui_kit::test]
254 fn paste_filters_code_and_disabled_input_ignores_events(cx: &mut TestAppContext) {
255 cx.update(crate::init);
256 let (view, cx) = cx.add_window_view(|window, cx| Harness {
257 state: cx.new(|cx| OtpState::new(6, window, cx)),
258 disabled: false,
259 });
260 let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
261 let seen = events.clone();
262 view.update(cx, |this, cx| {
263 cx.subscribe(
264 &this.state,
265 move |_, _, event: &gpui_kit::base::OtpEvent, _| seen.borrow_mut().push(*event),
266 )
267 .detach();
268 });
269 cx.update(|window, cx| {
270 view.read(cx)
271 .state
272 .read(cx)
273 .focus_handle(cx)
274 .focus(window, cx);
275 cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string("123-4567".into()));
276 window.draw(cx).clear(cx);
277 });
278 let paste = if cfg!(target_os = "macos") {
279 "cmd-v"
280 } else {
281 "ctrl-v"
282 };
283 cx.simulate_keystrokes(paste);
284 cx.update(|_, cx| assert_eq!(view.read(cx).state.read(cx).value().as_ref(), "123456"));
285 assert_eq!(
286 events
287 .borrow()
288 .iter()
289 .filter(|event| **event == gpui_kit::base::OtpEvent::Complete)
290 .count(),
291 1
292 );
293 cx.simulate_keystrokes(paste);
294 assert_eq!(
295 events
296 .borrow()
297 .iter()
298 .filter(|event| **event == gpui_kit::base::OtpEvent::Complete)
299 .count(),
300 1
301 );
302 cx.simulate_keystrokes("backspace ctrl-8 alt-9");
303 cx.update(|_, cx| assert_eq!(view.read(cx).state.read(cx).value().as_ref(), "12345"));
304 view.update(cx, |this, cx| {
305 this.disabled = true;
306 cx.notify();
307 });
308 cx.update(|window, cx| window.draw(cx).clear(cx));
309 cx.simulate_click(gpui_kit::point(px(10.), px(10.)), Default::default());
310 cx.simulate_keystrokes("backspace 9");
311 cx.simulate_keystrokes(paste);
312 cx.update(|_, cx| assert_eq!(view.read(cx).state.read(cx).value().as_ref(), "12345"));
313 cx.update(|window, cx| {
314 window.blur(cx);
315 window.draw(cx).clear(cx);
316 });
317 cx.simulate_click(gpui_kit::point(px(10.), px(10.)), Default::default());
318 cx.update(|window, cx| {
319 assert!(
320 !view
321 .read(cx)
322 .state
323 .read(cx)
324 .focus_handle(cx)
325 .is_focused(window)
326 )
327 });
328 }
329}