Skip to main content

gpui_macos/
otp_input.rs

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/// Numeric code entry using base state, with clipboard support and inert disabled cells.
7#[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                    // Modified digits are shortcuts, never code entry.
151                    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                    // The produced character owns text entry. The physical key
159                    // can name a different digit (or a symbol) on another layout.
160                    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}