egui_components/
otp_input.rs1use 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 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 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}