diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index e265a2f..98f174c 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -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() }); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 9af5203..365aed9 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -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(); }); diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 1106447..0f3c6d5 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -4,6 +4,7 @@ mod element; mod mask_pattern; mod state; mod text_input; +mod text_wrapper; pub(crate) mod clear_button; diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 9cfbdcc..5f2270c 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -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 bool + 'static>>; +#[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) { + 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) { + if let InputMode::MultiLine { height, .. } = self { + *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) new_line_on_enter: bool, + pub(super) text_wrapper: TextWrapper, pub(super) history: History, pub(super) blink_cursor: Entity, pub(super) loading: bool, - /// Range in UTF-8 length for the selected text. - /// - /// - "Hello δΈ–η•ŒπŸ’" = 16 - /// - "πŸ’" = 4 pub(super) selected_range: Range, /// Range for save the selected word, use to keep word range when drag move. pub(super) selected_word_range: Option>, @@ -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, - pub(super) rows: usize, - pub(super) min_rows: usize, - pub(super) max_rows: Option, - pub(super) auto_grow: bool, pub(super) pattern: Option, - pub(super) validate: Validate, + #[allow(clippy::type_complexity)] + pub(super) validate: Option bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, pub(super) scrollbar_state: Rc>, /// The size of the scrollable content. @@ -240,6 +315,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()); @@ -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) { 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, 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 { @@ -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(); } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index f81bb74..7708cbd 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -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)) }) diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs new file mode 100644 index 0000000..660d29c --- /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; + } +}