chore: update text input

This commit is contained in:
2025-05-21 18:48:00 +07:00
parent e851063de9
commit b0a6b73801
6 changed files with 249 additions and 80 deletions

View File

@@ -90,8 +90,8 @@ impl Chat {
.multi_line()
.prevent_new_line_on_enter()
.rows(1)
.max_rows(20)
.auto_grow()
.multi_line()
.auto_grow(1, 20)
.clean_on_escape()
});

View File

@@ -61,12 +61,11 @@ impl TextElement {
let mut cursor = None;
// If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input.
let bottom_margin = if input.auto_grow {
px(0.)
let bottom_margin = if input.is_auto_grow() {
px(0.) + line_height
} else {
BOTTOM_MARGIN_ROWS * line_height + line_height
};
// 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;
@@ -160,7 +159,7 @@ impl TextElement {
if input.show_cursor(window, cx) {
// cursor blink
let cursor_height =
window.text_style().font_size.to_pixels(window.rem_size()) + px(4.);
window.text_style().font_size.to_pixels(window.rem_size()) + px(2.);
cursor = Some(fill(
Bounds::new(
point(
@@ -224,10 +223,14 @@ impl TextElement {
(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;
}
// Ensure at least 6px width for the selection for empty lines.
end_x = end_x.max(start.x + px(6.));
line_corners.push(Corners {
top_left: line_origin + point(start.x, start.y),
top_right: line_origin + point(end_x, start.y),
@@ -363,28 +366,14 @@ impl Element for TextElement {
let mut style = Style::default();
style.size.width = relative(1.).into();
if self.input.read(cx).is_multi_line() {
style.flex_grow = 1.0;
if let Some(h) = input.height {
if let Some(h) = input.mode.height() {
style.size.height = h.into();
style.min_size.height = line_height.into();
} else {
// Check to auto grow
let rows = if input.auto_grow {
let rows = (input.scroll_size.height / line_height) as usize;
let max_rows = input
.max_rows
.unwrap_or(usize::MAX)
.min(rows)
.max(input.min_rows);
rows.clamp(input.min_rows, max_rows)
} else {
input.rows
};
style.size.height = relative(1.).into();
style.min_size.height = (rows.max(1) as f32 * line_height).into();
style.min_size.height = (input.mode.rows() * line_height).into();
}
} else {
// For single-line inputs, the minimum height should be the line height
@@ -534,7 +523,6 @@ impl Element for TextElement {
// 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);
@@ -546,7 +534,6 @@ impl Element for TextElement {
// 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| {
@@ -593,7 +580,6 @@ impl Element for TextElement {
.map(|l| l.width())
.max()
.unwrap_or_default();
let height = offset_y;
let scroll_size = size(width, height);
@@ -602,12 +588,15 @@ impl Element for TextElement {
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.set_input_bounds(input_bounds, cx);
input.last_selected_range = Some(selected_range);
input.scroll_size = scroll_size;
input
.scroll_handle
.set_offset(prepaint.cursor_scroll_offset);
cx.notify();
});

View File

@@ -4,6 +4,7 @@ mod element;
mod mask_pattern;
mod state;
mod text_input;
mod text_wrapper;
pub(crate) mod clear_button;

View File

@@ -17,6 +17,7 @@ use gpui::{
use super::{
blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern,
text_wrapper::TextWrapper,
};
use crate::{history::History, scroll::ScrollbarState, Root};
@@ -183,21 +184,99 @@ pub fn init(cx: &mut App) {
]);
}
type Validate = Option<Box<dyn Fn(&str) -> bool + 'static>>;
#[derive(Debug, Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
rows: usize,
height: Option<DefiniteLength>,
},
AutoGrow {
rows: usize,
min_rows: usize,
max_rows: usize,
},
}
impl InputMode {
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::MultiLine { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
max_rows,
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
_ => {}
}
}
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) {
if let Self::AutoGrow { .. } = self {
let wrapped_lines = text_wrapper.wrapped_lines.len();
self.set_rows(wrapped_lines);
}
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
match self {
InputMode::MultiLine { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
}
.max(1)
}
/// At least 1 row be return.
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
.max(1)
}
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1,
}
}
pub(super) fn set_height(&mut self, new_height: Option<DefiniteLength>) {
if let InputMode::MultiLine { height, .. } = self {
*height = new_height;
}
}
pub(super) fn height(&self) -> Option<DefiniteLength> {
match self {
InputMode::MultiLine { height, .. } => *height,
_ => None,
}
}
}
/// InputState to keep editing state of the [`super::TextInput`].
pub struct InputState {
pub(super) focus_handle: FocusHandle,
pub(super) mode: InputMode,
pub(super) text: SharedString,
pub(super) multi_line: bool,
pub(super) new_line_on_enter: bool,
pub(super) text_wrapper: TextWrapper,
pub(super) history: History<Change>,
pub(super) blink_cursor: Entity<BlinkCursor>,
pub(super) loading: bool,
/// Range in UTF-8 length for the selected text.
///
/// - "Hello 世界💝" = 16
/// - "💝" = 4
pub(super) selected_range: Range<usize>,
/// Range for save the selected word, use to keep word range when drag move.
pub(super) selected_word_range: Option<Range<usize>>,
@@ -216,13 +295,9 @@ pub struct InputState {
pub(super) disabled: bool,
pub(super) masked: bool,
pub(super) clean_on_escape: bool,
pub(super) height: Option<DefiniteLength>,
pub(super) rows: usize,
pub(super) min_rows: usize,
pub(super) max_rows: Option<usize>,
pub(super) auto_grow: bool,
pub(super) pattern: Option<regex::Regex>,
pub(super) validate: Validate,
#[allow(clippy::type_complexity)]
pub(super) validate: Option<Box<dyn Fn(&str) -> bool + 'static>>,
pub(crate) scroll_handle: ScrollHandle,
pub(super) scrollbar_state: Rc<Cell<ScrollbarState>>,
/// The size of the scrollable content.
@@ -240,6 +315,9 @@ pub struct InputState {
impl EventEmitter<InputEvent> for InputState {}
impl InputState {
/// Create a Input state with default [`InputMode::SingleLine`] mode.
///
/// See also: [`Self::multi_line`], [`Self::auto_grow`] to set other mode.
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let blink_cursor = cx.new(|_| BlinkCursor::new());
@@ -263,10 +341,16 @@ impl InputState {
cx.on_blur(&focus_handle, window, Self::on_blur),
];
let text_style = window.text_style();
Self {
focus_handle: focus_handle.clone(),
text: "".into(),
multi_line: false,
text_wrapper: TextWrapper::new(
text_style.font(),
text_style.font_size.to_pixels(window.rem_size()),
None,
),
new_line_on_enter: true,
blink_cursor,
history,
@@ -282,11 +366,7 @@ impl InputState {
loading: false,
pattern: None,
validate: None,
rows: 3,
min_rows: 3,
max_rows: None,
auto_grow: false,
height: None,
mode: InputMode::SingleLine,
last_layout: None,
last_bounds: None,
last_selected_range: None,
@@ -302,9 +382,24 @@ impl InputState {
}
}
/// Use the text input field as a multi-line Textarea.
/// Set Input to use [`InputMode::MultiLine`] mode.
///
/// Default rows is 2.
pub fn multi_line(mut self) -> Self {
self.multi_line = true;
self.mode = InputMode::MultiLine {
rows: 2,
height: None,
};
self
}
/// Set Input to use [`InputMode::AutoGrow`] mode with min, max rows limit.
pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self {
self.mode = InputMode::AutoGrow {
rows: min_rows,
min_rows,
max_rows,
};
self
}
@@ -460,12 +555,20 @@ impl InputState {
#[inline]
pub(super) fn is_multi_line(&self) -> bool {
self.multi_line
matches!(
self.mode,
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. }
)
}
#[inline]
pub(super) fn is_single_line(&self) -> bool {
!self.multi_line
matches!(self.mode, InputMode::SingleLine)
}
#[inline]
pub(super) fn is_auto_grow(&self) -> bool {
matches!(self.mode, InputMode::AutoGrow { .. })
}
/// Set the number of rows for the multi-line Textarea.
@@ -474,26 +577,19 @@ impl InputState {
///
/// default: 2
pub fn rows(mut self, rows: usize) -> Self {
self.rows = rows;
self.min_rows = rows;
self
}
/// Set the maximum number of rows for the multi-line Textarea.
///
/// If max_rows is more than rows, then will enable auto-grow.
///
/// This is only used when `multi_line` is set to true.
///
/// default: None
pub fn max_rows(mut self, max_rows: usize) -> Self {
self.max_rows = Some(max_rows);
self
}
/// Set the auto-grow mode for the multi-line Textarea.
pub fn auto_grow(mut self) -> Self {
self.auto_grow = true;
match self.mode {
InputMode::MultiLine { height, .. } => {
self.mode = InputMode::MultiLine { rows, height };
}
InputMode::AutoGrow { max_rows, .. } => {
self.mode = InputMode::AutoGrow {
rows,
min_rows: rows,
max_rows,
};
}
_ => {}
}
self
}
@@ -1120,7 +1216,7 @@ impl InputState {
pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(clipboard) = cx.read_from_clipboard() {
let mut new_text = clipboard.text().unwrap_or_default();
if !self.multi_line {
if !self.is_multi_line() {
new_text = new_text.replace('\n', "");
}
@@ -1232,11 +1328,10 @@ impl InputState {
for line in lines.iter() {
let line_origin = self.line_origin_with_y_offset(&mut y_offset, line, line_height);
let pos = inner_position - line_origin;
let closest_index = line.unwrapped_layout.closest_index_for_x(pos.x);
// Return offset by use closest_index_for_x if is single line mode.
if self.is_single_line() {
return closest_index;
return line.unwrapped_layout.closest_index_for_x(pos.x);
}
let index_result = line.closest_index_for_position(pos, line_height);
@@ -1251,13 +1346,14 @@ impl InputState {
// The fallback index is saved in Err from `index_for_position` method.
index += index_result.unwrap_err();
break;
} else if line.len() == 0 {
// empty line
} else if line.text.trim_end_matches('\r').is_empty() {
// empty line on Windows is `\r`, other is ''
let line_bounds = Bounds {
origin: line_origin,
size: gpui::size(bounds.size.width, line_height),
};
let pos = inner_position;
index += line.len();
if line_bounds.contains(&pos) {
break;
}
@@ -1265,7 +1361,7 @@ impl InputState {
index += line.len();
}
// add 1 for \n
// +1 for revert `lines` split `\n`
index += 1;
}
@@ -1539,6 +1635,18 @@ impl InputState {
}
cx.notify();
}
pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds<Pixels>, cx: &mut Context<Self>) {
let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width;
self.input_bounds = new_bounds;
// Update text_wrapper wrap_width if changed.
if wrap_width_changed {
self.text_wrapper
.set_wrap_width(Some(new_bounds.size.width), cx);
self.mode.update_auto_grow(&self.text_wrapper);
}
}
}
impl EntityInputHandler for InputState {
@@ -1614,14 +1722,13 @@ impl EntityInputHandler for InputState {
let new_pos = (range.start + new_text_len).min(mask_text.len());
self.push_history(&range, new_text, window, cx);
self.text = mask_text;
self.text_wrapper.update(self.text.clone(), cx);
self.selected_range = new_pos..new_pos;
self.marked_range.take();
self.update_preferred_x_offset(cx);
self.update_scroll_offset(None, cx);
self.mode.update_auto_grow(&self.text_wrapper);
cx.emit(InputEvent::Change(self.unmask_value()));
cx.notify();
}

View File

@@ -137,7 +137,7 @@ impl RenderOnce for TextInput {
const LINE_HEIGHT: Rems = Rems(1.25);
self.state.update(cx, |state, _| {
state.height = self.height;
state.mode.set_height(self.height);
state.disabled = self.disabled;
});
@@ -187,7 +187,7 @@ impl RenderOnce for TextInput {
.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| {
.when(state.is_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))
@@ -228,7 +228,7 @@ impl RenderOnce for TextInput {
.cursor_text()
.input_py(self.size)
.input_h(self.size)
.when(state.multi_line, |this| {
.when(state.is_multi_line(), |this| {
this.h_auto()
.when_some(self.height, |this, height| this.h(height))
})

View File

@@ -0,0 +1,72 @@
use std::ops::Range;
use gpui::{App, Font, LineFragment, Pixels, SharedString};
/// Used to prepare the text with soft_wrap to be get lines to displayed in the TextArea
///
/// After use lines to calculate the scroll size of the TextArea
pub(super) struct TextWrapper {
pub(super) text: SharedString,
/// The wrapped lines, value is start and end index of the line (by split \n).
pub(super) wrapped_lines: Vec<Range<usize>>,
pub(super) font: Font,
pub(super) font_size: Pixels,
/// If is none, it means the text is not wrapped
pub(super) wrap_width: Option<Pixels>,
}
#[allow(unused)]
impl TextWrapper {
pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
text: SharedString::default(),
font,
font_size,
wrap_width,
wrapped_lines: Vec::new(),
}
}
pub(super) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
if self.wrap_width == wrap_width {
return;
}
self.wrap_width = wrap_width;
self.update(self.text.clone(), cx);
}
pub(super) fn set_font(&mut self, font: Font, cx: &mut App) {
self.font = font;
self.update(self.text.clone(), cx);
}
pub(super) fn update(&mut self, text: SharedString, cx: &mut App) {
let mut wrapped_lines = vec![];
let wrap_width = self.wrap_width.unwrap_or(Pixels::MAX);
let mut line_wrapper = cx
.text_system()
.line_wrapper(self.font.clone(), self.font_size);
for line in text.lines() {
let mut prev_boundary_ix = 0;
for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) {
wrapped_lines.push(prev_boundary_ix..boundary.ix);
prev_boundary_ix = boundary.ix;
}
// Reset of the line
if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
wrapped_lines.push(prev_boundary_ix..line.len());
}
}
// Add last empty line.
if text.chars().last().unwrap_or('\n') == '\n' {
wrapped_lines.push(text.len()..text.len());
}
self.text = text;
self.wrapped_lines = wrapped_lines;
}
}