ui/components/
input_otp.rs1use gpui::{Context, FocusHandle, Focusable, KeyDownEvent, MouseButton, Render};
2
3use crate::prelude::*;
4
5#[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}