chore: Improve Chat Performance (#35)
* refactor * optimistically update message list * fix * update * handle duplicate messages * update ui * refactor input * update multi line input * clean up
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use gpui::{Context, Timer};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{Context, Timer};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{fmt::Debug, ops::Range};
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Change {
|
||||
pub(crate) old_range: Range<usize>,
|
||||
pub(crate) old_text: String,
|
||||
|
||||
16
crates/ui/src/input/clear_button.rs
Normal file
16
crates/ui/src/input/clear_button.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use gpui::{App, Styled};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
Icon, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CloseCircle))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_muted)
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path,
|
||||
Pixels, Point, Style, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::TextInput;
|
||||
use super::InputState;
|
||||
use crate::Root;
|
||||
|
||||
const RIGHT_MARGIN: Pixels = px(5.);
|
||||
const BOTTOM_MARGIN: Pixels = px(20.);
|
||||
const CURSOR_THICKNESS: Pixels = px(2.);
|
||||
|
||||
pub(super) struct TextElement {
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
placeholder: SharedString,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub(super) fn new(input: Entity<TextInput>) -> Self {
|
||||
Self { input }
|
||||
pub(super) fn new(input: Entity<InputState>) -> Self {
|
||||
Self {
|
||||
input,
|
||||
placeholder: SharedString::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the placeholder text of the input field.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = placeholder.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) {
|
||||
@@ -142,7 +153,6 @@ impl TextElement {
|
||||
// cursor blink
|
||||
let cursor_height =
|
||||
window.text_style().font_size.to_pixels(window.rem_size()) + px(4.);
|
||||
|
||||
cursor = Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
@@ -301,6 +311,31 @@ impl IntoElement for TextElement {
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug function to print points as SVG path.
|
||||
#[allow(unused)]
|
||||
fn print_points_as_svg_path(line_corners: &Vec<Corners<Point<Pixels>>>, points: &[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.is_empty() {
|
||||
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;
|
||||
@@ -319,11 +354,19 @@ impl Element for TextElement {
|
||||
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 * window.line_height()).into();
|
||||
style.flex_grow = 1.0;
|
||||
if let Some(h) = input.height {
|
||||
style.size.height = h.into();
|
||||
style.min_size.height = window.line_height().into();
|
||||
} else {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into();
|
||||
}
|
||||
} else {
|
||||
// For single-line inputs, the minimum height should be the line height
|
||||
style.size.height = window.line_height().into();
|
||||
};
|
||||
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
@@ -339,7 +382,7 @@ impl Element for TextElement {
|
||||
let line_height = window.line_height();
|
||||
let input = self.input.read(cx);
|
||||
let text = input.text.clone();
|
||||
let placeholder = input.placeholder.clone();
|
||||
let placeholder = self.placeholder.clone();
|
||||
let style = window.text_style();
|
||||
let mut bounds = bounds;
|
||||
|
||||
@@ -388,7 +431,6 @@ impl Element for TextElement {
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
|
||||
let wrap_width = if multi_line {
|
||||
Some(bounds.size.width - RIGHT_MARGIN)
|
||||
} else {
|
||||
@@ -465,6 +507,32 @@ impl Element for TextElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set Root focused_input when self is focused
|
||||
if focused {
|
||||
let state = self.input.clone();
|
||||
|
||||
if Root::read(window, cx).focused_input.as_ref() != Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = Some(state);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// And reset focused_input when next_frame start
|
||||
window.on_next_frame({
|
||||
let state = self.input.clone();
|
||||
|
||||
move |window, cx| {
|
||||
if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Paint selections
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
window.paint_path(path, cx.theme().element_disabled);
|
||||
@@ -475,7 +543,6 @@ impl Element for TextElement {
|
||||
let origin = bounds.origin;
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
|
||||
if self.input.read(cx).masked {
|
||||
// Move down offset for vertical centering the *****
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -484,10 +551,9 @@ impl Element for TextElement {
|
||||
offset_y = px(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
for line in prepaint.lines.iter() {
|
||||
let p = point(origin.x, origin.y + offset_y);
|
||||
_ = line.paint(p, line_height, gpui::TextAlign::Left, None, window, cx);
|
||||
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
|
||||
offset_y += line.size(line_height).height;
|
||||
}
|
||||
|
||||
|
||||
380
crates/ui/src/input/mask_pattern.rs
Normal file
380
crates/ui/src/input/mask_pattern.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum MaskToken {
|
||||
/// 0 Digit, equivalent to `[0]`
|
||||
// Digit0,
|
||||
/// Digit, equivalent to `[0-9]`
|
||||
Digit,
|
||||
/// Letter, equivalent to `[a-zA-Z]`
|
||||
Letter,
|
||||
/// Letter or digit, equivalent to `[a-zA-Z0-9]`
|
||||
LetterOrDigit,
|
||||
/// Separator
|
||||
Sep(char),
|
||||
/// Any character
|
||||
Any,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl MaskToken {
|
||||
/// Check if the token is any character.
|
||||
pub fn is_any(&self) -> bool {
|
||||
matches!(self, MaskToken::Any)
|
||||
}
|
||||
|
||||
/// Check if the token is a match for the given character.
|
||||
///
|
||||
/// The separator is always a match any input character.
|
||||
fn is_match(&self, ch: char) -> bool {
|
||||
match self {
|
||||
MaskToken::Digit => ch.is_ascii_digit(),
|
||||
MaskToken::Letter => ch.is_ascii_alphabetic(),
|
||||
MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(),
|
||||
MaskToken::Any => true,
|
||||
MaskToken::Sep(c) => *c == ch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the token a separator (Can be ignored)
|
||||
fn is_sep(&self) -> bool {
|
||||
matches!(self, MaskToken::Sep(_))
|
||||
}
|
||||
|
||||
/// Check if the token is a number.
|
||||
pub fn is_number(&self) -> bool {
|
||||
matches!(self, MaskToken::Digit)
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> char {
|
||||
match self {
|
||||
MaskToken::Sep(c) => *c,
|
||||
_ => '_',
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_char(&self, ch: char) -> char {
|
||||
match self {
|
||||
MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch,
|
||||
MaskToken::Sep(c) => *c,
|
||||
MaskToken::Any => ch,
|
||||
}
|
||||
}
|
||||
|
||||
fn unmask_char(&self, ch: char) -> Option<char> {
|
||||
match self {
|
||||
MaskToken::Digit => Some(ch),
|
||||
MaskToken::Letter => Some(ch),
|
||||
MaskToken::LetterOrDigit => Some(ch),
|
||||
MaskToken::Any => Some(ch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum MaskPattern {
|
||||
#[default]
|
||||
None,
|
||||
Pattern {
|
||||
pattern: SharedString,
|
||||
tokens: Vec<MaskToken>,
|
||||
},
|
||||
Number {
|
||||
/// Group separator, e.g. "," or " "
|
||||
separator: Option<char>,
|
||||
/// Number of fraction digits, e.g. 2 for 123.45
|
||||
fraction: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&str> for MaskPattern {
|
||||
fn from(pattern: &str) -> Self {
|
||||
Self::new(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
impl MaskPattern {
|
||||
/// Create a new mask pattern
|
||||
///
|
||||
/// - `9` - Digit
|
||||
/// - `A` - Letter
|
||||
/// - `#` - Letter or Digit
|
||||
/// - `*` - Any character
|
||||
/// - other characters - Separator
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - `(999)999-9999` - US phone number: (123)456-7890
|
||||
/// - `99999-9999` - ZIP code: 12345-6789
|
||||
/// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4
|
||||
/// - `*999*` - Custom pattern: (123) or [123]
|
||||
pub fn new(pattern: &str) -> Self {
|
||||
let tokens = pattern
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
// '0' => MaskToken::Digit0,
|
||||
'9' => MaskToken::Digit,
|
||||
'A' => MaskToken::Letter,
|
||||
'#' => MaskToken::LetterOrDigit,
|
||||
'*' => MaskToken::Any,
|
||||
_ => MaskToken::Sep(ch),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::Pattern {
|
||||
pattern: pattern.to_owned().into(),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn tokens(&self) -> Option<&Vec<MaskToken>> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => Some(tokens),
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mask pattern with group separator, e.g. "," or " "
|
||||
pub fn number(sep: Option<char>) -> Self {
|
||||
Self::Number {
|
||||
separator: sep,
|
||||
fraction: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
Some(tokens.iter().map(|token| token.placeholder()).collect())
|
||||
}
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the mask pattern is None or no any pattern.
|
||||
pub fn is_none(&self) -> bool {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => tokens.is_empty(),
|
||||
Self::Number { .. } => false,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check is the mask text is valid.
|
||||
///
|
||||
/// If the mask pattern is None, always return true.
|
||||
pub fn is_valid(&self, mask_text: &str) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut text_index = 0;
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
for token in tokens {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ch = mask_text_chars[text_index];
|
||||
if token.is_match(ch) {
|
||||
text_index += 1;
|
||||
}
|
||||
}
|
||||
text_index == mask_text.len()
|
||||
}
|
||||
Self::Number { separator, .. } => {
|
||||
if mask_text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if the text is valid number
|
||||
let mut parts = mask_text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
let frac_part = parts.next();
|
||||
|
||||
if int_part.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the integer part is valid
|
||||
if !int_part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the fraction part is valid
|
||||
if let Some(frac) = frac_part {
|
||||
if !frac
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if valid input char at the given position.
|
||||
pub fn is_valid_at(&self, ch: char, pos: usize) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
if let Some(token) = tokens.get(pos) {
|
||||
if token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if token.is_sep() {
|
||||
// If next token is match, it's valid
|
||||
if let Some(next_token) = tokens.get(pos + 1) {
|
||||
if next_token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Self::Number { .. } => true,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the text according to the mask pattern
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - pattern: (999)999-999
|
||||
/// - text: 123456789
|
||||
/// - mask_text: (123)456-789
|
||||
pub fn mask(&self, text: &str) -> SharedString {
|
||||
if self.is_none() {
|
||||
return text.to_owned().into();
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Number {
|
||||
separator,
|
||||
fraction,
|
||||
} => {
|
||||
if let Some(sep) = *separator {
|
||||
// Remove the existing group separator
|
||||
let text = text.replace(sep, "");
|
||||
|
||||
let mut parts = text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
|
||||
// Limit the fraction part to the given range, if not enough, pad with 0
|
||||
let frac_part = parts.next().map(|part| {
|
||||
part.chars()
|
||||
.take(fraction.unwrap_or(usize::MAX))
|
||||
.collect::<String>()
|
||||
});
|
||||
|
||||
// Reverse the integer part for easier grouping
|
||||
let chars: Vec<char> = int_part.chars().rev().collect();
|
||||
let mut result = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push(*ch);
|
||||
}
|
||||
let int_with_sep: String = result.chars().rev().collect();
|
||||
|
||||
let final_str = if let Some(frac) = frac_part {
|
||||
if fraction == &Some(0) {
|
||||
int_with_sep
|
||||
} else {
|
||||
format!("{}.{}", int_with_sep, frac)
|
||||
}
|
||||
} else {
|
||||
int_with_sep
|
||||
};
|
||||
return final_str.into();
|
||||
}
|
||||
|
||||
text.to_owned().into()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mut text_index = 0;
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
for (pos, token) in tokens.iter().enumerate() {
|
||||
if text_index >= text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = text_chars[text_index];
|
||||
// Break if expected char is not match
|
||||
if !token.is_sep() && !self.is_valid_at(ch, pos) {
|
||||
break;
|
||||
}
|
||||
let mask_ch = token.mask_char(ch);
|
||||
result.push(mask_ch);
|
||||
if ch == mask_ch {
|
||||
text_index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.into()
|
||||
}
|
||||
Self::None => text.to_owned().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract original text from masked text
|
||||
pub fn unmask(&self, mask_text: &str) -> String {
|
||||
match self {
|
||||
Self::Number { separator, .. } => {
|
||||
if let Some(sep) = *separator {
|
||||
let mut result = String::new();
|
||||
for ch in mask_text.chars() {
|
||||
if ch == sep {
|
||||
continue;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
if result.contains('.') {
|
||||
result = result.trim_end_matches('0').to_string();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
mask_text.to_owned()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
for (text_index, token) in tokens.iter().enumerate() {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = mask_text_chars[text_index];
|
||||
let unmask_ch = token.unmask_char(ch);
|
||||
if let Some(ch) = unmask_ch {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::None => mask_text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
mod blink_cursor;
|
||||
mod change;
|
||||
mod element;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod input;
|
||||
mod mask_pattern;
|
||||
mod state;
|
||||
mod text_input;
|
||||
|
||||
pub use input::*;
|
||||
pub(crate) mod clear_button;
|
||||
|
||||
#[allow(ambiguous_glob_reexports)]
|
||||
pub use state::*;
|
||||
pub use text_input::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
302
crates/ui/src/input/text_input.rs
Normal file
302
crates/ui/src/input/text_input.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, relative, AnyElement, App, DefiniteLength, Entity,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::InputState;
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
indicator::Indicator,
|
||||
input::clear_button::clear_button,
|
||||
scroll::{Scrollbar, ScrollbarAxis},
|
||||
IconName, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextInput {
|
||||
state: Entity<InputState>,
|
||||
size: Size,
|
||||
no_gap: bool,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
height: Option<DefiniteLength>,
|
||||
appearance: bool,
|
||||
cleanable: bool,
|
||||
mask_toggle: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Sizable for TextInput {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Create a new [`TextInput`] element bind to the [`InputState`].
|
||||
pub fn new(state: &Entity<InputState>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
size: Size::default(),
|
||||
no_gap: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
height: None,
|
||||
appearance: true,
|
||||
cleanable: false,
|
||||
mask_toggle: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set full height of the input (Multi-line only).
|
||||
pub fn h_full(mut self) -> Self {
|
||||
self.height = Some(relative(1.));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set height of the input (Multi-line only).
|
||||
pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
|
||||
self.height = Some(height.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the appearance of the input field.
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to enable toggle button for password mask state.
|
||||
pub fn mask_toggle(mut self) -> Self {
|
||||
self.mask_toggle = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to disable the input field.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to not use gap between input and prefix, suffix, and clear button.
|
||||
///
|
||||
/// Default: false
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn no_gap(mut self) -> Self {
|
||||
self.no_gap = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement {
|
||||
Button::new("toggle-mask")
|
||||
.icon(IconName::Eye)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(false, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(true, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TextInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const LINE_HEIGHT: Rems = Rems(1.25);
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.height = self.height;
|
||||
state.disabled = self.disabled;
|
||||
});
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let focused = state.focus_handle.is_focused(window);
|
||||
|
||||
let mut gap_x = match self.size {
|
||||
Size::Small => px(4.),
|
||||
Size::Large => px(8.),
|
||||
_ => px(4.),
|
||||
};
|
||||
|
||||
if self.no_gap {
|
||||
gap_x = px(0.);
|
||||
}
|
||||
|
||||
let prefix = self.prefix;
|
||||
let suffix = self.suffix;
|
||||
|
||||
let show_clear_button =
|
||||
self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line();
|
||||
|
||||
let bg = if state.disabled {
|
||||
cx.theme().surface_background
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
};
|
||||
|
||||
div()
|
||||
.id(("input", self.state.entity_id()))
|
||||
.flex()
|
||||
.key_context(crate::input::CONTEXT)
|
||||
.track_focus(&state.focus_handle)
|
||||
.when(!state.disabled, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::backspace))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete))
|
||||
.on_action(
|
||||
window.listener_for(&self.state, InputState::delete_to_beginning_of_line),
|
||||
)
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::enter))
|
||||
.on_action(window.listener_for(&self.state, InputState::escape))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::left))
|
||||
.on_action(window.listener_for(&self.state, InputState::right))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_left))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_right))
|
||||
.when(state.multi_line, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::up))
|
||||
.on_action(window.listener_for(&self.state, InputState::down))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_up))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_down))
|
||||
.on_action(window.listener_for(&self.state, InputState::shift_to_new_line))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::select_all))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::home))
|
||||
.on_action(window.listener_for(&self.state, InputState::end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::show_character_palette))
|
||||
.on_action(window.listener_for(&self.state, InputState::copy))
|
||||
.on_action(window.listener_for(&self.state, InputState::paste))
|
||||
.on_action(window.listener_for(&self.state, InputState::cut))
|
||||
.on_action(window.listener_for(&self.state, InputState::undo))
|
||||
.on_action(window.listener_for(&self.state, InputState::redo))
|
||||
.on_key_down(window.listener_for(&self.state, InputState::on_key_down))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_down),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_up),
|
||||
)
|
||||
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
|
||||
.size_full()
|
||||
.line_height(LINE_HEIGHT)
|
||||
.cursor_text()
|
||||
.input_py(self.size)
|
||||
.input_h(self.size)
|
||||
.when(state.multi_line, |this| {
|
||||
this.h_auto()
|
||||
.when_some(self.height, |this, height| this.h(height))
|
||||
})
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(bg)
|
||||
.rounded(cx.theme().radius)
|
||||
.when(focused, |this| this.border_color(cx.theme().ring))
|
||||
})
|
||||
.when(prefix.is_none(), |this| this.input_pl(self.size))
|
||||
.input_pr(self.size)
|
||||
.items_center()
|
||||
.gap(gap_x)
|
||||
.children(prefix)
|
||||
// TODO: Define height here, and use it in the input element
|
||||
.child(self.state.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.id("suffix")
|
||||
.absolute()
|
||||
.gap(gap_x)
|
||||
.when(self.appearance, |this| this.bg(bg))
|
||||
.items_center()
|
||||
.when(suffix.is_none(), |this| this.pr_1())
|
||||
.right_0()
|
||||
.when(state.loading, |this| {
|
||||
this.child(Indicator::new().color(cx.theme().text_muted))
|
||||
})
|
||||
.when(self.mask_toggle, |this| {
|
||||
this.child(Self::render_toggle_mask_button(self.state.clone()))
|
||||
})
|
||||
.when(show_clear_button, |this| {
|
||||
this.child(clear_button(cx).on_click({
|
||||
let state = self.state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.clean(window, cx);
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
.children(suffix),
|
||||
)
|
||||
.when(state.is_multi_line(), |this| {
|
||||
let entity_id = self.state.entity_id();
|
||||
|
||||
if state.last_layout.is_some() {
|
||||
let scroll_size = state.scroll_size;
|
||||
|
||||
this.relative().child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right(px(1.))
|
||||
.bottom_0()
|
||||
.child(
|
||||
Scrollbar::vertical(
|
||||
entity_id,
|
||||
state.scrollbar_state.clone(),
|
||||
state.scroll_handle.clone(),
|
||||
scroll_size,
|
||||
)
|
||||
.axis(ScrollbarAxis::Vertical),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user