diff --git a/README.md b/README.md index a1f7b0c0..76607680 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index b0a8941d..b7515ee7 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -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, large_input: View, small_input: View, + otp_input: View, + otp_value: Option, } 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 { 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) -> Vec { [ - &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() diff --git a/crates/ui/src/input.rs b/crates/ui/src/input/input.rs similarity index 99% rename from crates/ui/src/input.rs rename to crates/ui/src/input/input.rs index d3729bea..f083d330 100644 --- a/crates/ui/src/input.rs +++ b/crates/ui/src/input/input.rs @@ -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.focus_handle.focus(cx); + } + fn left(&mut self, _: &Left, cx: &mut ViewContext) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { diff --git a/crates/ui/src/input/input_otp.rs b/crates/ui/src/input/input_otp.rs new file mode 100644 index 00000000..52c49360 --- /dev/null +++ b/crates/ui/src/input/input_otp.rs @@ -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, + on_change: Option>, +} + +impl InputOtp { + pub fn new(length: usize, cx: &mut ViewContext) -> 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(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) { + cx.focus(&self.focus_handle); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext) { + let mut chars: Vec = 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::()); + + 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.blink_cursor.update(cx, |cursor, cx| { + cursor.start(cx); + }); + } + + fn on_blur(&mut self, cx: &mut ViewContext) { + self.blink_cursor.update(cx, |cursor, cx| { + cursor.stop(cx); + }); + } + + fn pause_blink_cursor(&mut self, cx: &mut ViewContext) { + 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) -> 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::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)), + ), + ) + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs new file mode 100644 index 00000000..01008dec --- /dev/null +++ b/crates/ui/src/input/mod.rs @@ -0,0 +1,6 @@ +mod blink_cursor; +mod input; +mod input_otp; + +pub use input::*; +pub use input_otp::*;