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
|
||||
|
||||
- [x] Theming
|
||||
- [ ] TextField
|
||||
- [ ] TextInput
|
||||
- [x] Ctrl+a, e to move cursor to start/end
|
||||
- [x] Copy, Cut, Paste by keyboard
|
||||
- [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
|
||||
- [ ] Textarea
|
||||
- [ ] ContextMenu to let user copy, cut, paste
|
||||
- [ ] InputOTP
|
||||
- [x] InputOTP
|
||||
- [x] Button
|
||||
- [x] Button with Icon
|
||||
- [x] IconButton
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
use gpui::{
|
||||
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::{
|
||||
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;
|
||||
|
|
@ -28,6 +33,8 @@ pub struct InputStory {
|
|||
both_input1: View<TextInput>,
|
||||
large_input: View<TextInput>,
|
||||
small_input: View<TextInput>,
|
||||
otp_input: View<InputOtp>,
|
||||
otp_value: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl InputStory {
|
||||
|
|
@ -35,7 +42,7 @@ impl InputStory {
|
|||
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 mut input = TextInput::new(cx).cleanable(true);
|
||||
input.set_text(
|
||||
|
|
@ -72,6 +79,15 @@ impl InputStory {
|
|||
.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 {
|
||||
input1,
|
||||
input2: cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")),
|
||||
|
|
@ -95,6 +111,8 @@ impl InputStory {
|
|||
prefix_input1,
|
||||
suffix_input1,
|
||||
both_input1,
|
||||
otp_input,
|
||||
otp_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,17 +133,18 @@ impl InputStory {
|
|||
impl FocusableCycle for InputStory {
|
||||
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle> {
|
||||
[
|
||||
&self.input1,
|
||||
&self.input2,
|
||||
&self.disabled_input,
|
||||
&self.mash_input,
|
||||
&self.prefix_input1,
|
||||
&self.both_input1,
|
||||
&self.suffix_input1,
|
||||
self.input1.focus_handle(cx),
|
||||
self.input2.focus_handle(cx),
|
||||
self.disabled_input.focus_handle(cx),
|
||||
self.mash_input.focus_handle(cx),
|
||||
self.prefix_input1.focus_handle(cx),
|
||||
self.both_input1.focus_handle(cx),
|
||||
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()
|
||||
.map(|v| v.focus_handle(cx))
|
||||
.collect()
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +179,13 @@ impl Render for InputStory {
|
|||
.child(self.large_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(
|
||||
h_flex()
|
||||
.items_center()
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
|
||||
use std::ops::Range;
|
||||
|
||||
use super::blink_cursor::BlinkCursor;
|
||||
use crate::button::{Button, ButtonStyle};
|
||||
use crate::styled_ext::Sizeful;
|
||||
use crate::theme::ActiveTheme;
|
||||
use crate::{event::InterativeElementExt as _, Size};
|
||||
use crate::{Clickable, IconName, StyledExt as _};
|
||||
use blink_cursor::BlinkCursor;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
actions, div, fill, point, px, relative, rems, size, AnyView, AppContext, Bounds, ClickEvent,
|
||||
|
|
@ -22,8 +22,6 @@ use gpui::{
|
|||
};
|
||||
use unicode_segmentation::*;
|
||||
|
||||
mod blink_cursor;
|
||||
|
||||
actions!(
|
||||
input,
|
||||
[
|
||||
|
|
@ -222,6 +220,10 @@ impl TextInput {
|
|||
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>) {
|
||||
self.pause_blink_cursor(cx);
|
||||
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