gpui_component/input/
otp_input.rs1use gpui::{
2 AnyElement, App, Entity, Focusable, InteractiveElement as _, IntoElement, MouseButton,
3 ParentElement as _, RenderOnce, Styled as _, Window, div, prelude::FluentBuilder,
4};
5
6use super::input::input_style;
7use super::state::sync_focused_input_registry;
8use crate::{ActiveTheme, Disableable, Icon, IconName, Sizable, Size, h_flex, v_flex};
9use crate::{StyleSized as _, ThemeStyled as _};
10use gpui_base::OtpInput as BaseOtpInput;
11pub use gpui_base::{OtpEvent, OtpState};
12
13#[derive(IntoElement)]
22pub struct OtpInput {
23 state: Entity<OtpState>,
24 number_of_groups: usize,
25 size: Size,
26 focus_ring_enabled: bool,
27 disabled: bool,
28}
29
30impl OtpInput {
31 pub fn new(state: &Entity<OtpState>) -> Self {
33 Self {
34 state: state.clone(),
35 number_of_groups: 2,
36 size: Size::Medium,
37 focus_ring_enabled: true,
38 disabled: false,
39 }
40 }
41
42 pub fn groups(mut self, n: usize) -> Self {
44 self.number_of_groups = n;
45 self
46 }
47
48 fn resolved_groups(length: usize, requested: usize) -> usize {
49 requested.max(1).min(length.max(1))
50 }
51}
52impl Disableable for OtpInput {
53 fn disabled(mut self, disabled: bool) -> Self {
54 self.disabled = disabled;
55 self
56 }
57}
58impl crate::FocusableExt for OtpInput {
59 fn focus_ring(mut self, enabled: bool) -> Self {
60 self.focus_ring_enabled = enabled;
61 self
62 }
63
64 fn is_focus_ring_enabled(&self) -> bool {
65 self.focus_ring_enabled
66 }
67}
68impl Sizable for OtpInput {
69 fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
70 self.size = size.into();
71 self
72 }
73}
74impl RenderOnce for OtpInput {
75 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
76 sync_focused_input_registry(self.state.clone(), window, cx);
77 let state = self.state.read(cx);
78 let blink_show = state.cursor_visible(cx);
79 let is_focused = state.focus_handle(cx).is_focused(window);
80
81 let cursor_ix = state
82 .value()
83 .chars()
84 .count()
85 .min(state.len().saturating_sub(1));
86 let number_of_groups = Self::resolved_groups(state.len(), self.number_of_groups);
87 let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(number_of_groups);
88 let mut group_ix = 0;
89 let group_items_count = state.len().div_ceil(number_of_groups).max(1);
90 for _ in 0..number_of_groups {
91 groups.push(vec![]);
92 }
93
94 let (bg, fg) = input_style(self.disabled, cx);
95
96 for ix in 0..state.len() {
97 let c = state.value().chars().nth(ix);
98 if ix % group_items_count == 0 && ix != 0 {
99 group_ix += 1;
100 }
101
102 let is_input_focused = ix == cursor_ix && is_focused;
103 let focus_visible = is_input_focused && !self.disabled && self.focus_ring_enabled;
104
105 groups[group_ix].push(
106 h_flex()
107 .id(ix)
108 .border_1()
109 .border_color(cx.theme().input)
110 .bg(bg)
111 .text_color(fg)
112 .when(self.disabled, |this| this.opacity(0.5))
113 .when(focus_visible, |this| this.border_color(cx.theme().ring))
114 .items_center()
115 .justify_center()
116 .rounded(cx.theme().radius)
117 .map(|this| match self.size {
118 Size::Size(v) => this.text_size(v * 0.5),
119 size => this.input_text_size(size),
120 })
121 .map(|this| match self.size {
122 Size::XSmall => this.w_6().h_6(),
123 Size::Small => this.w_6().h_6(),
124 Size::Medium => this.w_8().h_8(),
125 Size::Large => this.w_11().h_11(),
126 Size::Size(px) => this.w(px).h(px),
127 })
128 .when(focus_visible, |this| this.focus_ring_style(window, cx))
129 .on_mouse_down(MouseButton::Left, {
130 let state = self.state.clone();
131 move |_, window, cx| state.read(cx).focus_handle(cx).focus(window, cx)
132 })
133 .map(|this| match c {
134 Some(c) => {
135 if state.is_masked() {
136 this.child(
137 Icon::new(IconName::Asterisk)
138 .text_color(cx.theme().secondary_foreground)
139 .when(self.disabled, |this| {
140 this.text_color(cx.theme().muted_foreground)
141 }),
142 )
143 } else {
144 this.child(c.to_string())
145 }
146 }
147 None => this.when(is_input_focused && blink_show, |this| {
148 this.child(
149 div()
150 .h_4()
151 .w_0()
152 .border_l_3()
153 .border_color(cx.theme().caret),
154 )
155 }),
156 })
157 .into_any_element(),
158 );
159 }
160
161 BaseOtpInput::new(&self.state)
162 .disabled(self.disabled)
163 .child(
164 v_flex()
165 .id(("otp-input", self.state.entity_id()))
166 .items_center()
167 .child(
168 h_flex().items_center().gap_5().children(
169 groups
170 .into_iter()
171 .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
172 ),
173 ),
174 )
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::OtpInput;
181
182 #[test]
183 fn invalid_group_counts_are_safely_clamped() {
184 assert_eq!(OtpInput::resolved_groups(6, 0), 1);
185 assert_eq!(OtpInput::resolved_groups(6, 20), 6);
186 assert_eq!(OtpInput::resolved_groups(0, 0), 1);
187 assert_eq!(OtpInput::resolved_groups(5, 2), 2);
188 }
189}