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, px,
4};
5
6use super::input::input_style;
7use super::state::sync_focused_input_registry;
8use crate::ThemeStyled as _;
9use crate::{ActiveTheme, Disableable, Icon, IconName, Sizable, Size, h_flex, v_flex};
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 text_size = match self.size {
82 Size::XSmall => px(14.),
83 Size::Small => px(14.),
84 Size::Medium => px(16.),
85 Size::Large => px(18.),
86 Size::Size(v) => v * 0.5,
87 };
88
89 let cursor_ix = state
90 .value()
91 .chars()
92 .count()
93 .min(state.len().saturating_sub(1));
94 let number_of_groups = Self::resolved_groups(state.len(), self.number_of_groups);
95 let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(number_of_groups);
96 let mut group_ix = 0;
97 let group_items_count = state.len().div_ceil(number_of_groups).max(1);
98 for _ in 0..number_of_groups {
99 groups.push(vec![]);
100 }
101
102 let (bg, fg) = input_style(self.disabled, cx);
103
104 for ix in 0..state.len() {
105 let c = state.value().chars().nth(ix);
106 if ix % group_items_count == 0 && ix != 0 {
107 group_ix += 1;
108 }
109
110 let is_input_focused = ix == cursor_ix && is_focused;
111 let focus_visible = is_input_focused && !self.disabled && self.focus_ring_enabled;
112
113 groups[group_ix].push(
114 h_flex()
115 .id(ix)
116 .border_1()
117 .border_color(cx.theme().input)
118 .bg(bg)
119 .text_color(fg)
120 .when(self.disabled, |this| this.opacity(0.5))
121 .when(focus_visible, |this| this.border_color(cx.theme().ring))
122 .items_center()
123 .justify_center()
124 .rounded(cx.theme().radius)
125 .text_size(text_size)
126 .map(|this| match self.size {
127 Size::XSmall => this.w_6().h_6(),
128 Size::Small => this.w_6().h_6(),
129 Size::Medium => this.w_8().h_8(),
130 Size::Large => this.w_11().h_11(),
131 Size::Size(px) => this.w(px).h(px),
132 })
133 .when(focus_visible, |this| this.focus_ring_style(window, cx))
134 .on_mouse_down(MouseButton::Left, {
135 let state = self.state.clone();
136 move |_, window, cx| state.read(cx).focus_handle(cx).focus(window, cx)
137 })
138 .map(|this| match c {
139 Some(c) => {
140 if state.is_masked() {
141 this.child(
142 Icon::new(IconName::Asterisk)
143 .text_color(cx.theme().secondary_foreground)
144 .when(self.disabled, |this| {
145 this.text_color(cx.theme().muted_foreground)
146 })
147 .with_size(text_size),
148 )
149 } else {
150 this.child(c.to_string())
151 }
152 }
153 None => this.when(is_input_focused && blink_show, |this| {
154 this.child(
155 div()
156 .h_4()
157 .w_0()
158 .border_l_3()
159 .border_color(cx.theme().caret),
160 )
161 }),
162 })
163 .into_any_element(),
164 );
165 }
166
167 BaseOtpInput::new(&self.state)
168 .disabled(self.disabled)
169 .child(
170 v_flex()
171 .id(("otp-input", self.state.entity_id()))
172 .items_center()
173 .child(
174 h_flex().items_center().gap_5().children(
175 groups
176 .into_iter()
177 .map(|inputs| h_flex().items_center().gap_1().children(inputs)),
178 ),
179 ),
180 )
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::OtpInput;
187
188 #[test]
189 fn invalid_group_counts_are_safely_clamped() {
190 assert_eq!(OtpInput::resolved_groups(6, 0), 1);
191 assert_eq!(OtpInput::resolved_groups(6, 20), 6);
192 assert_eq!(OtpInput::resolved_groups(0, 0), 1);
193 assert_eq!(OtpInput::resolved_groups(5, 2), 2);
194 }
195}