Add InputOtp (#40)
https://github.com/user-attachments/assets/c92da4e7-21de-436f-968d-7cb47fa31aed
This commit is contained in:
parent
d71b697f41
commit
c7336fd5ea
5 changed files with 261 additions and 18 deletions
|
|
@ -8,7 +8,7 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- [x] Theming
|
- [x] Theming
|
||||||
- [ ] TextField
|
- [ ] TextInput
|
||||||
- [x] Ctrl+a, e to move cursor to start/end
|
- [x] Ctrl+a, e to move cursor to start/end
|
||||||
- [x] Copy, Cut, Paste by keyboard
|
- [x] Copy, Cut, Paste by keyboard
|
||||||
- [x] Selection by mouse, drag to select text
|
- [x] Selection by mouse, drag to select text
|
||||||
|
|
@ -16,7 +16,7 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs
|
||||||
- [x] Input icon
|
- [x] Input icon
|
||||||
- [ ] Textarea
|
- [ ] Textarea
|
||||||
- [ ] ContextMenu to let user copy, cut, paste
|
- [ ] ContextMenu to let user copy, cut, paste
|
||||||
- [ ] InputOTP
|
- [x] InputOTP
|
||||||
- [x] Button
|
- [x] Button
|
||||||
- [x] Button with Icon
|
- [x] Button with Icon
|
||||||
- [x] IconButton
|
- [x] IconButton
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, AppContext, ClickEvent, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
|
actions, AppContext, ClickEvent, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
|
||||||
ParentElement as _, Render, Styled, View, ViewContext, VisualContext, WindowContext,
|
ParentElement as _, Render, SharedString, Styled, View, ViewContext, VisualContext,
|
||||||
|
WindowContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::Button, h_flex, input::TextInput, v_flex, Clickable, FocusableCycle, IconName, Size,
|
button::Button,
|
||||||
|
h_flex,
|
||||||
|
input::{InputOtp, TextInput},
|
||||||
|
prelude::FluentBuilder as _,
|
||||||
|
v_flex, Clickable, FocusableCycle, IconName, Size,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::section;
|
use crate::section;
|
||||||
|
|
@ -28,6 +33,8 @@ pub struct InputStory {
|
||||||
both_input1: View<TextInput>,
|
both_input1: View<TextInput>,
|
||||||
large_input: View<TextInput>,
|
large_input: View<TextInput>,
|
||||||
small_input: View<TextInput>,
|
small_input: View<TextInput>,
|
||||||
|
otp_input: View<InputOtp>,
|
||||||
|
otp_value: Option<SharedString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputStory {
|
impl InputStory {
|
||||||
|
|
@ -35,7 +42,7 @@ impl InputStory {
|
||||||
cx.new_view(|cx| Self::new(cx))
|
cx.new_view(|cx| Self::new(cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(cx: &mut WindowContext) -> Self {
|
fn new(cx: &mut ViewContext<Self>) -> Self {
|
||||||
let input1 = cx.new_view(|cx| {
|
let input1 = cx.new_view(|cx| {
|
||||||
let mut input = TextInput::new(cx).cleanable(true);
|
let mut input = TextInput::new(cx).cleanable(true);
|
||||||
input.set_text(
|
input.set_text(
|
||||||
|
|
@ -72,6 +79,15 @@ impl InputStory {
|
||||||
.placeholder("This input have prefix and suffix.")
|
.placeholder("This input have prefix and suffix.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let view = cx.view().clone();
|
||||||
|
let otp_input = cx.new_view(|cx| {
|
||||||
|
InputOtp::new(6, cx).on_change(move |value: &SharedString, cx| {
|
||||||
|
view.update(cx, |view, _| {
|
||||||
|
view.otp_value = Some(value.clone());
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
input1,
|
input1,
|
||||||
input2: cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")),
|
input2: cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")),
|
||||||
|
|
@ -95,6 +111,8 @@ impl InputStory {
|
||||||
prefix_input1,
|
prefix_input1,
|
||||||
suffix_input1,
|
suffix_input1,
|
||||||
both_input1,
|
both_input1,
|
||||||
|
otp_input,
|
||||||
|
otp_value: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,17 +133,18 @@ impl InputStory {
|
||||||
impl FocusableCycle for InputStory {
|
impl FocusableCycle for InputStory {
|
||||||
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle> {
|
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle> {
|
||||||
[
|
[
|
||||||
&self.input1,
|
self.input1.focus_handle(cx),
|
||||||
&self.input2,
|
self.input2.focus_handle(cx),
|
||||||
&self.disabled_input,
|
self.disabled_input.focus_handle(cx),
|
||||||
&self.mash_input,
|
self.mash_input.focus_handle(cx),
|
||||||
&self.prefix_input1,
|
self.prefix_input1.focus_handle(cx),
|
||||||
&self.both_input1,
|
self.both_input1.focus_handle(cx),
|
||||||
&self.suffix_input1,
|
self.suffix_input1.focus_handle(cx),
|
||||||
|
self.large_input.focus_handle(cx),
|
||||||
|
self.small_input.focus_handle(cx),
|
||||||
|
self.otp_input.focus_handle(cx),
|
||||||
]
|
]
|
||||||
.iter()
|
.to_vec()
|
||||||
.map(|v| v.focus_handle(cx))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +179,13 @@ impl Render for InputStory {
|
||||||
.child(self.large_input.clone())
|
.child(self.large_input.clone())
|
||||||
.child(self.small_input.clone()),
|
.child(self.small_input.clone()),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
section("Input OTP", cx)
|
||||||
|
.child(self.otp_input.clone())
|
||||||
|
.when_some(self.otp_value.clone(), |this, otp| {
|
||||||
|
this.child(format!("You input OTP: {}", otp))
|
||||||
|
}),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use super::blink_cursor::BlinkCursor;
|
||||||
use crate::button::{Button, ButtonStyle};
|
use crate::button::{Button, ButtonStyle};
|
||||||
use crate::styled_ext::Sizeful;
|
use crate::styled_ext::Sizeful;
|
||||||
use crate::theme::ActiveTheme;
|
use crate::theme::ActiveTheme;
|
||||||
use crate::{event::InterativeElementExt as _, Size};
|
use crate::{event::InterativeElementExt as _, Size};
|
||||||
use crate::{Clickable, IconName, StyledExt as _};
|
use crate::{Clickable, IconName, StyledExt as _};
|
||||||
use blink_cursor::BlinkCursor;
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, div, fill, point, px, relative, rems, size, AnyView, AppContext, Bounds, ClickEvent,
|
actions, div, fill, point, px, relative, rems, size, AnyView, AppContext, Bounds, ClickEvent,
|
||||||
|
|
@ -22,8 +22,6 @@ use gpui::{
|
||||||
};
|
};
|
||||||
use unicode_segmentation::*;
|
use unicode_segmentation::*;
|
||||||
|
|
||||||
mod blink_cursor;
|
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
input,
|
input,
|
||||||
[
|
[
|
||||||
|
|
@ -222,6 +220,10 @@ impl TextInput {
|
||||||
self.text.clone()
|
self.text.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn focus(&self, cx: &mut ViewContext<Self>) {
|
||||||
|
self.focus_handle.focus(cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
|
fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
|
||||||
self.pause_blink_cursor(cx);
|
self.pause_blink_cursor(cx);
|
||||||
if self.selected_range.is_empty() {
|
if self.selected_range.is_empty() {
|
||||||
209
crates/ui/src/input/input_otp.rs
Normal file
209
crates/ui/src/input/input_otp.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
use gpui::{
|
||||||
|
div, prelude::FluentBuilder as _, AnyElement, Context, Div, FocusHandle, FocusableView,
|
||||||
|
InteractiveElement, IntoElement, KeyDownEvent, Model, MouseButton, MouseDownEvent,
|
||||||
|
ParentElement as _, Render, SharedString, Stateful, Styled as _, ViewContext, WindowContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{h_flex, theme::ActiveTheme, v_flex, StyledExt};
|
||||||
|
|
||||||
|
use super::blink_cursor::BlinkCursor;
|
||||||
|
|
||||||
|
pub struct InputOtp {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
length: usize,
|
||||||
|
number_of_groups: usize,
|
||||||
|
masked: bool,
|
||||||
|
value: SharedString,
|
||||||
|
blink_cursor: Model<BlinkCursor>,
|
||||||
|
on_change: Option<Box<dyn Fn(&SharedString, &mut WindowContext) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputOtp {
|
||||||
|
pub fn new(length: usize, cx: &mut ViewContext<Self>) -> Self {
|
||||||
|
let focus_handle = cx.focus_handle();
|
||||||
|
let blink_cursor = cx.new_model(|cx| BlinkCursor::new(cx));
|
||||||
|
let input = Self {
|
||||||
|
focus_handle: focus_handle.clone(),
|
||||||
|
length,
|
||||||
|
number_of_groups: 2,
|
||||||
|
value: SharedString::default(),
|
||||||
|
masked: true,
|
||||||
|
on_change: None,
|
||||||
|
blink_cursor: blink_cursor.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Observe the blink cursor to repaint the view when it changes.
|
||||||
|
cx.observe(&blink_cursor, |_, _, cx| cx.notify()).detach();
|
||||||
|
// Blink the cursor when the window is active, pause when it's not.
|
||||||
|
cx.observe_window_activation(|this, cx| {
|
||||||
|
if cx.is_window_active() {
|
||||||
|
let focus_handle = this.focus_handle.clone();
|
||||||
|
if focus_handle.is_focused(cx) {
|
||||||
|
this.blink_cursor.update(cx, |blink_cursor, cx| {
|
||||||
|
blink_cursor.start(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
cx.on_focus(&focus_handle, Self::on_focus).detach();
|
||||||
|
cx.on_blur(&focus_handle, Self::on_blur).detach();
|
||||||
|
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set number of groups in the OTP Input.
|
||||||
|
pub fn groups(mut self, n: usize) -> Self {
|
||||||
|
self.number_of_groups = n;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set callback to be called when the value of the OTP Input changes (All OTP input have filled).
|
||||||
|
pub fn on_change<F>(mut self, f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&SharedString, &mut WindowContext) + 'static,
|
||||||
|
{
|
||||||
|
self.on_change = Some(Box::new(f));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_input_mouse_down(&mut self, _: &MouseDownEvent, cx: &mut ViewContext<Self>) {
|
||||||
|
cx.focus(&self.focus_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) {
|
||||||
|
let mut chars: Vec<char> = self.value.chars().collect();
|
||||||
|
let ix = chars.len();
|
||||||
|
|
||||||
|
let key = event.keystroke.key.as_str();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"backspace" => {
|
||||||
|
if ix > 0 {
|
||||||
|
let ix = ix - 1;
|
||||||
|
chars.remove(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let c = key.chars().next().unwrap();
|
||||||
|
if !matches!(c, '0'..='9') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ix >= self.length {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chars.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pause_blink_cursor(cx);
|
||||||
|
self.value = SharedString::from(chars.iter().collect::<String>());
|
||||||
|
|
||||||
|
if self.value.chars().count() == self.length {
|
||||||
|
if let Some(on_change) = &self.on_change {
|
||||||
|
on_change(&self.value, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
|
||||||
|
self.blink_cursor.update(cx, |cursor, cx| {
|
||||||
|
cursor.start(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
|
||||||
|
self.blink_cursor.update(cx, |cursor, cx| {
|
||||||
|
cursor.stop(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause_blink_cursor(&mut self, cx: &mut ViewContext<Self>) {
|
||||||
|
self.blink_cursor.update(cx, |cursor, cx| {
|
||||||
|
cursor.pause(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusableView for InputOtp {
|
||||||
|
fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for InputOtp {
|
||||||
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||||
|
let blink_show = self.blink_cursor.read(cx).visible();
|
||||||
|
let is_focused = self.focus_handle.is_focused(cx);
|
||||||
|
|
||||||
|
let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(self.number_of_groups);
|
||||||
|
let mut group_ix = 0;
|
||||||
|
let group_items_count = self.length / self.number_of_groups;
|
||||||
|
for _ in 0..self.number_of_groups {
|
||||||
|
groups.push(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..self.length {
|
||||||
|
let c = self.value.chars().nth(i);
|
||||||
|
if i % group_items_count == 0 && i != 0 {
|
||||||
|
group_ix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_input_focused = i == self.value.chars().count() && is_focused;
|
||||||
|
|
||||||
|
groups[group_ix].push(
|
||||||
|
h_flex()
|
||||||
|
.id(("input-otp", i))
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().input)
|
||||||
|
.bg(cx.theme().background)
|
||||||
|
.when(is_input_focused, |this| this.border_color(cx.theme().ring))
|
||||||
|
.shadow_sm()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded_md()
|
||||||
|
.w_8()
|
||||||
|
.h_8()
|
||||||
|
.text_lg()
|
||||||
|
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_input_mouse_down))
|
||||||
|
.when_some_else(
|
||||||
|
c,
|
||||||
|
|this, c| {
|
||||||
|
this.child(if self.masked {
|
||||||
|
SharedString::from("•")
|
||||||
|
} else {
|
||||||
|
SharedString::from(c.to_string())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|this| {
|
||||||
|
this.when(is_input_focused && blink_show, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.h_4()
|
||||||
|
.w_0()
|
||||||
|
.border_l_3()
|
||||||
|
.border_color(crate::blue_500()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_any_element(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.track_focus(&self.focus_handle)
|
||||||
|
.on_key_down(cx.listener(Self::on_key_down))
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
h_flex().items_center().gap_5().children(
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|inputs| h_flex().items_center().gap_1().children(inputs)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/ui/src/input/mod.rs
Normal file
6
crates/ui/src/input/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
mod blink_cursor;
|
||||||
|
mod input;
|
||||||
|
mod input_otp;
|
||||||
|
|
||||||
|
pub use input::*;
|
||||||
|
pub use input_otp::*;
|
||||||
Loading…
Reference in a new issue