From b97abd63941161c0862f1ad911a12509ea0a713e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 19 May 2025 19:26:27 +0800 Subject: [PATCH] input: Fix auto-grow height. (#865) Fix #864 Updated InputState API to split `single_line`, `multi_line` and `auto_grow`. The `InputState::new` for default single_line, and added `multi_line` and `auto_grow` to change other mode. ### With no wrap https://github.com/user-attachments/assets/22a8f0d6-e264-4f39-bb30-386a41975d2b ### With soft wrap https://github.com/user-attachments/assets/b71f0fb1-5937-470b-a278-8b1de7cfd7d7 --- crates/story/src/form_story.rs | 4 +- crates/story/src/textarea_story.rs | 24 +++- crates/ui/src/input/element.rs | 55 ++++---- crates/ui/src/input/mod.rs | 1 + crates/ui/src/input/state.rs | 210 +++++++++++++++++++++------- crates/ui/src/input/text_input.rs | 6 +- crates/ui/src/input/text_wrapper.rs | 72 ++++++++++ 7 files changed, 290 insertions(+), 82 deletions(-) create mode 100644 crates/ui/src/input/text_wrapper.rs diff --git a/crates/story/src/form_story.rs b/crates/story/src/form_story.rs index 5e88362e..b908b78f 100644 --- a/crates/story/src/form_story.rs +++ b/crates/story/src/form_story.rs @@ -59,9 +59,7 @@ impl FormStory { cx.new(|cx| InputState::new(window, cx).placeholder("Enter text here...")); let bio_input = cx.new(|cx| { InputState::new(window, cx) - .multi_line() - .rows(5) - .max_rows(20) + .auto_grow(5, 20) .placeholder("Enter text here...") .default_value("Hello 世界,this is GPUI component.") }); diff --git a/crates/story/src/textarea_story.rs b/crates/story/src/textarea_story.rs index 2b709351..8fb73405 100644 --- a/crates/story/src/textarea_story.rs +++ b/crates/story/src/textarea_story.rs @@ -1,5 +1,5 @@ use gpui::{ - actions, App, AppContext as _, ClickEvent, Context, Entity, FocusHandle, Focusable, + actions, px, App, AppContext as _, ClickEvent, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement as _, Render, Styled, Window, }; @@ -24,6 +24,7 @@ pub fn init(cx: &mut App) { pub struct TextareaStory { textarea: Entity, + textarea_auto_grow: Entity, } impl super::Story for TextareaStory { @@ -78,7 +79,17 @@ impl TextareaStory { ) }); - Self { textarea } + let textarea_auto_grow = cx.new(|cx| { + InputState::new(window, cx) + .auto_grow(1, 5) + .placeholder("Enter text here...") + .default_value("Hello 世界,this is GPUI component.") + }); + + Self { + textarea, + textarea_auto_grow, + } } fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { @@ -138,7 +149,7 @@ impl Render for TextareaStory { v_flex() .gap_2() .w_full() - .child(TextInput::new(&self.textarea)) + .child(TextInput::new(&self.textarea).h(px(320.))) .child( h_flex() .gap_2() @@ -157,5 +168,12 @@ impl Render for TextareaStory { ), ), ) + .child( + section("Textarea Auto Grow").child( + v_flex() + .w_full() + .child(TextInput::new(&self.textarea_auto_grow)), + ), + ) } } diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 5a500a84..70a5a516 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -10,7 +10,7 @@ use crate::{ActiveTheme as _, Root}; use super::InputState; const RIGHT_MARGIN: Pixels = px(5.); -const BOTTOM_MARGIN: Pixels = px(20.); +const BOTTOM_MARGIN_ROWS: usize = 1; pub(super) struct TextElement { input: Entity, @@ -59,6 +59,12 @@ impl TextElement { let mut scroll_offset = input.scroll_handle.offset(); 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.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; @@ -114,16 +120,17 @@ impl TextElement { } else { scroll_offset.x }; - scroll_offset.y = - if scroll_offset.y + cursor_pos.y > (bounds.size.height - BOTTOM_MARGIN) { - // cursor is out of bottom - bounds.size.height - BOTTOM_MARGIN - 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 - }; + scroll_offset.y = if scroll_offset.y + cursor_pos.y + line_height + > bounds.size.height - bottom_margin + { + // cursor is out of bottom + bounds.size.height - bottom_margin - 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.) { @@ -353,20 +360,22 @@ impl Element for TextElement { cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let input = self.input.read(cx); + let line_height = window.line_height(); + 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 = window.line_height().into(); + style.min_size.height = line_height.into(); } else { style.size.height = relative(1.).into(); - style.min_size.height = (input.rows.max(1) as f32 * window.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 - style.size.height = window.line_height().into(); + style.size.height = line_height.into(); }; (window.request_layout(style, [], cx), ()) @@ -572,25 +581,21 @@ impl Element for TextElement { .map(|l| l.width()) .max() .unwrap_or_default(); - let height = prepaint - .lines - .iter() - .map(|l| l.size(line_height).height.0) - .sum::(); + let height = offset_y; + let scroll_size = size(width, height); - let scroll_size = size(width, px(height)); - - self.input.update(cx, |input, _cx| { + 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.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); - input.scroll_size = scroll_size; + cx.notify(); }); self.paint_mouse_listeners(window, cx); diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 26084b70..e46a0173 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -7,6 +7,7 @@ mod number_input; mod otp_input; mod state; mod text_input; +mod text_wrapper; pub(crate) use clear_button::*; pub use mask_pattern::MaskPattern; diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index c8722cc5..d1d2a8f9 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -24,7 +24,7 @@ use gpui::{ use super::{ blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern, - number_input, + number_input, text_wrapper::TextWrapper, }; use crate::{history::History, scroll::ScrollbarState, Root}; @@ -191,11 +191,101 @@ pub fn init(cx: &mut App) { number_input::init(cx); } +#[derive(Debug, Default, Clone)] +pub enum InputMode { + #[default] + SingleLine, + MultiLine { + rows: usize, + height: Option, + }, + 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) { + match self { + Self::AutoGrow { .. } => { + 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) { + match self { + InputMode::MultiLine { height, .. } => { + *height = new_height; + } + _ => {} + } + } + + pub(super) fn height(&self) -> Option { + 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) text_wrapper: TextWrapper, pub(super) history: History, pub(super) blink_cursor: Entity, pub(super) loading: bool, @@ -221,10 +311,6 @@ pub struct InputState { pub(super) disabled: bool, pub(super) masked: bool, pub(super) clean_on_escape: bool, - pub(super) height: Option, - pub(super) rows: usize, - pub(super) min_rows: usize, - pub(super) max_rows: Option, pub(super) pattern: Option, pub(super) validate: Option bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, @@ -244,6 +330,9 @@ pub struct InputState { impl EventEmitter 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 { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new(|_| BlinkCursor::new()); @@ -267,10 +356,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, + ), blink_cursor, history, selected_range: 0..0, @@ -285,10 +380,7 @@ impl InputState { loading: false, pattern: None, validate: None, - rows: 2, - min_rows: 2, - max_rows: None, - height: None, + mode: InputMode::SingleLine, last_layout: None, last_bounds: None, last_selected_range: None, @@ -304,9 +396,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: min_rows, + max_rows: max_rows, + }; self } @@ -456,12 +563,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. @@ -470,20 +585,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); + 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 } @@ -992,23 +1106,6 @@ impl InputState { }); } - fn check_to_auto_grow(&mut self, _: &mut Window, cx: &mut Context) { - if !self.is_multi_line() { - return; - } - let Some(max_rows) = self.max_rows else { - return; - }; - - let changed_rows = ((self.scroll_size.height - self.input_bounds.size.height) - / self.last_line_height) as isize; - - self.rows = (self.rows as isize + changed_rows) - .clamp(self.min_rows as isize, max_rows as isize) - .max(0) as usize; - cx.notify(); - } - pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); } @@ -1111,7 +1208,7 @@ impl InputState { pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { 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', ""); } @@ -1527,6 +1624,18 @@ impl InputState { } cx.notify(); } + + pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds, cx: &mut Context) { + 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 { @@ -1568,6 +1677,10 @@ impl EntityInputHandler for InputState { self.marked_range = None; } + /// Replace text in range. + /// + /// - If the new text is invalid, it will not be replaced. + /// - If `range_utf16` is not provided, the current selected range will be used. fn replace_text_in_range( &mut self, range_utf16: Option>, @@ -1598,11 +1711,12 @@ impl EntityInputHandler for InputState { 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.check_to_auto_grow(window, cx); + self.mode.update_auto_grow(&self.text_wrapper); cx.emit(InputEvent::Change(self.unmask_value())); cx.notify(); } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 3826dcb7..092f10a5 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -136,7 +136,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; }); @@ -182,7 +182,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)) @@ -222,7 +222,7 @@ impl RenderOnce for TextInput { .input_py(self.size) .input_h(self.size) .cursor_text() - .when(state.multi_line, |this| { + .when(state.is_multi_line(), |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) }) diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs new file mode 100644 index 00000000..660d29ce --- /dev/null +++ b/crates/ui/src/input/text_wrapper.rs @@ -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>, + pub(super) font: Font, + pub(super) font_size: Pixels, + /// If is none, it means the text is not wrapped + pub(super) wrap_width: Option, +} + +#[allow(unused)] +impl TextWrapper { + pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> 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, 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; + } +}