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:
reya
2025-05-18 15:35:33 +07:00
committed by GitHub
parent 4f066b7c00
commit 443dbc82a6
37 changed files with 3060 additions and 1979 deletions

View File

@@ -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);

View File

@@ -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,

View 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)
}

View File

@@ -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;
}

View 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(),
}
}
}

View File

@@ -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

View 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
}
})
}
}