move gpui-components to ui crate
This commit is contained in:
88
crates/ui/src/input/blink_cursor.rs
Normal file
88
crates/ui/src/input/blink_cursor.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{ModelContext, Timer};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// To manage the Input cursor blinking.
|
||||
///
|
||||
/// It will start blinking with a interval of 500ms.
|
||||
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
||||
///
|
||||
/// The input painter will check if this in visible state, then it will draw the cursor.
|
||||
pub(crate) struct BlinkCursor {
|
||||
visible: bool,
|
||||
paused: bool,
|
||||
epoch: usize,
|
||||
}
|
||||
|
||||
impl BlinkCursor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
visible: false,
|
||||
paused: false,
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the blinking
|
||||
pub fn start(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.blink(self.epoch, cx);
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.epoch = 0;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn next_epoch(&mut self) -> usize {
|
||||
self.epoch += 1;
|
||||
self.epoch
|
||||
}
|
||||
|
||||
fn blink(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
|
||||
if self.paused || epoch != self.epoch {
|
||||
return;
|
||||
}
|
||||
|
||||
self.visible = !self.visible;
|
||||
cx.notify();
|
||||
|
||||
// Schedule the next blink
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
Timer::after(INTERVAL).await;
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| this.blink(epoch, cx)).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn visible(&self) -> bool {
|
||||
// Keep showing the cursor if paused
|
||||
self.paused || self.visible
|
||||
}
|
||||
|
||||
/// Pause the blinking, and delay 500ms to resume the blinking.
|
||||
pub fn pause(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.paused = true;
|
||||
cx.notify();
|
||||
|
||||
// delay 500ms to start the blinking
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
Timer::after(PAUSE_DELAY).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.paused = false;
|
||||
this.blink(epoch, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
39
crates/ui/src/input/change.rs
Normal file
39
crates/ui/src/input/change.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::{fmt::Debug, ops::Range};
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Change {
|
||||
pub(crate) old_range: Range<usize>,
|
||||
pub(crate) old_text: String,
|
||||
pub(crate) new_range: Range<usize>,
|
||||
pub(crate) new_text: String,
|
||||
version: usize,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn new(
|
||||
old_range: Range<usize>,
|
||||
old_text: &str,
|
||||
new_range: Range<usize>,
|
||||
new_text: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_range,
|
||||
old_text: old_text.to_string(),
|
||||
new_range,
|
||||
new_text: new_text.to_string(),
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryItem for Change {
|
||||
fn version(&self) -> usize {
|
||||
self.version
|
||||
}
|
||||
|
||||
fn set_version(&mut self, version: usize) {
|
||||
self.version = version;
|
||||
}
|
||||
}
|
||||
18
crates/ui/src/input/clear_button.rs
Normal file
18
crates/ui/src/input/clear_button.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use gpui::{Styled, WindowContext};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
theme::ActiveTheme as _,
|
||||
Icon, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
pub(crate) struct ClearButton {}
|
||||
|
||||
impl ClearButton {
|
||||
pub fn new(cx: &mut WindowContext) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CircleX).text_color(cx.theme().muted_foreground))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
}
|
||||
}
|
||||
537
crates/ui/src/input/element.rs
Normal file
537
crates/ui/src/input/element.rs
Normal file
@@ -0,0 +1,537 @@
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels,
|
||||
Point, Style, TextRun, UnderlineStyle, View, WindowContext, WrappedLine,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::theme::ActiveTheme as _;
|
||||
|
||||
use super::TextInput;
|
||||
|
||||
const RIGHT_MARGIN: Pixels = px(5.);
|
||||
const CURSOR_INSET: Pixels = px(0.5);
|
||||
|
||||
pub(super) struct TextElement {
|
||||
input: View<TextInput>,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub(super) fn new(input: View<TextInput>) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
|
||||
fn paint_mouse_listeners(&mut self, cx: &mut WindowContext) {
|
||||
cx.on_mouse_event({
|
||||
let input = self.input.clone();
|
||||
|
||||
move |event: &MouseMoveEvent, _, cx| {
|
||||
if event.pressed_button == Some(MouseButton::Left) {
|
||||
input.update(cx, |input, cx| {
|
||||
input.on_drag_move(event, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn layout_cursor(
|
||||
&self,
|
||||
lines: &[WrappedLine],
|
||||
line_height: Pixels,
|
||||
bounds: &mut Bounds<Pixels>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (Option<PaintQuad>, Point<Pixels>) {
|
||||
let input = self.input.read(cx);
|
||||
let selected_range = &input.selected_range;
|
||||
let cursor_offset = input.cursor_offset();
|
||||
let mut scroll_offset = input.scroll_handle.offset();
|
||||
let mut cursor = None;
|
||||
|
||||
// The cursor corresponds to the current cursor position in the text no only the line.
|
||||
let mut cursor_pos = None;
|
||||
let mut cursor_start = None;
|
||||
let mut cursor_end = None;
|
||||
|
||||
let mut prev_lines_offset = 0;
|
||||
let mut offset_y = px(0.);
|
||||
for line in lines.iter() {
|
||||
// break loop if all cursor positions are found
|
||||
if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
let line_origin = point(px(0.), offset_y);
|
||||
if cursor_pos.is_none() {
|
||||
let offset = cursor_offset.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_pos = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
if cursor_start.is_none() {
|
||||
let offset = selected_range.start.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_start = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
if cursor_end.is_none() {
|
||||
let offset = selected_range.end.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_end = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
|
||||
offset_y += line.size(line_height).height;
|
||||
// +1 for skip the last `\n`
|
||||
prev_lines_offset += line.len() + 1;
|
||||
}
|
||||
|
||||
if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) =
|
||||
(cursor_pos, cursor_start, cursor_end)
|
||||
{
|
||||
let cursor_moved = input.last_cursor_offset != Some(cursor_offset);
|
||||
let selection_changed = input.last_selected_range != Some(selected_range.clone());
|
||||
|
||||
if cursor_moved || selection_changed {
|
||||
scroll_offset.x =
|
||||
if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) {
|
||||
// cursor is out of right
|
||||
bounds.size.width - RIGHT_MARGIN - cursor_pos.x
|
||||
} else if scroll_offset.x + cursor_pos.x < px(0.) {
|
||||
// cursor is out of left
|
||||
scroll_offset.x - cursor_pos.x
|
||||
} else {
|
||||
scroll_offset.x
|
||||
};
|
||||
scroll_offset.y = if scroll_offset.y + cursor_pos.y > (bounds.size.height) {
|
||||
// cursor is out of bottom
|
||||
bounds.size.height - cursor_pos.y
|
||||
} else if scroll_offset.y + cursor_pos.y < px(0.) {
|
||||
// cursor is out of top
|
||||
scroll_offset.y - cursor_pos.y
|
||||
} else {
|
||||
scroll_offset.y
|
||||
};
|
||||
|
||||
if input.selection_reversed {
|
||||
if scroll_offset.x + cursor_start.x < px(0.) {
|
||||
// selection start is out of left
|
||||
scroll_offset.x = -cursor_start.x;
|
||||
}
|
||||
if scroll_offset.y + cursor_start.y < px(0.) {
|
||||
// selection start is out of top
|
||||
scroll_offset.y = -cursor_start.y;
|
||||
}
|
||||
} else {
|
||||
if scroll_offset.x + cursor_end.x <= px(0.) {
|
||||
// selection end is out of left
|
||||
scroll_offset.x = -cursor_end.x;
|
||||
}
|
||||
if scroll_offset.y + cursor_end.y <= px(0.) {
|
||||
// selection end is out of top
|
||||
scroll_offset.y = -cursor_end.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bounds.origin = bounds.origin + scroll_offset;
|
||||
|
||||
if input.show_cursor(cx) {
|
||||
// cursor blink
|
||||
cursor = Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
bounds.left() + cursor_pos.x,
|
||||
bounds.top() + cursor_pos.y + CURSOR_INSET,
|
||||
),
|
||||
size(px(1.5), line_height),
|
||||
),
|
||||
crate::blue_500(),
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
(cursor, scroll_offset)
|
||||
}
|
||||
|
||||
fn layout_selections(
|
||||
&self,
|
||||
lines: &[WrappedLine],
|
||||
line_height: Pixels,
|
||||
bounds: &mut Bounds<Pixels>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Option<Path<Pixels>> {
|
||||
let input = self.input.read(cx);
|
||||
let selected_range = &input.selected_range;
|
||||
if selected_range.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (start_ix, end_ix) = if selected_range.start < selected_range.end {
|
||||
(selected_range.start, selected_range.end)
|
||||
} else {
|
||||
(selected_range.end, selected_range.start)
|
||||
};
|
||||
|
||||
let mut prev_lines_offset = 0;
|
||||
let mut line_corners = vec![];
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
for line in lines.iter() {
|
||||
let line_size = line.size(line_height);
|
||||
let line_wrap_width = line_size.width;
|
||||
|
||||
let line_origin = point(px(0.), offset_y);
|
||||
|
||||
let line_cursor_start =
|
||||
line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height);
|
||||
let line_cursor_end =
|
||||
line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height);
|
||||
|
||||
if line_cursor_start.is_some() || line_cursor_end.is_some() {
|
||||
let start = line_cursor_start
|
||||
.unwrap_or_else(|| line.position_for_index(0, line_height).unwrap());
|
||||
|
||||
let end = line_cursor_end
|
||||
.unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap());
|
||||
|
||||
// Split the selection into multiple items
|
||||
let wrapped_lines =
|
||||
(end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize;
|
||||
|
||||
let mut end_x = end.x;
|
||||
if wrapped_lines > 0 {
|
||||
end_x = line_wrap_width;
|
||||
}
|
||||
|
||||
line_corners.push(Corners {
|
||||
top_left: line_origin + point(start.x, start.y),
|
||||
top_right: line_origin + point(end_x, start.y),
|
||||
bottom_left: line_origin + point(start.x, start.y + line_height),
|
||||
bottom_right: line_origin + point(end_x, start.y + line_height),
|
||||
});
|
||||
|
||||
// wrapped lines
|
||||
for i in 1..=wrapped_lines {
|
||||
let start = point(px(0.), start.y + i as f32 * line_height);
|
||||
let mut end = point(end.x, end.y + i as f32 * line_height);
|
||||
if i < wrapped_lines {
|
||||
end.x = line_size.width;
|
||||
}
|
||||
|
||||
line_corners.push(Corners {
|
||||
top_left: line_origin + point(start.x, start.y),
|
||||
top_right: line_origin + point(end.x, start.y),
|
||||
bottom_left: line_origin + point(start.x, start.y + line_height),
|
||||
bottom_right: line_origin + point(end.x, start.y + line_height),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if line_cursor_start.is_some() && line_cursor_end.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
offset_y += line_size.height;
|
||||
// +1 for skip the last `\n`
|
||||
prev_lines_offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let mut points = vec![];
|
||||
if line_corners.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Fix corners to make sure the left to right direction
|
||||
for corners in &mut line_corners {
|
||||
if corners.top_left.x > corners.top_right.x {
|
||||
std::mem::swap(&mut corners.top_left, &mut corners.top_right);
|
||||
std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right);
|
||||
}
|
||||
}
|
||||
|
||||
for corners in &line_corners {
|
||||
points.push(corners.top_right);
|
||||
points.push(corners.bottom_right);
|
||||
points.push(corners.bottom_left);
|
||||
}
|
||||
|
||||
let mut rev_line_corners = line_corners.iter().rev().peekable();
|
||||
while let Some(corners) = rev_line_corners.next() {
|
||||
points.push(corners.top_left);
|
||||
if let Some(next) = rev_line_corners.peek() {
|
||||
if next.top_left.x > corners.top_left.x {
|
||||
points.push(point(next.top_left.x, corners.top_left.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// print_points_as_svg_path(&line_corners, &points);
|
||||
|
||||
let first_p = *points.get(0).unwrap();
|
||||
let mut path = gpui::Path::new(bounds.origin + first_p);
|
||||
for p in points.iter().skip(1) {
|
||||
path.line_to(bounds.origin + *p);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PrepaintState {
|
||||
lines: SmallVec<[WrappedLine; 1]>,
|
||||
cursor: Option<PaintQuad>,
|
||||
cursor_scroll_offset: Point<Pixels>,
|
||||
selection_path: Option<Path<Pixels>>,
|
||||
bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl IntoElement for TextElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug function to print points as SVG path.
|
||||
#[allow(unused)]
|
||||
fn print_points_as_svg_path(
|
||||
line_corners: &Vec<Corners<Point<Pixels>>>,
|
||||
points: &Vec<Point<Pixels>>,
|
||||
) {
|
||||
for corners in line_corners {
|
||||
println!(
|
||||
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
|
||||
corners.top_left.x.0 as i32,
|
||||
corners.top_left.y.0 as i32,
|
||||
corners.top_right.x.0 as i32,
|
||||
corners.top_right.y.0 as i32,
|
||||
corners.bottom_left.x.0 as i32,
|
||||
corners.bottom_left.y.0 as i32,
|
||||
corners.bottom_right.x.0 as i32,
|
||||
corners.bottom_right.y.0 as i32,
|
||||
);
|
||||
}
|
||||
|
||||
if points.len() > 0 {
|
||||
println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32);
|
||||
for p in points.iter().skip(1) {
|
||||
println!("L{},{}", p.x.0 as i32, p.y.0 as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let input = self.input.read(cx);
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
if self.input.read(cx).is_multi_line() {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * cx.line_height()).into();
|
||||
} else {
|
||||
style.size.height = cx.line_height().into();
|
||||
};
|
||||
(cx.request_layout(style, []), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
let multi_line = self.input.read(cx).is_multi_line();
|
||||
let line_height = cx.line_height();
|
||||
let input = self.input.read(cx);
|
||||
let text = input.text.clone();
|
||||
let placeholder = input.placeholder.clone();
|
||||
let style = cx.text_style();
|
||||
let mut bounds = bounds;
|
||||
|
||||
let (display_text, text_color) = if text.is_empty() {
|
||||
(placeholder, cx.theme().muted_foreground)
|
||||
} else if input.masked {
|
||||
(
|
||||
"*".repeat(text.chars().count()).into(),
|
||||
cx.theme().foreground,
|
||||
)
|
||||
} else {
|
||||
(text, cx.theme().foreground)
|
||||
};
|
||||
|
||||
let run = TextRun {
|
||||
len: display_text.len(),
|
||||
font: style.font(),
|
||||
color: text_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
|
||||
let runs = if let Some(marked_range) = input.marked_range.as_ref() {
|
||||
vec![
|
||||
TextRun {
|
||||
len: marked_range.start,
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: marked_range.end - marked_range.start,
|
||||
underline: Some(UnderlineStyle {
|
||||
color: Some(run.color),
|
||||
thickness: px(1.0),
|
||||
wavy: false,
|
||||
}),
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: display_text.len() - marked_range.end,
|
||||
..run.clone()
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|run| run.len > 0)
|
||||
.collect()
|
||||
} else {
|
||||
vec![run]
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(cx.rem_size());
|
||||
let wrap_width = if multi_line {
|
||||
Some(bounds.size.width - RIGHT_MARGIN)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let lines = cx
|
||||
.text_system()
|
||||
.shape_text(display_text, font_size, &runs, wrap_width)
|
||||
.unwrap();
|
||||
|
||||
// `position_for_index` for example
|
||||
//
|
||||
// #### text
|
||||
//
|
||||
// Hello 世界,this is GPUI component.
|
||||
// The GPUI Component is a collection of UI components for
|
||||
// GPUI framework, including Button, Input, Checkbox, Radio,
|
||||
// Dropdown, Tab, and more...
|
||||
//
|
||||
// wrap_width: 444px, line_height: 20px
|
||||
//
|
||||
// #### lines[0]
|
||||
//
|
||||
// | index | pos | line |
|
||||
// |-------|------------------|------|
|
||||
// | 5 | (37 px, 0.0) | 0 |
|
||||
// | 38 | (261.7 px, 20.0) | 0 |
|
||||
// | 40 | None | - |
|
||||
//
|
||||
// #### lines[1]
|
||||
//
|
||||
// | index | position | line |
|
||||
// |-------|-----------------------|------|
|
||||
// | 5 | (43.578125 px, 0.0) | 0 |
|
||||
// | 56 | (422.21094 px, 0.0) | 0 |
|
||||
// | 57 | (11.6328125 px, 20.0) | 1 |
|
||||
// | 114 | (429.85938 px, 20.0) | 1 |
|
||||
// | 115 | (11.3125 px, 40.0) | 2 |
|
||||
|
||||
// Calculate the scroll offset to keep the cursor in view
|
||||
|
||||
let (cursor, cursor_scroll_offset) =
|
||||
self.layout_cursor(&lines, line_height, &mut bounds, cx);
|
||||
|
||||
let selection_path = self.layout_selections(&lines, line_height, &mut bounds, cx);
|
||||
|
||||
PrepaintState {
|
||||
bounds,
|
||||
lines,
|
||||
cursor,
|
||||
cursor_scroll_offset,
|
||||
selection_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
input_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let focus_handle = self.input.read(cx).focus_handle.clone();
|
||||
let focused = focus_handle.is_focused(cx);
|
||||
let bounds = prepaint.bounds;
|
||||
let selected_range = self.input.read(cx).selected_range.clone();
|
||||
|
||||
cx.handle_input(
|
||||
&focus_handle,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
);
|
||||
|
||||
// Paint selections
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
cx.paint_path(path, cx.theme().selection);
|
||||
}
|
||||
|
||||
// Paint multi line text
|
||||
let line_height = cx.line_height();
|
||||
let origin = bounds.origin;
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
for line in prepaint.lines.iter() {
|
||||
let p = point(origin.x, origin.y + offset_y);
|
||||
_ = line.paint(p, line_height, cx);
|
||||
offset_y += line.size(line_height).height;
|
||||
}
|
||||
|
||||
if focused {
|
||||
if let Some(cursor) = prepaint.cursor.take() {
|
||||
cx.paint_quad(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
let width = prepaint
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.width())
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let height = prepaint
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.size(line_height).height.0)
|
||||
.sum::<f32>();
|
||||
|
||||
let scroll_size = size(width, px(height));
|
||||
|
||||
self.input.update(cx, |input, _cx| {
|
||||
input.last_layout = Some(prepaint.lines.clone());
|
||||
input.last_bounds = Some(bounds);
|
||||
input.last_cursor_offset = Some(input.cursor_offset());
|
||||
input.last_line_height = line_height;
|
||||
input.input_bounds = input_bounds;
|
||||
input.last_selected_range = Some(selected_range);
|
||||
input
|
||||
.scroll_handle
|
||||
.set_offset(prepaint.cursor_scroll_offset);
|
||||
input.scroll_size = scroll_size;
|
||||
});
|
||||
|
||||
self.paint_mouse_listeners(cx);
|
||||
}
|
||||
}
|
||||
1269
crates/ui/src/input/input.rs
Normal file
1269
crates/ui/src/input/input.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
crates/ui/src/input/mod.rs
Normal file
10
crates/ui/src/input/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod blink_cursor;
|
||||
mod change;
|
||||
mod clear_button;
|
||||
mod element;
|
||||
mod input;
|
||||
mod otp_input;
|
||||
|
||||
pub(crate) use clear_button::*;
|
||||
pub use input::*;
|
||||
pub use otp_input::*;
|
||||
274
crates/ui/src/input/otp_input.rs
Normal file
274
crates/ui/src/input/otp_input.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, AnyElement, Context, EventEmitter, FocusHandle, FocusableView,
|
||||
InteractiveElement, IntoElement, KeyDownEvent, Model, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Render, SharedString, Styled as _, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme, v_flex, Icon, IconName, Sizable, Size};
|
||||
|
||||
use super::{blink_cursor::BlinkCursor, InputEvent};
|
||||
|
||||
pub enum InputOptEvent {
|
||||
/// When all OTP input have filled, this event will be triggered.
|
||||
Change(SharedString),
|
||||
}
|
||||
|
||||
/// A One Time Password (OTP) input element.
|
||||
///
|
||||
/// This can accept a fixed length number and can be masked.
|
||||
///
|
||||
/// Use case example:
|
||||
///
|
||||
/// - SMS OTP
|
||||
/// - Authenticator OTP
|
||||
pub struct OtpInput {
|
||||
focus_handle: FocusHandle,
|
||||
length: usize,
|
||||
number_of_groups: usize,
|
||||
masked: bool,
|
||||
value: SharedString,
|
||||
blink_cursor: Model<BlinkCursor>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl OtpInput {
|
||||
pub fn new(length: usize, cx: &mut ViewContext<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let blink_cursor = cx.new_model(|_| BlinkCursor::new());
|
||||
let input = Self {
|
||||
focus_handle: focus_handle.clone(),
|
||||
length,
|
||||
number_of_groups: 2,
|
||||
value: SharedString::default(),
|
||||
masked: false,
|
||||
blink_cursor: blink_cursor.clone(),
|
||||
size: Size::Medium,
|
||||
};
|
||||
|
||||
// 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 default value of the OTP Input.
|
||||
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
|
||||
self.value = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set value of the OTP Input.
|
||||
pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
|
||||
self.value = value.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Return the value of the OTP Input.
|
||||
pub fn value(&self) -> SharedString {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
/// Set masked to true use masked input.
|
||||
pub fn masked(mut self, masked: bool) -> Self {
|
||||
self.masked = masked;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set masked to true use masked input.
|
||||
pub fn set_masked(&mut self, masked: bool, cx: &mut ViewContext<Self>) {
|
||||
self.masked = masked;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn focus(&self, cx: &mut ViewContext<Self>) {
|
||||
self.focus_handle.focus(cx);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
cx.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
_ => {
|
||||
let c = key.chars().next().unwrap();
|
||||
if !matches!(c, '0'..='9') {
|
||||
return;
|
||||
}
|
||||
if ix >= self.length {
|
||||
return;
|
||||
}
|
||||
|
||||
chars.push(c);
|
||||
|
||||
cx.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
|
||||
self.pause_blink_cursor(cx);
|
||||
self.value = SharedString::from(chars.iter().collect::<String>());
|
||||
|
||||
if self.value.chars().count() == self.length {
|
||||
cx.emit(InputEvent::Change(self.value.clone()));
|
||||
}
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.start(cx);
|
||||
});
|
||||
cx.emit(InputEvent::Focus);
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.stop(cx);
|
||||
});
|
||||
cx.emit(InputEvent::Blur);
|
||||
}
|
||||
|
||||
fn pause_blink_cursor(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.pause(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for OtpInput {
|
||||
fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for OtpInput {
|
||||
fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
impl EventEmitter<InputEvent> for OtpInput {}
|
||||
|
||||
impl Render for OtpInput {
|
||||
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 text_size = match self.size {
|
||||
Size::XSmall => px(14.),
|
||||
Size::Small => px(14.),
|
||||
Size::Medium => px(16.),
|
||||
Size::Large => px(18.),
|
||||
Size::Size(v) => v * 0.5,
|
||||
};
|
||||
|
||||
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))
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_md()
|
||||
.text_size(text_size)
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.w_6().h_6(),
|
||||
Size::Small => this.w_6().h_6(),
|
||||
Size::Medium => this.w_8().h_8(),
|
||||
Size::Large => this.w_11().h_11(),
|
||||
Size::Size(px) => this.w(px).h(px),
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_input_mouse_down))
|
||||
.map(|this| match c {
|
||||
Some(c) => {
|
||||
if self.masked {
|
||||
this.child(
|
||||
Icon::new(IconName::Asterisk)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.with_size(text_size),
|
||||
)
|
||||
} else {
|
||||
this.child(c.to_string())
|
||||
}
|
||||
}
|
||||
None => 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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user