From 993ce34bd8fc2ce1e0b9e287f9c39d88e79d6b60 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 23 Sep 2025 15:59:48 +0800 Subject: [PATCH] editor: Avoid scroll on select all. (#1277) - Add `replace_text_in_range_silent` for some case to avoid show completion, for example on cut, paster ... - Improve to only call validation, mask on single line mode. - Improve move cursor to scroll to visible to keep edge 3 lines. https://github.com/user-attachments/assets/ca8bcca8-a55b-4c98-af7c-7639d1c34923 - Fix `RopeExt::offset_to_position` may crash. - Add deffered_scroll_offset to improve paste to scroll to cursor. https://github.com/user-attachments/assets/1af09a50-37f0-47c8-8185-4b78820d064a --- Cargo.lock | 34 ++-- crates/story/examples/large-text.rs | 3 +- crates/ui/src/input/element.rs | 22 ++- crates/ui/src/input/lsp/mod.rs | 4 +- crates/ui/src/input/mode.rs | 4 + .../ui/src/input/popovers/completion_menu.rs | 8 +- crates/ui/src/input/rope_ext.rs | 3 +- crates/ui/src/input/search.rs | 8 +- crates/ui/src/input/state.rs | 156 ++++++++++++------ 9 files changed, 156 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af835cdb..05f4b45e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.11.0", "lazy_static", "lazycell", "log", @@ -780,7 +780,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -1449,7 +1449,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "indexmap 2.10.0", "rustc-hash 2.1.1", @@ -1918,7 +1918,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "proc-macro2", "quote", @@ -3112,7 +3112,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -3297,7 +3297,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "bytes", @@ -3586,7 +3586,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4544,7 +4544,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -5602,7 +5602,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "collections", "serde", @@ -6519,7 +6519,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "derive_refineable", "workspace-hack", @@ -6672,7 +6672,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "bytes", @@ -6756,7 +6756,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "arrayvec", "log", @@ -7263,7 +7263,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "serde", @@ -7900,7 +7900,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "arrayvec", "log", @@ -9216,7 +9216,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "anyhow", "async-fs 2.1.3", @@ -9251,7 +9251,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +source = "git+https://github.com/zed-industries/zed.git#4532765ae845b8c98c73c88cc916f7d771b429d5" dependencies = [ "perf", "quote", diff --git a/crates/story/examples/large-text.rs b/crates/story/examples/large-text.rs index 659cfd77..b009e70c 100644 --- a/crates/story/examples/large-text.rs +++ b/crates/story/examples/large-text.rs @@ -22,12 +22,11 @@ impl Example { let editor = cx.new(|cx| { InputState::new(window, cx) .multi_line() - .line_number(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, }) - .soft_wrap(false) + .soft_wrap(true) .placeholder("Enter your code here...") .default_value(text) }); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index a838e67b..fbce253a 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -164,10 +164,8 @@ impl TextElement { if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = (cursor_pos, cursor_start, cursor_end) { - let cursor_moved = state.last_cursor != Some(cursor); let selection_changed = state.last_selected_range != Some(selected_range); - - if cursor_moved || selection_changed { + if selection_changed { scroll_offset.x = if scroll_offset.x + cursor_pos.x > (bounds.size.width - line_number_width - RIGHT_MARGIN) { @@ -225,6 +223,10 @@ impl TextElement { )); } + if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + scroll_offset = deferred_scroll_offset; + } + bounds.origin = bounds.origin + scroll_offset; (cursor_bounds, scroll_offset, current_row) @@ -455,7 +457,11 @@ impl TextElement { } let total_lines = state.text_wrapper.len(); - let scroll_top = state.scroll_handle.offset().y; + let scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + deferred_scroll_offset.y + } else { + state.scroll_handle.offset().y + }; let mut visible_range = 0..total_lines; let mut line_bottom = px(0.); @@ -1084,11 +1090,7 @@ impl Element for TextElement { input_bounds.size.height, ), }, - cx.theme() - .highlight_theme - .style - .background - .unwrap_or(cx.theme().background), + cx.theme().background, )); // Each item is the normal lines. @@ -1126,6 +1128,8 @@ impl Element for TextElement { state .scroll_handle .set_offset(prepaint.cursor_scroll_offset); + state.deferred_scroll_offset = None; + cx.notify(); }); diff --git a/crates/ui/src/input/lsp/mod.rs b/crates/ui/src/input/lsp/mod.rs index 8715f55d..67ad61bc 100644 --- a/crates/ui/src/input/lsp/mod.rs +++ b/crates/ui/src/input/lsp/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use gpui::{App, Context, EntityInputHandler, MouseMoveEvent, Task, Window}; +use gpui::{App, Context, MouseMoveEvent, Task, Window}; use std::rc::Rc; use crate::input::{popovers::ContextMenu, InputState, RopeExt}; @@ -99,7 +99,7 @@ impl InputState { let end = self.text.position_to_offset(&edit.range.end); let range_utf16 = self.range_to_utf16(&(start..end)); - self.replace_text_in_range(Some(range_utf16), &edit.new_text, window, cx); + self.replace_text_in_range_silent(Some(range_utf16), &edit.new_text, window, cx); } } diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index 948b1242..051c00b1 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -105,6 +105,10 @@ impl InputMode { } pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { + if self.is_single_line() { + return; + } + let wrapped_lines = text_wrapper.len(); self.set_rows(wrapped_lines); } diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index 29fea31d..3785a7c1 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -2,9 +2,9 @@ use std::rc::Rc; use gpui::{ canvas, deferred, div, prelude::FluentBuilder, px, relative, Action, AnyElement, App, - AppContext, Bounds, Context, DismissEvent, Empty, Entity, EntityInputHandler, EventEmitter, - HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, - RenderOnce, SharedString, Styled, StyledText, Subscription, Window, + AppContext, Bounds, Context, DismissEvent, Empty, Entity, EventEmitter, HighlightStyle, + InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce, + SharedString, Styled, StyledText, Subscription, Window, }; use lsp_types::{CompletionItem, CompletionTextEdit}; @@ -262,7 +262,7 @@ impl CompletionMenu { range = offset..offset; } - editor.replace_text_in_range( + editor.replace_text_in_range_silent( Some(editor.range_to_utf16(&range)), &new_text, window, diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs index 4b0cab30..b0f7be9a 100644 --- a/crates/ui/src/input/rope_ext.rs +++ b/crates/ui/src/input/rope_ext.rs @@ -127,7 +127,8 @@ impl RopeExt for Rope { fn offset_to_position(&self, offset: usize) -> Position { let point = self.offset_to_point(offset); let line = self.line(point.row as usize); - let character = line.slice(0..point.column as usize).chars().count(); + let column = line.clip_offset(point.column as usize, sum_tree::Bias::Left); + let character = line.slice(0..column).chars().count(); Position::new(point.row, character as u32) } diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs index 72ab6bb8..f505e287 100644 --- a/crates/ui/src/input/search.rs +++ b/crates/ui/src/input/search.rs @@ -4,8 +4,8 @@ use std::{ops::Range, rc::Rc}; use gpui::{ actions, canvas, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, - Entity, EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, - KeyBinding, ParentElement as _, Pixels, Render, Styled, Subscription, Window, + Entity, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, + ParentElement as _, Pixels, Render, Styled, Subscription, Window, }; use rope::Rope; @@ -345,7 +345,7 @@ impl SearchPanel { text_state.update(cx, |state, cx| { let range_utf16 = state.range_to_utf16(&range); state.scroll_to(next_range.end, cx); - state.replace_text_in_range( + state.replace_text_in_range_silent( Some(range_utf16), new_text.as_str(), window, @@ -375,7 +375,7 @@ impl SearchPanel { for range in ranges.iter().rev() { rope.replace(range.clone(), new_text.as_str()); } - state.replace_text_in_range( + state.replace_text_in_range_silent( Some(0..state.text.len()), &rope.to_string(), window, diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 8e85cecc..db305380 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -5,7 +5,7 @@ use anyhow::Result; use gpui::{ actions, div, point, prelude::FluentBuilder as _, px, Action, App, AppContext, Bounds, - ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Half, + ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, Task, UTF16Selection, Window, @@ -284,6 +284,8 @@ pub struct InputState { pub(super) pattern: Option, pub(super) validate: Option) -> bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, + /// The deferred scroll offset to apply on next layout. + pub(crate) deferred_scroll_offset: Option>, pub(super) scroll_state: ScrollbarState, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, @@ -304,6 +306,9 @@ pub struct InputState { pub lsp: Lsp, + /// A flag to indicate if we should ignore the next completion event. + pub(super) silent_replace_text: bool, + /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. /// /// The first element is the x-coordinate (Pixels), preferred to use this. @@ -378,6 +383,7 @@ impl InputState { scroll_handle: ScrollHandle::new(), scroll_state: ScrollbarState::default(), scroll_size: gpui::size(px(0.), px(0.)), + deferred_scroll_offset: None, preferred_column: None, placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), @@ -387,6 +393,7 @@ impl InputState { completion_inserting: false, hover_popover: None, hover_definition: None, + silent_replace_text: false, _subscriptions, _context_menu_task: Task::ready(Ok(())), } @@ -448,6 +455,7 @@ impl InputState { /// Set this input is searchable, default is false (Default true for Code Editor). pub fn searchable(mut self, searchable: bool) -> Self { + debug_assert!(self.mode.is_multi_line()); self.searchable = searchable; self } @@ -460,6 +468,7 @@ impl InputState { /// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode. pub fn line_number(mut self, line_number: bool) -> Self { + debug_assert!(self.mode.is_code_editor()); if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode { *l = line_number; } @@ -468,6 +477,7 @@ impl InputState { /// Set line number, only for [`InputMode::CodeEditor`] mode. pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_code_editor()); if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode { *l = line_number; } @@ -478,6 +488,7 @@ impl InputState { /// /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. pub fn tab_size(mut self, tab: TabSize) -> Self { + debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor()); match &mut self.mode { InputMode::MultiLine { tab: t, .. } => *t = tab, InputMode::CodeEditor { tab: t, .. } => *t = tab, @@ -699,7 +710,7 @@ impl InputState { ) { let text: SharedString = text.into(); let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor())); - self.replace_text_in_range(Some(range_utf16), &text, window, cx); + self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } @@ -713,7 +724,7 @@ impl InputState { cx: &mut Context, ) { let text: SharedString = text.into(); - self.replace_text_in_range(None, &text, window, cx); + self.replace_text_in_range_silent(None, &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } @@ -725,7 +736,7 @@ impl InputState { ) { let text: SharedString = text.into(); let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); - self.replace_text_in_range(Some(range), &text, window, cx); + self.replace_text_in_range_silent(Some(range), &text, window, cx); self.reset_highlighter(cx); } @@ -739,13 +750,19 @@ impl InputState { } /// Set with password masked state. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn masked(mut self, masked: bool) -> Self { + debug_assert!(self.mode.is_single_line()); self.masked = masked; self } /// Set the password masked state of the input field. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_single_line()); self.masked = masked; cx.notify(); } @@ -758,12 +775,14 @@ impl InputState { /// Set the soft wrap mode for multi-line input, default is true. pub fn soft_wrap(mut self, wrap: bool) -> Self { + debug_assert!(self.mode.is_multi_line()); self.soft_wrap = wrap; self } /// Update the soft wrap mode for multi-line input, default is true. pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_multi_line()); self.soft_wrap = wrap; if wrap { let wrap_width = self @@ -785,29 +804,41 @@ impl InputState { } /// Set the regular expression pattern of the input field. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn pattern(mut self, pattern: regex::Regex) -> Self { + debug_assert!(self.mode.is_single_line()); self.pattern = Some(pattern); self } /// Set the regular expression pattern of the input field with reference. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn set_pattern( &mut self, pattern: regex::Regex, _window: &mut Window, _cx: &mut Context, ) { + debug_assert!(self.mode.is_single_line()); self.pattern = Some(pattern); } /// Set the validation function of the input field. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn validate(mut self, f: impl Fn(&str, &mut Context) -> bool + 'static) -> Self { + debug_assert!(self.mode.is_single_line()); self.validate = Some(Box::new(f)); self } /// Set true to show indicator at the input right. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn set_loading(&mut self, loading: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_single_line()); self.loading = loading; cx.notify(); } @@ -862,8 +893,6 @@ impl InputState { .text .point_to_offset(rope::Point::new(row as u32, col as u32)); - // TODO: Scroll to make the row in center of viewport. - self.move_to(offset, cx); self.update_preferred_column(); self.focus(window, cx); @@ -990,8 +1019,8 @@ impl InputState { } pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.move_to(0, cx); - self.select_to(self.text.len(), cx); + self.selected_range = (0..self.text.len()).into(); + cx.notify(); } pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { @@ -1101,6 +1130,7 @@ impl InputState { /// Return the start offset of the previous word. fn previous_start_of_word(&mut self) -> usize { let offset = self.selected_range.start; + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); // FIXME: Avoid to_string let left_part = self.text.slice(0..offset).to_string(); @@ -1114,6 +1144,7 @@ impl InputState { /// Return the next end offset of the next word. fn next_end_of_word(&mut self) -> usize { let offset = self.cursor(); + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); let right_part = self.text.slice(offset..self.text.len()).to_string(); UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) @@ -1230,7 +1261,7 @@ impl InputState { if offset == self.cursor() { offset = offset.saturating_sub(1); } - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..self.cursor()))), "", window, @@ -1250,7 +1281,7 @@ impl InputState { if offset == self.cursor() { offset = (offset + 1).clamp(0, self.text.len()); } - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(self.cursor()..offset))), "", window, @@ -1266,7 +1297,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..self.cursor()))), "", window, @@ -1282,7 +1313,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(self.cursor()..offset))), "", window, @@ -1306,7 +1337,8 @@ impl InputState { // Add newline and indent let new_line_text = format!("\n{}", indent); - self.replace_text_in_range(None, &new_line_text, window, cx); + self.replace_text_in_range_silent(None, &new_line_text, window, cx); + self.pause_blink_cursor(cx); } else { // Single line input, just emit the event (e.g.: In a modal dialog to confirm). cx.propagate(); @@ -1372,7 +1404,7 @@ impl InputState { .unwrap_or("".into()); for line in selected_text.split('\n') { - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..offset))), &tab_indent, window, @@ -1392,7 +1424,7 @@ impl InputState { } else { // Selected none let offset = self.selected_range.start; - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..offset))), &tab_indent, window, @@ -1430,7 +1462,7 @@ impl InputState { for line in selected_text.split('\n') { if line.starts_with(tab_indent.as_ref()) { - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), "", window, @@ -1457,6 +1489,7 @@ impl InputState { // Selected none let start_offset = self.selected_range.start; let offset = self.start_of_line_of_selection(window, cx); + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); // FIXME: To improve performance if self .text @@ -1464,7 +1497,7 @@ impl InputState { .to_string() .starts_with(tab_indent.as_ref()) { - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), "", window, @@ -1647,15 +1680,19 @@ impl InputState { // Check if row_offset_y is out of the viewport // If row offset is not in the viewport, scroll to make it visible - if row_offset_y < -scroll_offset.y { + let edge_height = 3 * line_height; + if row_offset_y - edge_height < -scroll_offset.y { // Scroll up - scroll_offset.y = -row_offset_y - line_height + bounds.size.height.half(); - } else if row_offset_y + line_height > -scroll_offset.y + bounds.size.height { + scroll_offset.y = -row_offset_y + edge_height; + } else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height { // Scroll down - scroll_offset.y = -(row_offset_y - bounds.size.height.half()); + scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height); } - self.update_scroll_offset(Some(scroll_offset), cx); + scroll_offset.x = scroll_offset.x.min(px(0.)); + scroll_offset.y = scroll_offset.y.min(px(0.)); + self.deferred_scroll_offset = Some(scroll_offset); + cx.notify(); } pub(super) fn show_character_palette( @@ -1683,7 +1720,8 @@ impl InputState { let selected_text = self.text.slice(self.selected_range.into()).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - self.replace_text_in_range(None, "", window, cx); + + self.replace_text_in_range_silent(None, "", window, cx); } pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { @@ -1693,7 +1731,8 @@ impl InputState { new_text = new_text.replace('\n', ""); } - self.replace_text_in_range(None, &new_text, window, cx); + self.replace_text_in_range_silent(None, &new_text, window, cx); + self.scroll_to(self.cursor(), cx); } } @@ -1715,7 +1754,7 @@ impl InputState { if let Some(changes) = self.history.undo() { for change in changes { let range_utf16 = self.range_to_utf16(&change.new_range.into()); - self.replace_text_in_range(Some(range_utf16), &change.old_text, window, cx); + self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); } } self.history.ignore = false; @@ -1726,7 +1765,7 @@ impl InputState { if let Some(changes) = self.history.redo() { for change in changes { let range_utf16 = self.range_to_utf16(&change.old_range.into()); - self.replace_text_in_range(Some(range_utf16), &change.new_text, window, cx); + self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); } } self.history.ignore = false; @@ -2152,7 +2191,9 @@ impl InputState { } pub(super) fn selected_text(&self) -> Rope { - self.text.slice(self.selected_range.into()) + let range_utf16 = self.range_to_utf16(&self.selected_range.into()); + let range = self.range_from_utf16(&range_utf16); + self.text.slice(range) } pub(crate) fn range_to_bounds(&self, range: &Range) -> Option> { @@ -2193,13 +2234,28 @@ impl InputState { ) { let start = self.text.position_to_offset(&lsp_range.start); let end = self.text.position_to_offset(&lsp_range.end); - self.replace_text_in_range( + self.replace_text_in_range_silent( Some(self.range_to_utf16(&(start..end))), new_text, window, cx, ); } + + /// Replace text in range in silent. + /// + /// This will not trigger any UI interaction, such as auto-completion. + pub(crate) fn replace_text_in_range_silent( + &mut self, + range_utf16: Option>, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + self.silent_replace_text = true; + self.replace_text_in_range(range_utf16, new_text, window, cx); + self.silent_replace_text = false; + } } impl EntityInputHandler for InputState { @@ -2269,20 +2325,23 @@ impl EntityInputHandler for InputState { let old_text = self.text.clone(); self.text.replace(range.clone(), new_text); - let pending_text = self.text.to_string(); - // Check if the new text is valid - if !self.is_valid_input(&pending_text, cx) { - self.text = old_text; - return; - } - let mut new_offset = (range.start + new_text.len()).min(self.text.len()); - if !self.mask_pattern.is_none() { - let mask_text = self.mask_pattern.mask(&pending_text); - self.text = Rope::from(mask_text.as_str()); - let new_text_len = - (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); - new_offset = (range.start + new_text_len).min(mask_text.len()); + + if self.mode.is_single_line() { + let pending_text = self.text.to_string(); + // Check if the new text is valid + if !self.is_valid_input(&pending_text, cx) { + self.text = old_text; + return; + } + + if !self.mask_pattern.is_none() { + let mask_text = self.mask_pattern.mask(&pending_text); + self.text = Rope::from(mask_text.as_str()); + let new_text_len = + (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); + new_offset = (range.start + new_text_len).min(mask_text.len()); + } } self.push_history(&old_text, &range, &new_text); @@ -2296,10 +2355,11 @@ impl EntityInputHandler for InputState { self.selected_range = (new_offset..new_offset).into(); self.ime_marked_range.take(); self.update_preferred_column(); - self.update_scroll_offset(None, cx); self.update_search(cx); self.mode.update_auto_grow(&self.text_wrapper); - self.handle_completion_trigger(&range, &new_text, window, cx); + if !self.silent_replace_text { + self.handle_completion_trigger(&range, &new_text, window, cx); + } cx.emit(InputEvent::Change); cx.notify(); } @@ -2328,11 +2388,13 @@ impl EntityInputHandler for InputState { let old_text = self.text.clone(); self.text.replace(range.clone(), new_text); - let pending_text = self.text.to_string(); - if !self.is_valid_input(&pending_text, cx) { - self.text = old_text; - return; + if self.mode.is_single_line() { + let pending_text = self.text.to_string(); + if !self.is_valid_input(&pending_text, cx) { + self.text = old_text; + return; + } } self.push_history(&old_text, &range, new_text);