Skip to main content

ui/components/
input_otp.rs

1use gpui::{Context, FocusHandle, Focusable, KeyDownEvent, MouseButton, Render};
2
3use crate::prelude::*;
4
5/// Fixed-length OTP input with per-slot focus navigation and paste-split.
6///
7/// Paste handling splits sanitized alphanumeric characters across remaining
8/// slots starting at the focused slot (not only slot 0). Tab and arrow keys
9/// move focus between slots.
10///
11/// Stateful view — create with `cx.new(|cx| InputOtp::new(cx, 6))`.
12#[derive(RegisterComponent)]
13pub struct InputOtp {
14    slots: Vec<SharedString>,
15    focus_index: usize,
16    focus_handle: FocusHandle,
17    disabled: bool,
18}
19
20impl InputOtp {
21    pub fn new(cx: &mut Context<Self>, length: usize) -> Self {
22        let length = length.clamp(1, 12);
23        Self {
24            slots: vec![SharedString::default(); length],
25            focus_index: 0,
26            focus_handle: cx.focus_handle(),
27            disabled: false,
28        }
29    }
30
31    pub fn disabled(mut self, disabled: bool) -> Self {
32        self.disabled = disabled;
33        self
34    }
35
36    pub fn value(&self) -> String {
37        self.slots.iter().map(|s| s.as_ref()).collect()
38    }
39
40    pub fn set_value(&mut self, value: impl AsRef<str>, cx: &mut Context<Self>) {
41        let chars: Vec<char> = value
42            .as_ref()
43            .chars()
44            .filter(|c| c.is_ascii_alphanumeric())
45            .take(self.slots.len())
46            .collect();
47        for (i, slot) in self.slots.iter_mut().enumerate() {
48            *slot = chars
49                .get(i)
50                .map(|c| c.to_string().into())
51                .unwrap_or_default();
52        }
53        self.focus_index = chars.len().min(self.slots.len().saturating_sub(1));
54        cx.notify();
55    }
56
57    fn move_focus(&mut self, delta: isize, cx: &mut Context<Self>) {
58        let len = self.slots.len() as isize;
59        let next = (self.focus_index as isize + delta).clamp(0, len - 1) as usize;
60        if next != self.focus_index {
61            self.focus_index = next;
62            cx.notify();
63        }
64    }
65
66    fn fill_from_index(&mut self, text: &str, start: usize, cx: &mut Context<Self>) {
67        let sanitized: Vec<char> = text.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
68        let mut idx = start;
69        for c in sanitized {
70            if idx >= self.slots.len() {
71                break;
72            }
73            self.slots[idx] = c.to_string().into();
74            idx += 1;
75        }
76        if idx > start {
77            self.focus_index = idx.min(self.slots.len().saturating_sub(1));
78            cx.notify();
79        }
80    }
81
82    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
83        if self.disabled {
84            return;
85        }
86        let keystroke = &event.keystroke;
87
88        if keystroke.modifiers.platform || keystroke.modifiers.control {
89            if keystroke.key.as_str() == "v" {
90                if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
91                    self.fill_from_index(&text, self.focus_index, cx);
92                }
93            }
94            return;
95        }
96
97        match keystroke.key.as_str() {
98            "left" | "backtab" => self.move_focus(-1, cx),
99            "right" | "tab" => self.move_focus(1, cx),
100            "backspace" => {
101                if !self.slots[self.focus_index].is_empty() {
102                    self.slots[self.focus_index] = SharedString::default();
103                } else if self.focus_index > 0 {
104                    self.focus_index -= 1;
105                    self.slots[self.focus_index] = SharedString::default();
106                }
107                cx.notify();
108            }
109            _ => {
110                if let Some(text) = &keystroke.key_char {
111                    let mut chars = text.chars().filter(|c| c.is_ascii_alphanumeric());
112                    if let Some(c) = chars.next() {
113                        self.slots[self.focus_index] = c.to_string().into();
114                        if self.focus_index + 1 < self.slots.len() {
115                            self.focus_index += 1;
116                        }
117                        cx.notify();
118                    }
119                }
120            }
121        }
122    }
123}
124
125impl Focusable for InputOtp {
126    fn focus_handle(&self, _cx: &App) -> FocusHandle {
127        self.focus_handle.clone()
128    }
129}
130
131impl Render for InputOtp {
132    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
133        let focused = self.focus_handle.is_focused(window);
134        let focus_index = self.focus_index;
135        let disabled = self.disabled;
136        let slots = self.slots.clone();
137
138        let mut row = h_flex().gap_2().items_center();
139        for (i, slot) in slots.iter().enumerate() {
140            let display = if slot.is_empty() { " " } else { slot.as_ref() };
141            let slot_focused = focused && i == focus_index;
142            row = row.child(
143                div()
144                    .id(("otp-slot", i))
145                    .w(px(40.))
146                    .h(px(44.))
147                    .flex()
148                    .items_center()
149                    .justify_center()
150                    .rounded_md()
151                    .border_1()
152                    .border_color(if slot_focused {
153                        palette::primary(500)
154                    } else {
155                        semantic::border(cx)
156                    })
157                    .bg(semantic::surface(cx))
158                    .text_ui(cx)
159                    .text_color(semantic::text(cx))
160                    .when(!disabled, |this| {
161                        this.cursor_pointer().on_mouse_down(
162                            MouseButton::Left,
163                            cx.listener(move |this, _, window, cx| {
164                                this.focus_index = i;
165                                window.focus(&this.focus_handle, cx);
166                                cx.notify();
167                            }),
168                        )
169                    })
170                    .child(Label::new(display).size(LabelSize::Large)),
171            );
172            if i == 2 && slots.len() > 4 {
173                row = row.child(Label::new("—").color(Color::Muted));
174            }
175        }
176
177        let field = div()
178            .id("input-otp")
179            .track_focus(&self.focus_handle)
180            .on_key_down(cx.listener(Self::on_key_down))
181            .when(disabled, |this| this.opacity(0.5))
182            .child(row);
183
184        focus_ring(field, focused, palette::primary(500))
185    }
186}
187
188impl Component for InputOtp {
189    fn scope() -> ComponentScope {
190        ComponentScope::Input
191    }
192
193    fn description() -> Option<&'static str> {
194        Some("A fixed-length one-time-password input with slot focus navigation and paste-split.")
195    }
196
197    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
198        Some(
199            v_flex()
200                .gap_4()
201                .child(cx.new(|cx| InputOtp::new(cx, 6)))
202                .into_any_element(),
203        )
204    }
205}