Skip to main content

egui_components/
otp_input.rs

1//! `OtpInput` — a row of single-character boxes for one-time codes.
2//!
3//! Backed by a single `&mut String`; only the configured number of characters
4//! is kept. Click to focus the group, then type — digits fill left to right
5//! and `Backspace` removes the last one. The returned [`Response`] reports
6//! `.changed()` on edits.
7//!
8//! ```ignore
9//! ui.add(sc::OtpInput::new(&mut self.code).length(6));
10//! ```
11
12use egui::{vec2, FontId, Key, Rect, Response, Sense, Stroke, Ui, Widget};
13use egui_components_theme::Theme;
14
15pub struct OtpInput<'a> {
16    value: &'a mut String,
17    length: usize,
18    digits_only: bool,
19    box_size: f32,
20    gap: f32,
21}
22
23impl<'a> OtpInput<'a> {
24    pub fn new(value: &'a mut String) -> Self {
25        Self {
26            value,
27            length: 6,
28            digits_only: true,
29            box_size: 40.0,
30            gap: 8.0,
31        }
32    }
33    pub fn length(mut self, n: usize) -> Self {
34        self.length = n.max(1);
35        self
36    }
37    /// Allow any character (otherwise only ASCII digits are accepted).
38    pub fn any_char(mut self) -> Self {
39        self.digits_only = false;
40        self
41    }
42    pub fn box_size(mut self, s: f32) -> Self {
43        self.box_size = s;
44        self
45    }
46}
47
48impl<'a> Widget for OtpInput<'a> {
49    fn ui(self, ui: &mut Ui) -> Response {
50        let theme = Theme::get(ui.ctx());
51        let m = theme.metrics;
52        let c = theme.colors;
53
54        let total_w = self.length as f32 * self.box_size + (self.length - 1) as f32 * self.gap;
55        let desired = vec2(total_w, self.box_size);
56        let (rect, mut response) = ui.allocate_exact_size(desired, Sense::click());
57
58        if response.clicked() {
59            response.request_focus();
60        }
61        let has_focus = response.has_focus();
62
63        // Collect typed input while focused.
64        let mut changed = false;
65        if has_focus {
66            let mut chars: Vec<char> = self.value.chars().collect();
67            ui.input(|i| {
68                for ev in &i.events {
69                    match ev {
70                        egui::Event::Text(t) => {
71                            for ch in t.chars() {
72                                if chars.len() >= self.length {
73                                    break;
74                                }
75                                if self.digits_only && !ch.is_ascii_digit() {
76                                    continue;
77                                }
78                                if ch.is_control() {
79                                    continue;
80                                }
81                                chars.push(ch);
82                                changed = true;
83                            }
84                        }
85                        egui::Event::Key {
86                            key: Key::Backspace,
87                            pressed: true,
88                            ..
89                        } => {
90                            changed |= chars.pop().is_some();
91                        }
92                        _ => {}
93                    }
94                }
95            });
96            if changed {
97                *self.value = chars.into_iter().take(self.length).collect();
98            }
99        }
100
101        if changed {
102            response.mark_changed();
103        }
104
105        if ui.is_rect_visible(rect) {
106            let radius = theme.corner();
107            let filled = self.value.chars().count();
108            let font = FontId::proportional(m.font_size_lg);
109            for idx in 0..self.length {
110                let x = rect.left() + idx as f32 * (self.box_size + self.gap);
111                let box_rect =
112                    Rect::from_min_size(egui::pos2(x, rect.top()), vec2(self.box_size, self.box_size));
113                let painter = ui.painter();
114                painter.rect_filled(box_rect, radius, c.background);
115
116                let active = has_focus && idx == filled.min(self.length - 1);
117                let border = if active {
118                    c.ring
119                } else {
120                    c.input_border
121                };
122                painter.rect_stroke(
123                    box_rect,
124                    radius,
125                    Stroke::new(if active { m.focus_ring_width } else { m.border_width }, border),
126                    egui::StrokeKind::Inside,
127                );
128
129                if let Some(ch) = self.value.chars().nth(idx) {
130                    painter.text(
131                        box_rect.center(),
132                        egui::Align2::CENTER_CENTER,
133                        ch,
134                        font.clone(),
135                        c.foreground,
136                    );
137                }
138            }
139        }
140
141        if response.hovered() {
142            ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
143        }
144
145        response
146    }
147}